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:
2026-08-08 11:15:42 +08:00
parent 554dea6252
commit ebcf3c5779
16 changed files with 956 additions and 446 deletions
Binary file not shown.
-305
View File
@@ -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 = "参考图人物脸部覆盖的细密网格线仅为隐私打码,不是人物本身的特征:生成时请完全忽略这些网格线,不要在画面中出现任何网格、方格、纹理或遮挡;请依据参考图的五官轮廓完整还原人物真实面孔,保留妆容、眉眼、发型、发饰、耳饰、头冠等一切面部与头部装饰细节,人物面部必须清晰完整、前后镜头保持一致。"
+45 -85
View File
@@ -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
View File
@@ -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