diff --git a/backend/internal/provider/runway/client.go b/backend/internal/provider/runway/client.go index b0dac27..df23a7f 100644 --- a/backend/internal/provider/runway/client.go +++ b/backend/internal/provider/runway/client.go @@ -19,6 +19,7 @@ import ( http "github.com/bogdanfinn/fhttp" tlsclient "github.com/bogdanfinn/tls-client" "github.com/bogdanfinn/tls-client/profiles" + "github.com/google/uuid" ) const ( @@ -29,8 +30,46 @@ const ( // registration + generation in the reference HAR (Edge 150 on Windows). userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0" secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"` + + // sourceApp / buildHash are the x-runway-source-application[-version] the real + // web app stamps on EVERY authed API call (319/336 requests in the reference + // HAR). buildHash is the web bundle's git sha; it also appears as the + // `sentry-release` segment of the baggage header. Runway anti-abuse keys on + // these being present + client-id (see clientIDFromToken): a write/upload that + // lacks them is judged a non-web client and the account's free credits are + // zeroed on the FIRST upload ("上传清零积分"). buildHash tracks Runway's web + // releases — refresh it from a current HAR if uploads start getting flagged. + sourceApp = "web" + buildHash = "3e96b2f0f85b8c0cafb7c0dcd7a6878305aaa0f0" ) +// clientIDOverride, when non-empty, forces the x-runway-client-id (used to pin an +// account to the exact persistent device id its real browser session used). +var clientIDOverride string + +// clientIDUUIDNamespace is a fixed namespace so clientIDFromToken is stable +// across processes/restarts — the same account always derives the same +// x-runway-client-id, exactly like the browser's localStorage-persisted id. +var clientIDUUIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8") + +// clientIDFromToken derives the persistent per-account x-runway-client-id. The +// real SPA generates this UUID once and stores it in localStorage; every request +// from that browser reuses the SAME value (5b89c786-… appears on all 319 authed +// calls in the HAR). We reproduce that stability by deterministically deriving a +// v5 UUID from the account's JWT "id" claim, so one account == one client-id for +// its whole lifetime without needing a DB column. Falls back to the raw token if +// the id claim is missing. +func clientIDFromToken(token string) string { + if clientIDOverride != "" { + return clientIDOverride + } + seed := TeamIDFromToken(token) + if seed == "" { + seed = strings.TrimPrefix(token, "Bearer ") + } + return uuid.NewSHA1(clientIDUUIDNamespace, []byte("runway-client-id:"+seed)).String() +} + // randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids. func randHex(n int) string { b := make([]byte, n) @@ -49,29 +88,37 @@ func browserHeaders(token, teamID string) http.Header { traceID := randHex(16) spanID := randHex(8) h := http.Header{ - "accept": {"application/json"}, - "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, - "authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")}, - "baggage": {"sentry-environment=production,sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID}, - "content-type": {"application/json"}, - "origin": {origin}, - "priority": {"u=1, i"}, - "referer": {origin + "/"}, - "sec-ch-ua": {secChUA}, - "sec-ch-ua-mobile": {"?0"}, - "sec-ch-ua-platform": {`"Windows"`}, - "sec-fetch-dest": {"empty"}, - "sec-fetch-mode": {"cors"}, - "sec-fetch-site": {"same-site"}, - "sentry-trace": {traceID + "-" + spanID}, - "user-agent": {userAgent}, - "x-runway-workspace": {teamID}, + "accept": {"application/json"}, + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, + "authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")}, + "baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID}, + "content-type": {"application/json"}, + "origin": {origin}, + "priority": {"u=1, i"}, + "referer": {origin + "/"}, + "sec-ch-ua": {secChUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-site"}, + "sentry-trace": {traceID + "-" + spanID}, + "user-agent": {userAgent}, + // x-runway-* identify this as the real web app to Runway's anti-abuse. + // client-id must be persistent per account (see clientIDFromToken); + // omitting these is what zeroes an account's credits on first upload. + "x-runway-client-id": {clientIDFromToken(token)}, + "x-runway-source-application": {sourceApp}, + "x-runway-source-application-version": {buildHash}, + "x-runway-workspace": {teamID}, http.HeaderOrderKey: { "accept", "accept-language", "authorization", "baggage", "content-type", "origin", "priority", "referer", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", - "sentry-trace", "user-agent", "x-runway-workspace", + "sentry-trace", "user-agent", + "x-runway-client-id", "x-runway-source-application", + "x-runway-source-application-version", "x-runway-workspace", }, } if strings.TrimSpace(teamID) == "" { diff --git a/backend/internal/provider/runway/image.go b/backend/internal/provider/runway/image.go index 4a24863..8f15347 100644 --- a/backend/internal/provider/runway/image.go +++ b/backend/internal/provider/runway/image.go @@ -10,7 +10,6 @@ import ( "time" tlsclient "github.com/bogdanfinn/tls-client" - "github.com/google/uuid" ) // GenerateImage runs a Runway gemini image text/image-to-image pipeline: @@ -48,6 +47,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom } var refImages []map[string]any + var refAssetIDs []string for i, raw := range refs { if len(raw) == 0 { continue @@ -62,11 +62,23 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom "assetId": assetID, "url": url, }) + refAssetIDs = append(refAssetIDs, assetID) } - assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort + // Browser order: create the real session FIRST, attach the references and its + // own asset group, THEN submit the task into it (see createSession). The task + // carries this session's assetGroupId, NOT the account-wide "Generations" + // group. + sessionID, err := c.createSession(ctx, submitClient, token, teamID) + if err != nil { + return nil, nil, err + } + for _, aid := range refAssetIDs { + c.attachReference(ctx, submitClient, token, teamID, sessionID, aid) + } + assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort - taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID, refImages) + taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID, refImages) if err != nil { return nil, nil, err } @@ -111,13 +123,19 @@ func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClien return assetID, refURL, nil } -// createImageTask creates a gemini image task (workflow_gemini_image for Pro, -// gemini_3_1_flash_image for Nano Banana 2) and returns its id. -func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID string, refImages []map[string]any) (string, error) { +// createImageTask submits a gemini image task (workflow_gemini_image for Pro, +// gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and +// returns its id. sessionID must be a real server-side session (see +// createSession) and assetGroupID must be that session's own group. +func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, sessionID, assetGroupID string, refImages []map[string]any) (string, error) { + if strings.TrimSpace(aspectRatio) == "" { + aspectRatio = "16:9" + } taskType := "workflow_gemini_image" opts := map[string]any{ "name": "Nano Banana Pro - " + prompt, "text_prompt": prompt, + "aspect_ratio": aspectRatio, "num_images": 1, "image_size": imageSize, "model": "gemini-3-pro-image-preview", @@ -128,25 +146,21 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien taskType = "gemini_3_1_flash_image" opts["name"] = "Nano Banana 2 - " + prompt opts["model"] = "gemini-3.1-flash-image-preview" - if strings.TrimSpace(aspectRatio) == "" { - aspectRatio = "16:9" - } - opts["aspect_ratio"] = aspectRatio } - // assetGroupId is present on every real browser task submit; omitting it is a - // bot tell. Best-effort — only attach when we resolved the "Generations" - // group for this workspace. if assetGroupID != "" { opts["assetGroupId"] = assetGroupID } if len(refImages) > 0 { opts["reference_images"] = refImages } + // Pre-flight cost estimate, exactly as the web app does before every spend. + c.estimateCost(ctx, client, token, teamID, "gemini_image", opts) + res, err := c.submitTask(ctx, client, token, teamID, map[string]any{ "taskType": taskType, "options": opts, "asTeamId": jsonNumberOrString(teamID), - "sessionId": uuid.NewString(), + "sessionId": sessionID, }) if err != nil { return "", err @@ -156,5 +170,7 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien if id == "" { return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream) } + // Post-submit: generation record + session play (session already exists). + c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts) return id, nil } diff --git a/backend/internal/provider/runway/video.go b/backend/internal/provider/runway/video.go index 93df90b..c617c65 100644 --- a/backend/internal/provider/runway/video.go +++ b/backend/internal/provider/runway/video.go @@ -17,7 +17,6 @@ import ( http "github.com/bogdanfinn/fhttp" tlsclient "github.com/bogdanfinn/tls-client" - "github.com/google/uuid" ) // ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size. @@ -96,9 +95,16 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec if err != nil { return nil, nil, err } - assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort + // Browser order: real session first, attach the first-frame asset + its own + // asset group, THEN submit the task into it (see createSession). + sessionID, err := c.createSession(ctx, submitClient, token, teamID) + if err != nil { + return nil, nil, err + } + c.attachReference(ctx, submitClient, token, teamID, sessionID, assetID) + assetGroupID, _ := c.sessionAssetGroup(ctx, submitClient, token, teamID, sessionID) // best-effort - taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds) + taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio, seconds) if err != nil { return nil, nil, err } @@ -195,7 +201,7 @@ func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, return strings.TrimSpace(stringValue(ag["id"])), nil } -func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio string, seconds int) (string, error) { +func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, sessionID, aspectRatio string, seconds int) (string, error) { w, h := ratioDimensions(aspectRatio) opts := map[string]any{ "route": "i2v", @@ -218,7 +224,7 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to "taskType": "gen4_turbo", "options": opts, "asTeamId": jsonNumberOrString(teamID), - "sessionId": uuid.NewString(), + "sessionId": sessionID, }) if err != nil { return "", err @@ -228,6 +234,8 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to if id == "" { return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream) } + // Post-submit: generation record + session play (session already exists). + c.recordGeneration(ctx, client, token, teamID, id, sessionID, prompt, opts) return id, nil } @@ -272,6 +280,98 @@ func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, toke } } +// estimateCost fires the /v1/billing/estimate_feature_cost_credits pre-flight the +// web app ALWAYS does before a spend (52 times in the reference HAR, always with +// the exact taskOptions right before /v1/tasks). It returns no reservation token, +// so its purpose is purely to mark the spend as coming from the real UI flow; +// submitting a task with no preceding estimate for that spec is a scripted-abuse +// tell. Best-effort — the estimate itself costs nothing. +func (c *Client) estimateCost(ctx context.Context, client tlsclient.HttpClient, token, teamID, feature string, taskOptions map[string]any) { + _, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/billing/estimate_feature_cost_credits", map[string]any{ + "feature": feature, + "count": 1, + "asTeamId": jsonNumberOrString(teamID), + "taskOptions": taskOptions, + }) +} + +// createSession creates an EMPTY tool-mode session (POST /v1/sessions with an +// empty taskIds array) and returns its real server-side id. This is the crux of +// the whole flow: the browser creates the session BEFORE submitting the first +// task, then submits the task INTO that real session. Submitting /v1/tasks with a +// sessionId that was never created server-side is a forgery tell that makes +// Runway revoke the account's free credits (permitted numPlanCredits 500 -> 0) +// the instant the task lands ("提交生图就清零"). Note taskIds must be [] — passing +// a bogus id yields "Invalid task IDs". +func (c *Client) createSession(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) { + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions", map[string]any{ + "asTeamId": jsonNumberOrString(teamID), + "taskIds": []string{}, + }) + if err != nil { + return "", err + } + sess, _ := res["session"].(map[string]any) + id := strings.TrimSpace(stringValue(sess["id"])) + if id == "" { + return "", fmt.Errorf("%w: session missing id", ErrTemporaryUpstream) + } + return id, nil +} + +// attachReference attaches an uploaded asset to the session (the browser does +// this before the task, so the task's reference_images belong to a real session). +func (c *Client) attachReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID, assetID string) { + if strings.TrimSpace(assetID) == "" { + return + } + _, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, + "/v1/sessions/"+sessionID+"/references", map[string]any{ + "assetId": assetID, + "asTeamId": jsonNumberOrString(teamID), + }) +} + +// sessionAssetGroup creates the session's OWN asset group (POST +// /v1/sessions/{id}/assetGroup) and returns its id — this, NOT the account-wide +// "Generations" group, is the assetGroupId the browser puts on the task. +func (c *Client) sessionAssetGroup(ctx context.Context, client tlsclient.HttpClient, token, teamID, sessionID string) (string, error) { + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, + "/v1/sessions/"+sessionID+"/assetGroup", map[string]any{"asTeamId": jsonNumberOrString(teamID)}) + if err != nil { + return "", err + } + ag, _ := res["assetGroup"].(map[string]any) + return strings.TrimSpace(stringValue(ag["id"])), nil +} + +// recordGeneration is the post-submit lifecycle the browser fires AFTER the task +// lands: the generation record (POST /v1/generations, recordingEnabled=true) and +// a session /play. The session itself already exists (createSession ran before +// the task), so this no longer creates it. Best-effort. +func (c *Client) recordGeneration(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID, sessionID, prompt string, taskOptions map[string]any) { + settings := map[string]any{"taskId": taskID, "recordingEnabled": true} + for k, v := range taskOptions { + if k == "exploreMode" { // not present on the browser's generation record + continue + } + settings[k] = v + } + // The generation record carries the model id WITHOUT the "-preview" suffix + // the task uses (gemini-3-pro-image-preview -> gemini-3-pro-image in the HAR). + if m, ok := settings["model"].(string); ok { + settings["model"] = strings.TrimSuffix(m, "-preview") + } + _, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/generations", map[string]any{ + "toolId": "generate", + "prompt": prompt, + "outputs": map[string]any{"outputUrls": []any{}}, + "settings": settings, + }) + _, _ = c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/sessions/"+sessionID+"/play", + map[string]any{"asTeamId": jsonNumberOrString(teamID), "taskId": taskID}) +} + // submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures // surface as ErrTemporaryUpstream so the pool fails over to the NEXT account // (换号重试) instead of retrying this one. @@ -293,6 +393,21 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token } req = req.WithContext(ctx) req.Header = browserHeaders(token, teamID) + // The generate-submit endpoint is the ONE authed call the real web bundle + // sends WITHOUT x-runway-client-id / source-application[-version] — every + // other endpoint carries them, /v1/tasks carries only x-runway-workspace + // (verified across the whole reference HAR). Leaving them on /v1/tasks is a + // bot tell that gets the account's free credits revoked (permitted + // numPlanCredits 500 -> 0) the instant a task is submitted. Strip them here so + // the submit matches the browser exactly. + if method == http.MethodPost && path == "/v1/tasks" { + // NOTE: raw map delete, NOT req.Header.Del() — fhttp canonicalizes the key + // in Del() ("X-Runway-Client-Id") but our headers are stored lowercase (so + // they go on the h2 wire lowercase like Chrome), so Del() silently no-ops. + delete(req.Header, "x-runway-client-id") + delete(req.Header, "x-runway-source-application") + delete(req.Header, "x-runway-source-application-version") + } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)