更新runway 2api风控
This commit is contained in:
@@ -65,6 +65,13 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio,
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-heal the x-statsig-id anti-bot challenge for THIS session before the
|
||||||
|
// gated submit. Without it the header falls back to the static defaults,
|
||||||
|
// which go stale on a new grok web build and make conversations/new answer
|
||||||
|
// 403 (anti-bot). Fetch via the proxy client so the derived seed matches the
|
||||||
|
// submit's egress IP. Failure is non-fatal (statsigID then uses defaults).
|
||||||
|
c.ensureChallenge(ctx, submitClient, token)
|
||||||
|
|
||||||
// Image-to-video: upload each reference frame and collect its asset URL.
|
// Image-to-video: upload each reference frame and collect its asset URL.
|
||||||
var imageRefs []string
|
var imageRefs []string
|
||||||
for _, f := range frames {
|
for _, f := range frames {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ package runway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
crand "crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,8 +24,81 @@ import (
|
|||||||
const (
|
const (
|
||||||
apiBase = "https://api.runwayml.com"
|
apiBase = "https://api.runwayml.com"
|
||||||
origin = "https://app.runwayml.com"
|
origin = "https://app.runwayml.com"
|
||||||
|
|
||||||
|
// userAgent / secChUA mirror the real browser that produced a successful
|
||||||
|
// 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"`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids.
|
||||||
|
func randHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := crand.Read(b); err != nil {
|
||||||
|
return strings.Repeat("0", n*2)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserHeaders builds the full Chrome/Edge header set the Runway web app sends
|
||||||
|
// on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does
|
||||||
|
// NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without
|
||||||
|
// them a request looks like a headless bot and Runway's anti-abuse revokes the
|
||||||
|
// account's free credits within minutes ("秒死"). teamID is optional.
|
||||||
|
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},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(teamID) == "" {
|
||||||
|
h.Del("x-runway-workspace")
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserAssetHeaders is the lighter header set the browser sends on cross-site
|
||||||
|
// asset transfers (presigned S3 upload / CloudFront artifact download): a
|
||||||
|
// User-Agent and client hints, but no Runway authorization.
|
||||||
|
func browserAssetHeaders() http.Header {
|
||||||
|
return http.Header{
|
||||||
|
"accept": {"*/*"},
|
||||||
|
"accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"},
|
||||||
|
"origin": {origin},
|
||||||
|
"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": {"cross-site"},
|
||||||
|
"user-agent": {userAgent},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAuth = errors.New("runway auth failed")
|
ErrAuth = errors.New("runway auth failed")
|
||||||
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
||||||
@@ -112,22 +187,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
req.Header = http.Header{
|
req.Header = browserHeaders(token, teamID)
|
||||||
"accept": {"application/json"},
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"origin": {origin},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"authorization": {"Bearer " + token},
|
|
||||||
"x-runway-workspace": {teamID},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept",
|
|
||||||
"content-type",
|
|
||||||
"origin",
|
|
||||||
"referer",
|
|
||||||
"authorization",
|
|
||||||
"x-runway-workspace",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, refImages)
|
assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort
|
||||||
|
|
||||||
|
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID, refImages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClien
|
|||||||
|
|
||||||
// createImageTask creates a gemini image task (workflow_gemini_image for Pro,
|
// createImageTask creates 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) and returns its id.
|
||||||
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize string, refImages []map[string]any) (string, error) {
|
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID string, refImages []map[string]any) (string, error) {
|
||||||
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,
|
||||||
@@ -131,6 +133,12 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
|
|||||||
}
|
}
|
||||||
opts["aspect_ratio"] = aspectRatio
|
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 {
|
if len(refImages) > 0 {
|
||||||
opts["reference_images"] = refImages
|
opts["reference_images"] = refImages
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,39 +70,37 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec
|
|||||||
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the task-create (generate submit) egresses via the proxy; first-frame
|
// Use a single client (and therefore a single exit IP) for the whole flow.
|
||||||
// upload, polling and download run on the local IP.
|
// The web app runs upload, task-create, polling and download from one IP; a
|
||||||
submitClient, err := c.newTLSClient()
|
// mid-flow IP switch (proxy for submit, local for the rest) is a strong
|
||||||
if err != nil {
|
// bot/risk signal, so we route everything through the proxy-aware client.
|
||||||
return nil, nil, err
|
apiClient, err := c.newTLSClient()
|
||||||
}
|
|
||||||
directClient, err := c.newDirectTLSClient()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
||||||
previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame)
|
previewUploadID, _, err := c.uploadFile(ctx, apiClient, token, teamID, filename, "DATASET_PREVIEW", frame)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame)
|
datasetUploadID, _, err := c.uploadFile(ctx, apiClient, token, teamID, filename, "DATASET", frame)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
assetID, imageURL, err := c.createDataset(ctx, apiClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort
|
assetGroupID, _ := c.assetGroupID(ctx, apiClient, token, teamID) // best-effort
|
||||||
|
|
||||||
taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds)
|
taskID, err := c.createTask(ctx, apiClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
|
artifactURL, err := c.pollTask(ctx, apiClient, token, teamID, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -115,7 +113,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec
|
|||||||
if !downloadResult {
|
if !downloadResult {
|
||||||
return nil, meta, nil
|
return nil, meta, nil
|
||||||
}
|
}
|
||||||
data, err := c.download(ctx, directClient, artifactURL)
|
data, err := c.download(ctx, apiClient, artifactURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -291,17 +289,7 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
req.Header = http.Header{
|
req.Header = browserHeaders(token, teamID)
|
||||||
"accept": {"application/json"},
|
|
||||||
"content-type": {"application/json"},
|
|
||||||
"origin": {origin},
|
|
||||||
"referer": {origin + "/"},
|
|
||||||
"authorization": {"Bearer " + token},
|
|
||||||
"x-runway-workspace": {teamID},
|
|
||||||
http.HeaderOrderKey: {
|
|
||||||
"accept", "content-type", "origin", "referer", "authorization", "x-runway-workspace",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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)
|
||||||
@@ -343,7 +331,8 @@ func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
req.Header = http.Header{"content-type": {contentType}}
|
req.Header = browserAssetHeaders()
|
||||||
|
req.Header.Set("content-type", contentType)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||||
@@ -362,6 +351,10 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
|
req.Header = browserAssetHeaders()
|
||||||
|
req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||||
|
req.Header.Set("sec-fetch-dest", "image")
|
||||||
|
req.Header.Set("sec-fetch-mode", "no-cors")
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user