feat(grok): 视频改走 Grok Console 并新增图片模型
- grok 视频/图片统一走 console.x.ai:sso 换 DPoP 短期 token,绕开 grok.com 的 statsig 反爬 - 新增 grok-image:不带参考图走 /v1/images/generations,带图自动走 quality 上游 + /v1/images/edits - grok 额度改本地写死 图5/视频2,成功各扣一次,两份归零直接判死,无恢复时间 - 视频渲染预算统一 20 分钟(adobe/runway/grok/custom) - 删除参考图人脸打码(facemask + onnxruntime 依赖 + 前台开关)
This commit is contained in:
Binary file not shown.
@@ -1,305 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"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()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
|
||||
offset := image.Pt(-src.Bounds().Min.X, -src.Bounds().Min.Y)
|
||||
for _, box := range boxes {
|
||||
r := box.Add(offset).Intersect(dst.Bounds())
|
||||
// 网眼只盖脸中央,留出边缘的发型与轮廓。
|
||||
r = image.Rect(r.Min.X+r.Dx()/8, r.Min.Y+r.Dy()/8, r.Max.X-r.Dx()/8, r.Max.Y-r.Dy()/8)
|
||||
drawStocking(dst, r)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// drawStocking 在给定区域上盖一层黑丝网眼:细密的深色网格线,遮住五官细节但保留轮廓。
|
||||
func drawStocking(dst *image.RGBA, r image.Rectangle) {
|
||||
r = r.Intersect(dst.Bounds())
|
||||
step := r.Dx() / 24
|
||||
if step < 3 {
|
||||
step = 3
|
||||
}
|
||||
line := step / 2
|
||||
if line < 1 {
|
||||
line = 1
|
||||
}
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
if (x-r.Min.X)%step >= line && (y-r.Min.Y)%step >= line {
|
||||
continue
|
||||
}
|
||||
c := dst.RGBAAt(x, y)
|
||||
c.R = uint8(uint32(c.R) * 10 / 100)
|
||||
c.G = uint8(uint32(c.G) * 10 / 100)
|
||||
c.B = uint8(uint32(c.B) * 10 / 100)
|
||||
dst.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// faceMaskPromptNote 附加到 Seedance 提示词后:告知模型参考图脸部的网格线只是打码,
|
||||
// 需要忽略网格本身并完整还原面部细节。
|
||||
const faceMaskPromptNote = "参考图人物脸部覆盖的细密网格线仅为隐私打码,不是人物本身的特征:生成时请完全忽略这些网格线,不要在画面中出现任何网格、方格、纹理或遮挡;请依据参考图的五官轮廓完整还原人物真实面孔,保留妆容、眉眼、发型、发饰、耳饰、头冠等一切面部与头部装饰细节,人物面部必须清晰完整、前后镜头保持一致。"
|
||||
@@ -964,30 +964,13 @@ func (s *TokenService) checkPendingGrok(tokenID, ssoToken string) {
|
||||
} else if strings.TrimSpace(email) != "" {
|
||||
_, _ = s.tokens.Update(ctx, "grok", tokenID, map[string]any{"account_email": strings.TrimSpace(email)})
|
||||
}
|
||||
data, err := s.grok.FetchCreditsBalance(ctx, ssoToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, grok.ErrAuth) {
|
||||
s.finishPending(ctx, "grok", tokenID, "disabled", true, nil)
|
||||
return
|
||||
}
|
||||
s.finishPending(ctx, "grok", tokenID, "active", false, nil)
|
||||
return
|
||||
}
|
||||
quotaMeta := map[string]any{}
|
||||
if rem, ok := data["remaining"].(int); ok {
|
||||
quotaMeta["cached_quota_remaining"] = rem
|
||||
quotaMeta["cached_quota_at"] = int(time.Now().Unix())
|
||||
}
|
||||
if used, ok := data["used"].(int); ok {
|
||||
quotaMeta["cached_quota_used"] = used
|
||||
}
|
||||
if total, ok := data["total"].(int); ok {
|
||||
quotaMeta["cached_quota_total"] = total
|
||||
}
|
||||
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
|
||||
_, _ = s.tokens.Update(ctx, "grok", tokenID, map[string]any{"cached_quota_reset_after": reset})
|
||||
}
|
||||
s.finishPending(ctx, "grok", tokenID, "active", false, quotaMeta)
|
||||
// Console 没有额度接口,所以额度不查上游:导入即写死 图 5 / 视频 2,生成时各扣各的,
|
||||
// 两个都归零就判死;没有恢复时间。
|
||||
s.finishPending(ctx, "grok", tokenID, "active", false, map[string]any{
|
||||
repo.GrokImageQuotaKey: repo.GrokImageQuota,
|
||||
repo.GrokVideoQuotaKey: repo.GrokVideoQuota,
|
||||
"cached_quota_at": int(time.Now().Unix()),
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshGrokLiveness re-validates every live grok account each maintenance tick.
|
||||
@@ -1039,28 +1022,15 @@ func (s *TokenService) RefreshGrokLiveness(ctx context.Context) {
|
||||
_, _ = s.tokens.Update(ctx, "grok", it.ID, map[string]any{"status": "disabled", "dead": true})
|
||||
continue
|
||||
}
|
||||
data, derr := s.grok.FetchCreditsBalance(ctx, it.Value)
|
||||
if derr != nil {
|
||||
// Same policy as the subscription probe: a credits-balance 401/403 is
|
||||
// transient, never a reason to kill a live account. Skip and retry.
|
||||
// 额度不查上游(Console 无额度接口):只给老号补齐写死的 图 5 / 视频 2。
|
||||
if _, ok := jsonMapInt(it.Meta, repo.GrokImageQuotaKey); ok {
|
||||
continue
|
||||
}
|
||||
meta := cloneJSONMap(it.Meta)
|
||||
meta[repo.GrokImageQuotaKey] = repo.GrokImageQuota
|
||||
meta[repo.GrokVideoQuotaKey] = repo.GrokVideoQuota
|
||||
meta["cached_quota_at"] = int(time.Now().Unix())
|
||||
if rem, ok := data["remaining"].(int); ok {
|
||||
meta["cached_quota_remaining"] = rem
|
||||
}
|
||||
if used, ok := data["used"].(int); ok {
|
||||
meta["cached_quota_used"] = used
|
||||
}
|
||||
if total, ok := data["total"].(int); ok {
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
patch := map[string]any{"meta": meta}
|
||||
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
|
||||
patch["cached_quota_reset_after"] = reset
|
||||
}
|
||||
_, _ = s.tokens.Update(ctx, "grok", it.ID, patch)
|
||||
_, _ = s.tokens.Update(ctx, "grok", it.ID, map[string]any{"meta": meta})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,53 +1537,28 @@ func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]a
|
||||
"error": data["error"],
|
||||
}, nil
|
||||
}
|
||||
if poolToType(item.Pool) == "grok" && s.grok != nil {
|
||||
data, err := s.grok.FetchCreditsBalance(ctx, item.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, grok.ErrAuth) {
|
||||
_, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{
|
||||
"status": "disabled",
|
||||
"dead": true,
|
||||
"fails": gorm.Expr("fails + 1"),
|
||||
})
|
||||
}
|
||||
return nil, err
|
||||
if poolToType(item.Pool) == "grok" {
|
||||
// 本地写死的额度(图 5 / 视频 2),没有上游接口可查,所以刷新只是回读本地计数。
|
||||
images, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey)
|
||||
if !ok {
|
||||
images = repo.GrokImageQuota
|
||||
}
|
||||
patch := map[string]any{}
|
||||
meta := cloneJSONMap(item.Meta)
|
||||
meta["cached_quota_at"] = int(time.Now().Unix())
|
||||
if remaining, ok := data["remaining"].(int); ok {
|
||||
// Refresh only updates the displayed credit number; never flips status.
|
||||
// Out-of-credits is judged at generation time (dead/401, no renewal).
|
||||
meta["cached_quota_remaining"] = remaining
|
||||
}
|
||||
if used, ok := data["used"].(int); ok {
|
||||
meta["cached_quota_used"] = used
|
||||
}
|
||||
if total, ok := data["total"].(int); ok {
|
||||
meta["cached_quota_total"] = total
|
||||
}
|
||||
patch["meta"] = meta
|
||||
// Recovery time is the credits' weekly reset (when the grant refills) —
|
||||
// purely informational, NOT a death deadline (liveness is judged by the
|
||||
// subscriptions sweep / real 401s), so it's safe to refresh every time.
|
||||
if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" {
|
||||
patch["cached_quota_reset_after"] = reset
|
||||
item.CachedQuotaResetAfter = reset
|
||||
}
|
||||
if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil {
|
||||
item = updated
|
||||
videos, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey)
|
||||
if !ok {
|
||||
videos = repo.GrokVideoQuota
|
||||
}
|
||||
return map[string]any{
|
||||
"supported": true,
|
||||
"remaining": data["remaining"],
|
||||
"used": data["used"],
|
||||
"total": data["total"],
|
||||
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
|
||||
"quota_cached_at": meta["cached_quota_at"],
|
||||
"unchanged": false,
|
||||
"unknown": boolValueWithDefault(data["unknown"], false),
|
||||
"error": data["error"],
|
||||
"remaining": images + videos,
|
||||
"image_remaining": images,
|
||||
"video_remaining": videos,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"reset_after": nil,
|
||||
"quota_cached_at": item.Meta["cached_quota_at"],
|
||||
"unchanged": true,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining")
|
||||
@@ -1734,6 +1679,19 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
|
||||
if item.Meta != nil {
|
||||
teamID = strings.TrimSpace(stringValue(item.Meta["team_id"]))
|
||||
}
|
||||
// grok 额度是本地写死的两个计数(图 / 视频),前台单独一列展示成 "5/2"。
|
||||
var grokImages, grokVideos any
|
||||
if typeLabel == "grok" {
|
||||
images, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey)
|
||||
if !ok {
|
||||
images = repo.GrokImageQuota
|
||||
}
|
||||
videos, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey)
|
||||
if !ok {
|
||||
videos = repo.GrokVideoQuota
|
||||
}
|
||||
grokImages, grokVideos = images, videos
|
||||
}
|
||||
hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" || typeLabel == "grok"
|
||||
return map[string]any{
|
||||
"id": item.ID,
|
||||
@@ -1742,6 +1700,8 @@ func accountRow(item model.TokenAccount, inFlight int64) map[string]any {
|
||||
"email": emptyToNil(email),
|
||||
"team_id": emptyToNil(teamID),
|
||||
"remaining": valueOrNil(hasQuota && hasRemaining, remaining),
|
||||
"image_remaining": grokImages,
|
||||
"video_remaining": grokVideos,
|
||||
"reset_after": emptyToNil(item.CachedQuotaResetAfter),
|
||||
"quota_cached_at": valueOrNil(quotaAt != 0, quotaAt),
|
||||
"created_at": unixOrNil(item.AddedAt),
|
||||
|
||||
+129
-28
@@ -568,6 +568,24 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
}
|
||||
imageBytes = b
|
||||
upstreamURL = u
|
||||
case "grok":
|
||||
b, u, execErr := s.generateGrokImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||
if execErr != nil {
|
||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||
switch {
|
||||
case errors.Is(execErr, grok.ErrAuth):
|
||||
return nil, ErrProviderAuth
|
||||
case errors.Is(execErr, grok.ErrQuotaExhausted):
|
||||
return nil, ErrProviderQuota
|
||||
case errors.Is(execErr, grok.ErrTemporaryUpstream):
|
||||
return nil, ErrProviderTemporary
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr)
|
||||
}
|
||||
}
|
||||
imageBytes = b
|
||||
upstreamURL = u
|
||||
case "runway":
|
||||
b, u, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||
if execErr != nil {
|
||||
@@ -718,7 +736,7 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
||||
genCtx, cancel := context.WithTimeout(ctx, videoGenBudget)
|
||||
defer cancel()
|
||||
|
||||
// Per-user concurrency gate (画图台 + API key combined); admin tests exempt.
|
||||
@@ -920,7 +938,7 @@ func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal,
|
||||
// runVideoJob renders the clip in the background, capturing the upstream URL
|
||||
// (downloadResult=false → no bytes, no RustFS) and storing it on the event.
|
||||
func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, modelItem *model.ModelConfig, eventID, aspectRatio, resolution, duration string, price float64) {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute)
|
||||
genCtx, cancel := context.WithTimeout(ctx, videoGenBudget)
|
||||
defer cancel()
|
||||
s.inflight.Add(eventID, cancel)
|
||||
defer s.inflight.Done(eventID)
|
||||
@@ -1487,6 +1505,10 @@ func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string
|
||||
return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0)
|
||||
}
|
||||
|
||||
// videoGenBudget caps one video render end-to-end (submit + poll + download).
|
||||
// 上游慢的时候(seedance 长镜头)12 分钟不够,统一给 20 分钟。
|
||||
const videoGenBudget = 20 * time.Minute
|
||||
|
||||
// grokConcurrencyPerAccount is how many simultaneous generations one grok account
|
||||
// may run (grok tolerates 10, unlike the 1-per-account default elsewhere).
|
||||
const grokConcurrencyPerAccount = 10
|
||||
@@ -1801,26 +1823,7 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode
|
||||
imgRefs = append(imgRefs, r)
|
||||
}
|
||||
}
|
||||
// Seedance 参考图先做人脸打码再上传;检不出人脸就沿用原图,
|
||||
// 检测器不可用则直接报错,避免把未打码的人脸传给上游。
|
||||
prompt := in.Prompt
|
||||
if isSeedanceModel(modelItem.ID) {
|
||||
faceMasked := false
|
||||
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
|
||||
faceMasked = true
|
||||
}
|
||||
if faceMasked {
|
||||
prompt = strings.TrimSpace(prompt + "\n\n" + faceMaskPromptNote)
|
||||
}
|
||||
}
|
||||
|
||||
engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID)
|
||||
referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame")
|
||||
@@ -2279,11 +2282,11 @@ func upstreamQuality(resolution string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateGrokVideo runs grok's imagine video pipeline across the grok pool.
|
||||
// Mirrors the runway policy: no pre-deduct, skip accounts known out of credits
|
||||
// (cached remaining <= 0), and treat an out-of-credits / auth failure as a dead
|
||||
// account (the grok sso can't be renewed — 失效就失效). Text-to-video only for
|
||||
// now (grok reference-image upload isn't wired yet).
|
||||
// generateGrokVideo runs grok's imagine video pipeline across the grok pool,
|
||||
// via Grok Console (console.x.ai) — the same sso account, but the clean JSON
|
||||
// media API instead of the anti-bot gated grok.com website flow.
|
||||
// 额度是本地写死的(每号 图 5 / 视频 2):视频计数归零的号不再调度,成功一次扣一个,
|
||||
// 图/视频都归零直接判死;auth / 额度错误同样判死换号(grok sso 不续期,失效就失效)。
|
||||
func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
|
||||
if s.grok == nil {
|
||||
return nil, "", errors.New("grok client not configured")
|
||||
@@ -2309,7 +2312,7 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem <= 0 {
|
||||
if rem, ok := jsonMapInt(item.Meta, repo.GrokVideoQuotaKey); ok && rem <= 0 {
|
||||
continue
|
||||
}
|
||||
active = append(active, item)
|
||||
@@ -2338,13 +2341,15 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
d, meta, genErr := s.grok.GenerateVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
|
||||
d, meta, genErr := s.grok.GenerateConsoleVideo(ctx, token.Value, in.Prompt, aspectRatio, res, durationSeconds, frames, downloadResult)
|
||||
if genErr == nil {
|
||||
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
|
||||
"last_used_at": time.Now(),
|
||||
"success_total": gorm.Expr("success_total + 1"),
|
||||
"fails": 0,
|
||||
})
|
||||
// 本地额度各扣各的;图/视频都归零时账号直接判死。
|
||||
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "video")
|
||||
data = d
|
||||
videoURL = strings.TrimSpace(stringValue(meta["video_url"]))
|
||||
return true, false
|
||||
@@ -2378,6 +2383,102 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// generateGrokImage runs Grok Console's image pipeline (grok-imagine-image)
|
||||
// across the grok pool. 额度策略同视频路径,只是扣的是图片那份计数。带参考图时
|
||||
// (最多 3 张,内联在请求里)自动走 /images/edits 的 quality 上游 — 图生图。
|
||||
func (s *V1Service) generateGrokImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||
// API-key (noStore) requests skip the download and return the upstream URL.
|
||||
urlOnly := noStore
|
||||
if s.grok == nil {
|
||||
return nil, "", errors.New("grok client not configured")
|
||||
}
|
||||
if s.settings != nil {
|
||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||
s.grok.SetProxy(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
items, err := s.tokens.ListByPool(ctx, "grok")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var active []model.TokenAccount
|
||||
for _, item := range items {
|
||||
if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if rem, ok := jsonMapInt(item.Meta, repo.GrokImageQuotaKey); ok && rem <= 0 {
|
||||
continue
|
||||
}
|
||||
active = append(active, item)
|
||||
}
|
||||
active = pinTestAccount(items, active, in.AccountID)
|
||||
if len(active) == 0 {
|
||||
return nil, "", ErrNoProviderAccount
|
||||
}
|
||||
s.rotateRoundRobin("grok", active)
|
||||
|
||||
var lastErr error
|
||||
busy := 0
|
||||
for _, token := range active {
|
||||
// Per-account concurrency gate.
|
||||
if !s.acctAcquire(ctx, token.ID, eventID, accountConcurrency(token)) {
|
||||
busy++
|
||||
continue
|
||||
}
|
||||
var data []byte
|
||||
var artURL string
|
||||
done, failover := func() (bool, bool) {
|
||||
defer s.acctRelease(ctx, token.ID, eventID)
|
||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||
d, meta, genErr := s.grok.GenerateConsoleImage(ctx, token.Value, in.Prompt, aspectRatio, resolution, refs, urlOnly)
|
||||
if genErr == nil {
|
||||
_, _ = s.tokens.Update(ctx, "grok", token.ID, map[string]any{
|
||||
"last_used_at": time.Now(),
|
||||
"success_total": gorm.Expr("success_total + 1"),
|
||||
"fails": 0,
|
||||
})
|
||||
// 本地额度各扣各的;图/视频都归零时账号直接判死。
|
||||
_ = s.tokens.ConsumeGrokQuota(ctx, token.ID, "image")
|
||||
data = d
|
||||
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||
return true, false
|
||||
}
|
||||
lastErr = genErr
|
||||
switch {
|
||||
case errors.Is(genErr, grok.ErrAuth), errors.Is(genErr, grok.ErrQuotaExhausted):
|
||||
// 失效 / 额度没了 → 当 401 判死(不续期),换号。
|
||||
s.markTokenFailure(ctx, "grok", token, "image", true, false)
|
||||
return false, true
|
||||
case errors.Is(genErr, grok.ErrTemporaryUpstream):
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}()
|
||||
if done {
|
||||
return data, artURL, nil
|
||||
}
|
||||
if failover {
|
||||
continue
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
if lastErr == nil {
|
||||
if busy > 0 {
|
||||
return nil, "", ErrConcurrencyFull
|
||||
}
|
||||
lastErr = ErrProviderExecution
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// generateRunwayImage runs the Runway gemini image pipeline (Nano Banana Pro or
|
||||
// Nano Banana 2, selected by the model id) across the runway pool. Unlike the
|
||||
// video path it does NOT pre-deduct credits: it simply round-robins the pool and
|
||||
|
||||
Reference in New Issue
Block a user