更新尺寸兼容

This commit is contained in:
2026-07-03 10:27:49 +08:00
parent 1f205fc096
commit 53dc178804
5 changed files with 100 additions and 12 deletions
@@ -401,7 +401,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
"id": "nano-banana-2", "id": "nano-banana-2",
"provider": "runway", "provider": "runway",
"type": "image", "type": "image",
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "5:4", "4:5", "2:3"}, "ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"}, "resolutions": []string{"1K", "2K", "4K"},
"image_to_image": true, "image_to_image": true,
"max_reference_images": 6, "max_reference_images": 6,
@@ -562,7 +562,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
"id": "nano-banana-2", "id": "nano-banana-2",
"provider": "runway", "provider": "runway",
"kind": "image", "kind": "image",
"ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "3:2", "5:4", "4:5", "2:3"}, "ratios": []string{"1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"},
"resolutions": []string{"1K", "2K", "4K"}, "resolutions": []string{"1K", "2K", "4K"},
"description": "Runway Nano Banana 2", "description": "Runway Nano Banana 2",
"stub": false, "stub": false,
+49 -4
View File
@@ -4,11 +4,13 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"regexp" "regexp"
"strings" "strings"
"time"
http "github.com/bogdanfinn/fhttp" http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
@@ -166,12 +168,31 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio,
// uploadImage uploads one reference frame via /rest/app-chat/upload-file (JSON // uploadImage uploads one reference frame via /rest/app-chat/upload-file (JSON
// with base64 content) and returns its asset content URL for imageReferences. // with base64 content) and returns its asset content URL for imageReferences.
// Cloudflare's bot score is per-request, so a big base64 upload can hit a
// "Just a moment…" 403 intermittently while identical requests pass — retry
// transient failures with backoff instead of failing the whole task.
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) { func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
res, err := c.postJSON(ctx, client, token, "/rest/app-chat/upload-file", map[string]any{ body := map[string]any{
"fileName": "ref.png", "fileName": "ref.png",
"fileMimeType": "image/png", "fileMimeType": "image/png",
"content": base64.StdEncoding.EncodeToString(img), "content": base64.StdEncoding.EncodeToString(img),
}) }
var res map[string]any
var err error
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
for _, wait := range backoffs {
if wait > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(wait):
}
}
res, err = c.postJSON(ctx, client, token, "/rest/app-chat/upload-file", body)
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
break
}
}
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -252,7 +273,9 @@ func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token,
defer resp.Body.Close() defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body) raw, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, resp.StatusCode, err // Mid-body HTTP/2 stream resets ("stream error: ... INTERNAL_ERROR") are
// transient — surface them as retryable.
return nil, resp.StatusCode, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
} }
return raw, resp.StatusCode, nil return raw, resp.StatusCode, nil
} }
@@ -297,7 +320,29 @@ func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadClose
return resp.Body, ct, nil return resp.Body, ct, nil
} }
// download fetches the rendered artifact. The clip is already generated at this
// point, so a transient failure here (HTTP/2 stream reset, CF hiccup) must not
// fail the whole task — retry with backoff.
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) { func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
var data []byte
var err error
for _, wait := range []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
if wait > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
data, err = c.downloadOnce(ctx, client, token, url)
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
break
}
}
return data, err
}
func (c *Client) downloadOnce(ctx context.Context, client tlsclient.HttpClient, token, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -318,7 +363,7 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, toke
} }
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
} }
if len(data) == 0 { if len(data) == 0 {
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream) return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
+43 -4
View File
@@ -995,6 +995,10 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
// an explicit resolution; the OpenAI /v1 path derives it from size. There is no // an explicit resolution; the OpenAI /v1 path derives it from size. There is no
// `quality` param — size is the single source of truth for resolution. // `quality` param — size is the single source of truth for resolution.
aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution) aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution)
// Snap to the nearest ratio the model actually supports — a `size`-derived
// ratio (e.g. 1:3) must never be passed through to an upstream that rejects
// it (Runway 400s on ratios outside its list).
aspectRatio = snapRatio(aspectRatio, repo.JSONStrings(modelItem.Ratios))
// parseImageSize defaults a blank resolution to "2K" (OpenAI-size parity). // parseImageSize defaults a blank resolution to "2K" (OpenAI-size parity).
// For a model that doesn't price that tier — e.g. gpt-image-2 is 1K-only — // For a model that doesn't price that tier — e.g. gpt-image-2 is 1K-only —
// fall back to its first supported tier so a missing/stale resolution from // fall back to its first supported tier so a missing/stale resolution from
@@ -2639,19 +2643,54 @@ func parseImageSize(size, aspectRatio, resolution string) (string, string) {
return ar, rs return ar, rs
} }
// snapRatio returns the entry in supported closest in value to ar ("W:H").
// ar is returned as-is when it's already supported, unparsable, or the model
// has no ratio list.
func snapRatio(ar string, supported []string) string {
parse := func(s string) (float64, bool) {
var w, h int
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%d:%d", &w, &h); err != nil || w <= 0 || h <= 0 {
return 0, false
}
return float64(w) / float64(h), true
}
v, ok := parse(ar)
if !ok || len(supported) == 0 {
return ar
}
best, bestDelta := "", 0.0
for _, s := range supported {
if strings.TrimSpace(strings.ReplaceAll(s, "x", ":")) == ar {
return ar
}
sv, sok := parse(strings.ReplaceAll(s, "x", ":"))
if !sok {
continue
}
if d := absFloat(v - sv); best == "" || d < bestDelta {
best, bestDelta = strings.TrimSpace(strings.ReplaceAll(s, "x", ":")), d
}
}
if best == "" {
return ar
}
return best
}
func guessRatio(w, h int) string { func guessRatio(w, h int) string {
type candidate struct { type candidate struct {
W int W int
H int H int
} }
// The 13 ratios actually used across our models. Must stay in sync with the // The 17 ratios actually used across our models. Must stay in sync with the
// custom-model picker (CustomModelModal RATIO_OPTS) and the docs 对照表, so a // custom-model picker (CustomModelModal RATIO_OPTS) and the docs 对照表, so a
// /v1 `size` maps to exactly one of them. 9:21 is intentionally absent — // /v1 `size` maps to exactly one of them. 9:21 is intentionally absent —
// no image provider accepts it (Runway 400s on it). // no image provider accepts it (Runway 400s on it). snapRatio then clamps
// the guess to the target model's own supported list.
candidates := []candidate{ candidates := []candidate{
{1, 1}, {1, 1},
{5, 4}, {4, 3}, {3, 2}, {16, 9}, {2, 1}, {21, 9}, {3, 1}, // 横 {5, 4}, {4, 3}, {3, 2}, {16, 9}, {2, 1}, {21, 9}, {3, 1}, {4, 1}, {8, 1}, // 横
{4, 5}, {3, 4}, {2, 3}, {9, 16}, {1, 3}, // 竖 {4, 5}, {3, 4}, {2, 3}, {9, 16}, {1, 3}, {1, 4}, {1, 8}, // 竖
} }
best := candidates[0] best := candidates[0]
bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H)) bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H))
+2 -2
View File
@@ -6,9 +6,9 @@ import SelectMenu from './SelectMenu.vue'
const emit = defineEmits(['close', 'saved']) const emit = defineEmits(['close', 'saved'])
// 13 ratios — the union of what our models actually support; kept in sync with // 17 ratios — the union of what our models actually support; kept in sync with
// the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it). // the backend guessRatio() and the docs 对照表. 9:21 removed (no image provider accepts it).
const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:5', '3:4', '2:3', '9:16', '1:3'] const RATIO_OPTS = ['1:1', '5:4', '4:3', '3:2', '16:9', '2:1', '21:9', '3:1', '4:1', '8:1', '4:5', '3:4', '2:3', '9:16', '1:3', '1:4', '1:8']
const IMG_RES = ['1K', '2K', '4K'] const IMG_RES = ['1K', '2K', '4K']
const VID_RES = ['720p', '1080p', '2K', '4K'] const VID_RES = ['720p', '1080p', '2K', '4K']
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p'] const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
+4
View File
@@ -70,11 +70,15 @@ const sizeTable = [
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' }, { ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' }, { ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' }, { ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
{ ratio: '4:1 · 超宽', k1: '1728x432', k2: '2880x720', k4: '4096x1024' },
{ ratio: '8:1 · 超宽', k1: '1728x216', k2: '2880x360', k4: '4096x512' },
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' }, { ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' }, { ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' }, { ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' }, { ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' }, { ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
{ ratio: '1:4 · 竖', k1: '432x1728', k2: '720x2880', k4: '1024x4096' },
{ ratio: '1:8 · 竖', k1: '216x1728', k2: '360x2880', k4: '512x4096' },
] ]
// ---- 视频 size → 比例 × 分辨率(720p / 1080p)---- // ---- 视频 size → 比例 × 分辨率(720p / 1080p)----