更新runway
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user