diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 542737a..5be054d 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -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, diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index edf3069..241f9f8 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -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) diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index ceb1c93..d315b2b 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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)) diff --git a/frontend/src/components/CustomModelModal.vue b/frontend/src/components/CustomModelModal.vue index 3f17a0e..def3c6b 100644 --- a/frontend/src/components/CustomModelModal.vue +++ b/frontend/src/components/CustomModelModal.vue @@ -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'] diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue index 70139d5..3de8c65 100644 --- a/frontend/src/views/DocsView.vue +++ b/frontend/src/views/DocsView.vue @@ -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)----