更新runway

This commit is contained in:
2026-07-14 22:44:18 +08:00
parent 4a1b91b759
commit 7a781fecfc
3 changed files with 215 additions and 37 deletions
+65 -18
View File
@@ -19,6 +19,7 @@ import (
http "github.com/bogdanfinn/fhttp" http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles" "github.com/bogdanfinn/tls-client/profiles"
"github.com/google/uuid"
) )
const ( const (
@@ -29,8 +30,46 @@ const (
// registration + generation in the reference HAR (Edge 150 on Windows). // 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" 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"` 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. // randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
func randHex(n int) string { func randHex(n int) string {
b := make([]byte, n) b := make([]byte, n)
@@ -49,29 +88,37 @@ func browserHeaders(token, teamID string) http.Header {
traceID := randHex(16) traceID := randHex(16)
spanID := randHex(8) spanID := randHex(8)
h := http.Header{ h := http.Header{
"accept": {"application/json"}, "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"}, "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 ")}, "authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")},
"baggage": {"sentry-environment=production,sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID}, "baggage": {"sentry-environment=production,sentry-release=" + buildHash + ",sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID},
"content-type": {"application/json"}, "content-type": {"application/json"},
"origin": {origin}, "origin": {origin},
"priority": {"u=1, i"}, "priority": {"u=1, i"},
"referer": {origin + "/"}, "referer": {origin + "/"},
"sec-ch-ua": {secChUA}, "sec-ch-ua": {secChUA},
"sec-ch-ua-mobile": {"?0"}, "sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`}, "sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-dest": {"empty"}, "sec-fetch-dest": {"empty"},
"sec-fetch-mode": {"cors"}, "sec-fetch-mode": {"cors"},
"sec-fetch-site": {"same-site"}, "sec-fetch-site": {"same-site"},
"sentry-trace": {traceID + "-" + spanID}, "sentry-trace": {traceID + "-" + spanID},
"user-agent": {userAgent}, "user-agent": {userAgent},
"x-runway-workspace": {teamID}, // 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: { http.HeaderOrderKey: {
"accept", "accept-language", "authorization", "baggage", "accept", "accept-language", "authorization", "baggage",
"content-type", "origin", "priority", "referer", "content-type", "origin", "priority", "referer",
"sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "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) == "" { if strings.TrimSpace(teamID) == "" {
+30 -14
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
) )
// GenerateImage runs a Runway gemini image text/image-to-image pipeline: // 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 refImages []map[string]any
var refAssetIDs []string
for i, raw := range refs { for i, raw := range refs {
if len(raw) == 0 { if len(raw) == 0 {
continue continue
@@ -62,11 +62,23 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
"assetId": assetID, "assetId": assetID,
"url": url, "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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -111,13 +123,19 @@ func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClien
return assetID, refURL, nil return assetID, refURL, nil
} }
// createImageTask creates a gemini image task (workflow_gemini_image for Pro, // createImageTask submits a gemini image task (workflow_gemini_image for Pro,
// gemini_3_1_flash_image for Nano Banana 2) and returns its id. // gemini_3_1_flash_image for Nano Banana 2) INTO the pre-created session and
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID string, refImages []map[string]any) (string, error) { // 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" taskType := "workflow_gemini_image"
opts := map[string]any{ opts := map[string]any{
"name": "Nano Banana Pro - " + prompt, "name": "Nano Banana Pro - " + prompt,
"text_prompt": prompt, "text_prompt": prompt,
"aspect_ratio": aspectRatio,
"num_images": 1, "num_images": 1,
"image_size": imageSize, "image_size": imageSize,
"model": "gemini-3-pro-image-preview", "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" taskType = "gemini_3_1_flash_image"
opts["name"] = "Nano Banana 2 - " + prompt opts["name"] = "Nano Banana 2 - " + prompt
opts["model"] = "gemini-3.1-flash-image-preview" 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 != "" { if assetGroupID != "" {
opts["assetGroupId"] = assetGroupID opts["assetGroupId"] = assetGroupID
} }
if len(refImages) > 0 { if len(refImages) > 0 {
opts["reference_images"] = refImages 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{ res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
"taskType": taskType, "taskType": taskType,
"options": opts, "options": opts,
"asTeamId": jsonNumberOrString(teamID), "asTeamId": jsonNumberOrString(teamID),
"sessionId": uuid.NewString(), "sessionId": sessionID,
}) })
if err != nil { if err != nil {
return "", err return "", err
@@ -156,5 +170,7 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
if id == "" { if id == "" {
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream) 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 return id, nil
} }
+120 -5
View File
@@ -17,7 +17,6 @@ import (
http "github.com/bogdanfinn/fhttp" http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
) )
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size. // 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 { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err 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 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) w, h := ratioDimensions(aspectRatio)
opts := map[string]any{ opts := map[string]any{
"route": "i2v", "route": "i2v",
@@ -218,7 +224,7 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to
"taskType": "gen4_turbo", "taskType": "gen4_turbo",
"options": opts, "options": opts,
"asTeamId": jsonNumberOrString(teamID), "asTeamId": jsonNumberOrString(teamID),
"sessionId": uuid.NewString(), "sessionId": sessionID,
}) })
if err != nil { if err != nil {
return "", err return "", err
@@ -228,6 +234,8 @@ func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, to
if id == "" { if id == "" {
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream) 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 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 // submitTask POSTs a /v1/tasks create. Transient (network / 5xx) failures
// surface as ErrTemporaryUpstream so the pool fails over to the NEXT account // surface as ErrTemporaryUpstream so the pool fails over to the NEXT account
// (换号重试) instead of retrying this one. // (换号重试) 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 = req.WithContext(ctx)
req.Header = browserHeaders(token, teamID) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)