From 678d213f4001f4d4e05f4a1e971393847597a006 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 25 Aug 2026 14:06:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=94=9F=E5=9B=BE5.0?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + app/globals.css | 930 ++++++++++++++++++ backend/internal/billing/defaults.go | 14 + backend/internal/billing/defaults_test.go | 1 + backend/internal/billing/ledger.go | 5 +- backend/internal/billing/seedream.go | 30 + backend/internal/billing/service.go | 11 + backend/internal/billing/service_test.go | 24 + backend/internal/httpapi/jobs.go | 12 + backend/internal/httpapi/jobs_test.go | 20 +- backend/internal/httpapi/openapi.go | 16 +- backend/internal/jobs/provider.go | 65 +- backend/internal/jobs/provider_test.go | 47 + backend/internal/orchestration/creation.go | 83 +- .../orchestration/orchestration_test.go | 69 +- backend/internal/orchestration/settlement.go | 134 ++- .../internal/orchestration/settlement_test.go | 22 + backend/internal/postgres/generation_state.go | 8 + .../postgres/generation_state_test.go | 28 + backend/internal/providers/providers_test.go | 25 + backend/internal/providers/seedream.go | 13 +- components/create-studio.tsx | 882 +++++++++++++++-- components/seedream-workspace.tsx | 422 ++++++++ lib/client/asset-urls.ts | 10 + lib/client/seedream-layer-compose.ts | 84 ++ lib/client/seedream-markup.ts | 56 ++ lib/client/zip-download.ts | 121 +++ lib/seedream/creation.ts | 135 +++ tests/private-asset-urls.test.ts | 4 +- tests/seedream-creation.test.ts | 63 ++ tests/seedream-layer-compose.test.ts | 49 + 31 files changed, 3281 insertions(+), 107 deletions(-) create mode 100644 components/seedream-workspace.tsx create mode 100644 lib/client/seedream-layer-compose.ts create mode 100644 lib/client/seedream-markup.ts create mode 100644 lib/client/zip-download.ts create mode 100644 lib/seedream/creation.ts create mode 100644 tests/seedream-creation.test.ts create mode 100644 tests/seedream-layer-compose.test.ts diff --git a/.env.example b/.env.example index fa417e2..cbd796a 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ EVOLINK_IMAGE_QUALITY=medium # Volcengine Ark (shared by Seedance video and Seedream 5.0 Pro image generation) SEEDANCE_API_KEY= SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +# Seedream 5.0 Pro is synchronous; interactive edits and layer decomposition +# can take longer than the 30-second application default. +ZHINIAN_PROVIDER_TIMEOUT_MS=180000 +ZHINIAN_PROVIDER_MAX_RESPONSE_BYTES=2097152 # 默认模型;创作页仍可逐任务选择 Seedance 2.0 或 2.5。 SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_RATIO=9:16 @@ -94,3 +98,4 @@ ALI_OSS_ACCESS_KEY_ID= ALI_OSS_ACCESS_KEY_SECRET= ALI_OSS_PREFIX=zhinian ALI_OSS_PUBLIC_BASE_URL= +ZHINIAN_OSS_TIMEOUT_MS=120000 diff --git a/app/globals.css b/app/globals.css index 28a6bf6..a0c6213 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4209,6 +4209,936 @@ h3 { } } +/* Seedream 5.0 Pro creation modes */ +.seedream-creation-mode-bar { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + flex: 0 0 auto; + margin-bottom: 12px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 12px; + background: #edf2f4; +} + +.seedream-creation-mode-bar button { + min-width: 0; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted-strong); + font-size: 12px; + font-weight: 850; +} + +.seedream-creation-mode-bar button:hover { + color: var(--ink); + background: rgba(255, 255, 255, 0.56); +} + +.seedream-creation-mode-bar button.active { + color: var(--green-dark); + background: #ffffff; + box-shadow: var(--shadow-soft); +} + +.seedream-workspace { + min-width: 0; + min-height: 0; + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; + padding: 12px; + overflow: auto; + border: 1px solid #c8d9d5; + border-radius: 12px; + background: #f6faf9; +} + +.seedream-workspace-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.seedream-workspace-head > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.seedream-workspace-head strong { + color: var(--ink); + font-size: 13px; +} + +.seedream-workspace-head span:not(.seedream-model-pill) { + color: var(--muted); + font-size: 11px; + font-weight: 700; + line-height: 1.45; +} + +.seedream-model-pill { + flex: 0 0 auto; + padding: 5px 8px; + border-radius: 999px; + background: #dff2e9; + color: var(--green-dark); + font-size: 10px; + font-weight: 900; +} + +.seedream-source-empty { + min-height: 210px; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + padding: 24px; + border: 1px dashed #b9ccc7; + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); + text-align: center; +} + +.seedream-source-empty-icon { + width: 44px; + height: 44px; + display: grid; + place-items: center; + border-radius: 12px; + background: #e7f2ef; + color: var(--green-dark); +} + +.seedream-source-empty > strong { + font-size: 14px; +} + +.seedream-source-empty > span:not(.seedream-source-empty-icon) { + max-width: 38ch; + color: var(--muted); + font-size: 12px; +} + +.seedream-source-empty .button, +.seedream-editor-toolbar label { + cursor: pointer; +} + +.seedream-source-empty input, +.seedream-editor-toolbar input { + display: none; +} + +.seedream-editor-shell { + min-height: 0; + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; +} + +.seedream-editor-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + min-height: 38px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 9px; + background: #ffffff; +} + +.seedream-current-image-badge { + min-width: 38px; + min-height: 28px; + display: inline-grid; + place-items: center; + padding: 0 7px; + border-radius: 7px; + background: #163c35; + color: #ffffff; + font-size: 10px; + font-weight: 900; +} + +.seedream-editor-toolbar button, +.seedream-toolbar-upload { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--muted-strong); + font-size: 11px; + font-weight: 850; +} + +.seedream-editor-toolbar button:hover:not(:disabled), +.seedream-toolbar-upload:hover, +.seedream-editor-toolbar button.active { + border-color: #c8d9d5; + background: #edf7f4; + color: var(--green-dark); +} + +.seedream-editor-toolbar button.danger:hover:not(:disabled) { + border-color: #efc7c1; + background: #fff3f1; + color: var(--danger); +} + +.seedream-editor-toolbar button:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.seedream-editor-toolbar-divider { + width: 1px; + height: 20px; + margin: 0 2px; + background: var(--line); +} + +.seedream-toolbar-upload { + margin-left: auto; +} + +.seedream-canvas-viewport { + min-height: 220px; + flex: 1 1 auto; + display: grid; + place-items: center; + overflow: auto; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background-color: #e8eeee; + background-image: + linear-gradient(45deg, rgba(106, 126, 123, 0.08) 25%, transparent 25%), + linear-gradient(-45deg, rgba(106, 126, 123, 0.08) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(106, 126, 123, 0.08) 75%), + linear-gradient(-45deg, transparent 75%, rgba(106, 126, 123, 0.08) 75%); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; +} + +.seedream-canvas-stage { + position: relative; + width: fit-content; + max-width: 100%; + line-height: 0; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.13); +} + +.seedream-canvas-stage > img { + width: auto; + max-width: 100%; + max-height: 390px; + display: block; + user-select: none; +} + +.seedream-annotation-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +.seedream-annotation-canvas.tool-box { + cursor: crosshair; +} + +.seedream-annotation-canvas.tool-point { + cursor: cell; +} + +.seedream-annotation-canvas.tool-brush { + cursor: crosshair; +} + +.seedream-annotation-shape { + cursor: pointer; +} + +.seedream-annotation-box { + fill: rgba(37, 99, 235, 0.14); + stroke: #2563eb; + stroke-width: 4; + vector-effect: non-scaling-stroke; +} + +.seedream-annotation-box.draft { + fill: rgba(15, 118, 110, 0.12); + stroke: #0f766e; + stroke-dasharray: 8 6; +} + +.seedream-annotation-point { + fill: #2563eb; + stroke: #ffffff; + stroke-width: 4; + vector-effect: non-scaling-stroke; +} + +.seedream-annotation-halo { + fill: rgba(37, 99, 235, 0.2); +} + +.seedream-annotation-shape.selected .seedream-annotation-box, +.seedream-annotation-shape.selected .seedream-annotation-point, +.seedream-annotation-shape.selected .seedream-annotation-stroke { + stroke: #f59e0b; +} + +.seedream-annotation-stroke, +.seedream-annotation-stroke-hit { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.seedream-annotation-stroke { + stroke: #2563eb; + stroke-width: 9; +} + +.seedream-annotation-stroke.draft { + opacity: 0.84; +} + +.seedream-annotation-stroke-hit { + stroke: transparent; + stroke-width: 24; +} + +.seedream-annotation-label circle { + fill: #2563eb; + stroke: #ffffff; + stroke-width: 3; + vector-effect: non-scaling-stroke; +} + +.seedream-annotation-label text { + fill: #ffffff; + font-size: 24px; + font-weight: 900; + pointer-events: none; +} + +.seedream-annotation-strip { + min-height: 36px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.seedream-annotation-strip button { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--ink); + font-size: 11px; + font-weight: 850; +} + +.seedream-annotation-strip button:hover, +.seedream-annotation-strip button.active { + border-color: #8ebcb1; + background: #edf7f4; +} + +.seedream-annotation-strip button > span { + display: grid; + place-items: center; + color: #2563eb; +} + +.seedream-annotation-strip button small { + color: var(--muted); + font-size: 10px; +} + +.seedream-annotation-empty { + color: var(--muted); + font-size: 11px; +} + +.seedream-canvas-note { + margin-top: -2px; + color: var(--muted); + font-size: 10px; + font-weight: 650; + line-height: 1.45; +} + +.seedream-source-picker { + display: grid; + gap: 6px; +} + +.seedream-source-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.seedream-source-picker-head > span { + color: var(--muted-strong); + font-size: 11px; + font-weight: 850; +} + +.seedream-source-picker-head small { + color: var(--muted); + font-size: 9px; + font-weight: 650; +} + +.seedream-source-picker > div:last-child { + display: flex; + gap: 7px; + overflow-x: auto; + padding: 2px 2px 5px; +} + +.seedream-source-card { + position: relative; + width: 104px; + min-width: 104px; + display: grid; + gap: 4px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.seedream-source-card.active { + border-color: #70aa9c; + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.12); +} + +.seedream-source-card:not(.included) { + opacity: 0.64; +} + +.seedream-source-card-main { + min-width: 0; + display: grid; + gap: 4px; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: var(--muted-strong); + font-size: 10px; + text-align: left; +} + +.seedream-source-card-main img { + width: 100%; + height: 62px; + display: block; + border-radius: 5px; + object-fit: cover; +} + +.seedream-source-card-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-source-toggle { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + padding: 0 5px; + border: 0; + border-radius: 6px; + background: #edf2f3; + color: var(--muted); + font-size: 9px; + font-weight: 850; +} + +.seedream-source-card.included .seedream-source-toggle { + background: #dff2e9; + color: var(--green-dark); +} + +.seedream-source-toggle:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.main.create-main .create-main-column > .seedream-creation-mode-bar { + flex: 0 0 auto; +} + +.main.create-main .create-main-column > .seedream-workspace { + flex: 1 1 auto; +} + +.main.create-main .prompt-field.specialized-prompt-field { + flex: 0 0 auto; + min-height: 120px; + overflow: visible; +} + +.main.create-main .prompt-field.specialized-prompt-field textarea { + min-height: 82px; +} + +.specialized-prompt-warning { + margin-top: 8px; + padding: 8px 10px; + border: 1px solid rgba(217, 119, 6, 0.28); + border-radius: 9px; + background: rgba(255, 247, 237, 0.92); + color: #9a4d06; + font-size: 11px; + font-weight: 700; + line-height: 1.45; +} + +.prompt-token-card.annotation { + background: rgba(96, 165, 250, 0.28); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28); +} + +.billing-estimate-note { + color: var(--muted-strong); + font-size: 10px; + font-weight: 700; + line-height: 1.3; +} + +/* Layer decomposition result viewer */ +.seedream-layer-viewer { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(250px, 0.8fr); + gap: 12px; +} + +.seedream-layer-preview-column { + min-width: 0; + display: grid; + align-content: start; + gap: 8px; +} + +.seedream-layer-editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 30px; +} + +.seedream-layer-editor-toolbar > span { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted-strong); + font-size: 10px; + font-weight: 700; +} + +.seedream-layer-editor-toolbar > div, +.seedream-layer-preview-actions > div { + display: flex; + align-items: center; + gap: 6px; +} + +.seedream-layer-preview { + position: relative; + width: 100%; + max-height: 500px; + margin-inline: auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 9px; + background-color: #ffffff; + background-image: + linear-gradient(45deg, #e7ecec 25%, transparent 25%), + linear-gradient(-45deg, #e7ecec 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e7ecec 75%), + linear-gradient(-45deg, transparent 75%, #e7ecec 75%); + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + background-size: 20px 20px; + isolation: isolate; + touch-action: none; + user-select: none; +} + +.seedream-layer-preview > .seedream-layer-base { + position: absolute; + display: block; + object-fit: fill; +} + +.seedream-layer-base { + inset: 0; + width: 100%; + height: 100%; +} + +.seedream-layer-overlay { + position: absolute; + display: block; + padding: 0; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + cursor: grab; + touch-action: none; + appearance: none; +} + +.seedream-layer-overlay:active, +.seedream-layer-preview.is-editing .seedream-layer-overlay.selected { + cursor: grabbing; +} + +.seedream-layer-overlay img { + width: 100%; + height: 100%; + display: block; + object-fit: fill; + pointer-events: none; +} + +.seedream-layer-overlay.selected { + border-color: #2563eb; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.82), 0 0 0 3px rgba(37, 99, 235, 0.24); +} + +.seedream-layer-overlay:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.seedream-layer-resize-handle { + position: absolute; + right: -7px; + bottom: -7px; + width: 13px; + height: 13px; + display: block; + border: 2px solid #ffffff; + border-radius: 50%; + background: #2563eb; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.28); + cursor: nwse-resize; + opacity: 0; +} + +.seedream-layer-overlay.selected .seedream-layer-resize-handle { + opacity: 1; +} + +.seedream-layer-inspector { + min-height: 30px; + display: flex; + align-items: center; + gap: 12px; + padding: 6px 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fbfb; + color: var(--muted-strong); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.seedream-layer-inspector strong { + min-width: 72px; + max-width: 180px; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-layer-preview-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.seedream-layer-preview-actions > span { + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.seedream-layer-download-error { + color: var(--danger); + font-size: 11px; +} + +.seedream-layer-list { + min-width: 0; + max-height: 500px; + display: grid; + align-content: start; + gap: 6px; + overflow-y: auto; + padding-right: 3px; +} + +.seedream-layer-list-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 1px 2px 3px; + color: var(--ink); + font-size: 11px; + font-weight: 800; +} + +.seedream-layer-list-head small { + color: var(--muted); + font-size: 9px; + font-weight: 600; +} + +.seedream-layer-list article { + min-width: 0; + display: grid; + grid-template-columns: 30px 46px minmax(0, 1fr) 52px 34px; + align-items: center; + gap: 7px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdfd; + cursor: pointer; +} + +.seedream-layer-list article:hover, +.seedream-layer-list article.active, +.seedream-layer-list article:focus-visible { + border-color: #91bcb2; + background: #f0f8f6; + outline: none; +} + +.seedream-layer-visibility { + width: 30px; + height: 30px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted-strong); +} + +.seedream-layer-visibility:hover { + background: #e2efec; + color: var(--green-dark); +} + +.seedream-layer-thumb { + width: 46px; + height: 42px; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background-color: #ffffff; + background-image: linear-gradient(45deg, #eef2f2 25%, transparent 25%), linear-gradient(-45deg, #eef2f2 25%, transparent 25%); + background-size: 10px 10px; +} + +.seedream-layer-thumb img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.seedream-layer-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.seedream-layer-copy strong, +.seedream-layer-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.seedream-layer-copy strong { + color: var(--ink); + font-size: 11px; +} + +.seedream-layer-copy small { + color: var(--muted); + font-size: 9px; +} + +.seedream-layer-order-actions { + display: grid; + grid-template-columns: repeat(2, 24px); + gap: 3px; +} + +.seedream-layer-order-actions button { + width: 24px; + height: 28px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--muted-strong); +} + +.seedream-layer-order-actions button:hover:not(:disabled) { + border-color: #91bcb2; + color: var(--green-dark); +} + +.seedream-layer-order-actions button:disabled { + opacity: 0.34; +} + +.seedream-layer-fixed { + color: var(--muted); + font-size: 9px; + font-weight: 700; + text-align: center; +} + +.seedream-layer-list .icon-button { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; +} + +@media (max-width: 720px) { + .seedream-workspace { + min-height: 360px; + overflow: visible; + } + + .seedream-editor-toolbar { + overflow-x: auto; + } + + .seedream-editor-toolbar button, + .seedream-toolbar-upload { + flex: 0 0 auto; + } + + .seedream-layer-viewer { + grid-template-columns: 1fr; + } + + .seedream-layer-list { + max-height: 360px; + } + + .main.create-main .prompt-field.specialized-prompt-field { + flex-basis: 160px; + } +} + +@media (max-width: 560px) { + .seedream-creation-mode-bar button { + gap: 4px; + padding-inline: 4px; + font-size: 11px; + } + + .seedream-workspace { + padding: 10px; + } + + .seedream-model-pill, + .seedream-editor-toolbar-divider { + display: none; + } + + .seedream-canvas-viewport { + min-height: 260px; + padding: 6px; + } + + .seedream-layer-editor-toolbar { + align-items: stretch; + flex-direction: column; + } + + .seedream-layer-editor-toolbar > div { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + .seedream-layer-inspector { + flex-wrap: wrap; + gap: 6px 12px; + } + + .seedream-layer-preview-actions { + align-items: stretch; + flex-direction: column; + } + + .seedream-layer-preview-actions > div { + display: grid; + grid-template-columns: 1fr; + } + + .seedream-layer-preview-actions .button { + width: 100%; + } +} + /* Price source metadata stays on one measured baseline instead of relying on flex stretching. */ .billing-price-service-meta { display: grid; diff --git a/backend/internal/billing/defaults.go b/backend/internal/billing/defaults.go index a6e7546..f0a5aa7 100644 --- a/backend/internal/billing/defaults.go +++ b/backend/internal/billing/defaults.go @@ -25,6 +25,7 @@ func DefaultBillingPriceRules() []PriceRule { {ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()}, {ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")}, {ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图:1K/1.5K ¥0.30,2K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, + {ID: "base-seedream-5-0-pro-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Note: "图层拆分:≤261万像素 ¥0.15/张,>261万像素 ¥0.30/张;最多预留底图加16个图层,完成后按每张实际像素结算。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()}, {ID: "base-bailian-wan27-i2v-720p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, {ID: "base-bailian-wan27-i2v-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 100, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")}, {ID: "base-seedance-2-0-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 46, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")}, @@ -46,6 +47,7 @@ func seedreamDimensions() []ParameterDimension { return []ParameterDimension{{ Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K", Tiers: []ParameterTier{ + {Value: "auto", Label: "自动(图层拆分)", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, {Value: "1K", Label: "1K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, {Value: "1.5K", Label: "1.5K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, {Value: "2K", Label: "2K", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true}, @@ -100,6 +102,18 @@ func NormalizeBillingParameters(payload map[string]any, fallback Parameters) Par setNumber(out, "scale", first(input["scale"], settings["scale"]), false) setText(out, "outputFormat", first(settings["outputFormat"], providerPayload["output_format"], inputSettings["outputFormat"], input["outputFormat"])) setText(out, "optimizeMode", first(settings["optimizeMode"], record(providerPayload["optimize_prompt_options"])["mode"], inputSettings["optimizeMode"], input["optimizeMode"])) + setText(out, "creationMode", first(settings["creationMode"], inputSettings["creationMode"], input["creationMode"])) + if value, ok := boolean(first(settings["layerDecomposition"], providerPayload["layer_decomposition"], inputSettings["layerDecomposition"], input["layerDecomposition"])); ok { + out["layerDecomposition"] = value + } + if strings.EqualFold(fmt.Sprint(out["creationMode"]), "layers") { + out["layerDecomposition"] = true + } + if value, _ := out["layerDecomposition"].(bool); value { + // Seedream can return one base image plus at most sixteen layers. Reserve + // the documented maximum, then settle each returned image by pixel tier. + out["imageCount"] = float64(17) + } width, widthOK := finiteNumber(first(providerPayload["width"], input["width"])) height, heightOK := finiteNumber(first(providerPayload["height"], input["height"])) diff --git a/backend/internal/billing/defaults_test.go b/backend/internal/billing/defaults_test.go index 0c33492..545707b 100644 --- a/backend/internal/billing/defaults_test.go +++ b/backend/internal/billing/defaults_test.go @@ -9,6 +9,7 @@ func TestDefaultBillingPriceRulesFreezeOfficialCatalogMatchKeys(t *testing.T) { "base-evolink-gpt-image-2": false, "base-bailian-wan27-image-pro": false, "base-seedream-5-0-pro": false, + "base-seedream-5-0-pro-layers": false, "base-bailian-wan27-i2v-720p": false, "base-bailian-wan27-i2v-1080p": false, "base-seedance-2-0-480p": false, diff --git a/backend/internal/billing/ledger.go b/backend/internal/billing/ledger.go index 1c5ec7e..c912620 100644 --- a/backend/internal/billing/ledger.go +++ b/backend/internal/billing/ledger.go @@ -10,8 +10,9 @@ import ( ) var ( - ErrInsufficientBalance = errors.New("insufficient billing balance") - ErrIdempotencyConflict = errors.New("billing idempotency conflict") + ErrInsufficientBalance = errors.New("insufficient billing balance") + ErrIdempotencyConflict = errors.New("billing idempotency conflict") + ErrOrganizationUnavailable = errors.New("billing organization is unavailable") ) type WalletPostParams struct { diff --git a/backend/internal/billing/seedream.go b/backend/internal/billing/seedream.go index 48d4887..fe11700 100644 --- a/backend/internal/billing/seedream.go +++ b/backend/internal/billing/seedream.go @@ -8,6 +8,13 @@ import ( const Seedream50ProModel = "doubao-seedream-5-0-pro-260628" +const SeedreamLayerPixelThreshold = 2_610_000 + +type SeedreamLayerImage struct { + Width int + Height int +} + // CalculateSeedreamAmountFen applies Seedream's additive input-image price // before the platform markup: the first reference image is free and every // additional reference image costs 2 fen. @@ -28,3 +35,26 @@ func CalculateSeedreamAmountFen(outputPriceFen int64, referenceImageCount int, m func IsSeedreamBillingModel(model string) bool { return strings.TrimSpace(model) == Seedream50ProModel } + +// CalculateSeedreamLayerAmountFen charges every returned image independently: +// 15 fen up to 2.61M pixels and 30 fen above that tier, followed by markup. +func CalculateSeedreamLayerAmountFen(images []SeedreamLayerImage, markupMultiplier float64) (int64, error) { + if len(images) == 0 || len(images) > 17 { + return 0, errors.New("seedream layer output count must be between 1 and 17") + } + if math.IsNaN(markupMultiplier) || math.IsInf(markupMultiplier, 0) || markupMultiplier < 1 { + return 0, errors.New("seedream markup multiplier must be at least 1") + } + standardAmountFen := int64(0) + for _, image := range images { + if image.Width <= 0 || image.Height <= 0 { + return 0, errors.New("seedream layer output dimensions must be positive") + } + price := int64(15) + if int64(image.Width)*int64(image.Height) > SeedreamLayerPixelThreshold { + price = 30 + } + standardAmountFen += price + } + return int64(math.Ceil(float64(standardAmountFen) * markupMultiplier)), nil +} diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go index 1d33e60..4468219 100644 --- a/backend/internal/billing/service.go +++ b/backend/internal/billing/service.go @@ -82,6 +82,17 @@ func (s *Service) Quote(ctx context.Context, command QuoteCommand) (*Quote, erro return quote, err } if command.Provider == "seedream" && IsSeedreamBillingModel(command.ReqKey) { + layerDecomposition, _ := parameters["layerDecomposition"].(bool) + if layerDecomposition { + quote.ReservedAmountFen = quote.AmountFen + quote.SettlementStatus = "pending" + if quote.Parameters == nil { + quote.Parameters = Parameters{} + } + quote.Parameters["layerDecomposition"] = true + quote.Parameters["maximumOutputCount"] = float64(17) + return quote, nil + } referenceImageCount := 0 if count, ok := number(parameters["referenceImageCount"]); ok { referenceImageCount = int(math.Ceil(count)) diff --git a/backend/internal/billing/service_test.go b/backend/internal/billing/service_test.go index 5c67837..fc67a82 100644 --- a/backend/internal/billing/service_test.go +++ b/backend/internal/billing/service_test.go @@ -134,6 +134,30 @@ func TestCalculateSeedreamAmountFenOfficialVectors(t *testing.T) { } } +func TestServiceQuoteReservesSeedreamLayerMaximumAndSettlesByPixelTier(t *testing.T) { + store := "eStoreStub{rules: []PriceRule{ + {ID: "seedream-basic", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, MarkupMultiplier: 1.2, Enabled: true, Dimensions: seedreamDimensions()}, + {ID: "seedream-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, MarkupMultiplier: 1.2, Enabled: true, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Dimensions: seedreamDimensions()}, + }} + quote, err := NewService(store, nil).Quote(context.Background(), QuoteCommand{ + Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, + Payload: map[string]any{ + "settings": map[string]any{"size": "auto", "imageCount": 1.0, "creationMode": "layers", "layerDecomposition": true}, + "materials": []any{map[string]any{"type": "image", "url": "https://in.test/source.png"}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if quote.PriceRuleID != "seedream-layers" || quote.Quantity != 17 || quote.AmountFen != 612 || quote.ReservedAmountFen != 612 || quote.SettlementStatus != "pending" { + t.Fatalf("quote=%#v", quote) + } + actual, err := CalculateSeedreamLayerAmountFen([]SeedreamLayerImage{{Width: 2048, Height: 2048}, {Width: 1273, Height: 265}}, 1.2) + if err != nil || actual != 54 { + t.Fatalf("actual=%d err=%v", actual, err) + } +} + func TestSeedanceEstimateMatchesExecutableTypeScriptVectors(t *testing.T) { withoutVideo, err := EstimateSeedanceAmountFen(SeedanceEstimateInput{ Model: Seedance20Model, diff --git a/backend/internal/httpapi/jobs.go b/backend/internal/httpapi/jobs.go index 5be4c5a..5744ff8 100644 --- a/backend/internal/httpapi/jobs.go +++ b/backend/internal/httpapi/jobs.go @@ -458,10 +458,22 @@ func writeJobError(w http.ResponseWriter, err error, public bool) { message := "生成任务计费失败。" if public { message = "Generation job billing failed." + } else if errors.Is(err, billing.ErrInsufficientBalance) { + message = "余额不足,请先充值。" + } else if errors.Is(err, billing.ErrOrganizationUnavailable) { + message = "账号未绑定有效组织,请联系管理员。" } writeJSON(w, billingStatus.Status, map[string]string{"error": message}) return } + if errors.Is(err, billing.ErrProviderUnavailable) { + message := "计费服务暂不可用,请稍后重试。" + if public { + message = "Generation billing is temporarily unavailable." + } + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": message}) + return + } writeJSON(w, 500, map[string]string{"error": "Internal server error."}) } func randomJobID() string { diff --git a/backend/internal/httpapi/jobs_test.go b/backend/internal/httpapi/jobs_test.go index c369eea..06fa528 100644 --- a/backend/internal/httpapi/jobs_test.go +++ b/backend/internal/httpapi/jobs_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -231,7 +232,7 @@ func TestJobsHTTPMapsBillingChargeErrorsAndPublicUsesPublicCreation(t *testing.T h, _ := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}, Creation: creation}, JobsConfig{}) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/generations/image", bytes.NewBufferString(`{"prompt":"hello"}`))) - if w.Code != 402 || w.Body.String() != "{\"error\":\"生成任务计费失败。\"}\n" { + if w.Code != 402 || w.Body.String() != "{\"error\":\"余额不足,请先充值。\"}\n" { t.Fatalf("platform=%d %s", w.Code, w.Body.String()) } creation.err = nil @@ -246,13 +247,16 @@ func TestJobsHTTPMapsBillingChargeErrorsAndPublicUsesPublicCreation(t *testing.T func TestJobsHTTPMapsAllCreationBillingOutcomesWithoutLeakingErrors(t *testing.T) { tests := []struct { - name string - err error - want int + name string + err error + want int + message string }{ - {name: "insufficient", err: &billing.StatusError{Status: 402, Err: billing.ErrInsufficientBalance}, want: 402}, - {name: "idempotency", err: &billing.StatusError{Status: 409, Err: billing.ErrIdempotencyConflict}, want: 409}, - {name: "unknown", err: errors.New("postgres password leaked"), want: 500}, + {name: "insufficient", err: &billing.StatusError{Status: 402, Err: billing.ErrInsufficientBalance}, want: 402, message: "余额不足,请先充值。"}, + {name: "organization", err: &billing.StatusError{Status: 409, Err: billing.ErrOrganizationUnavailable}, want: 409, message: "账号未绑定有效组织,请联系管理员。"}, + {name: "idempotency", err: &billing.StatusError{Status: 409, Err: billing.ErrIdempotencyConflict}, want: 409, message: "生成任务计费失败。"}, + {name: "unavailable", err: billing.ErrProviderUnavailable, want: 503, message: "计费服务暂不可用,请稍后重试。"}, + {name: "unknown", err: errors.New("postgres password leaked"), want: 500, message: "Internal server error."}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -262,7 +266,7 @@ func TestJobsHTTPMapsAllCreationBillingOutcomesWithoutLeakingErrors(t *testing.T h, _ := NewJobsHandler(JobsDependencies{Service: jobs.NewService(store, time.Now), Platform: platform, Public: publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent-a:secret"}), Builder: &jobBuilderStub{}, Creation: creation}, JobsConfig{}) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/generations/image", bytes.NewBufferString(`{"prompt":"hello"}`))) - if w.Code != test.want || strings.Contains(w.Body.String(), "postgres") { + if w.Code != test.want || w.Body.String() != fmt.Sprintf("{\"error\":%q}\n", test.message) || strings.Contains(w.Body.String(), "postgres") { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } }) diff --git a/backend/internal/httpapi/openapi.go b/backend/internal/httpapi/openapi.go index 0366a72..3138529 100644 --- a/backend/internal/httpapi/openapi.go +++ b/backend/internal/httpapi/openapi.go @@ -96,7 +96,7 @@ func openAPISchemas() map[string]any { "capability": openAPIRef("GenerationCapability"), "engine": map[string]any{"type": "string", "enum": []string{"jimeng", "seedream", "evolink", "bailian", "seedance"}}, "model": map[string]any{"type": "string", "enum": []string{"doubao-seedance-2-0-260128", "doubao-seedance-2-5-260628"}, "description": "Optional allow-listed Seedance model for video.generate."}, - "prompt": map[string]any{"type": "string", "description": "Prompt text. Required for image.generate unless promptAssembly is supplied."}, + "prompt": map[string]any{"type": "string", "description": "Prompt text. Required for generation except Seedream layer decomposition, where an empty prompt triggers automatic decomposition."}, "inputUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Reference image URLs for image capabilities."}, "imageUrls": map[string]any{"type": "array", "items": map[string]any{"type": "string", "format": "uri"}, "description": "Alias for image input URLs."}, "inputAssetIds": openAPIStringArray(), @@ -104,12 +104,14 @@ func openAPISchemas() map[string]any { "settings": map[string]any{ "type": "object", "description": "Provider-specific image or video settings.", "properties": map[string]any{ - "ratio": map[string]any{"type": "string", "enum": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}}, - "duration": map[string]any{"type": "integer", "minimum": 4, "maximum": 30, "description": "Seedance 2.0 supports up to 15 seconds; Seedance 2.5 supports up to 30 seconds."}, - "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p"}}, - "size": map[string]any{"type": "string", "enum": []string{"1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size."}, - "outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}}, - "optimizeMode": map[string]any{"type": "string", "enum": []string{"standard", "fast"}}, + "ratio": map[string]any{"type": "string", "enum": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}}, + "duration": map[string]any{"type": "integer", "minimum": 4, "maximum": 30, "description": "Seedance 2.0 supports up to 15 seconds; Seedance 2.5 supports up to 30 seconds."}, + "resolution": map[string]any{"type": "string", "enum": []string{"480p", "720p", "1080p"}}, + "size": map[string]any{"type": "string", "enum": []string{"auto", "1K", "1.5K", "2K"}, "description": "Seedream 5.0 Pro output size. auto is available for layer decomposition only."}, + "outputFormat": map[string]any{"type": "string", "enum": []string{"png", "jpeg"}}, + "optimizeMode": map[string]any{"type": "string", "enum": []string{"standard", "fast"}}, + "creationMode": map[string]any{"type": "string", "enum": []string{"basic", "interactive", "layers"}, "description": "Seedream 5.0 Pro image workflow."}, + "layerDecomposition": map[string]any{"type": "boolean", "description": "Enables Seedream 5.0 Pro layer decomposition; exactly one image is required."}, }, }, "width": map[string]any{"type": "integer", "example": 1440}, diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index 086118c..98db5d4 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -401,7 +401,7 @@ func prepareProviderRequest(capability, engine, model string, defaults map[strin text = prompt.Assemble(*assembly).Prompt } text = strings.TrimSpace(text) - if text == "" { + if text == "" && !seedreamLayerDecompositionRequested(capability, engine, body) { return preparedProviderRequest{}, invalidPreparation("prompt is required") } assetIDs := stringsFromAny(body["inputAssetIds"]) @@ -541,7 +541,7 @@ func validateMaterialCoverage(text string, materials []prompt.Material) error { func imageSettings(body map[string]any) (map[string]any, error) { out := map[string]any{} if nested, ok := body["settings"].(map[string]any); ok { - for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount", "force_single", "quality", "size", "outputFormat", "optimizeMode"} { + for _, key := range []string{"scale", "width", "height", "min_ratio", "max_ratio", "imageCount", "force_single", "quality", "size", "outputFormat", "optimizeMode", "creationMode", "layerDecomposition"} { if nested[key] != nil { out[key] = nested[key] } @@ -597,6 +597,24 @@ func imageSettings(body map[string]any) (map[string]any, error) { } out[key] = strings.TrimSpace(value) } + creationModeRaw := firstNonNil(body["creationMode"], out["creationMode"]) + delete(out, "creationMode") + if creationModeRaw != nil && creationModeRaw != "" { + value, ok := creationModeRaw.(string) + if !ok || strings.TrimSpace(value) == "" { + return nil, invalidPreparation("invalid image parameter: creationMode") + } + out["creationMode"] = strings.ToLower(strings.TrimSpace(value)) + } + layerRaw := firstNonNil(body["layerDecomposition"], out["layerDecomposition"]) + delete(out, "layerDecomposition") + if layerRaw != nil { + value, ok := layerRaw.(bool) + if !ok { + return nil, invalidPreparation("invalid image parameter: layerDecomposition") + } + out["layerDecomposition"] = value + } if count, ok := out["imageCount"].(float64); ok && (count <= 0 || count > 9 || math.Trunc(count) != count) { return nil, invalidPreparation("imageCount must be an integer between 1 and 9") } @@ -604,9 +622,26 @@ func imageSettings(body map[string]any) (map[string]any, error) { } func validateSeedreamImage(urls []string, materials []prompt.Material, settings map[string]any) error { - if len(urls) > 10 { + creationMode := strings.ToLower(strings.TrimSpace(stringValue(settings["creationMode"]))) + layerDecomposition, _ := settings["layerDecomposition"].(bool) + if layerDecomposition { + creationMode = "layers" + } + if creationMode == "" { + creationMode = "basic" + } + if creationMode != "basic" && creationMode != "interactive" && creationMode != "layers" { + return invalidPreparation("unsupported seedream creation mode") + } + if creationMode == "basic" && len(urls) > 10 { return invalidPreparation("seedream supports at most 10 reference images") } + if creationMode == "interactive" && (len(urls) < 1 || len(urls) > 10) { + return invalidPreparation("seedream interactive editing requires between 1 and 10 input images") + } + if creationMode == "layers" && len(urls) != 1 { + return invalidPreparation("seedream layer decomposition requires exactly one input image") + } for _, material := range materials { if material.Type != "image" { return invalidPreparation("seedream supports image materials only") @@ -616,7 +651,12 @@ func validateSeedreamImage(urls []string, materials []prompt.Material, settings return invalidPreparation("seedream requires a size preset instead of width and height") } size := strings.ToUpper(strings.TrimSpace(stringValue(settings["size"]))) - if size != "1K" && size != "1.5K" && size != "2K" { + if creationMode == "layers" && size == "AUTO" { + size = "auto" + } else if size != "1K" && size != "1.5K" && size != "2K" { + if creationMode == "layers" { + return invalidPreparation("seedream layer size must be auto, 1K, 1.5K, or 2K") + } return invalidPreparation("seedream size must be 1K, 1.5K, or 2K") } format := strings.ToLower(strings.TrimSpace(stringValue(settings["outputFormat"]))) @@ -634,9 +674,26 @@ func validateSeedreamImage(urls []string, materials []prompt.Material, settings settings["outputFormat"] = format settings["optimizeMode"] = mode settings["imageCount"] = float64(1) + settings["creationMode"] = creationMode + if creationMode == "layers" { + settings["layerDecomposition"] = true + } else { + delete(settings, "layerDecomposition") + } return nil } +func seedreamLayerDecompositionRequested(capability, engine string, body map[string]any) bool { + if capability != "image.generate" || engine != "seedream" { + return false + } + settings, _ := body["settings"].(map[string]any) + if value, ok := firstNonNil(body["layerDecomposition"], settings["layerDecomposition"]).(bool); ok && value { + return true + } + return strings.EqualFold(strings.TrimSpace(stringValue(firstNonNil(body["creationMode"], settings["creationMode"]))), "layers") +} + func validateBailianImage(urls []string, settings map[string]any) error { if len(urls) > 9 { return invalidPreparation("bailian supports at most 9 reference images") diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 4809e2f..7933bcd 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -101,6 +101,53 @@ func TestProviderJobBuilderPreparesSeedreamImageRequest(t *testing.T) { } } +func TestProviderJobBuilderPreparesSeedreamLayerDecompositionWithoutPrompt(t *testing.T) { + b := testProviderBuilder() + cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "seedream", + "materials": []any{map[string]any{"id": "image-1", "url": "https://in.test/source.png", "type": "image"}}, + "settings": map[string]any{"size": "auto", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "layers", "layerDecomposition": true}, + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if cmd.Job.Prompt != "" || len(request.InputURLs) != 1 || request.Settings["size"] != "auto" || request.Settings["creationMode"] != "layers" || request.Settings["layerDecomposition"] != true { + t.Fatalf("job=%#v request=%#v", cmd.Job, request) + } + _, err = b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "seedream", "settings": map[string]any{"size": "auto", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "layers", "layerDecomposition": true}, + }) + if err == nil || !strings.Contains(err.Error(), "exactly one input image") { + t.Fatalf("missing input error=%v", err) + } +} + +func TestProviderJobBuilderPreparesSeedreamInteractiveMultiImageRequest(t *testing.T) { + b := testProviderBuilder() + cmd, err := b.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{ + "engine": "seedream", "prompt": "move the subject between images", + "materials": []any{ + map[string]any{"id": "image-1", "url": "https://in.test/one.png", "type": "image"}, + map[string]any{"id": "image-2", "url": "https://in.test/two.png", "type": "image"}, + }, + "settings": map[string]any{"size": "2K", "outputFormat": "png", "optimizeMode": "standard", "creationMode": "interactive"}, + }) + if err != nil { + t.Fatal(err) + } + var request providers.Request + if err := json.Unmarshal(cmd.Job.RequestPayload, &request); err != nil { + t.Fatal(err) + } + if len(request.InputURLs) != 2 || request.Settings["creationMode"] != "interactive" || request.Settings["layerDecomposition"] != nil { + t.Fatalf("job=%#v request=%#v", cmd.Job, request) + } +} + func TestProviderJobBuilderPreparesSeedanceVideoRequest(t *testing.T) { b := testProviderBuilder() body := map[string]any{ diff --git a/backend/internal/orchestration/creation.go b/backend/internal/orchestration/creation.go index a26d001..f690ead 100644 --- a/backend/internal/orchestration/creation.go +++ b/backend/internal/orchestration/creation.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log" "strings" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" @@ -161,8 +162,15 @@ func (c *CreationCoordinator) charge(ctx context.Context, job jobs.Job, reused b return c.writeCharge(ctx, job, reused, charge.raw) } var use usageContext - if json.Unmarshal(job.UsageContext, &use) != nil || use.OrganizationID == "" || charge.AmountFen <= 0 { - return c.failCharge(ctx, job, reused, errors.New("charge generation job"), charge.raw) + if json.Unmarshal(job.UsageContext, &use) != nil || charge.AmountFen <= 0 { + cause := billing.ErrProviderUnavailable + logGenerationBillingFailure(job.ID, "validate", cause, cause) + return c.failCharge(ctx, job, reused, cause, charge.raw) + } + if use.OrganizationID == "" { + cause := &billing.StatusError{Status: 409, Err: billing.ErrOrganizationUnavailable} + logGenerationBillingFailure(job.ID, "validate", cause, cause) + return c.failCharge(ctx, job, reused, cause, charge.raw) } request := billing.ChargeRequest{OrganizationID: use.OrganizationID, AccountID: use.AccountID, JobID: job.ID, AmountFen: charge.AmountFen, Description: capabilityLabel(job.Capability) + " · " + job.ReqKey, Metadata: map[string]any{"quote": cloneMap(charge.raw), "accountName": use.DisplayName, "organizationName": use.OrganizationName}} if atomic, ok := c.state.(AtomicCreationCharger); ok { @@ -173,16 +181,22 @@ func (c *CreationCoordinator) charge(ctx context.Context, job jobs.Job, reused b encoded, atomicErr := atomic.ChargeAndActivateCreation(ctx, request, pending) if atomicErr != nil { if errors.Is(atomicErr, billing.ErrCommitOutcomeUnknown) { - return jobs.Job{}, reused, errors.New("charge generation job") + publicErr := errors.New("charge generation job") + logGenerationBillingFailure(job.ID, "commit", atomicErr, publicErr) + return jobs.Job{}, reused, publicErr } - return c.failCharge(ctx, job, reused, safeBillingError(atomicErr), charge.raw) + safeErr := safeBillingError(atomicErr) + logGenerationBillingFailure(job.ID, "charge", atomicErr, safeErr) + return c.failCharge(ctx, job, reused, safeErr, charge.raw) } job.Billing = encoded return job, reused, nil } posting, err := c.charges.Charge(ctx, request) if err != nil { - return c.failCharge(ctx, job, reused, safeBillingError(err), charge.raw) + safeErr := safeBillingError(err) + logGenerationBillingFailure(job.ID, "charge", err, safeErr) + return c.failCharge(ctx, job, reused, safeErr, charge.raw) } charge.raw["status"] = "charged" charge.raw["ledgerEntryId"] = posting.LedgerID @@ -205,7 +219,8 @@ func (c *CreationCoordinator) failCharge(ctx context.Context, job jobs.Job, reus job.Status = jobs.StatusFailed job.Error = &jobs.JobError{Message: "generation charge failed", Retryable: false} if err := c.state.FailCreation(ctx, job); err != nil { - return jobs.Job{}, reused, errors.New("persist failed generation charge") + logGenerationBillingFailure(job.ID, "persist_failure", err, billing.ErrProviderUnavailable) + return jobs.Job{}, reused, billing.ErrProviderUnavailable } return job, reused, cause } @@ -231,10 +246,64 @@ func safeBillingError(err error) error { if strings.Contains(message, "BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH") { return &billing.StatusError{Status: 409, Err: billing.ErrIdempotencyConflict} } - return errors.New("charge generation job") + return billing.ErrProviderUnavailable } } +func logGenerationBillingFailure(jobID, phase string, rawErr, publicErr error) { + log.Printf( + "zhinian-api generation billing failed jobId=%s phase=%s status=%d code=%q errorClass=%s", + jobID, + phase, + billing.HTTPStatus(publicErr), + billingSQLState(rawErr), + generationBillingErrorClass(rawErr, publicErr), + ) +} + +func generationBillingErrorClass(rawErr, publicErr error) string { + switch { + case errors.Is(publicErr, billing.ErrInsufficientBalance): + return "insufficient_balance" + case errors.Is(publicErr, billing.ErrIdempotencyConflict): + return "idempotency_conflict" + case errors.Is(publicErr, billing.ErrOrganizationUnavailable): + return "organization_unavailable" + case errors.Is(rawErr, billing.ErrCommitOutcomeUnknown): + return "commit_outcome_unknown" + case errors.Is(rawErr, context.DeadlineExceeded): + return "timeout" + case errors.Is(rawErr, context.Canceled): + return "canceled" + case billingSQLState(rawErr) != "": + return "database" + case errors.Is(publicErr, billing.ErrProviderUnavailable): + return "unavailable" + default: + return "unknown" + } +} + +func billingSQLState(err error) string { + type sqlStateCarrier interface{ SQLState() string } + var carrier sqlStateCarrier + if !errors.As(err, &carrier) { + return "" + } + code := strings.TrimSpace(carrier.SQLState()) + if len(code) != 5 { + return "" + } + for _, char := range code { + isDigit := char >= '0' && char <= '9' + isUppercase := char >= 'A' && char <= 'Z' + if !isDigit && !isUppercase { + return "" + } + } + return code +} + func retryEngine(provider, capability string) string { if capability == "video.generate" { if provider == "seedance" || provider == "bailian" { diff --git a/backend/internal/orchestration/orchestration_test.go b/backend/internal/orchestration/orchestration_test.go index 975339e..9c7a7ba 100644 --- a/backend/internal/orchestration/orchestration_test.go +++ b/backend/internal/orchestration/orchestration_test.go @@ -1,10 +1,13 @@ package orchestration import ( + "bytes" "context" "encoding/json" "errors" + "log" "reflect" + "strings" "testing" "time" @@ -233,6 +236,30 @@ func TestCreationCoordinatorUsesAtomicChargeAndActivationWhenStoreSupportsIt(t * } } +func TestCreationCoordinatorMapsAtomicInsufficientBalanceBeforeDispatch(t *testing.T) { + creator := &creationStoreStub{} + quoter := "erStub{quote: &billing.Quote{AmountFen: 39, Currency: "CNY"}} + state := &atomicCreationStateStub{err: errors.New("charge and activate: BILLING_INSUFFICIENT_BALANCE")} + coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, &chargeLedgerStub{}, state) + + _, _, err := coordinator.CreatePlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, CreationInput{Capability: "image.generate", Body: map[string]any{"prompt": "fresh"}}) + if billing.HTTPStatus(err) != 402 || state.failed.Status != jobs.StatusFailed { + t.Fatalf("error=%v status=%d failed=%#v", err, billing.HTTPStatus(err), state.failed) + } +} + +func TestCreationCoordinatorReturnsActionableErrorWithoutBillingOrganization(t *testing.T) { + creator := &creationStoreStub{} + quoter := "erStub{quote: &billing.Quote{AmountFen: 39, Currency: "CNY"}} + state := &creationStateStub{} + coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, &chargeLedgerStub{}, state) + + _, _, err := coordinator.CreatePlatform(context.Background(), identity.Session{User: identity.User{ID: "account", Role: "user"}}, CreationInput{Capability: "image.generate", Body: map[string]any{"prompt": "fresh"}}) + if billing.HTTPStatus(err) != 409 || state.failed.Status != jobs.StatusFailed { + t.Fatalf("error=%v status=%d failed=%#v", err, billing.HTTPStatus(err), state.failed) + } +} + func TestCreationCoordinatorRetryRebuildsAnyOwnedImageJobWithFreshQuote(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{quote: &billing.Quote{AmountFen: 41, Currency: "CNY"}} @@ -266,7 +293,7 @@ func TestSafeBillingErrorMapsPostgresWalletFailures(t *testing.T) { }{ {name: "insufficient", err: errors.New("charge and activate: BILLING_INSUFFICIENT_BALANCE"), want: 402}, {name: "idempotency", err: errors.New("charge and activate: BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH"), want: 409}, - {name: "unknown", err: errors.New("database unavailable"), want: 500}, + {name: "unknown", err: errors.New("database unavailable"), want: 503}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -274,13 +301,40 @@ func TestSafeBillingErrorMapsPostgresWalletFailures(t *testing.T) { if status := billing.HTTPStatus(got); status != test.want { t.Fatalf("status = %d, want %d (error %v)", status, test.want, got) } - if test.want == 500 && got.Error() != "charge generation job" { + if test.want == 503 && got.Error() == test.err.Error() { t.Fatalf("unknown error leaked: %v", got) } }) } } +func TestGenerationBillingFailureLogKeepsOnlySafeClassification(t *testing.T) { + var output bytes.Buffer + previousWriter, previousFlags := log.Writer(), log.Flags() + log.SetOutput(&output) + log.SetFlags(0) + defer func() { + log.SetOutput(previousWriter) + log.SetFlags(previousFlags) + }() + + logGenerationBillingFailure( + "job-safe-log", + "charge", + &billingSQLStateTestError{code: "42501", message: "database password leaked"}, + billing.ErrProviderUnavailable, + ) + got := output.String() + for _, expected := range []string{"jobId=job-safe-log", "phase=charge", "status=503", `code="42501"`, "errorClass=database"} { + if !strings.Contains(got, expected) { + t.Fatalf("safe log %q missing %q", got, expected) + } + } + if strings.Contains(got, "password") || strings.Contains(got, "leaked") { + t.Fatalf("safe log leaked raw error: %q", got) + } +} + func TestCreationCoordinatorPublicCreationNeverQuotesOrCharges(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{} @@ -457,12 +511,21 @@ type creationStateStub struct { type atomicCreationStateStub struct { creationStateStub charged json.RawMessage + err error calls int } +type billingSQLStateTestError struct { + code string + message string +} + +func (e *billingSQLStateTestError) Error() string { return e.message } +func (e *billingSQLStateTestError) SQLState() string { return e.code } + func (s *atomicCreationStateStub) ChargeAndActivateCreation(_ context.Context, _ billing.ChargeRequest, _ json.RawMessage) (json.RawMessage, error) { s.calls++ - return append(json.RawMessage(nil), s.charged...), nil + return append(json.RawMessage(nil), s.charged...), s.err } func (s *creationStateStub) WriteBilling(_ context.Context, _ string, _ json.RawMessage) error { diff --git a/backend/internal/orchestration/settlement.go b/backend/internal/orchestration/settlement.go index 56a0840..6d4db18 100644 --- a/backend/internal/orchestration/settlement.go +++ b/backend/internal/orchestration/settlement.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strconv" "strings" "time" @@ -23,8 +24,9 @@ type FencedSettlementStateWriter interface { WriteBillingFenced(context.Context, string, json.RawMessage, jobs.Status, string) error } -// SettlementProcessor adds Seedance actual-usage settlement to a jobs -// processor. Other providers and non-successful advances pass through. +// SettlementProcessor adds actual-usage settlement for Seedance videos and +// Seedream layer decomposition. Other providers and non-successful advances +// pass through. type SettlementProcessor struct { inner jobs.Processor ledger SettlementLedger @@ -44,9 +46,15 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J return jobs.Job{}, errors.New("advance generation job") } advanced, err := p.inner.Advance(ctx, job) - if err != nil || advanced.Provider != "seedance" || advanced.Status != jobs.StatusSucceeded || len(advanced.Billing) == 0 { + if err != nil || advanced.Status != jobs.StatusSucceeded || len(advanced.Billing) == 0 { return advanced, err } + if advanced.Provider == "seedream" && seedreamLayerRequest(advanced.RequestPayload) { + return p.settleSeedreamLayers(ctx, advanced) + } + if advanced.Provider != "seedance" { + return advanced, nil + } var charge billingState if json.Unmarshal(advanced.Billing, &charge) != nil { @@ -131,13 +139,84 @@ func (p *SettlementProcessor) Advance(ctx context.Context, job jobs.Job) (jobs.J return p.write(ctx, advanced, charge.raw) } +func (p *SettlementProcessor) settleSeedreamLayers(ctx context.Context, job jobs.Job) (jobs.Job, error) { + var charge billingState + if json.Unmarshal(job.Billing, &charge) != nil { + return jobs.Job{}, errors.New("settle seedream layer charge") + } + status, _ := charge.raw["settlementStatus"].(string) + if status == "settled" || status == "estimated" { + return job, nil + } + var use usageContext + if len(job.UsageContext) != 0 && json.Unmarshal(job.UsageContext, &use) != nil { + return jobs.Job{}, errors.New("settle seedream layer charge") + } + quotaExempt := charge.QuotaExempt || use.Source == "platform" && use.Role == "super_admin" + chargeReady := charge.Status == "charged" || quotaExempt && charge.Status == "not_charged" + if !chargeReady || !quotaExempt && use.OrganizationID == "" { + return job, nil + } + + images := seedreamLayerImages(job.ResponsePayload) + if len(images) == 0 { + charge.raw["settlementStatus"] = "estimated" + charge.raw["settlementReason"] = "provider_layer_metadata_unavailable" + charge.raw["settledAt"] = p.now().UTC().Format(time.RFC3339Nano) + return p.write(ctx, job, charge.raw) + } + actualAmount, err := billing.CalculateSeedreamLayerAmountFen(images, numberOrZero(charge.raw["markupMultiplier"])) + if err != nil { + return jobs.Job{}, errors.New("settle seedream layer charge") + } + delta := actualAmount - charge.AmountFen + settledAt := p.now().UTC() + var settlementLedgerID string + if delta != 0 && !quotaExempt { + if p.ledger == nil { + return jobs.Job{}, errors.New("settle seedream layer charge") + } + description := capabilityLabel(job.Capability) + "图层拆分实际费用退回" + if delta > 0 { + description = capabilityLabel(job.Capability) + "图层拆分实际费用补扣" + } + posting, postErr := p.ledger.Settle(ctx, billing.SettlementRequest{ + OrganizationID: use.OrganizationID, AccountID: use.AccountID, JobID: job.ID, + DeltaFen: delta, Description: description, + Metadata: map[string]any{ + "operation": "seedream_layer_actual_settlement", "reservedAmountFen": charge.AmountFen, + "actualAmountFen": actualAmount, "outputCount": len(images), "model": job.ReqKey, + }, + }) + if postErr != nil { + return jobs.Job{}, errors.New("settle seedream layer charge") + } + settlementLedgerID = posting.LedgerID + if !posting.CreatedAt.IsZero() { + settledAt = posting.CreatedAt.UTC() + } + } + charge.raw["amountFen"] = actualAmount + charge.raw["settlementStatus"] = "settled" + charge.raw["settledAt"] = settledAt.Format(time.RFC3339Nano) + if settlementLedgerID != "" { + charge.raw["settlementLedgerEntryId"] = settlementLedgerID + } else { + delete(charge.raw, "settlementLedgerEntryId") + } + charge.raw["providerUsage"] = map[string]any{ + "model": job.ReqKey, "layerDecomposition": true, "generatedImages": len(images), + } + return p.write(ctx, job, charge.raw) +} + func (p *SettlementProcessor) write(ctx context.Context, job jobs.Job, snapshot map[string]any) (jobs.Job, error) { if p.state == nil { - return jobs.Job{}, errors.New("persist seedance generation settlement") + return jobs.Job{}, errors.New("persist generation settlement") } encoded, err := json.Marshal(snapshot) if err != nil { - return jobs.Job{}, errors.New("persist seedance generation settlement") + return jobs.Job{}, errors.New("persist generation settlement") } var writeErr error if fenced, ok := p.state.(FencedSettlementStateWriter); ok && job.LockedBy != "" { @@ -163,6 +242,51 @@ func seedanceCompletionTokens(payload json.RawMessage) int64 { return value } +func seedreamLayerRequest(payload json.RawMessage) bool { + var request struct { + Settings map[string]any `json:"settings"` + } + if json.Unmarshal(payload, &request) != nil { + return false + } + value, _ := request.Settings["layerDecomposition"].(bool) + creationMode, _ := request.Settings["creationMode"].(string) + return value || strings.EqualFold(strings.TrimSpace(creationMode), "layers") +} + +func seedreamLayerImages(payload json.RawMessage) []billing.SeedreamLayerImage { + var response struct { + Raw json.RawMessage `json:"raw"` + } + if json.Unmarshal(payload, &response) != nil || len(response.Raw) == 0 { + return nil + } + var providerResponse struct { + Data []struct { + Size string `json:"size"` + } `json:"data"` + } + if json.Unmarshal(response.Raw, &providerResponse) != nil || len(providerResponse.Data) == 0 || len(providerResponse.Data) > 17 { + return nil + } + images := make([]billing.SeedreamLayerImage, 0, len(providerResponse.Data)) + for _, output := range providerResponse.Data { + parts := strings.FieldsFunc(strings.TrimSpace(output.Size), func(character rune) bool { + return character == 'x' || character == 'X' || character == '×' || character == '*' + }) + if len(parts) != 2 { + return nil + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return nil + } + images = append(images, billing.SeedreamLayerImage{Width: width, Height: height}) + } + return images +} + func seedanceResolution(snapshot map[string]any) string { parameters, _ := snapshot["parameters"].(map[string]any) resolution, _ := parameters["resolution"].(string) diff --git a/backend/internal/orchestration/settlement_test.go b/backend/internal/orchestration/settlement_test.go index e4bb1af..8543128 100644 --- a/backend/internal/orchestration/settlement_test.go +++ b/backend/internal/orchestration/settlement_test.go @@ -102,6 +102,28 @@ func TestSettlementProcessorLeavesNonSeedanceSuccessAlone(t *testing.T) { } } +func TestSettlementProcessorRefundsUnusedSeedreamLayerReservation(t *testing.T) { + state := &settlementStateStub{} + ledger := &settlementLedgerStub{posting: billing.WalletPosting{LedgerID: "layer-settlement", CreatedAt: time.Date(2026, 8, 21, 8, 0, 0, 0, time.UTC)}} + advanced := jobs.Job{ + ID: "layer-job", Provider: "seedream", ReqKey: billing.Seedream50ProModel, Capability: "image.generate", Status: jobs.StatusSucceeded, + RequestPayload: json.RawMessage(`{"settings":{"creationMode":"layers","layerDecomposition":true,"size":"2K"}}`), + ResponsePayload: json.RawMessage(`{"status":"succeeded","raw":{"data":[{"size":"2048x2048","z_index":0},{"size":"1273x265","z_index":1}]}}`), + Billing: json.RawMessage(`{"status":"charged","amountFen":612,"reservedAmountFen":612,"markupMultiplier":1.2,"settlementStatus":"pending"}`), + UsageContext: json.RawMessage(`{"organizationId":"org-1","accountId":"account-1"}`), + } + processor := NewSettlementProcessor(settlementProcessorStub{job: advanced}, ledger, state, time.Now) + got, err := processor.Advance(context.Background(), jobs.Job{ID: "layer-job", Provider: "seedream", Status: jobs.StatusRunning}) + if err != nil || ledger.calls != 1 || ledger.request.DeltaFen != -558 || ledger.request.Metadata["actualAmountFen"] != int64(54) { + t.Fatalf("job=%#v request=%#v calls=%d err=%v", got, ledger.request, ledger.calls, err) + } + var snapshot map[string]any + _ = json.Unmarshal(got.Billing, &snapshot) + if snapshot["amountFen"] != float64(54) || snapshot["settlementStatus"] != "settled" || snapshot["settlementLedgerEntryId"] != "layer-settlement" { + t.Fatalf("billing snapshot=%#v", snapshot) + } +} + func seedanceSettlementJob(response, billingJSON string) jobs.Job { return jobs.Job{ ID: "job-1", Provider: "seedance", Capability: "video.generate", Status: jobs.StatusSucceeded, diff --git a/backend/internal/postgres/generation_state.go b/backend/internal/postgres/generation_state.go index 56fc70b..147f1ac 100644 --- a/backend/internal/postgres/generation_state.go +++ b/backend/internal/postgres/generation_state.go @@ -51,7 +51,11 @@ func (db *Database) ChargeAndActivateCreation(ctx context.Context, request billi } var posting WalletEntry if !rows.Next() { + rowsErr := rows.Err() rows.Close() + if rowsErr != nil { + return nil, fmt.Errorf("charge and activate generation creation: read wallet: %w", rowsErr) + } return nil, fmt.Errorf("charge and activate generation creation: wallet returned no row") } if err := rows.Scan(&posting.LedgerID, &posting.BalanceAfterFen, &posting.BalanceFen, &posting.TotalRechargedFen, &posting.TotalChargedFen, &posting.CreatedAt, &posting.UpdatedAt, &posting.DeltaFen); err != nil { @@ -79,7 +83,11 @@ func (db *Database) ChargeAndActivateCreation(ctx context.Context, request billi return nil, fmt.Errorf("charge and activate generation creation: activate job: %w", err) } if !activation.Next() { + rowsErr := activation.Err() activation.Close() + if rowsErr != nil { + return nil, fmt.Errorf("charge and activate generation creation: read activation: %w", rowsErr) + } return nil, fmt.Errorf("charge and activate generation creation: job is not pending") } var id string diff --git a/backend/internal/postgres/generation_state_test.go b/backend/internal/postgres/generation_state_test.go index 51c8104..1a97d3d 100644 --- a/backend/internal/postgres/generation_state_test.go +++ b/backend/internal/postgres/generation_state_test.go @@ -48,6 +48,34 @@ func TestChargeAndActivateCreationRollsBackWhenActivationFails(t *testing.T) { } } +func TestChargeAndActivateCreationPreservesDeferredWalletError(t *testing.T) { + deferred := &pgconn.PgError{Code: "P0001", Message: "BILLING_INSUFFICIENT_BALANCE"} + tx := &generationStateTransaction{results: []*jobRows{{err: deferred}}} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + + _, err := db.ChargeAndActivateCreation(context.Background(), billing.ChargeRequest{OrganizationID: "org", JobID: "job", AmountFen: 39}, json.RawMessage(`{"status":"pending"}`)) + var postgresErr *pgconn.PgError + if !errors.As(err, &postgresErr) || postgresErr.Code != "P0001" || tx.rollbacks != 1 { + t.Fatalf("err=%v postgres=%#v rollbacks=%d", err, postgresErr, tx.rollbacks) + } +} + +func TestChargeAndActivateCreationPreservesDeferredActivationError(t *testing.T) { + now := time.Now() + deferred := &pgconn.PgError{Code: "40001", Message: "could not serialize access"} + tx := &generationStateTransaction{results: []*jobRows{ + {rows: [][]any{{"ledger-1", int64(1), int64(1), int64(1), int64(1), now, now, int64(-1)}}}, + {err: deferred}, + }} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + + _, err := db.ChargeAndActivateCreation(context.Background(), billing.ChargeRequest{OrganizationID: "org", JobID: "job", AmountFen: 1}, json.RawMessage(`{"status":"pending"}`)) + var postgresErr *pgconn.PgError + if !errors.As(err, &postgresErr) || postgresErr.Code != "40001" || tx.rollbacks != 1 { + t.Fatalf("err=%v postgres=%#v rollbacks=%d", err, postgresErr, tx.rollbacks) + } +} + func TestChargeAndActivateCreationTreatsCommittedButLostResponseAsSuccess(t *testing.T) { now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) charged := json.RawMessage(`{"status":"charged","amountFen":35,"ledgerEntryId":"ledger-1"}`) diff --git a/backend/internal/providers/providers_test.go b/backend/internal/providers/providers_test.go index 4c3b36a..f25a24b 100644 --- a/backend/internal/providers/providers_test.go +++ b/backend/internal/providers/providers_test.go @@ -666,6 +666,31 @@ func TestSeedreamPayloadAndSynchronousResponse(t *testing.T) { } } +func TestSeedreamLayerDecompositionPayloadAndResponseMetadata(t *testing.T) { + client := roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["image"] != "https://in.test/source.png" || body["layer_decomposition"] != true || body["prompt"] != nil || body["optimize_prompt_options"] != nil { + t.Fatalf("body=%#v", body) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{ + "data":[ + {"url":"https://cdn.test/base.jpeg","size":"2048x2048","z_index":0}, + {"url":"https://cdn.test/title.png","size":"500x100","z_index":1,"name":"标题"} + ] + }`)), Header: http.Header{}}, nil + }) + result, err := NewSeedream(Config{BaseURL: "https://ark.test/api/v3", APIKey: "secret", Model: Seedream50ProModel}, client).Submit(context.Background(), Request{ + Capability: "image.generate", InputURLs: []string{"https://in.test/source.png"}, + Settings: map[string]any{"size": "2K", "outputFormat": "jpeg", "layerDecomposition": true}, + }) + if err != nil || result.Status != StatusSucceeded || !reflect.DeepEqual(result.OutputURLs, []string{"https://cdn.test/base.jpeg", "https://cdn.test/title.png"}) || !strings.Contains(string(result.Raw), `"z_index":1`) { + t.Fatalf("result=%#v err=%v", result, err) + } +} + func TestMockIsDeterministic(t *testing.T) { m := NewMock("fixture") a, _ := m.Submit(context.Background(), Request{Capability: "video.generate", Prompt: "hello"}) diff --git a/backend/internal/providers/seedream.go b/backend/internal/providers/seedream.go index 47ca155..fadd42a 100644 --- a/backend/internal/providers/seedream.go +++ b/backend/internal/providers/seedream.go @@ -26,14 +26,21 @@ func NewSeedream(c Config, client HTTPClient) *Seedream { payload: func(r Request) any { payload := map[string]any{ "model": requestModel(r, c.Model), - "prompt": r.Prompt, "size": seedreamSetting(r.Settings, "size", "1.5K"), "output_format": seedreamSetting(r.Settings, "outputFormat", "png"), "response_format": "url", "watermark": false, - "optimize_prompt_options": map[string]any{ + } + if prompt := strings.TrimSpace(r.Prompt); prompt != "" { + payload["prompt"] = prompt + } + layerDecomposition, _ := r.Settings["layerDecomposition"].(bool) + if layerDecomposition { + payload["layer_decomposition"] = true + } else { + payload["optimize_prompt_options"] = map[string]any{ "mode": seedreamSetting(r.Settings, "optimizeMode", "standard"), - }, + } } switch len(r.InputURLs) { case 0: diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 5f1bf59..5ac431f 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; -import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Loader2, Music, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react"; +import { ArrowDown, ArrowUp, Check, CircleDollarSign, Download, Eye, EyeOff, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Move, Music, Pencil, Plus, RefreshCw, RotateCcw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react"; import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; +import { SeedreamWorkspace } from "@/components/seedream-workspace"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { SEEDANCE_20_MODEL, @@ -26,7 +27,20 @@ import { } from "@/lib/prompt/material-draft"; import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; import { formatBillingAmount } from "@/lib/billing"; -import { assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls"; +import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls"; +import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose"; +import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup"; +import { downloadFilesAsZip } from "@/lib/client/zip-download"; +import { + buildSeedreamInteractivePrompt, + buildSeedreamLayerPrompt, + missingSeedreamAnnotationTokens, + seedreamAnnotationReferences, + seedreamAnnotationToken, + seedreamCreationModeLabel, + type SeedreamAnnotation, + type SeedreamCreationMode +} from "@/lib/seedream/creation"; import type { BillingQuote } from "@/lib/types"; type GenerateMode = "image" | "video"; @@ -52,7 +66,7 @@ type MaterialDraftOption = { token: string; label: string; caption: string; - type: MaterialKind; + type: MaterialKind | "annotation"; }; type HealthCapability = { @@ -109,6 +123,7 @@ const evolinkQualityOptions = [ ]; const seedreamSizeOptions = ["1K", "1.5K", "2K"] as const; +const seedreamLayerSizeOptions = ["auto", "1K", "1.5K", "2K"] as const; const seedreamOutputFormatOptions = [ { id: "png", label: "PNG" }, { id: "jpeg", label: "JPEG" } @@ -191,8 +206,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); const [seedreamSize, setSeedreamSize] = useState("1.5K"); + const [seedreamLayerSize, setSeedreamLayerSize] = useState("auto"); const [seedreamOutputFormat, setSeedreamOutputFormat] = useState("png"); const [seedreamOptimizeMode, setSeedreamOptimizeMode] = useState("standard"); + const [imageCreationMode, setImageCreationMode] = useState("basic"); + const [seedreamSourceKey, setSeedreamSourceKey] = useState(null); + const [seedreamInteractiveSourceKeys, setSeedreamInteractiveSourceKeys] = useState([]); + const [seedreamInteractiveAnnotations, setSeedreamInteractiveAnnotations] = useState([]); + const [seedreamLayerAnnotations, setSeedreamLayerAnnotations] = useState([]); const [videoEngine, setVideoEngine] = useState("bailian"); const [videoModel, setVideoModel] = useState(SEEDANCE_20_MODEL); const [videoRatio, setVideoRatio] = useState("9:16"); @@ -223,14 +244,78 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const templateFormPlaceholders = useMemo(() => extractMaterialPlaceholders(templateForm.prompt), [templateForm.prompt]); const materialPlaceholders = useMemo(() => extractMaterialPlaceholders(prompt), [prompt]); const referencedMaterialLabels = useMemo(() => new Set(materialPlaceholders.map((placeholder) => placeholder.token)), [materialPlaceholders]); + const seedreamImageMaterials = useMemo(() => materials.filter((material) => material.type === "image"), [materials]); + const selectedSeedreamInteractiveSources = useMemo(() => { + const byKey = new Map(seedreamImageMaterials.map((material) => [materialKey(material), material])); + return seedreamInteractiveSourceKeys.map((key) => byKey.get(key)).filter((material): material is PromptMaterial => Boolean(material)); + }, [seedreamImageMaterials, seedreamInteractiveSourceKeys]); + const selectedSeedreamSource = useMemo(() => { + if (imageCreationMode === "interactive") { + return selectedSeedreamInteractiveSources.find((material) => materialKey(material) === seedreamSourceKey) + || selectedSeedreamInteractiveSources[0]; + } + const selected = seedreamImageMaterials.find((material) => materialKey(material) === seedreamSourceKey); + return selected || (seedreamImageMaterials.length === 1 ? seedreamImageMaterials[0] : undefined); + }, [imageCreationMode, seedreamImageMaterials, seedreamSourceKey, selectedSeedreamInteractiveSources]); + const selectedSeedreamSources = useMemo( + () => imageCreationMode === "interactive" + ? selectedSeedreamInteractiveSources + : selectedSeedreamSource ? [selectedSeedreamSource] : [], + [imageCreationMode, selectedSeedreamInteractiveSources, selectedSeedreamSource] + ); + const selectedSeedreamSourceIdentity = selectedSeedreamSource ? materialKey(selectedSeedreamSource) : null; + const seedreamAnnotations = imageCreationMode === "layers" ? seedreamLayerAnnotations : seedreamInteractiveAnnotations; + const effectiveSeedreamSize = imageCreationMode === "layers" ? seedreamLayerSize : seedreamSize; + const selectedSeedreamSourceKeys = useMemo(() => new Set(selectedSeedreamSources.map(materialKey)), [selectedSeedreamSources]); + const seedreamRequestAnnotations = useMemo( + () => seedreamAnnotations.filter((annotation) => !annotation.sourceKey || selectedSeedreamSourceKeys.has(annotation.sourceKey)), + [seedreamAnnotations, selectedSeedreamSourceKeys] + ); + const specializedImageMode = generateMode === "image" && imageCreationMode !== "basic"; + const unsupportedSpecializedPlaceholders = useMemo( + () => specializedImageMode ? materialPlaceholders.filter((placeholder) => ( + placeholder.type !== "image" + || imageCreationMode === "layers" && placeholder.index > 1 + || imageCreationMode === "interactive" && placeholder.index > 10 + )) : [], + [imageCreationMode, materialPlaceholders, specializedImageMode] + ); const missingMaterialPlaceholders = useMemo(() => { const existing = new Set(materials.map((material) => normalizeMaterialLabel(material.label)).filter((label): label is string => Boolean(label))); - return materialPlaceholders.filter((placeholder) => !existing.has(placeholder.token)); - }, [materialPlaceholders, materials]); - const submitDisabled = busy || !prompt.trim() || missingMaterialPlaceholders.length > 0; - const submitTitle = missingMaterialPlaceholders.length - ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}` - : generateMode === "image" ? "生成图片" : "生成视频"; + return materialPlaceholders.filter((placeholder) => { + if (specializedImageMode && placeholder.type !== "image") return false; + if (specializedImageMode && placeholder.type === "image") { + const supportedIndex = imageCreationMode === "layers" ? placeholder.index === 1 : placeholder.index <= 10; + return supportedIndex && selectedSeedreamSources.length < placeholder.index; + } + return !existing.has(placeholder.token); + }); + }, [imageCreationMode, materialPlaceholders, materials, selectedSeedreamSources.length, specializedImageMode]); + const missingAnnotationPlaceholders = useMemo( + () => generateMode === "image" && imageCreationMode !== "basic" ? missingSeedreamAnnotationTokens(prompt, seedreamRequestAnnotations) : [], + [generateMode, imageCreationMode, prompt, seedreamRequestAnnotations] + ); + const sourceRequired = specializedImageMode && selectedSeedreamSources.length === 0; + const annotationRequired = generateMode === "image" && imageCreationMode === "interactive" && seedreamRequestAnnotations.length === 0; + const promptRequired = !(generateMode === "image" && imageCreationMode === "layers"); + const generationInputReady = (!promptRequired || Boolean(prompt.trim())) + && !sourceRequired + && !annotationRequired + && unsupportedSpecializedPlaceholders.length === 0 + && missingMaterialPlaceholders.length === 0 + && missingAnnotationPlaceholders.length === 0; + const submitDisabled = busy || !generationInputReady; + const submitTitle = unsupportedSpecializedPlaceholders.length + ? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}` + : missingMaterialPlaceholders.length + ? `请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}` + : missingAnnotationPlaceholders.length + ? `请删除或重新创建 ${missingAnnotationPlaceholders.join("、")}` + : sourceRequired + ? "请先选择一张底图" + : annotationRequired + ? "请先框选或点选编辑位置" + : submitActionLabel(generateMode, imageCreationMode); const visibleMaterials = pageItems(materials, materialPage, MATERIAL_PAGE_SIZE); const materialPageOffset = (clampPage(materialPage, materials.length, MATERIAL_PAGE_SIZE) - 1) * MATERIAL_PAGE_SIZE; const mentionSuggestions = useMemo(() => { @@ -243,9 +328,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate target: materialDraft.target, query: materialDraft.query, prompt: materialDraft.target === "template" ? templateForm.prompt : prompt, - materials + materials, + annotations: materialDraft.target === "prompt" && specializedImageMode ? seedreamRequestAnnotations : [] }); - }, [materialDraft, materials, prompt, templateForm.prompt]); + }, [materialDraft, materials, prompt, seedreamRequestAnnotations, specializedImageMode, templateForm.prompt]); const taskAssetById = useMemo(() => { const map = new Map(); for (const asset of taskAssets) map.set(asset.id, asset); @@ -271,13 +357,30 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setMaterialPage((page) => clampPage(page, materials.length, MATERIAL_PAGE_SIZE)); }, [materials.length]); + useEffect(() => { + const validKeys = new Set(seedreamImageMaterials.map(materialKey)); + setSeedreamInteractiveSourceKeys((keys) => keys.filter((key) => validKeys.has(key))); + setSeedreamInteractiveAnnotations((items) => items.filter((annotation) => !annotation.sourceKey || validKeys.has(annotation.sourceKey))); + setSeedreamLayerAnnotations((items) => items.filter((annotation) => !annotation.sourceKey || validKeys.has(annotation.sourceKey))); + }, [seedreamImageMaterials]); + + useEffect(() => { + if (imageCreationMode === "interactive") { + if (seedreamSourceKey && seedreamInteractiveSourceKeys.includes(seedreamSourceKey)) return; + setSeedreamSourceKey(seedreamInteractiveSourceKeys[0] || null); + return; + } + if (seedreamSourceKey && seedreamImageMaterials.some((material) => materialKey(material) === seedreamSourceKey)) return; + setSeedreamSourceKey(seedreamImageMaterials.length === 1 ? materialKey(seedreamImageMaterials[0]) : null); + }, [imageCreationMode, seedreamImageMaterials, seedreamInteractiveSourceKeys, seedreamSourceKey]); + useEffect(() => { return runScopedMotion(studioRef, (scope) => revealChildren(scope)); }, []); useEffect(() => { crossfadeIn(modePanelRef.current); - }, [mode]); + }, [mode, imageCreationMode]); useEffect(() => { pulseFeedback(feedbackRef.current); @@ -355,7 +458,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate useEffect(() => { let active = true; const timer = window.setTimeout(async () => { - if (!prompt.trim() || missingMaterialPlaceholders.length) { + if (!generationInputReady) { if (active) setBillingQuote(null); if (active) setBillingQuoteError(null); if (active) setBillingQuoteLoading(false); @@ -392,7 +495,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate active = false; window.clearTimeout(timer); }; - }, [generateMode, imageEngine, imageSize.height, imageSize.width, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, missingMaterialPlaceholders.length, prompt]); + }, [generateMode, imageCreationMode, imageEngine, imageSize.height, imageSize.width, seedreamInteractiveSourceKeys, seedreamRequestAnnotations, seedreamLayerSize, seedreamOptimizeMode, seedreamOutputFormat, seedreamSize, seedreamSourceKey, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoModel, videoRatio, videoResolution, materials, generationInputReady, prompt]); async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); @@ -449,6 +552,57 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setPromptByMode((items) => ({ ...items, [generateMode]: value })); } + function selectImageCreationMode(nextMode: SeedreamCreationMode) { + setImageCreationMode(nextMode); + setMentionState(null); + setMaterialDraft(null); + setError(null); + setNotice(null); + if (nextMode !== "basic") { + setImageEngine("seedream"); + if (nextMode === "interactive") { + const availableKeys = new Set(seedreamImageMaterials.map(materialKey)); + const preservedKeys = seedreamInteractiveSourceKeys.filter((key) => availableKeys.has(key)); + const initialKey = seedreamSourceKey && availableKeys.has(seedreamSourceKey) + ? seedreamSourceKey + : seedreamImageMaterials[0] ? materialKey(seedreamImageMaterials[0]) : null; + const nextKeys = preservedKeys.length ? preservedKeys : initialKey ? [initialKey] : []; + setSeedreamInteractiveSourceKeys(nextKeys); + if (!seedreamSourceKey || !nextKeys.includes(seedreamSourceKey)) setSeedreamSourceKey(nextKeys[0] || null); + } else if (!seedreamSourceKey || !seedreamImageMaterials.some((material) => materialKey(material) === seedreamSourceKey)) { + setSeedreamSourceKey(seedreamImageMaterials[0] ? materialKey(seedreamImageMaterials[0]) : null); + } + } + } + + function toggleSeedreamInteractiveSource(material: PromptMaterial) { + const key = materialKey(material); + if (seedreamInteractiveSourceKeys.includes(key)) { + const nextKeys = seedreamInteractiveSourceKeys.filter((value) => value !== key); + setSeedreamInteractiveSourceKeys(nextKeys); + setSeedreamInteractiveAnnotations((items) => items.filter((annotation) => annotation.sourceKey !== key)); + if (seedreamSourceKey === key) setSeedreamSourceKey(nextKeys[0] || null); + return; + } + if (seedreamInteractiveSourceKeys.length >= 10) { + setError("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); + return; + } + setSeedreamInteractiveSourceKeys((keys) => [...keys, key]); + setSeedreamSourceKey(key); + setError(null); + } + + function selectSeedreamWorkspaceSource(material: PromptMaterial) { + const key = materialKey(material); + if (imageCreationMode === "interactive") { + if (seedreamInteractiveSourceKeys.includes(key)) setSeedreamSourceKey(key); + return; + } + if (key !== selectedSeedreamSourceIdentity) setSeedreamLayerAnnotations([]); + setSeedreamSourceKey(key); + } + function handlePromptInput(value: string, cursor: number) { const draft = detectMaterialDraftStart(prompt, value, cursor); if (draft) { @@ -476,14 +630,27 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate return payload.assets as Asset[]; } - async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind } = {}) { - const selectedFiles = Array.from(files || []); + async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind; seedreamSourceMode?: "single" | "append" } = {}) { + const allFiles = Array.from(files || []); + const remainingInteractiveSlots = Math.max(0, 10 - seedreamInteractiveSourceKeys.length); + const selectedFiles = options.seedreamSourceMode === "single" + ? allFiles.slice(0, 1) + : options.seedreamSourceMode === "append" + ? allFiles.slice(0, remainingInteractiveSlots) + : allFiles; const expectedType = options.expectedType; - if (!selectedFiles.length) return; + if (!selectedFiles.length) { + if (allFiles.length && options.seedreamSourceMode === "append") setError("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); + return; + } if (expectedType && selectedFiles.some((file) => !fileMatchesMaterialKind(file, expectedType))) { setError(`请上传${shortTypeName(expectedType)}素材。`); return; } + if (options.seedreamSourceMode === "single" && selectedFiles.some((file) => !isSeedreamLayerInputFile(file))) { + setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。"); + return; + } setUploading(true); setError(null); setNotice(null); @@ -506,6 +673,16 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate }); return next; }); + if (options.seedreamSourceMode === "single" && uploaded[0]) { + setSeedreamSourceKey(uploaded[0].id || uploaded[0].url); + setSeedreamLayerAnnotations([]); + } + if (options.seedreamSourceMode === "append" && uploaded.length) { + const uploadedKeys = uploaded.map((asset) => asset.id || asset.url); + setSeedreamInteractiveSourceKeys((keys) => [...new Set([...keys, ...uploadedKeys])].slice(0, 10)); + setSeedreamSourceKey(uploadedKeys[0]); + if (allFiles.length > selectedFiles.length) setNotice(`已添加 ${selectedFiles.length} 张图片,交互编辑最多支持 10 张输入图。`); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -623,7 +800,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } else { setPromptByMode((items) => ({ ...items, [generateMode]: "" })); setImageSize(imageSizePresets[0]); - setImageEngine("jimeng"); + setImageEngine(imageCreationMode === "basic" ? "jimeng" : "seedream"); setJimengInfluence(jimengInfluenceOptions[1].id); setEvolinkQuality(evolinkQualityOptions[1].id); setSeedreamSize("1.5K"); @@ -639,7 +816,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setMaterialDraft(null); if (generateMode === "image") { const templateEngine = normalizeImageEngine(template.settings.engine); - if (templateEngine) setImageEngine(templateEngine); + if (templateEngine) setImageEngine(imageCreationMode === "basic" ? templateEngine : "seedream"); const preset = imageSizePresets.find((item) => item.width === template.settings.width && item.height === template.settings.height); if (preset) setImageSize(preset); const templateInfluence = jimengInfluenceOptions.find((option) => option.scale === template.settings.scale); @@ -854,19 +1031,29 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } } - function buildGenerationBody(): Record { + function buildGenerationBody(seedreamMaterialOverride?: PromptMaterial[]): Record { if (generateMode === "image") { - if (imageEngine === "seedream") { + if (imageEngine === "seedream" || imageCreationMode !== "basic") { + const selectedMaterials = imageCreationMode === "basic" + ? materials.filter((material) => material.type === "image") + : seedreamMaterialOverride || selectedSeedreamSources.map((material, index) => ({ ...material, label: `@图片${index + 1}` })); + const submittedPrompt = imageCreationMode === "interactive" + ? buildSeedreamInteractivePrompt(prompt, seedreamRequestAnnotations, selectedSeedreamSources.map(materialKey)) + : imageCreationMode === "layers" + ? buildSeedreamLayerPrompt(prompt, seedreamRequestAnnotations) + : prompt; return { capability: "image.generate", - engine: imageEngine, - prompt, - materials: materials.filter((material) => material.type === "image"), + engine: "seedream", + prompt: submittedPrompt, + materials: selectedMaterials, settings: { - size: seedreamSize, + size: effectiveSeedreamSize, outputFormat: seedreamOutputFormat, optimizeMode: seedreamOptimizeMode, - imageCount: 1 + imageCount: 1, + creationMode: imageCreationMode, + ...(imageCreationMode === "layers" ? { layerDecomposition: true } : {}) } }; } @@ -898,7 +1085,29 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate }; } + async function prepareMarkedSeedreamMaterials() { + const prepared: PromptMaterial[] = selectedSeedreamSources.map((material, index) => ({ ...material, label: `@图片${index + 1}` })); + const markedFiles = await Promise.all(selectedSeedreamSources.map(async (material, index) => { + const key = materialKey(material); + const sourceAnnotations = seedreamRequestAnnotations.filter((annotation) => annotation.kind === "stroke" && (annotation.sourceKey ? annotation.sourceKey === key : index === 0)); + const file = await createSeedreamMarkedFile(material, sourceAnnotations, index + 1); + return file ? { index, file } : null; + })); + const composites = markedFiles.filter((entry): entry is { index: number; file: File } => Boolean(entry)); + if (!composites.length) return prepared; + const uploaded = await uploadAssetFiles(composites.map((entry) => entry.file)); + if (uploaded.length !== composites.length) throw new Error("标记图片上传不完整,请重试"); + composites.forEach((entry, uploadedIndex) => { + prepared[entry.index] = materialFromAsset(uploaded[uploadedIndex], [], `@图片${entry.index + 1}`); + }); + return prepared; + } + async function submit() { + if (unsupportedSpecializedPlaceholders.length) { + setError(`${imageCreationMode === "layers" ? "图层拆分仅支持 1 张输入图片" : "交互编辑仅支持最多 10 张输入图片,且不接受视频或音频"},请删除 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); + return; + } if (missingMaterialPlaceholders.length) { setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); return; @@ -908,16 +1117,28 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setNotice(null); try { const endpoint = generateMode === "image" ? "/api/generations/image" : "/api/generations/video"; - if (generateMode === "image" && imageEngine === "seedream" && materials.filter((material) => material.type === "image").length > 10) { + if (generateMode === "image" && imageEngine === "seedream" && imageCreationMode === "basic" && materials.filter((material) => material.type === "image").length > 10) { throw new Error("Seedream 5.0 Pro 最多支持 10 张参考图。"); } + if (generateMode === "image" && imageCreationMode !== "basic" && !selectedSeedreamSources.length) { + throw new Error("请先选择至少一张图片作为输入图。"); + } + if (generateMode === "image" && imageCreationMode === "interactive" && !seedreamRequestAnnotations.length) { + throw new Error("请先框选、点选或涂鸦至少一个编辑位置。"); + } + if (generateMode === "image" && imageCreationMode === "interactive" && selectedSeedreamSources.length > 10) { + throw new Error("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); + } if (generateMode === "video" && videoEngine === "bailian") { const imageCount = materials.filter((material) => material.type === "image").length; if (imageCount < 1 || imageCount > 2 || materials.some((material) => material.type !== "image")) { throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); } } - const body = buildGenerationBody(); + const markedMaterials = generateMode === "image" && imageCreationMode !== "basic" + ? await prepareMarkedSeedreamMaterials() + : undefined; + const body = buildGenerationBody(markedMaterials); const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -930,13 +1151,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); } void loadTaskModuleData(() => true, { silent: true }); - setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入任务模块,生成完成后结果可在任务详情中查看和下载。`); - setPrompt(""); - setActiveTemplateId(null); - setMaterials([]); - setMaterialPage(1); - setMentionState(null); - setPromptScrollTop(0); + setNotice(`${submitActionLabel(generateMode, imageCreationMode)}已提交。任务已进入任务模块,生成完成后结果可在任务详情中查看和下载。`); + if (!specializedImageMode) { + setPrompt(""); + setActiveTemplateId(null); + setMaterials([]); + setMaterialPage(1); + setMentionState(null); + setPromptScrollTop(0); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -963,11 +1186,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate type="button" disabled={submitDisabled} onClick={submit} - aria-label={generateMode === "image" ? "生成图片" : "生成视频"} + aria-label={submitActionLabel(generateMode, imageCreationMode)} title={submitTitle} > {busy ? : } - {generateMode === "image" ? "生成图片" : "生成视频"} + {submitActionLabel(generateMode, imageCreationMode)} @@ -1050,17 +1273,59 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate ) : null} -
+ {generateMode === "image" ? ( +
+ + + +
+ ) : null} + + {specializedImageMode ? ( + } + sources={seedreamImageMaterials} + selectedSources={selectedSeedreamSources} + source={selectedSeedreamSource} + uploading={uploading} + annotations={seedreamRequestAnnotations} + onAnnotationsChange={(nextAnnotations) => { + if (imageCreationMode === "layers") setSeedreamLayerAnnotations(nextAnnotations); + else setSeedreamInteractiveAnnotations(nextAnnotations); + }} + onSelectSource={selectSeedreamWorkspaceSource} + onToggleSource={toggleSeedreamInteractiveSource} + onUpload={(files) => void uploadFiles(files, { + expectedType: "image", + seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single" + })} + onInsertAnnotation={insertAtCursor} + /> + ) : null} + +
- +