更新尺寸兼容
This commit is contained in:
@@ -401,7 +401,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
||||
"id": "nano-banana-2",
|
||||
"provider": "runway",
|
||||
"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"},
|
||||
"image_to_image": true,
|
||||
"max_reference_images": 6,
|
||||
@@ -562,7 +562,7 @@ func (h *UserGenerationHandler) publicModels() ([]gin.H, error) {
|
||||
"id": "nano-banana-2",
|
||||
"provider": "runway",
|
||||
"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"},
|
||||
"description": "Runway Nano Banana 2",
|
||||
"stub": false,
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
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
|
||||
// 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) {
|
||||
res, err := c.postJSON(ctx, client, token, "/rest/app-chat/upload-file", map[string]any{
|
||||
body := map[string]any{
|
||||
"fileName": "ref.png",
|
||||
"fileMimeType": "image/png",
|
||||
"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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -252,7 +273,9 @@ func (c *Client) doPost(ctx context.Context, client tlsclient.HttpClient, token,
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
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
|
||||
}
|
||||
@@ -297,7 +320,29 @@ func (c *Client) OpenAsset(ctx context.Context, token, url string) (io.ReadClose
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -318,7 +363,7 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, toke
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty video download", ErrTemporaryUpstream)
|
||||
|
||||
@@ -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
|
||||
// `quality` param — size is the single source of truth for 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).
|
||||
// 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
|
||||
@@ -2639,19 +2643,54 @@ func parseImageSize(size, aspectRatio, resolution string) (string, string) {
|
||||
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 {
|
||||
type candidate struct {
|
||||
W 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
|
||||
// /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{
|
||||
{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}, // 竖
|
||||
{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}, // 竖
|
||||
}
|
||||
best := candidates[0]
|
||||
bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H))
|
||||
|
||||
@@ -6,9 +6,9 @@ import SelectMenu from './SelectMenu.vue'
|
||||
|
||||
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).
|
||||
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 VID_RES = ['720p', '1080p', '2K', '4K']
|
||||
const ALL_RES = ['1K', '2K', '4K', '720p', '1080p']
|
||||
|
||||
@@ -70,11 +70,15 @@ const sizeTable = [
|
||||
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
|
||||
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
|
||||
{ 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: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
|
||||
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
|
||||
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
|
||||
{ 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)----
|
||||
|
||||
Reference in New Issue
Block a user