Files
image2api/backend/internal/provider/runway/image.go
T
chiyi 33b5fc3b0e fix(runway): retry the /v1/tasks submit on transient errors
A dropped proxy connection (EOF) or 5xx on the task-create failed the whole generation. submitTask now retries transient (ErrTemporaryUpstream) submits up to 3x with backoff, honoring ctx. Applies to both Nano Banana image and Gen-4 video. Validated: 10/10 4K images (19-24MB PNG each) generated across 10 accounts.
2026-07-09 17:06:43 +08:00

145 lines
4.6 KiB
Go

package runway
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"strings"
"time"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
)
// GenerateImage runs the Runway "Nano Banana 2" (gemini_3_1_flash_image)
// text/image-to-image pipeline: upload each reference image (DATASET +
// DATASET_PREVIEW → dataset) to obtain its {assetId, url}, create a gemini image
// task and poll it to completion, then download the rendered PNG. teamID is the
// workspace id; if empty it's derived from the token. aspectRatio is passed
// through as-is (e.g. "16:9"); imageSize is the "1K"/"2K"/"4K" tier. refs may be
// empty (pure text-to-image).
func (c *Client) GenerateImage(ctx context.Context, token, teamID, prompt, aspectRatio, imageSize string, refs [][]byte) ([]byte, map[string]any, error) {
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
if token == "" {
return nil, nil, ErrAuth
}
if teamID == "" {
teamID = TeamIDFromToken(token)
}
if teamID == "" {
return nil, nil, errors.New("runway: no team id")
}
if strings.TrimSpace(aspectRatio) == "" {
aspectRatio = "16:9"
}
if strings.TrimSpace(imageSize) == "" {
imageSize = "1K"
}
// Only the task-create (generate submit) egresses via the proxy; reference
// upload, polling and download run on the local IP.
submitClient, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
directClient, err := c.newDirectTLSClient()
if err != nil {
return nil, nil, err
}
var refImages []map[string]any
for i, raw := range refs {
if len(raw) == 0 {
continue
}
filename := fmt.Sprintf("ref_%s_%d.png", time.Now().UTC().Format("20060102_150405"), i+1)
assetID, url, upErr := c.uploadReference(ctx, directClient, token, teamID, filename, raw)
if upErr != nil {
return nil, nil, upErr
}
refImages = append(refImages, map[string]any{
"tag": fmt.Sprintf("IMG_%d", i+1),
"assetId": assetID,
"url": url,
})
}
taskID, err := c.createImageTask(ctx, submitClient, token, teamID, prompt, aspectRatio, imageSize, refImages)
if err != nil {
return nil, nil, err
}
artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID)
if err != nil {
return nil, nil, err
}
data, err := c.download(ctx, directClient, artifactURL)
if err != nil {
return nil, nil, err
}
meta := map[string]any{
"provider": "runway",
"task_id": taskID,
"team_id": teamID,
"image_url": artifactURL,
}
return data, meta, nil
}
// uploadReference uploads one reference image through the dataset pipeline
// (DATASET_PREVIEW + DATASET uploads → /v1/datasets) and returns its asset id
// (= dataset id) and the cloudfront URL the task references.
func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename string, data []byte) (string, string, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", "", errors.New("runway: failed to decode reference image")
}
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", data)
if err != nil {
return "", "", err
}
// The DATASET upload's completed URL is exactly what the task references.
datasetUploadID, refURL, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", data)
if err != nil {
return "", "", err
}
assetID, _, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
if err != nil {
return "", "", err
}
return assetID, refURL, nil
}
// createImageTask creates a gemini_3_1_flash_image task and returns its id.
func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, aspectRatio, imageSize string, refImages []map[string]any) (string, error) {
opts := map[string]any{
"name": "Nano Banana 2 - " + prompt,
"text_prompt": prompt,
"aspect_ratio": aspectRatio,
"num_images": 1,
"image_size": imageSize,
"model": "gemini-3.1-flash-image-preview",
"exploreMode": false,
"creationSource": "tool-mode",
}
if len(refImages) > 0 {
opts["reference_images"] = refImages
}
res, err := c.submitTask(ctx, client, token, teamID, map[string]any{
"taskType": "gemini_3_1_flash_image",
"options": opts,
"asTeamId": jsonNumberOrString(teamID),
"sessionId": uuid.NewString(),
})
if err != nil {
return "", err
}
task, _ := res["task"].(map[string]any)
id := strings.TrimSpace(stringValue(task["id"]))
if id == "" {
return "", fmt.Errorf("%w: image task missing id", ErrTemporaryUpstream)
}
return id, nil
}