diff --git a/backend/Dockerfile b/backend/Dockerfile index a460589..322ad58 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,20 +1,30 @@ # syntax=docker/dockerfile:1 +# onnxruntime 版本要与 go.mod 里 yalue/onnxruntime_go 的 API 版本匹配 +ARG ORT_VERSION=1.28.0 + # --- Stage 1: build the Go binary from source --- -FROM golang:1.26-alpine AS build +# YuNet 人脸检测走 onnxruntime,需要 CGO(gcc) + libonnxruntime 动态库, +# 因此构建/运行镜像都用 glibc 的 debian(onnxruntime 官方包不支持 musl/alpine)。 +FROM golang:1.26-bookworm AS build +ARG ORT_VERSION WORKDIR /src -RUN apk add --no-cache git +RUN wget -qO /tmp/ort.tgz https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/onnxruntime-linux-x64-${ORT_VERSION}.tgz \ + && tar -xzf /tmp/ort.tgz -C /opt && rm /tmp/ort.tgz # Cache deps first for faster rebuilds. COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api +RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api # --- Stage 2: minimal runtime image --- -FROM alpine:3.20 +FROM debian:bookworm-slim +ARG ORT_VERSION # ca-certificates: outbound HTTPS to the AI providers. tzdata: POSTGRES_DSN sets # TimeZone=Asia/Shanghai. wget: container healthcheck. -RUN apk add --no-cache ca-certificates tzdata wget +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata wget \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /opt/onnxruntime-linux-x64-${ORT_VERSION}/lib/libonnxruntime.so* /usr/local/lib/ WORKDIR /app COPY --from=build /out/api /app/api # Local fallback for generated media / reference uploads (RustFS/S3 is primary). diff --git a/backend/go.mod b/backend/go.mod index e2d4df0..1d71b82 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/redis/go-redis/v9 v9.16.0 + github.com/yalue/onnxruntime_go v1.32.0 golang.org/x/crypto v0.54.0 golang.org/x/image v0.43.0 gorm.io/datatypes v1.2.7 diff --git a/backend/go.sum b/backend/go.sum index 49a24f7..3f35ee9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -156,6 +156,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yalue/onnxruntime_go v1.32.0 h1:O4pPw3IT+46CRrfuT0lcHWkczVoFvtfq6kMAO/iIVKc= +github.com/yalue/onnxruntime_go v1.32.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 748af88..1796951 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -368,7 +368,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { "provider": "adobe", "durations": []string{"4s", "6s", "8s"}, "ratios": []string{"16:9", "9:16"}, - "resolutions": []string{"720p"}, + "resolutions": []string{"720p", "1080p"}, }, { "key": "seedance-2.0-fast", diff --git a/backend/internal/service/assets/yunet.onnx b/backend/internal/service/assets/yunet.onnx new file mode 100644 index 0000000..73a37b1 Binary files /dev/null and b/backend/internal/service/assets/yunet.onnx differ diff --git a/backend/internal/service/facemask.go b/backend/internal/service/facemask.go new file mode 100644 index 0000000..cad18c5 --- /dev/null +++ b/backend/internal/service/facemask.go @@ -0,0 +1,462 @@ +package service + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "os" + "runtime" + "sort" + "sync" + + ort "github.com/yalue/onnxruntime_go" +) + +// YuNet 人脸检测模型(opencv_zoo face_detection_yunet_2023mar,输入尺寸已改为 +// 动态),编译进二进制,运行时只额外依赖 onnxruntime 动态库。 +// +//go:embed assets/yunet.onnx +var yunetModel []byte + +const ( + // 检出分数下限与 NMS 的 IoU 阈值 + faceScoreThreshold = 0.35 + faceNMSIoU = 0.3 + // 推理输入的长边上限:更大的图先等比缩小,检出框再映射回原图, + // 避免超大参考图把内存和耗时拉爆。 + faceMaxInferSide = 2560 +) + +// YuNet 的三个输出分支步长 +var faceStrides = []int{8, 16, 32} + +// ErrNoFaceDetected 表示图中没有检出人脸,调用方按原图处理。 +var ErrNoFaceDetected = errors.New("no face detected") + +var ( + faceOnce sync.Once + faceSession *ort.DynamicAdvancedSession + faceInitErr error +) + +// onnxruntimeLibPath 返回 onnxruntime 动态库路径:ONNXRUNTIME_LIB_PATH 优先, +// 否则用各平台的默认位置。 +func onnxruntimeLibPath() string { + if p := os.Getenv("ONNXRUNTIME_LIB_PATH"); p != "" { + return p + } + if runtime.GOOS == "windows" { + return "onnxruntime.dll" + } + return "/usr/local/lib/libonnxruntime.so" +} + +// faceOutputNames 是 YuNet 需要读取的输出名,顺序与 readOutputs 的下标约定一致: +// 先 cls_*、再 obj_*、最后 bbox_*(关键点分支用不到)。 +func faceOutputNames() []string { + names := make([]string, 0, len(faceStrides)*3) + for _, prefix := range []string{"cls", "obj", "bbox"} { + for _, s := range faceStrides { + names = append(names, fmt.Sprintf("%s_%d", prefix, s)) + } + } + return names +} + +func faceDetector() (*ort.DynamicAdvancedSession, error) { + faceOnce.Do(func() { + ort.SetSharedLibraryPath(onnxruntimeLibPath()) + if err := ort.InitializeEnvironment(); err != nil { + faceInitErr = fmt.Errorf("onnxruntime init: %w", err) + return + } + faceSession, faceInitErr = ort.NewDynamicAdvancedSessionWithONNXData( + yunetModel, []string{"input"}, faceOutputNames(), nil) + }) + if faceInitErr != nil { + return nil, faceInitErr + } + return faceSession, nil +} + +type faceDetection struct { + rect image.Rectangle + score float32 +} + +// detectFaces 返回图中的人脸矩形框(坐标基于 src 的原始尺寸)。 +func detectFaces(src image.Image) ([]image.Rectangle, error) { + sess, err := faceDetector() + if err != nil { + return nil, err + } + bounds := src.Bounds() + long := bounds.Dx() + if bounds.Dy() > long { + long = bounds.Dy() + } + scale := 1.0 + if long > faceMaxInferSide { + scale = float64(faceMaxInferSide) / float64(long) + } + inW := int(float64(bounds.Dx()) * scale) + inH := int(float64(bounds.Dy()) * scale) + if inW < 1 || inH < 1 { + return nil, nil + } + // 输入补齐到 32 的整数倍,三个步长分支才有整数网格 + padW := (inW + 31) / 32 * 32 + padH := (inH + 31) / 32 * 32 + + // YuNet 吃 BGR、NCHW、未归一化的 0~255 像素 + pixels := make([]float32, 3*padW*padH) + plane := padW * padH + for y := 0; y < inH; y++ { + srcY := bounds.Min.Y + int(float64(y)/scale) + for x := 0; x < inW; x++ { + r, g, b, _ := src.At(bounds.Min.X+int(float64(x)/scale), srcY).RGBA() + i := y*padW + x + pixels[i] = float32(b >> 8) + pixels[plane+i] = float32(g >> 8) + pixels[2*plane+i] = float32(r >> 8) + } + } + input, err := ort.NewTensor(ort.NewShape(1, 3, int64(padH), int64(padW)), pixels) + if err != nil { + return nil, err + } + defer input.Destroy() + + outputs := make([]ort.Value, len(faceStrides)*3) + if err := sess.Run([]ort.Value{input}, outputs); err != nil { + return nil, err + } + defer func() { + for _, out := range outputs { + if out != nil { + out.Destroy() + } + } + }() + + branch := func(i int) ([]float32, error) { + t, ok := outputs[i].(*ort.Tensor[float32]) + if !ok { + return nil, fmt.Errorf("yunet output %d is not a float32 tensor", i) + } + return t.GetData(), nil + } + inferBounds := image.Rect(0, 0, inW, inH) + var dets []faceDetection + for si, stride := range faceStrides { + cls, err := branch(si) + if err != nil { + return nil, err + } + obj, err := branch(len(faceStrides) + si) + if err != nil { + return nil, err + } + box, err := branch(2*len(faceStrides) + si) + if err != nil { + return nil, err + } + cols, rows := padW/stride, padH/stride + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + idx := row*cols + col + score := float32(math.Sqrt(float64(clampUnit(cls[idx]) * clampUnit(obj[idx])))) + if score < faceScoreThreshold { + continue + } + cx := (float32(col) + box[idx*4]) * float32(stride) + cy := (float32(row) + box[idx*4+1]) * float32(stride) + w := float32(math.Exp(float64(box[idx*4+2]))) * float32(stride) + h := float32(math.Exp(float64(box[idx*4+3]))) * float32(stride) + rect := image.Rect(int(cx-w/2), int(cy-h/2), int(cx+w/2), int(cy+h/2)).Intersect(inferBounds) + if rect.Dx() > 0 && rect.Dy() > 0 { + dets = append(dets, faceDetection{rect: rect, score: score}) + } + } + } + } + + boxes := make([]image.Rectangle, 0, len(dets)) + for _, d := range suppressOverlaps(dets, faceNMSIoU) { + rect := d.rect + if scale != 1 { + rect = image.Rect( + int(float64(rect.Min.X)/scale), int(float64(rect.Min.Y)/scale), + int(float64(rect.Max.X)/scale), int(float64(rect.Max.Y)/scale), + ).Intersect(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + } + if rect.Dx() > 0 && rect.Dy() > 0 { + boxes = append(boxes, rect.Add(bounds.Min)) + } + } + return boxes, nil +} + +func clampUnit(v float32) float32 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// suppressOverlaps 按分数从高到低做 NMS,丢掉与已保留框 IoU 超过阈值的框。 +func suppressOverlaps(dets []faceDetection, iouThreshold float64) []faceDetection { + sort.SliceStable(dets, func(i, j int) bool { return dets[i].score > dets[j].score }) + kept := make([]faceDetection, 0, len(dets)) + for _, d := range dets { + overlaps := false + for _, k := range kept { + if rectIoU(d.rect, k.rect) > iouThreshold { + overlaps = true + break + } + } + if !overlaps { + kept = append(kept, d) + } + } + return kept +} + +func rectIoU(a, b image.Rectangle) float64 { + inter := a.Intersect(b) + if inter.Empty() { + return 0 + } + interArea := float64(inter.Dx() * inter.Dy()) + return interArea / (float64(a.Dx()*a.Dy()+b.Dx()*b.Dy()) - interArea) +} + +// applyFaceNotice 给图中每张人脸盖一层红色网格点,返回 PNG。 +// 没检出人脸(或不是可解码的图片)时返回 ErrNoFaceDetected,调用方应继续用原图; +// 其它错误说明检测器不可用,调用方不应把未打码的图上传。 +func applyFaceNotice(b []byte) ([]byte, error) { + src, _, err := image.Decode(bytes.NewReader(b)) + if err != nil { + return nil, ErrNoFaceDetected + } + boxes, err := detectFaces(src) + if err != nil { + return nil, err + } + if len(boxes) == 0 { + return nil, ErrNoFaceDetected + } + + w, h := src.Bounds().Dx(), src.Bounds().Dy() + orig := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(orig, orig.Bounds(), src, src.Bounds().Min, draw.Src) + + offset := image.Pt(-src.Bounds().Min.X, -src.Bounds().Min.Y) + + // 脸多(>4 张)时不做顶部条,直接给每张脸盖一层白点阵。 + if len(boxes) > 4 { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, dst.Bounds(), orig, image.Point{}, draw.Src) + for _, box := range boxes { + drawWhiteDots(dst, box.Add(offset).Intersect(dst.Bounds())) + } + var out bytes.Buffer + if err := png.Encode(&out, dst); err != nil { + return nil, err + } + return out.Bytes(), nil + } + + // 紧贴脸的检出框:切片和盖黑都用它,两者一样大。 + heads := make([]image.Rectangle, 0, len(boxes)) + for _, box := range boxes { + heads = append(heads, box.Add(offset).Intersect(orig.Bounds())) + } + + // 取面积最大的一张脸切成 4 块——切割线正好穿过脸中心,每块只有 1/4 张脸, + // 打乱后分开摆到图片上方新加的黑色条带里。 + main := largestRect(heads) + quads := faceQuadrants(orig, main) // 已按打乱顺序排列的 4 块 + gap := main.Dx() / 8 + if gap < 8 { + gap = 8 + } + stripH := main.Dy()/2 + 2*gap + + black := image.NewUniform(color.RGBA{A: 255}) + dst := image.NewRGBA(image.Rect(0, 0, w, h+stripH)) + draw.Draw(dst, dst.Bounds(), black, image.Point{}, draw.Src) // 条带底色黑 + draw.Draw(dst, image.Rect(0, stripH, w, h+stripH), orig, image.Point{}, draw.Src) + + // 4 块横向排开,块间留黑缝,明显是碎片而非整脸。 + x := gap + for _, q := range quads { + qw, qh := q.Bounds().Dx(), q.Bounds().Dy() + if x+qw > w { + break + } + draw.Draw(dst, image.Rect(x, gap, x+qw, gap+qh), q, image.Point{}, draw.Src) + x += qw + gap + } + + // 原图里每张脸整块盖黑(坐标下移 stripH)。 + for _, hd := range heads { + draw.Draw(dst, hd.Add(image.Pt(0, stripH)), black, image.Point{}, draw.Src) + } + + var out bytes.Buffer + if err := png.Encode(&out, dst); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +// expandFaceBox 把 YuNet 的紧致人脸框向外扩到大致一个头部的范围(额头到下巴), +// 只挡眼睛不足以让 Adobe 认不出大图正脸。结果裁剪到图像范围内。 +func expandFaceBox(b image.Rectangle, bounds image.Rectangle) image.Rectangle { + w := b.Dx() + h := b.Dy() + padX := w * 60 / 100 + minX := b.Min.X - padX + maxX := b.Max.X + padX + width := maxX - minX + // 高度按脸宽推算(额头到下巴 ≈ 1.2×脸宽)。YuNet 框常只圈住眼部, + // 只按框高向下扩不足以盖住嘴和下巴。 + height := width * 115 / 100 + if hh := h * 180 / 100; hh > height { + height = hh + } + top := b.Min.Y - h*85/100 + out := image.Rect(minX, top, maxX, top+height) + return out.Intersect(bounds) +} + +// faceQuadrants 把 box 区域切成 2×2 四块,按固定置换打乱顺序后返回这 4 块子图。 +func faceQuadrants(src *image.RGBA, box image.Rectangle) []*image.RGBA { + hw, hh := box.Dx()/2, box.Dy()/2 + rects := [4]image.Rectangle{ + image.Rect(box.Min.X, box.Min.Y, box.Min.X+hw, box.Min.Y+hh), + image.Rect(box.Min.X+hw, box.Min.Y, box.Max.X, box.Min.Y+hh), + image.Rect(box.Min.X, box.Min.Y+hh, box.Min.X+hw, box.Max.Y), + image.Rect(box.Min.X+hw, box.Min.Y+hh, box.Max.X, box.Max.Y), + } + perm := [4]int{3, 1, 2, 0} // 打乱顺序 + rot := [4]int{1, 2, 3, 2} // 每块各自旋转 90°/180°/270°,打断人脸连续性 + out := make([]*image.RGBA, 0, 4) + for i, s := range perm { + r := rects[s] + q := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) + draw.Draw(q, q.Bounds(), src, r.Min, draw.Src) + out = append(out, rotateRGBA(q, rot[i])) + } + return out +} + +// rotateRGBA 顺时针旋转 90° 的 k 倍,返回旋转后的新图。 +func rotateRGBA(src *image.RGBA, k int) *image.RGBA { + k = ((k % 4) + 4) % 4 + if k == 0 { + return src + } + w, h := src.Bounds().Dx(), src.Bounds().Dy() + var dst *image.RGBA + if k == 2 { + dst = image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dst.SetRGBA(w-1-x, h-1-y, src.RGBAAt(x, y)) + } + } + return dst + } + dst = image.NewRGBA(image.Rect(0, 0, h, w)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if k == 1 { + dst.SetRGBA(h-1-y, x, src.RGBAAt(x, y)) + } else { + dst.SetRGBA(y, w-1-x, src.RGBAAt(x, y)) + } + } + } + return dst +} + +// drawWhiteDots 在脸框内铺一层小白点阵,盖住五官(只盖脸、不外扩)。 +func drawWhiteDots(dst *image.RGBA, box image.Rectangle) { + step := box.Dx() / 16 + if step < 4 { + step = 4 + } + r := step / 3 + if r < 2 { + r = 2 + } + white := color.RGBA{R: 255, G: 255, B: 255, A: 255} + for cy := box.Min.Y + step/2; cy < box.Max.Y; cy += step { + for cx := box.Min.X + step/2; cx < box.Max.X; cx += step { + fillDot(dst, cx, cy, r, box, white) + } + } +} + +// largestRect 返回面积最大的矩形。 +func largestRect(rs []image.Rectangle) image.Rectangle { + best := rs[0] + for _, r := range rs[1:] { + if r.Dx()*r.Dy() > best.Dx()*best.Dy() { + best = r + } + } + return best +} + +// minInt 返回两数中较小者。 +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// faceDotAlpha 网格点的不透明度(255 = 实心)。 +const faceDotAlpha uint8 = 26 + +// blendPixel 把前景色按其 alpha 叠到底色上。 +func blendPixel(bg, fg color.RGBA) color.RGBA { + a := int(fg.A) + mix := func(f, b uint8) uint8 { + return uint8((int(f)*a + int(b)*(255-a)) / 255) + } + return color.RGBA{R: mix(fg.R, bg.R), G: mix(fg.G, bg.G), B: mix(fg.B, bg.B), A: 255} +} + +// fillDot 以 (cx,cy) 为心画一个半径 r 的圆,按 c.A 与底图混合,裁剪在 clip 内。 +func fillDot(dst *image.RGBA, cx, cy, r int, clip image.Rectangle, c color.RGBA) { + r2 := r * r + for y := cy - r; y <= cy+r; y++ { + if y < clip.Min.Y || y >= clip.Max.Y { + continue + } + for x := cx - r; x <= cx+r; x++ { + if x < clip.Min.X || x >= clip.Max.X { + continue + } + dx := x - cx + dy := y - cy + if dx*dx+dy*dy <= r2 { + dst.SetRGBA(x, y, blendPixel(dst.RGBAAt(x, y), c)) + } + } + } +} diff --git a/backend/internal/service/refresh_profiles.go b/backend/internal/service/refresh_profiles.go index c36c77b..e0b88bd 100644 --- a/backend/internal/service/refresh_profiles.go +++ b/backend/internal/service/refresh_profiles.go @@ -123,9 +123,16 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error tokenPatch["meta"] = meta planCap := strings.ToLower(strings.TrimSpace(stringValue(quotaData["plan"]))) - meta["plan"] = planCap isVIP := planCap != "" && !strings.EqualFold(planCap, "free") planKnown = planCap != "" + // 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次刷新自动还原真实身份。 + if isVIP { + if rem, ok := quotaData["remaining"].(int); ok && rem <= 0 { + isVIP = false + planCap = "free" + } + } + meta["plan"] = planCap // VIP: use Adobe's reset time; set concurrency 5. // 母号/子号 身份固定,不随积分动态变化——只有降级普号才刷新身份。 @@ -134,6 +141,18 @@ func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error tokenPatch["cached_quota_reset_after"] = resetAfter } tokenPatch["concurrency"] = 5 + // 身份固定:这里重建了整个 meta,若不带上 is_sub_account,刷新会把导入时 + // 确定的 母号/子号 标记冲掉。身份只在导入时确定,刷新只沿用、绝不靠积分猜: + // 已有标记就带过来;取不到(没 meta / 缺字段)说明身份未知,直接置死号。 + if existing, gerr := s.tokens.Get(ctx, "adobe", id); gerr == nil { + if v, ok := existing.Meta["is_sub_account"]; ok { + meta["is_sub_account"] = v + } + } + if _, ok := meta["is_sub_account"]; !ok { + tokenPatch["status"] = "disabled" + tokenPatch["dead"] = true + } } else { // Free account: concurrency 1, limit image+video tokenPatch["concurrency"] = 1 diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go index d9b4a9a..72e466e 100644 --- a/backend/internal/service/tokens.go +++ b/backend/internal/service/tokens.go @@ -713,15 +713,24 @@ func (s *TokenService) checkPendingAdobe(tokenID, cookie string) { if cb, e := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); e == nil { quotaMeta["cached_quota_at"] = int(time.Now().Unix()) planCap := strings.ToLower(strings.TrimSpace(stringValue(cb["plan"]))) - quotaMeta["plan"] = planCap isVIP := planCap != "" && planCap != "free" planKnown = planCap != "" - if rem, ok := cb["remaining"].(int); ok { + rem, remOK := cb["remaining"].(int) + if remOK { quotaMeta["cached_quota_remaining"] = rem - if isVIP && rem > 0 && rem <= 4000 { - quotaMeta["is_sub_account"] = true - } + } + // 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次刷新自动还原真实身份。 + if isVIP && remOK && rem <= 0 { + isVIP = false + planCap = "free" + } + quotaMeta["plan"] = planCap + // VIP 号导入即固定 母号/子号 身份并显式写入 is_sub_account:积分 1..4000 视为子号, + // 其余(含 >4000)为母号(false)。母号必须显式写 false,否则 isVipMotherAccount + // 因字段缺失把它判为非母号,导致 Seedance 无号可调度。 + if isVIP { + quotaMeta["is_sub_account"] = rem > 0 && rem <= 4000 } // Set concurrency + limits based on plan @@ -1314,6 +1323,11 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a } if remaining, ok := data["remaining"].(int); ok { meta["cached_quota_remaining"] = remaining + // 额度打到 0 的 VIP(母号/子号) 视为普号:写死 plan=free,额度恢复(>0)后下次扫描自动还原真实身份。 + if plan != "" && plan != "free" && remaining <= 0 { + plan = "free" + meta["plan"] = "free" + } // is_sub_account 仅在导入时写入,刷新配额时不覆盖 if _, hasFlag := meta["is_sub_account"]; !hasFlag && plan != "free" { meta["is_sub_account"] = remaining > 0 && remaining <= 4000 diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 7fa8cb2..dd4b358 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -1801,6 +1801,20 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode imgRefs = append(imgRefs, r) } } + // Seedance 参考图先做人脸打码再上传;检不出人脸就沿用原图, + // 检测器不可用则直接报错,避免把未打码的人脸传给上游。 + if isSeedanceModel(modelItem.ID) { + for i, r := range imgRefs { + marked, mErr := applyFaceNotice(r) + if errors.Is(mErr, ErrNoFaceDetected) { + continue + } + if mErr != nil { + return nil, "", fmt.Errorf("face mask: %w", mErr) + } + imgRefs[i] = marked + } + } engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID) referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")