更新尺寸兼容
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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)----
|
||||||
|
|||||||
Reference in New Issue
Block a user