diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 6a99b11c1..d8aa83ba8 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. -- Keep generated artifacts in the canvas and asset library together. Character animation may need a post-completion library fallback from the first returned frame when no direct asset is present; the helper implements it. +- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. ## Documentation Navigation @@ -107,6 +107,8 @@ client.generate_image( Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`. +For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame. + ## Guardrails - Do not change the fixed production base URL in generated examples. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 06fbf30a1..1597a2a12 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -46,7 +46,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | | Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | -| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion` | +| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | @@ -64,7 +64,8 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM - Pass `projectId` and `canvasCompletion` to write generated output into the canvas. - Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported. - UI extraction uses `assetFolderId` and `spritesheetLabel`. -- Character animation does not accept the same library fields. If its completed compact result lacks a direct `asset`, create a library record from the first returned frame; do not duplicate one when an asset already exists. +- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset. +- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`. - Reload project/library state after completion when full current state is required. ## Reference Field Mapping diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 6b7091c3c..82e70f48d 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -20,7 +20,7 @@ Before the first generation in a new conversation, obtain a canvas name unless t 2. Read the asset library. Reuse a folder with the same label or create one with the canvas name. 3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state. -Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation may return no direct library asset; after completion, create one from the first returned frame only when the compact result still lacks an asset. +Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame. ## Art Spec Routing diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index fe62ee1ee..1f3d04e3c 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -98,7 +98,9 @@ A minimal `canvasCompletion` is: `dialogId` is optional. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed. -Character animation may complete without a direct `asset` field. To preserve the canvas/library invariant, create a library asset from the first returned frame only if the compact result lacks one. Prefer `client.animate_character(..., canvasSession=session, canvasTitle="...")`, which implements this fallback. +Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame. + +For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence. ## Art Spec and Image Request @@ -178,7 +180,9 @@ For character animation from a local-only source, use actual dimensions and a st "ratio": "9:16", "frameCount": 40, "durationSeconds": 5, - "model": "seedance2.0-fast" + "model": "seedance2.0-fast", + "assetFolderId": "", + "assetLabel": "角色呼吸动画" } ``` diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 0e45ccf6d..bce78e20e 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -600,16 +600,14 @@ class GenarrativeExternalClient: source_layer_id: str, **fields: Any, ) -> Any: - session, asset_label = self._apply_canvas_session_fields( + self._apply_canvas_session_fields( fields, fields.get("canvasTitle", "角色动画"), source_width, source_height, - asset_label_field=None, + asset_label_field="assetLabel", ) prompt_text = self._apply_art_spec(fields, prompt_text) - fields.pop("assetFolderId", None) - fields.pop("assetLabel", None) idempotency_key = fields.pop("idempotencyKey", None) body = { "sourceLayerId": source_layer_id, @@ -624,34 +622,11 @@ class GenarrativeExternalClient: **fields, "model": "seedance2.0-fast", } - result = self.submit_and_wait_generation( + return self.submit_and_wait_generation( "/api/external/v1/editor/character-animations/generations", body, idempotency_key=idempotency_key, ) - if isinstance(session, dict) and isinstance(result, dict) and not result.get("asset"): - frames = result.get("frames") - first_frame = frames[0] if isinstance(frames, list) and frames else None - folder_id = normalize_optional_text(session.get("assetFolderId")) - if isinstance(first_frame, dict) and folder_id: - asset = self.create_asset( - folder_id, - asset_label, - first_frame["imageSrc"], - int(first_frame["width"]), - int(first_frame["height"]), - prompt=result.get("prompt"), - model=result.get("model"), - provider="ark", - taskId=result.get("taskId"), - assetKind="character-animation", - generationInputs={ - "frames": frames, - "previewVideoPath": result.get("previewVideoPath"), - }, - ) - result["asset"] = unwrap_envelope(asset).get("asset") - return result def generate_video(self, prompt: str, **fields: Any) -> Any: fields.pop("mode", None) @@ -732,14 +707,31 @@ def _self_test() -> None: "timeout": timeout, "headers": headers, }) - if path == "/api/external/v1/editor/assets": - return {"asset": {"assetId": "editor-asset-demo"}} generated = { "taskId": "task-demo", "model": "seedance2.0-fast", "prompt": "角色呼吸", "previewVideoPath": "/generated/preview.mp4", - "frames": [{"frameIndex": 1, "imageSrc": "/generated/frame01.png", "width": 512, "height": 768}], + "frames": [{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}], + "resource": { + "resourceId": "editor-resource-demo", + "assetKind": "character-animation", + "sourceResourceId": "editor-resource-preview-demo", + "imageSequenceFrames": [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ], + "imageSequenceDurationMs": 4000, + }, + "asset": { + "assetId": "editor-asset-demo", + "assetKind": "character-animation", + "imageSequenceFrames": [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ], + "imageSequenceDurationMs": 4000, + }, } if method == "POST": return {"operationId": "task-operation-demo", "status": "queued", "pollAfterMs": 1} @@ -760,10 +752,14 @@ def _self_test() -> None: assert calls[0]["timeout"] == DEFAULT_REQUEST_TIMEOUT_SECONDS assert calls[0]["headers"]["Idempotency-Key"] assert calls[0]["body"]["projectId"] == "proj-demo" + assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo" + assert calls[0]["body"]["assetLabel"] == "角色呼吸动画" assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画" assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" - assert calls[2]["path"] == "/api/external/v1/editor/assets" assert result["asset"]["assetId"] == "editor-asset-demo" + assert result["asset"]["assetKind"] == "character-animation" + assert len(result["asset"]["imageSequenceFrames"]) == 2 + assert result["asset"]["imageSequenceDurationMs"] == 4000 calls.clear() client.generate_icon_spritesheet( "editor-resource-spec", diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 43030fd35..38c4a8e10 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -332,6 +332,14 @@ export interface AdminEditorAssetListQuery { limit?: number | null; } +export interface AdminEditorImageSequenceFramePayload { + imageSrc: string; + objectKey?: string | null; + assetObjectId?: string | null; + width: number; + height: number; +} + export interface AdminEditorAssetPayload { assetId: string; ownerUserId: string; @@ -362,6 +370,8 @@ export interface AdminEditorAssetPayload { taskGenerator: string; taskCostMudPoints: number; children: AdminEditorAssetPayload[]; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export interface AdminEditorAssetListResponse { @@ -413,6 +423,8 @@ export interface AdminEditorShowcaseAssetPayload { rejectedAt?: string | null; updatedAt: string; showcaseCategory?: string | null; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export interface AdminEditorShowcaseListResponse { diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx new file mode 100644 index 000000000..de3169194 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx @@ -0,0 +1,354 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { getAdminAssetReadUrl } from '../api/adminApiClient'; +import { AdminEditorAssetPreviewDialog } from './AdminEditorAssetMedia'; + +vi.mock('../api/adminApiClient', () => ({ + getAdminAssetReadUrl: vi.fn(), + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +test('角色动作预览跨窗口回播时复用父级换签缓存并等待目标帧就绪', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T10:50:00Z')); + const frameObjectKeys = [ + 'generated-animations/editor/task-cache/frame00.png', + 'generated-animations/editor/task-cache/frame01.png', + 'generated-animations/editor/task-cache/frame02.png', + 'generated-animations/editor/task-cache/frame03.png', + 'generated-animations/editor/task-cache/frame04.png', + ] as const; + const delayedFrame = createDeferred<{ + read: { + objectKey: string; + signedUrl: string; + expiresAt: string; + }; + }>(); + vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { + const objectKey = request.objectKey ?? ''; + if (objectKey === frameObjectKeys[3]) { + return delayedFrame.promise; + } + return Promise.resolve({ + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + }); + + render( + ({ + imageSrc: `/${objectKey}`, + objectKey, + width: 192, + height: 256, + })), + imageSequenceDurationMs: 500, + }} + token="admin-token" + onClose={vi.fn()} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '素材预览' }); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + const initialFrames = Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ); + expect(initialFrames).toHaveLength(3); + initialFrames.forEach((frame) => fireEvent.load(frame)); + + fireEvent.click(within(dialog).getByRole('button', { name: '播放角色动作' })); + for (const elapsedMs of [100, 40, 60, 40, 60]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(elapsedMs); + }); + } + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: frameObjectKeys[3], + expireSeconds: 300, + }); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[2]}`, + ); + + await act(async () => { + delayedFrame.resolve({ + read: { + objectKey: frameObjectKeys[3], + signedUrl: `https://signed.example.com/${frameObjectKeys[3]}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + await Promise.resolve(); + }); + const fourthFrame = resolveFrameByObjectKey(dialog, frameObjectKeys[3]); + expect(fourthFrame?.style.opacity).toBe('0'); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[2]}`, + ); + fireEvent.load(fourthFrame!); + fireEvent.load(resolveFrameByObjectKey(dialog, frameObjectKeys[4])!); + + for (let index = 0; index < 3; index += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + } + expect(within(dialog).getByText('1/5')).toBeTruthy(); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/${frameObjectKeys[0]}`, + ); + expect( + vi + .mocked(getAdminAssetReadUrl) + .mock.calls.filter( + ([, request]) => request.objectKey === frameObjectKeys[0], + ), + ).toHaveLength(1); + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame').length, + ).toBeLessThanOrEqual(3); +}); + +test('角色动作预览打开超过五分钟后回绕播放会在过期窗口内自动换签', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + const frameObjectKeys = Array.from( + { length: 5 }, + (_, index) => `generated-animations/editor/task-expiry/frame0${index}.png`, + ); + const requestCounts = new Map(); + vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { + const objectKey = request.objectKey ?? ''; + const requestCount = (requestCounts.get(objectKey) ?? 0) + 1; + requestCounts.set(objectKey, requestCount); + return Promise.resolve({ + read: { + objectKey, + signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`, + expiresAt: + requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z', + }, + }); + }); + + render( + ({ + imageSrc: `/${objectKey}`, + objectKey, + width: 192, + height: 256, + })), + imageSequenceDurationMs: 5_000, + }} + token="admin-token" + onClose={vi.fn()} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '素材预览' }); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + loadMountedFrames(dialog); + + for (let index = 0; index < frameObjectKeys.length; index += 1) { + fireEvent.click( + within(dialog).getByRole('button', { name: '播放角色动作' }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + fireEvent.click( + within(dialog).getByRole('button', { name: '暂停角色动作' }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + loadMountedFrames(dialog); + } + expect(within(dialog).getByText('1/5')).toBeTruthy(); + frameObjectKeys.forEach((objectKey) => { + expect(requestCounts.get(objectKey)).toBe(1); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300_001); + }); + loadMountedFrames(dialog); + expect(resolveVisibleFrameSrc(dialog)).toContain('/v2/'); + + for (let index = 0; index < frameObjectKeys.length; index += 1) { + fireEvent.click( + within(dialog).getByRole('button', { name: '播放角色动作' }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + fireEvent.click( + within(dialog).getByRole('button', { name: '暂停角色动作' }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + loadMountedFrames(dialog); + } + + expect(within(dialog).getByText('1/5')).toBeTruthy(); + expect(resolveVisibleFrameSrc(dialog)).toBe( + `https://signed.example.com/v2/${frameObjectKeys[0]}`, + ); + frameObjectKeys.forEach((objectKey) => { + expect(requestCounts.get(objectKey)).toBe(2); + }); + expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); +}); + +test('角色动作预览提前换签失败时保留已就绪帧并有限重试', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + const frameObjectKeys = [ + 'generated-animations/editor/task-refresh-failure/frame00.png', + 'generated-animations/editor/task-refresh-failure/frame01.png', + ] as const; + const requestCounts = new Map(); + vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { + const objectKey = request.objectKey ?? ''; + const requestCount = (requestCounts.get(objectKey) ?? 0) + 1; + requestCounts.set(objectKey, requestCount); + if (objectKey === frameObjectKeys[0] && requestCount > 1) { + return Promise.reject(new Error('refresh unavailable')); + } + return Promise.resolve({ + read: { + objectKey, + signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`, + expiresAt: + requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z', + }, + }); + }); + + render( + ({ + imageSrc: `/${objectKey}`, + objectKey, + width: 192, + height: 256, + })), + imageSequenceDurationMs: 2_000, + }} + token="admin-token" + onClose={vi.fn()} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '素材预览' }); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + loadMountedFrames(dialog); + const readyFrameSrc = resolveVisibleFrameSrc(dialog); + expect(readyFrameSrc).toBe( + `https://signed.example.com/v1/${frameObjectKeys[0]}`, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(270_100); + }); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); + expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); + + for (const retryWindowMs of [450, 1_250, 3_050]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(retryWindowMs); + }); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); + } + expect(requestCounts.get(frameObjectKeys[0])).toBe(5); + expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(requestCounts.get(frameObjectKeys[0])).toBe(5); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); +}); + +function loadMountedFrames(dialog: HTMLElement) { + dialog + .querySelectorAll('.admin-asset-query-sequence-frame') + .forEach((frame) => fireEvent.load(frame)); +} + +function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) { + return Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ).find((frame) => frame.src.endsWith(objectKey)); +} + +function resolveVisibleFrameSrc(dialog: HTMLElement) { + return Array.from( + dialog.querySelectorAll( + '.admin-asset-query-sequence-frame', + ), + ).find((frame) => frame.style.opacity === '1')?.src; +} diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index 9e93be572..c2f6fba40 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -1,8 +1,13 @@ -import { X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { Pause, Play, X } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + parseSignedReadUrlExpiresAtMs, + SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS, +} from '../../../../packages/shared/src/utils/signedReadUrlCache'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; +import type { AdminEditorImageSequenceFramePayload } from '../api/adminApiTypes'; const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; @@ -18,6 +23,8 @@ export interface AdminPreviewableEditorAsset { objectKey?: string | null; assetKind?: string | null; thumbnailSrc?: string | null; + imageSequenceFrames?: AdminEditorImageSequenceFramePayload[] | null; + imageSequenceDurationMs?: number | null; } export function AdminEditorAssetThumbnail({ @@ -31,7 +38,7 @@ export function AdminEditorAssetThumbnail({ }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); - const imageSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: imageSrc } = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, @@ -98,14 +105,53 @@ function AdminEditorAssetPreviewMedia({ token: string; }) { const mediaKind = resolveAdminAssetMediaKind(entry); + const label = entry.label || entry.assetId; + + if (mediaKind === 'image-sequence') { + const sequence = normalizeAdminImageSequence(entry); + return sequence ? ( + + ) : ( +
+ 序列数据损坏,无法预览 +
+ ); + } + + return ( + + ); +} + +function AdminEditorStandardAssetPreviewMedia({ + entry, + mediaKind, + token, +}: { + entry: AdminPreviewableEditorAsset; + mediaKind: Exclude; + token: string; +}) { const isAudio = mediaKind === 'audio'; const isVideo = mediaKind === 'video'; - const mediaSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: mediaSrc } = useAdminResolvedAssetUrl( token, entry.imageSrc, entry.objectKey, ); - const posterSrc = useAdminResolvedAssetUrl( + const { resolvedUrl: posterSrc } = useAdminResolvedAssetUrl( token, isVideo ? (entry.thumbnailSrc ?? '') : '', null, @@ -162,8 +208,542 @@ function AdminEditorAssetPreviewMedia({ ); } +function AdminEditorImageSequenceFrame({ + entry, + frameKey, + resolvedUrl, + visible, + ready, + onReady, + onFailed, +}: { + entry: AdminPreviewableEditorAsset; + frameKey: string; + resolvedUrl: string; + visible: boolean; + ready: boolean; + onReady: (frameKey: string) => void; + onFailed: (frameKey: string) => void; +}) { + const failureReportedRef = useRef(false); + const reportFailure = useCallback(() => { + if (failureReportedRef.current) { + return; + } + failureReportedRef.current = true; + onFailed(frameKey); + }, [frameKey, onFailed]); + + if (!resolvedUrl) { + return null; + } + return ( + {visible onReady(frameKey)} + onError={reportFailure} + /> + ); +} + +function AdminEditorImageSequencePreview({ + entry, + frames, + durationMs, + token, +}: { + entry: AdminPreviewableEditorAsset; + frames: AdminEditorImageSequenceFramePayload[]; + durationMs: number; + token: string; +}) { + const frameItems = useMemo( + () => + frames.map((frame, index) => ({ + frame, + index, + key: [entry.assetId, frame.objectKey ?? '', frame.imageSrc, index].join( + ':', + ), + })), + [entry.assetId, frames], + ); + const sequenceKey = frameItems.map((item) => item.key).join('|'); + const frameCacheOwnerKey = `${token}\u0000${sequenceKey}`; + const firstFrameKey = frameItems[0]?.key ?? ''; + const [frameIndex, setFrameIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(true); + const { + frameCache, + resolveFrames, + markFrameReady, + markFrameFailed, + retryFailedFrames: clearFailedFrameCache, + } = useAdminImageSequenceFrameCache( + token, + frameCacheOwnerKey, + frameItems.length, + ); + const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const failedFrameKeys = useMemo( + () => + new Set( + Array.from(frameCache.entries()).flatMap(([frameKey, cached]) => + cached.status === 'failed' ? [frameKey] : [], + ), + ), + [frameCache], + ); + const currentFrameItem = + frameItems[Math.min(frameIndex, frameItems.length - 1)]; + const currentFrameKey = currentFrameItem?.key ?? ''; + const mountedFrameKeys = useMemo(() => { + const nextMountedFrameKeys = new Set(); + if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) { + nextMountedFrameKeys.add(visibleFrameKey); + } + for ( + let offset = 0; + offset < frameItems.length && nextMountedFrameKeys.size < 3; + offset += 1 + ) { + const item = frameItems[(frameIndex + offset) % frameItems.length]; + if (item && !failedFrameKeys.has(item.key)) { + nextMountedFrameKeys.add(item.key); + } + } + return nextMountedFrameKeys; + }, [failedFrameKeys, frameIndex, frameItems, visibleFrameKey]); + const mountedFrameItems = useMemo( + () => frameItems.filter((item) => mountedFrameKeys.has(item.key)), + [frameItems, mountedFrameKeys], + ); + const availableFrameCount = frameItems.length - failedFrameKeys.size; + + const retryFailedFrames = useCallback(() => { + setFrameIndex(0); + setIsPlaying(true); + clearFailedFrameCache(); + }, [clearFailedFrameCache]); + + useEffect(() => { + setFrameIndex(0); + setIsPlaying(true); + setVisibleFrameKey(firstFrameKey); + }, [firstFrameKey, sequenceKey]); + + useEffect(() => { + resolveFrames(mountedFrameItems); + }, [mountedFrameItems, resolveFrames]); + + useEffect(() => { + const refreshAtMs = mountedFrameItems.reduce( + (earliest, item) => { + const cached = frameCache.get(item.key); + if ( + !cached || + cached.expiresAtMs === null || + cached.status === 'resolving' || + cached.status === 'refreshing' || + cached.status === 'failed' + ) { + return earliest; + } + const candidate = cached.nextRefreshAtMs; + if (candidate === null) { + return earliest; + } + return earliest === null || candidate < earliest ? candidate : earliest; + }, + null as number | null, + ); + if (refreshAtMs === null) { + return undefined; + } + const timer = window.setTimeout( + () => resolveFrames(mountedFrameItems), + Math.min(2_147_483_647, Math.max(0, refreshAtMs - Date.now())), + ); + return () => window.clearTimeout(timer); + }, [frameCache, mountedFrameItems, resolveFrames]); + + useEffect(() => { + if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) { + return; + } + const nextIndex = findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + frameIndex, + ); + if (nextIndex === null) { + setVisibleFrameKey(''); + setIsPlaying(false); + return; + } + setFrameIndex(nextIndex); + }, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]); + + useEffect(() => { + if (!currentFrameKey) { + setVisibleFrameKey(''); + return; + } + if ( + !isAdminImageSequenceFrameReady(frameCache.get(currentFrameKey)) && + visibleFrameKey + ) { + return; + } + setVisibleFrameKey(currentFrameKey); + }, [currentFrameKey, frameCache, visibleFrameKey]); + + useEffect(() => { + if (!isPlaying || availableFrameCount < 2) { + return undefined; + } + const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length); + const timer = window.setInterval(() => { + setFrameIndex( + (currentIndex) => + findNextAvailableFrameIndex( + frameItems, + failedFrameKeys, + currentIndex, + ) ?? currentIndex, + ); + }, frameIntervalMs); + return () => window.clearInterval(timer); + }, [ + availableFrameCount, + durationMs, + failedFrameKeys, + frameItems, + frames.length, + isPlaying, + ]); + + return ( +
+ {mountedFrameItems.map((item) => { + const cachedFrame = frameCache.get(item.key); + return ( + + ); + })} +
+ + {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`} + {failedFrameKeys.size ? ( + <> + {`${failedFrameKeys.size} 帧加载失败`} + + + ) : null} +
+
+ ); +} + +type AdminImageSequenceFrameItem = { + frame: AdminEditorImageSequenceFramePayload; + index: number; + key: string; +}; + +type AdminImageSequenceFrameCacheEntry = { + expiresAtMs: number | null; + nextRefreshAtMs: number | null; + refreshRetryIndex: number; + resolvedUrl: string; + status: 'resolving' | 'refreshing' | 'loading' | 'ready' | 'failed'; +}; + +type AdminImageSequenceFrameCacheState = { + ownerKey: string; + entries: ReadonlyMap; +}; + +const EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE = new Map< + string, + AdminImageSequenceFrameCacheEntry +>(); + +function useAdminImageSequenceFrameCache( + token: string, + ownerKey: string, + maximumEntryCount: number, +) { + const [cacheState, setCacheState] = + useState(() => ({ + ownerKey, + entries: new Map(), + })); + const resolutionControllersRef = useRef(new Map()); + const frameCache = + cacheState.ownerKey === ownerKey + ? cacheState.entries + : EMPTY_ADMIN_IMAGE_SEQUENCE_FRAME_CACHE; + + useEffect(() => { + const resolutionControllers = resolutionControllersRef.current; + resolutionControllers.forEach((controller) => controller.abort()); + resolutionControllers.clear(); + setCacheState({ ownerKey, entries: new Map() }); + return () => { + resolutionControllers.forEach((controller) => controller.abort()); + resolutionControllers.clear(); + }; + }, [ownerKey]); + + const updateFrameCacheEntry = useCallback( + ( + frameKey: string, + update: ( + current: AdminImageSequenceFrameCacheEntry | undefined, + ) => AdminImageSequenceFrameCacheEntry | null, + ) => { + setCacheState((currentState) => { + const currentEntries = + currentState.ownerKey === ownerKey + ? currentState.entries + : new Map(); + const nextEntry = update(currentEntries.get(frameKey)); + if (nextEntry === currentEntries.get(frameKey)) { + return currentState; + } + const nextEntries = new Map(currentEntries); + if (nextEntry) { + if ( + !nextEntries.has(frameKey) && + nextEntries.size >= maximumEntryCount + ) { + return currentState; + } + nextEntries.set(frameKey, nextEntry); + } else { + nextEntries.delete(frameKey); + } + return { ownerKey, entries: nextEntries }; + }); + }, + [maximumEntryCount, ownerKey], + ); + + const resolveFrames = useCallback( + (frameItemsToResolve: ReadonlyArray) => { + for (const item of frameItemsToResolve) { + const cached = frameCache.get(item.key); + if (resolutionControllersRef.current.has(item.key)) { + continue; + } + if (cached && shouldReuseAdminImageSequenceFrameCacheEntry(cached)) { + continue; + } + const refreshReadyFrame = isAdminImageSequenceFrameReady(cached); + const controller = new AbortController(); + resolutionControllersRef.current.set(item.key, controller); + updateFrameCacheEntry(item.key, (current) => + refreshReadyFrame && current?.resolvedUrl + ? { ...current, status: 'refreshing' } + : { + expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, + resolvedUrl: '', + status: 'resolving', + }, + ); + void resolveAdminAssetUrl( + token, + item.frame.imageSrc, + item.frame.objectKey, + controller.signal, + ) + .then((resolved) => { + updateFrameCacheEntry(item.key, () => ({ + ...resolved, + nextRefreshAtMs: + resolved.expiresAtMs === null + ? null + : resolved.expiresAtMs - + SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS, + refreshRetryIndex: 0, + status: refreshReadyFrame ? 'refreshing' : 'loading', + })); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + updateFrameCacheEntry(item.key, (current) => { + if (!isAdminImageSequenceFrameUrlUsable(current)) { + return { + expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, + resolvedUrl: '', + status: 'failed', + }; + } + const retryDelay = + isAdminApiError(error) && error.status === 429 + ? undefined + : ADMIN_ASSET_READ_RETRY_DELAYS_MS[ + current.refreshRetryIndex + ]; + return { + ...current, + nextRefreshAtMs: + typeof retryDelay === 'number' + ? Math.min( + current.expiresAtMs ?? Number.POSITIVE_INFINITY, + Date.now() + retryDelay, + ) + : current.expiresAtMs, + refreshRetryIndex: + typeof retryDelay === 'number' + ? current.refreshRetryIndex + 1 + : current.refreshRetryIndex, + status: 'ready', + }; + }); + } + }) + .finally(() => { + if (resolutionControllersRef.current.get(item.key) === controller) { + resolutionControllersRef.current.delete(item.key); + } + }); + } + }, + [frameCache, token, updateFrameCacheEntry], + ); + + const markFrameReady = useCallback( + (frameKey: string) => { + updateFrameCacheEntry(frameKey, (current) => + current?.resolvedUrl + ? { ...current, status: 'ready' } + : (current ?? null), + ); + }, + [updateFrameCacheEntry], + ); + + const markFrameFailed = useCallback( + (frameKey: string) => { + resolutionControllersRef.current.get(frameKey)?.abort(); + resolutionControllersRef.current.delete(frameKey); + updateFrameCacheEntry(frameKey, () => ({ + expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, + resolvedUrl: '', + status: 'failed', + })); + }, + [updateFrameCacheEntry], + ); + + const retryFailedFrames = useCallback(() => { + setCacheState((currentState) => { + if (currentState.ownerKey !== ownerKey) { + return { ownerKey, entries: new Map() }; + } + const nextEntries = new Map( + Array.from(currentState.entries).filter( + ([, cached]) => cached.status !== 'failed', + ), + ); + return { ownerKey, entries: nextEntries }; + }); + }, [ownerKey]); + + return { + frameCache, + resolveFrames, + markFrameReady, + markFrameFailed, + retryFailedFrames, + }; +} + +function isAdminImageSequenceFrameReady( + cached: AdminImageSequenceFrameCacheEntry | undefined, +) { + return cached?.status === 'ready' || cached?.status === 'refreshing'; +} + +function shouldReuseAdminImageSequenceFrameCacheEntry( + cached: AdminImageSequenceFrameCacheEntry, +) { + if (cached.status === 'failed') { + return false; + } + return cached.nextRefreshAtMs === null || cached.nextRefreshAtMs > Date.now(); +} + +function isAdminImageSequenceFrameUrlUsable( + cached: AdminImageSequenceFrameCacheEntry | undefined, +): cached is AdminImageSequenceFrameCacheEntry { + return Boolean( + cached?.resolvedUrl && + (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), + ); +} + +function findNextAvailableFrameIndex( + frameItems: ReadonlyArray<{ key: string }>, + failedFrameKeys: ReadonlySet, + currentIndex: number, +) { + for (let offset = 1; offset <= frameItems.length; offset += 1) { + const nextIndex = (currentIndex + offset) % frameItems.length; + const item = frameItems[nextIndex]; + if (item && !failedFrameKeys.has(item.key)) { + return nextIndex; + } + } + return null; +} + function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) { const mediaKind = resolveAdminAssetMediaKind(entry); + if (mediaKind === 'image-sequence') { + const sequence = normalizeAdminImageSequence(entry); + const firstFrame = sequence?.frames[0]; + return firstFrame + ? { src: firstFrame.imageSrc, objectKey: firstFrame.objectKey ?? null } + : { src: '', objectKey: null }; + } if (mediaKind === 'audio') { return { src: AUDIO_ASSET_COVER_SRC, objectKey: null }; } @@ -216,19 +796,58 @@ function useAdminAssetThumbnailVisibility() { return { observeElement, shouldLoad }; } -type AdminAssetMediaKind = 'image' | 'audio' | 'video'; +type AdminAssetMediaKind = 'image' | 'audio' | 'video' | 'image-sequence'; + +function normalizeAdminImageSequence(entry: AdminPreviewableEditorAsset) { + const frames = entry.imageSequenceFrames; + const durationMs = entry.imageSequenceDurationMs; + if ( + !Array.isArray(frames) || + frames.length < 2 || + typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs <= 0 + ) { + return null; + } + const normalizedFrames = frames.flatMap((frame) => { + const imageSrc = frame.imageSrc?.trim() ?? ''; + if ( + !imageSrc || + !Number.isFinite(frame.width) || + frame.width <= 0 || + !Number.isFinite(frame.height) || + frame.height <= 0 + ) { + return []; + } + return [ + { + ...frame, + imageSrc, + objectKey: frame.objectKey?.trim() || null, + assetObjectId: frame.assetObjectId?.trim() || null, + }, + ]; + }); + return normalizedFrames.length === frames.length + ? { frames: normalizedFrames, durationMs } + : null; +} function resolveAdminAssetMediaKind( entry: AdminPreviewableEditorAsset, ): AdminAssetMediaKind { + const assetKind = entry.assetKind?.trim() ?? ''; + if (assetKind === 'character-animation') { + return 'image-sequence'; + } const pathMediaKind = resolveAdminAssetMediaKindFromPath(entry.imageSrc) ?? resolveAdminAssetMediaKindFromPath(entry.objectKey ?? ''); if (pathMediaKind) { return pathMediaKind; } - - const assetKind = entry.assetKind?.trim() ?? ''; if ( assetKind === 'sound-effect' || assetKind === 'background-music' || @@ -279,6 +898,7 @@ function useAdminResolvedAssetUrl( imageSrc: string | null | undefined, objectKey: string | null | undefined, enabled = true, + refreshKey: string | number | null = null, ) { const normalizedImageSrc = imageSrc?.trim() ?? ''; const normalizedObjectKey = normalizeAdminObjectKey(objectKey); @@ -290,18 +910,22 @@ function useAdminResolvedAssetUrl( const [resolvedImageSrc, setResolvedImageSrc] = useState( shouldResolve ? '' : normalizedImageSrc, ); + const [hasError, setHasError] = useState(false); useEffect(() => { if (!normalizedImageSrc && !normalizedObjectKey) { setResolvedImageSrc(''); + setHasError(false); return; } if (!shouldResolve) { setResolvedImageSrc(normalizedImageSrc); + setHasError(false); return; } if (!enabled) { setResolvedImageSrc(''); + setHasError(false); return; } @@ -310,6 +934,7 @@ function useAdminResolvedAssetUrl( let retryIndex = 0; const dispatchController = new AbortController(); setResolvedImageSrc(''); + setHasError(false); const resolveReadUrl = async () => { try { @@ -330,7 +955,9 @@ function useAdminResolvedAssetUrl( }, ); if (!cancelled) { - setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response)); + const nextUrl = resolveAdminAssetReadSignedUrl(response); + setResolvedImageSrc(nextUrl); + setHasError(!nextUrl); } } catch (error: unknown) { if (cancelled) { @@ -347,6 +974,7 @@ function useAdminResolvedAssetUrl( return; } setResolvedImageSrc(''); + setHasError(true); } }; @@ -364,11 +992,84 @@ function useAdminResolvedAssetUrl( normalizedImageSrc, normalizedLegacyPublicPath, normalizedObjectKey, + refreshKey, shouldResolve, token, ]); - return resolvedImageSrc; + return { + resolvedUrl: resolvedImageSrc, + hasError, + }; +} + +async function resolveAdminAssetUrl( + token: string, + imageSrc: string | null | undefined, + objectKey: string | null | undefined, + signal: AbortSignal, +) { + const normalizedImageSrc = imageSrc?.trim() ?? ''; + const normalizedObjectKey = normalizeAdminObjectKey(objectKey); + const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc) + ? normalizedImageSrc + : resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc); + if (!normalizedObjectKey && !normalizedLegacyPublicPath) { + return { + expiresAtMs: null, + resolvedUrl: normalizedImageSrc, + }; + } + + let retryIndex = 0; + while (!signal.aborted) { + try { + await waitForAdminAssetReadDispatch(signal); + const response = await getAdminAssetReadUrl( + token, + normalizedObjectKey + ? { + objectKey: normalizedObjectKey, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + } + : { + legacyPublicPath: normalizedLegacyPublicPath, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + }, + ); + if (signal.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + const read = response.read ?? response; + const resolvedUrl = resolveAdminAssetReadSignedUrl(response); + const expiresAt = + typeof read.expiresAt === 'string' ? read.expiresAt.trim() : ''; + const expiresAtMs = parseSignedReadUrlExpiresAtMs(expiresAt); + if (!resolvedUrl) { + throw new Error('素材读取地址为空'); + } + if (expiresAtMs <= Date.now()) { + throw new Error('素材读取地址过期时间无效'); + } + return { expiresAtMs, resolvedUrl }; + } catch (error: unknown) { + if (signal.aborted) { + throw error; + } + const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex]; + if ( + isAdminApiError(error) && + error.status === 429 && + typeof retryDelay === 'number' + ) { + retryIndex += 1; + await waitForAdminAssetReadDelay(retryDelay, signal); + continue; + } + throw error; + } + } + throw new DOMException('The operation was aborted.', 'AbortError'); } async function waitForAdminAssetReadDispatch(signal: AbortSignal) { @@ -381,6 +1082,16 @@ async function waitForAdminAssetReadDispatch(signal: AbortSignal) { } async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) { + await waitForAdminAssetReadDelay( + ADMIN_ASSET_READ_DISPATCH_SPACING_MS, + signal, + ); +} + +async function waitForAdminAssetReadDelay( + delayMs: number, + signal: AbortSignal, +) { if (signal.aborted) { throw new DOMException('The operation was aborted.', 'AbortError'); } @@ -388,7 +1099,7 @@ async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) { const timer = setTimeout(() => { signal.removeEventListener('abort', handleAbort); resolve(); - }, ADMIN_ASSET_READ_DISPATCH_SPACING_MS); + }, delayMs); function handleAbort() { clearTimeout(timer); diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 5bd550cd3..bedb0adb1 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1068,7 +1068,7 @@ test('后台素材查询点击图片缩略图可打开放大预览', async () => }); }); -test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => { +test('后台素材查询在现有预览弹窗播放完整角色动作序列', async () => { const user = userEvent.setup(); vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -1081,17 +1081,65 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () assetKind: 'character-animation', thumbnailSrc: '/generated-animations/editor/source-1/task-1/frame00.png', + imageSequenceFrames: [ + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame00.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame00.png', + width: 192, + height: 256, + }, + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame01.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame02.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame02.png', + width: 192, + height: 256, + }, + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame03.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame03.png', + width: 192, + height: 256, + }, + { + imageSrc: + '/generated-animations/editor/source-1/task-1/frame04.png', + objectKey: + 'generated-animations/editor/source-1/task-1/frame04.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 250, }, ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ - read: { - objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', - signedUrl: 'https://signed.example.com/character-animation-frame00.png', - expiresAt: '2026-07-04T11:00:00Z', + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const objectKey = request.objectKey ?? undefined; + return { + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey ?? ''}`, + expiresAt: '2099-01-01T00:05:00Z', + }, + }; }, - }); + ); render( , @@ -1102,15 +1150,56 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = await within(dialog).findByRole('img', { - name: '图片预览:角色动作首帧', - }); + await user.click( + await within(dialog).findByRole('button', { + name: '暂停角色动作', + }), + ); await waitFor(() => { - expect(image.getAttribute('src')).toBe( - 'https://signed.example.com/character-animation-frame00.png', + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame'), + ).toHaveLength(3); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame01.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame02.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame03.png', + expireSeconds: 300, + }); + + const failedFrame = dialog.querySelector( + '.admin-asset-query-sequence-frame', + ) as HTMLImageElement; + fireEvent.error(failedFrame); + expect(await within(dialog).findByText('1 帧加载失败')).toBeTruthy(); + await waitFor(() => { + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-animations/editor/source-1/task-1/frame03.png', + expireSeconds: 300, + }); + }); + const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length; + await user.click( + within(dialog).getByRole('button', { name: '重试失败帧' }), + ); + await waitFor(() => { + expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan( + callsBeforeRetry, ); }); - expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull(); + expect( + within(dialog).getByRole('button', { name: '暂停角色动作' }), + ).toBeTruthy(); }); test('后台素材查询音频素材使用统一封面缩略图', async () => { diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index f207ba285..c6bef5f79 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -318,6 +318,101 @@ test('后台精选审核缩略图进入视口后换签并可打开图片预览', expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull(); }); +test('后台精选审核在共用预览弹窗播放完整角色动作', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + label: '待机动作', + assetKind: 'character-animation', + imageSrc: '/generated/action/frame-01.png', + objectKey: 'generated/action/frame-01.png', + imageSequenceFrames: [ + { + imageSrc: '/generated/action/frame-01.png', + objectKey: 'generated/action/frame-01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/frame-02.png', + objectKey: 'generated/action/frame-02.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 250, + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const objectKey = request.objectKey ?? undefined; + return { + read: { + objectKey, + signedUrl: `https://signed.example.com/${objectKey ?? ''}`, + expiresAt: '2099-01-01T00:05:00Z', + }, + }; + }, + ); + + render( + , + ); + fireEvent.click(await screen.findByTitle('预览素材')); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + expect( + await within(dialog).findByRole('button', { name: '暂停角色动作' }), + ).toBeTruthy(); + await waitFor(() => { + expect( + dialog.querySelectorAll('.admin-asset-query-sequence-frame'), + ).toHaveLength(2); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated/action/frame-02.png', + expireSeconds: 300, + }); +}); + +test('后台精选审核对损坏角色动作显示错误而不回退首帧', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + label: '损坏动作', + assetKind: 'character-animation', + imageSequenceFrames: null, + imageSequenceDurationMs: null, + }, + ], + nextCursor: null, + }); + + render( + , + ); + fireEvent.click(await screen.findByTitle('预览素材')); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + expect( + within(dialog).getByLabelText('角色动作序列损坏:损坏动作'), + ).toBeTruthy(); + expect( + within(dialog).queryByRole('img', { name: '图片预览:损坏动作' }), + ).toBeNull(); +}); + test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [ diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 191fbd840..e7610f4f0 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1703,6 +1703,58 @@ button:disabled { object-fit: contain; } +.admin-asset-query-sequence-preview { + position: relative; + width: min(100%, 720px); + min-height: min(64dvh, 560px); + overflow: hidden; +} + +.admin-asset-query-sequence-frame { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} + +.admin-asset-query-sequence-controls { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 2; + display: flex; + max-width: calc(100% - 24px); + flex-wrap: wrap; + align-items: center; + gap: 8px; + border-radius: 999px; + background: rgba(43, 31, 22, 0.82); + color: #fff; + padding: 6px 10px; + font-size: 12px; +} + +.admin-asset-query-sequence-controls button { + display: grid; + min-width: 44px; + min-height: 44px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0; + place-items: center; +} + +.admin-asset-query-sequence-error { + display: grid; + color: #9d3127; + place-items: center; +} + .admin-asset-query-preview-audio { display: grid; justify-items: center; diff --git a/docs/README.md b/docs/README.md index 98bf94676..04f98b1d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,9 @@ - [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md) - [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md) - [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md) +- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md) +- [音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md) +- [BGM 提示词优化 T6 测试与发布门禁](./【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md) - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 1ddc9543d..659c13a6f 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2051,6 +2051,47 @@ }, "ExternalEditorAssetCreateRequest": { "type": "object", + "allOf": [ + { + "if": { + "properties": { + "assetKind": { + "const": "character-animation" + } + }, + "required": [ + "assetKind" + ] + }, + "then": { + "required": [ + "imageSequenceFrames", + "imageSequenceDurationMs" + ], + "properties": { + "generationInputs": { + "$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs" + } + } + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "imageSequenceFrames" + ] + }, + { + "required": [ + "imageSequenceDurationMs" + ] + } + ] + } + } + } + ], "required": [ "folderId", "label", @@ -2132,16 +2173,71 @@ "type": [ "string", "null" - ] + ], + "description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。" + }, + "imageSequenceFrames": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/components/schemas/EditorImageSequenceFrame" + } + }, + "imageSequenceDurationMs": { + "type": "integer", + "minimum": 1, + "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。" } }, "additionalProperties": false }, "ExternalEditorProjectResourceCreateRequest": { "type": "object", + "allOf": [ + { + "if": { + "properties": { + "assetKind": { + "const": "character-animation" + } + }, + "required": [ + "assetKind" + ] + }, + "then": { + "required": [ + "imageSequenceFrames", + "imageSequenceDurationMs" + ], + "properties": { + "generationInputs": { + "$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs" + } + } + }, + "else": { + "not": { + "anyOf": [ + { + "required": [ + "imageSequenceFrames" + ] + }, + { + "required": [ + "imageSequenceDurationMs" + ] + } + ] + } + } + } + ], "required": [ "imageSrc", "width", @@ -2215,14 +2311,71 @@ "type": [ "string", "null" - ] + ], + "description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。" + }, + "imageSequenceFrames": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/components/schemas/EditorImageSequenceFrame" + } + }, + "imageSequenceDurationMs": { + "type": "integer", + "minimum": 1, + "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。" } }, "additionalProperties": false }, + "ExternalCharacterAnimationGenerationInputs": { + "not": { + "anyOf": [ + { + "type": "object", + "required": [ + "characterAnimation" + ] + }, + { + "type": "object", + "required": [ + "frames" + ] + }, + { + "type": "object", + "required": [ + "previewVideoPath" + ] + }, + { + "type": "object", + "required": [ + "frameCount" + ] + }, + { + "type": "object", + "required": [ + "fps" + ] + }, + { + "type": "object", + "required": [ + "durationSeconds" + ] + } + ] + }, + "description": "assetKind=character-animation 时 generationInputs 不得包含旧运行字段;正式媒体数据使用 imageSequenceFrames 和 imageSequenceDurationMs。" + }, "ExternalEditorAssetUpdateRequest": { "type": "object", "properties": { @@ -2545,7 +2698,19 @@ "type": [ "string", "null" - ] + ], + "description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。" + }, + "imageSequenceFrames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorImageSequenceFrame" + } + }, + "imageSequenceDurationMs": { + "type": "integer", + "minimum": 1, + "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" @@ -2688,6 +2853,18 @@ "sourceType": { "type": "string" }, + "imageSequenceFrames": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorImageSequenceFrame" + }, + "description": "assetKind=character-animation 时的完整可播放帧集合。" + }, + "imageSequenceDurationMs": { + "type": "integer", + "minimum": 1, + "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" + }, "prompt": { "type": [ "string", @@ -2716,7 +2893,8 @@ "type": [ "string", "null" - ] + ], + "description": "权威媒体类别。character-animation 渲染为序列帧,video 渲染为视频,audio/sound-effect/background-music 渲染为音频,其余渲染为图片。" }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" @@ -3852,7 +4030,7 @@ "string", "null" ], - "description": "角色动作绿幕预览视频写入账号素材库的文件夹;省略时写入默认项目素材文件夹。" + "description": "角色动作预览视频和最终透明序列帧素材写入账号素材库的文件夹;省略时写入默认项目素材文件夹。" }, "assetLabel": { "type": [ @@ -3865,22 +4043,29 @@ }, "additionalProperties": false }, - "EditorCharacterAnimationFrame": { + "EditorImageSequenceFrame": { "type": "object", "required": [ - "frameIndex", "imageSrc", + "objectKey", + "assetObjectId", "width", "height" ], "properties": { - "frameIndex": { - "type": "integer", - "minimum": 0 - }, "imageSrc": { "type": "string" }, + "objectKey": { + "type": "string", + "minLength": 1, + "description": "帧对应的稳定 OSS Object Key;服务端据此重建持久 imageSrc,不能只提交临时签名 URL。" + }, + "assetObjectId": { + "type": "string", + "minLength": 1, + "description": "帧对应的稳定资产对象 ID。" + }, "width": { "type": "integer", "minimum": 1 @@ -3889,7 +4074,9 @@ "type": "integer", "minimum": 1 } - } + }, + "additionalProperties": false, + "description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。" }, "EditorCharacterAnimationGenerationResponse": { "type": "object", @@ -3926,7 +4113,7 @@ "frames": { "type": "array", "items": { - "$ref": "#/components/schemas/EditorCharacterAnimationFrame" + "$ref": "#/components/schemas/EditorImageSequenceFrame" } }, "frameCount": { @@ -3964,6 +4151,28 @@ ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ], + "description": "最终透明角色动作对应的项目资源。携带 projectId 时,画布图层必须直接引用其 resourceId,不得再次创建重复资源。" + }, + "asset": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorAsset" + }, + { + "type": "null" + } + ], + "description": "最终透明角色动作素材。assetKind 为 character-animation,并直接包含序列帧字段。" + }, "queueState": { "anyOf": [ { diff --git a/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md b/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md new file mode 100644 index 000000000..5abe42b8a --- /dev/null +++ b/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md @@ -0,0 +1,188 @@ +# 音频生成 Composer 恢复共享分流方案 + +日期:`2026-08-06` + +状态:`已实施` + +## 一、目标 + +图片画布的音效与背景音乐恢复使用同一个音频 composer。`ImageCanvasGenerationComposerView.tsx` 内只保留一个 `ImageCanvasAudioGenerationComposerView`,并在组件内定义: + +```ts +const isSoundEffect = dialog.mode === 'audio-sound-effect'; +``` + +`isSoundEffect === true` 渲染现有 SFX 分支,`isSoundEffect === false` 渲染现有 BGM V1 分支。删除完整的独立 BGM composer 文件,但保留确有独立职责的 BGM 纯模型、助手 controller 和预设跑马灯组件。 + +这次只调整前端视图组织方式,不改变任何生成契约、业务规则、请求时序、计费或持久化语义。 + +## 二、范围与非目标 + +### 2.1 必须保留的 BGM 能力 + +- `gpt_description_prompt` 可见原文、Unicode `White_Space` canonicalization 和 200 字生成限制。 +- 30 个预设、字符计数、AI 补全、一键简化和单层交换式撤销。 +- AI 处理锁、同步正式提交锁、`generating` 锁和迟到响应隔离。 +- 稳定 dialog ID、账号 / 项目 scope 校验和按 dialog ID 写回。 +- 固定 `Suno`、当前动态泥点价格和隐藏 `make_instrumental` 的现状。 + +### 2.2 必须保持不变的现有 SFX 能力 + +- 固定 Vidu `audio1.0`,Prompt 同值映射到现有 `prompt + sound` 请求字段。 +- `2–10` 秒、步长 `1` 秒、默认 `5` 秒。 +- 当前 Prompt 规范化、全空白时回退“游戏音效”、1500 字限制、价格和失败态。 +- 现有提交 callback、生成占位和完成链路。 +- SFX 中不出现 Suno、BGM 预设、AI 补全、一键简化、BGM 字符计数或撤销。 + +### 2.3 本次明确不做 + +- 不实现 SFX V2 独有的一键优化、自动中译英、ElevenLabs、自动时长、30 秒、Loop 或 SFX 预设。 +- 不修改 BGM 或 SFX 需求原文。 +- 不修改 BFF、队列、`platform-audio`、External v1、OpenAPI、SpacetimeDB schema、计费或重试。 +- 不把 BGM Prompt controller 泛化为音频通用 controller。 +- 不新建配置驱动的 composer 框架、第二套音频组件或其它顺带重构。 + +## 三、当前问题 + +当前 `ImageCanvasGenerationComposerView.tsx` 分别渲染 SFX 内部组件和 `ImageCanvasBackgroundMusicGenerationComposerView.tsx`。这层完整视图拆分让同一个音频入口形成两套 composer 边界,而 SFX V2 需求已经表明预设、AI 写回、撤销和锁定等交互并非 BGM 永久独占,继续用“BGM 专属交互较多”作为完整组件分叉理由不再成立。 + +同时,最近一次合并把共享架构中的 `isSoundEffect` 条件表达式带回了当前 SFX-only 组件,却没有带回变量定义,形成 `isSoundEffect is not defined`。该问题不是增加一个局部常量后就可以收口的长期架构问题;本次重构应恢复单一音频组件,使变量与它控制的两个分支重新处于同一组件边界。 + +## 四、目标结构 + +```text +ImageCanvasEditorView +└─ useImageCanvasGenerationSurface + └─ ImageCanvasGenerationComposerView + └─ ImageCanvasAudioGenerationComposerView + ├─ isSoundEffect === true → 现有 SFX UI 与行为 + └─ isSoundEffect === false → 现有 BGM V1 UI 与行为 + └─ ImageCanvasBackgroundMusicPresetMarquee +``` + +继续保留的 BGM 专项模块: + +- `ImageCanvasBackgroundMusicPromptModel.ts`:canonicalization、计数、动作资格和 dialog 类型收窄。 +- `useImageCanvasBackgroundMusicPromptAssist.ts`:按 dialog ID 隔离的异步助手状态与提交锁。 +- `ImageCanvasBackgroundMusicPresetModel.ts`:30 个预设与追加规则。 +- `ImageCanvasBackgroundMusicPresetMarquee.tsx`:展开、滚动、hover、触摸和 reduced-motion。 + +删除的完整视图模块: + +- `ImageCanvasBackgroundMusicGenerationComposerView.tsx` +- `ImageCanvasBackgroundMusicGenerationComposerView.test.tsx` + +删除测试文件不等于删除覆盖;其中全部用例必须迁入总 composer 测试。 + +## 五、实现边界 + +### 5.1 单一渲染入口 + +`ImageCanvasGenerationComposerView` 对 `audio-sound-effect` 与 `audio-background-music` 只保留一个渲染条件和一个 `ImageCanvasAudioGenerationComposerView` 调用。组件内部以 `isSoundEffect` 选择两棵现有表单子树,不为本次重构新造配置层。 + +共享组件使用基于 dialog ID 与 mode 的稳定 `key`。连续切换 BGM dialog 时,预设展开、滚动、hover 和触摸状态不得继承;从 SFX 切到 BGM 时,音效时长菜单状态也不得泄漏。 + +### 5.2 Hook 与类型安全 + +- React Hook 不得放进 `isSoundEffect` 条件分支。`useState`、`useRef`、`useId` 和 `useImageCanvasFloatingOptionDismiss` 保持固定调用顺序。 +- `GenerateDialogState` 不是可判别联合。BGM 分支继续通过 `toBackgroundMusicGenerationDialog` 取得带稳定 ID 的 `BackgroundMusicGenerationDialogState`。 +- BGM 缺少稳定 ID 或 `backgroundMusicPromptAssist` 时失败关闭,不渲染不完整的 BGM 表单;SFX 不依赖这两个条件。 + +### 5.3 两套状态写回不得合并 + +- SFX 继续走现有 `setGenerateDialog` 路径,保持按 mode 更新、失败态复位和时长写回语义。 +- BGM 继续走 `updateCanvasGenerationDialogById(dialog.id, updater)`,不能降级成只比较 mode。助手响应、预设、撤销和提交锁仍按稳定 dialog ID 隔离。 +- `backgroundMusicPromptAssist` 可以继续由 surface 传给共享 composer,但只允许 BGM 分支读取或调用。 + +### 5.4 锁定与提交资格不得串用 + +|分支|锁定条件|生成资格| +|---|---|---| +|SFX|沿用现有 `dialog.status === 'generating'`|沿用现有 SFX 提交入口和默认 Prompt 规则| +|BGM|AI processing、`submitting` 或 `generating` 任一成立|沿用 canonical Prompt 的有效字符与 `1–200` code point 规则| + +BGM 使用 `PlatformTextField + readOnly + aria-invalid`;SFX 继续使用 `AutoGrowTextArea + disabled`。本次不统一这两个输入控件,也不改错误、可访问名称或按钮文案。 + +## 六、预计代码改动 + +|文件|最小改动| +|---|---| +|`ImageCanvasGenerationComposerView.tsx`|恢复共享 `ImageCanvasAudioGenerationComposerView` 与 `isSoundEffect`;移入现有 BGM JSX、常量和依赖;删除独立 BGM import 与双渲染入口| +|`ImageCanvasGenerationComposerView.test.tsx`|迁入独立 BGM composer 的全部测试,并增加双 mode 隔离与切换回归| +|`ImageCanvasBackgroundMusicGenerationComposerView.tsx`|删除| +|`ImageCanvasBackgroundMusicGenerationComposerView.test.tsx`|覆盖迁完后删除| +|`useImageCanvasBackgroundMusicPromptAssist.ts`|只修正指向独立 composer 的历史注释;不改 controller 行为| + +以下生产文件预计不改:`useImageCanvasGenerationSurface.tsx` 的现有 props 接线、BGM Prompt / 预设模型、预设跑马灯、submission workflow、submission model、dialog model、`src/index.css` 的现有 BGM 样式,以及全部后端代码。 + +如实施时发现必须超出该清单才能保持现有行为,应先停下并重新确认边界,不能借本次组件归并顺带重构。 + +## 七、测试迁移与回归矩阵 + +### 7.1 BGM 原覆盖完整迁移 + +独立组件测试中的下列覆盖必须逐项迁入 `ImageCanvasGenerationComposerView.test.tsx`: + +- canonical preview 计数、超限展示和不改写输入框。 +- 0 / 1 / 2 个有效字符及 200 / 201 / 2000 / 2001 边界。 +- 补全、简化、撤销和预设按正确 dialog ID 路由。 +- `preparePreset` 拒绝时不写回,预设成功时沿用追加和清快照语义。 +- 助手错误与生成错误的展示顺序。 +- Suno、泥点价格、生成允许 / 拒绝。 +- `completing`、`simplifying`、`submitting`、`generating` 锁定。 +- 完整单层撤销按钮矩阵和现有可访问属性。 + +### 7.2 mode 隔离与切换 + +- SFX 渲染时不存在 BGM 控件,也不存在本次明确排除的 SFX V2 控件。 +- BGM 渲染时不存在 Vidu 和音效时长控件。 +- SFX 仍保持 `audio1.0`、2–10 秒、默认 5 秒、当前价格和既有提交参数。 +- BGM dialog A 展开预设后切到 dialog B,局部展开与滚动状态不继承。 +- SFX 与 BGM 相互切换时,菜单、锁和 Prompt 助手状态不跨 mode 泄漏。 +- 缺少稳定 ID 或 controller 的 BGM 失败关闭;同样条件不影响 SFX 正常渲染。 + +### 7.3 最小验证命令 + +```powershell +npm run test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts src/components/image-editor/ImageCanvasBackgroundMusicPresetModel.test.ts src/components/image-editor/ImageCanvasBackgroundMusicPresetMarquee.test.tsx src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx +npm run test -- src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +npm run typecheck +npm run check:encoding +git diff --check +``` + +删除旧测试文件后,还要确认测试收集清单中不再引用它。若本次改动触发其它现有图片画布测试失败,只修复由共享 composer 归并直接造成的回归,不扩大到无关模块。 + +## 八、实施顺序 + +1. 在总 composer 内恢复共享音频组件、`isSoundEffect` 和单一音频渲染入口。 +2. 原样迁入 BGM 分支,保留按 ID 写回、锁定、错误顺序、Suno 与预设行为。 +3. 迁移独立 BGM 组件测试,并补齐 SFX/BGM 隔离与切换用例。 +4. 删除独立 BGM composer 及其测试文件,修正相关注释与文档引用。 +5. 运行定向测试、typecheck、编码检查和差异检查;实现完成后再把实际结果补入 T6 后续记录。 + +## 九、完成定义 + +- 两个音频 mode 均从同一个 `ImageCanvasAudioGenerationComposerView` 渲染,且组件内存在唯一的 `isSoundEffect` 分流。 +- 独立完整 BGM composer 文件和独立测试文件已删除,原测试覆盖无遗漏地迁入总 composer。 +- BGM V1 所有已交付能力与正式提交语义不变。 +- 现有 SFX UI、Prompt、Vidu、时长、价格、校验和提交链路无回归。 +- 没有实现任何 SFX V2 独有功能,没有修改需求原文或后端契约。 +- 规定的定向测试、typecheck、编码检查和 `git diff --check` 全部通过。 + +## 十、实施结果 + +2026-08-06 已按本文边界完成: + +- `ImageCanvasGenerationComposerView.tsx` 恢复唯一 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM 继续按稳定 dialog ID 写回,SFX 继续走原 `setGenerateDialog` 路径。 +- 删除 `ImageCanvasBackgroundMusicGenerationComposerView.tsx`,保留 BGM Prompt / 预设纯模型、助手 controller 和预设跑马灯。 +- 把独立组件全部用例迁入 `ImageCanvasGenerationComposerView.test.tsx` 后删除旧测试文件;总 composer 现有 50 项测试同时覆盖 BGM 完整动作矩阵和 SFX/BGM 控件、状态、dialog 切换隔离。 +- 只修正 `useImageCanvasBackgroundMusicPromptAssist.ts` 的组件归属注释;没有修改 controller、surface、submission workflow、样式、后端、契约或需求原文,也没有实现 SFX V2 独有功能。 + +实际验证: + +- Prompt / 预设 / controller / 总 composer:`121/121`。 +- surface 与 submission workflow:`72/72`。 +- `npm run typecheck`、变更文件 ESLint、Prettier、`npm run check:encoding` 和 `git diff --check`:全部通过。 + +2026-08-06 已执行共享 composer 归并后的浏览器视觉 smoke,未发现阻断性问题;本轮未创建真实音频生成任务或产生扣费。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 75bfde2ad..c5e6f721d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5944,6 +5944,21 @@ - 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。 - 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。 +## 2026-07-28 编辑器媒体类型统一由 assetKind 判定 + +- 决策:`assetKind` 是编辑器资源和素材唯一权威媒体类别;不新增或返回 `mediaType`。`character-animation` 渲染为序列帧,`video` 渲染为视频,`audio/sound-effect/background-music` 渲染为音频,其余类别渲染为图片。前端内部可保留派生的 `CanvasMediaType` 选择渲染器,但不能把它作为后端事实。 +- 持久化边界:`editor_project_resource` 与 `editor_asset` 表尾只保存 `image_sequence_frames_json` 与 `image_sequence_duration_ms`,默认均为 `None`;`editor_showcase_asset` 作为提交时冻结的审核与公开快照,同样在表尾保存这两个字段并从账号素材逐字段复制,旧行默认均为 `None`。正式帧对象和角色动作生成响应都不保存或返回 `frameIndex`,数组位置是唯一播放顺序;帧数取数组长度,FPS 由帧数和毫秒时长即时推导,不持久化 `frame_count`、`fps` 或通用 `duration_seconds`。后端处理抽帧和逐帧去背时仍保留内部 `frame_index`,仅用于乱序并发收口、OSS 命名、日志和错误定位;仓库内旧 helper 同步升级,不为它保留外部兼容字段。 +- 时长口径:`image_sequence_duration_ms` 只表示角色图片序列完整播放一次的毫秒时长,与音频 / 视频生成请求中的 `durationSeconds` 完全分离。角色动作与视频生成响应仍可携带各自既有的请求 / 结果级 `frameCount/fps/durationSeconds`;通用音视频秒数不进入资源 / 素材正式列、`EditorAsset`、`CanvasLayer` 或画布 layout,只允许把上传探测值或生成请求值格式化为用户可见字符串后写入 `generation_inputs_json.fields[]` 供素材详情展示。素材详情和画布 ZIP 用户可见元数据只透传实际存在的 `fields[]` 时长项;缺少时直接省略,不生成 `--:--` 占位。素材放置与工程恢复不做媒体探测,也不从 layout / resource 回退;音频播放只使用媒体 `loadedmetadata.duration`。不得为音频生成响应新增 `durationSeconds`,也不得把音视频秒数写入图片序列字段。前端角色动作播放器使用 `imageSequenceDurationMs / imageSequenceFrames.length`,Spine 导出时才换算秒数并推导 FPS。 +- 写入与复用边界:`assetKind=character-animation` 必须在 SpacetimeDB storage/procedure 边界同时提供至少两帧有效数组与大于 0 的图片序列毫秒时长;其他类别不得携带图片序列字段。同项目同源同媒体资源只允许 `None → Some` 单调回填,非空冲突失败关闭,延迟重试的 `updated_at` 取请求时间与既有时间的较大值。 +- 生成关联边界:角色动作生成响应同时返回已经持久化的最终 `resource` / `asset`;带项目上下文时前端结果图层必须直接使用 `resource.resourceId` 及其预览视频来源血缘,缺少 resource 直接失败。无项目放置也必须绑定响应中的正式账号素材,不能从响应 `frames` 构造无资产真相的本地动作结果。“动作(原始视频)”只是 `asset_kind = video` 的 provider 中间产物,其 `editor_project_resource` 与 `editor_asset` 两行都固定 `generation_inputs_json = NULL`,不得携带引用或开放参数复用;完整用户生成配方只保存在最终 `asset_kind = character-animation` 的序列资源 / 素材。角色动作规范化迁移对权威证明的 preview video 同样清空整份生成输入,但不改写其 `source_resource_id`。 +- repair 幂等边界:legacy 音频 repair 不再派生或回填任何资源级通用时长,因此新列上线前已完成 repair 的重放继续按原字段精确匹配;图片序列字段在音频行上均为 `None`。 +- 布局边界:`editor_project_resource` 的正式字段是角色动作唯一媒体真相;动作 layout 只保存资源引用和 placement,不保存帧、时长、预览、生成输入、资源元数据或顶层 `mediaType`。前端仍可从 `assetKind` 派生内部 `CanvasMediaType`,但后端响应清洗不得把普通 layer 的旧 `mediaType` 传回前端,也不得递归删除 `generationInputs.references[*].mediaType`。 +- 迁移边界(2026-08-04 收口):存量 `generation_inputs_json.characterAnimation`、已证明动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧 `frameIndex`、误标预览 MP4 和动作 layout 副本,由 `normalize_editor_character_animation_metadata_and_return` 按 `asset → project-resource → showcase → canvas` 一次性规范化。顶层字段只有先证明动作身份后才解释;普通图片 / 视频任意 JSON 中的同名字段不动。canvas dry-run 可消费前置 scope 的计划态结果,但 apply 仍要求前置 scope 已物理完成;同 task 候选先按权威对象规划分类并排除预览视频,只有唯一最终图片序列可补建资源。正式序列每帧必须按首帧 bucket 的稳定路径精确匹配同 owner / task 的图片 `editor_character_animation` 对象并补齐 `objectKey/assetObjectId`。迁移永不验证 layout 复制的 `sourceResourceId`,补建资源采用最终素材的 DB 血缘;新生成直接来源链仍严格校验。正式/旧版冲突、最终候选为零或多个、对象不匹配形成 blocker;apply 必须绑定同批 dry-run SHA-256,结束后全量复核零匹配、零 blocker。迁移完成后删除 api-server、admin、Web 与 helper 的 action fallback。 +- 新写入边界:`assetKind=character-animation` 的 `generationInputs` 若含旧运行字段或 `screenColorHex`,正式帧若含 `frameIndex`,api-server 和 SpacetimeDB storage 均失败关闭;门禁只对角色动作生效,不误伤其它素材的任意 generation input JSON。 +- 交互边界:角色动作素材下载导出完整序列 ZIP;点击、HTML5 拖放和指针拖放创建可移动、循环播放且可保存恢复的序列图层。`assetKind=character-animation` 但缺少有效帧时按损坏素材失败关闭,不回退首帧 PNG。 +- 精选展示边界:`/creation` 精选卡片和预览弹窗继续扩展各自现有媒体 renderer,不复用或重构画布图层组件;卡片静止时只读取首帧,hover / focus 后才加载并播放完整序列,预览弹窗提供播放暂停。后台素材查询与精选审核继续共用 `AdminEditorAssetMedia`,列表只读首帧,预览弹窗才逐帧使用管理员换签。两端均按 `imageSequenceDurationMs / imageSequenceFrames.length` 切帧,下一帧未就绪时保留上一帧且禁止淡入;损坏动作不退回普通图片。 +- 精选帧授权边界:公开换签只对当前已通过、已展示、返还完成且未删除的精选动作,按同 owner 的冻结帧 `assetObjectId` / `objectKey` 形成 exact grant;不从 `imageSrc` 或前缀推导。隐藏、拒绝、删除或快照损坏后逐帧授权随当前事务真相撤销,`read-url` 与 `read-bytes` 继续共用该判断。 + ## 2026-07-24 后台用户详情展示历史花费泥点 - 口径:`historicalConsumedPoints` 表示用户历史总消费,只累计 `profile_wallet_ledger.source_type = asset_operation_consume` 且 `amount_delta < 0` 的绝对值;`asset_operation_refund` 不冲减,充值退款追回、余额重置、赠送和退款 hold 均不计入。 @@ -6240,6 +6255,46 @@ - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 +## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁 + +- 背景:图片画布背景音乐链路原先会在前端、BFF 和 Suno body 构造阶段执行不一致的 Prompt 清理,并为空值提供默认回退,可能形成输入框不可见的实际提交文本;新增预设和 AI 助手后,需要统一唯一可见 Prompt、首尾 Unicode 空白 canonicalization,以及与 SFX、请求字段和 External v1 的边界。 +- 决策:本规则只适用于 `/editor/canvas` 的 `audio-background-music`,并在该模式内取代 2026-08-03「编辑器持久化的 `prompt` 统一表示规范化用户意图」的泛化表述;其它图片、角色和图标生成语义不变。输入框是唯一 BGM Prompt 真相,预设可见文本和 AI 成功结果写回后都成为当前 Prompt;不得在输入框之外维护或向 Suno 发送另一份隐藏 Prompt。进入预设追加、AI 补全、简化、撤销或正式生成边界前,只允许执行一项 canonicalization:移除首尾属于 Unicode `White_Space` 属性的 code point,并在后续动作前把结果同步写回同一个输入框;TypeScript 不得用会额外移除 U+FEFF 的 `String.trim()` 代替 Unicode `White_Space` 判定。canonical Prompt 内部的空格、CR / LF 和其它 Unicode 空白保持原位,U+200B、U+FEFF、组合字符、ZWJ emoji 等非 `White_Space` code point 即使位于首尾也必须保留;除此之外不得做 NFC、空白折叠、换行转换、标点替换或静默截断。canonical Prompt 满足 `有效字符数 = 0 ⇔ 总字符数 = 0`,所以任意长度的纯 Unicode `White_Space` 原始输入规范化后都禁止补全、简化和正式生成;只有至少含 1 个有效字符且总数超过 200 才允许简化。预设 ID、分组、颜色、助手系统模板和内部引导不得拼入 Suno Prompt;Prompt 助手可以使用服务端固定模板生成可见结果,但正式 Suno 请求只携带输入框可见的 canonical Prompt。200 字上限和有效字符数都以 canonical Prompt 计算,有效字符定义为非 Unicode `White_Space` code point:0 个不能补全或正式生成;1 个且总数不超过 200 时只能正式生成;至少 2 个且总数不超过 200 时可以补全和正式生成;至少含 1 个有效字符且总数超过 200 时禁止补全和正式生成,只允许简化。请求字段继续使用 `gptDescriptionPrompt` / `gpt_description_prompt`,不得改名为 `actualPrompt`;`actual_prompt` 继续保留资源 / 素材审计语义,canonical 输入框、BFF、队列载荷、Suno body、生成记录 `prompt` / `actual_prompt` 和结果响应 `prompt` / `actualPrompt` 必须等值,BGM 成功响应同时填充后两者。AI 补全与 `180 -> 170`、最多两次的一键简化只走登录态内部 BFF,不调用 Suno、不创建任务、不扣正式音乐生成泥点;简化全程冻结入站 canonical `originalPrompt`,第一次以它为 `currentPrompt`、目标 180,第二次优先以第一次响应对象中可提取且包含有效字符的 canonical 字符串 `prompt` 为 `currentPrompt`、目标 170,即使其它 envelope 字段无效;连对象或字符串 `prompt` 都无法提取,或候选 canonicalize 后不含有效字符时回退原文,同时始终用同一 `originalPrompt` 作保真参照。内部 envelope 固定为 `prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`;候选仅在四字段结构有效、canonical 后包含有效字符且不超过 200 字、后三个字段依次为 `true / true / false` 时通过。`isDirectWritebackFormat` 表示候选只含一条可直接写回的中文 BGM Prompt,不含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明;程序只解析字段、canonicalize、计数和执行布尔结果,不用关键词或未定义正则猜测语义判断。程序不截断,也不硬编码内容保留规则,成功后只保存一层 canonical Prompt 交换快照。正式生成在点击事件内、任何 `await` 前同步锁定当前 BGM generation dialog,并完成 canonicalization、写回、校验和请求值冻结,后续重复点击忽略;拒绝时保留 canonical Prompt 和既有快照,接受后进入现有 `queued/generating` 占位,不锁整个画布或其它 dialog。 +- T2 助手补充决策:补全与简化共用 `prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean` 四字段内部 envelope;补全候选也只有在结构与 canonical 字符校验通过、三个判断为 `true / true / false` 时才成功。助手显式使用现有 OpenAI Chat 协议,envelope 只允许由完整 `response.text` 中唯一一个 JSON object 承载;服务端仅用 `serde_json` 对完整文本全量解析,允许 object 外围 JSON whitespace,但不接受代码块、前后解释、多个 JSON 值、子串提取或自动修复。请求不发送 function tools,不做运行时双协议 fallback,响应出现 tool call 也按结构非法处理。补全固定一个业务语义轮,简化固定最多两个业务语义轮;单轮内部由现有 `LlmClient` 执行的 transport retry 不计入业务语义轮数。简化第一轮最终发生 transport、超时或上游失败时直接失败,不进入 170 字内容修复轮;只有成功取得第一轮响应但候选不合格时才派生第二轮。第二轮只有在第一轮完整文本已全量解析为单个 JSON object 后,才可从 object 读取字符串 `prompt`;禁止从未完整解析的响应中捞取候选。助手成功响应只暴露 canonical `prompt` 和程序计算的 `charCount`,失败响应不得暴露任一未通过候选、可提取的 `prompt`、内部 envelope 或判断字段。 +- 影响范围:画板音乐权威设计、BGM composer 与临时状态模型、`editorProjectClient`、`shared-contracts` 内部助手 DTO、`api-server` 登录态 Prompt 助手与正式 BGM BFF、正式 generation queue 载荷、`platform-audio` Suno body builder,以及 BGM 提交与端到端测试。SFX 继续使用 Vidu `audio1.0`、现有规范化、默认 Prompt、1500 字限制和时长契约,不应用 BGM 的 canonicalization 或 0 / 1 / 2 有效字符规则;本规则不修改 SpacetimeDB schema,也不改变或扩展 External v1 / OpenAPI 的背景音乐请求、异步语义和路由,Suno 三字段 body、固定模型 / 泥点展示和现有 LLM 原文日志策略不变。 +- 验证方式:TypeScript 与 Rust 对 CR / LF、CRLF、组合字符、ZWJ emoji、U+0085、U+200B、U+FEFF 和 199 / 200 / 201 code point 得出一致结果;首尾 U+0085 等 Unicode `White_Space` 被移除并同步反映到输入框,U+FEFF 与零宽字符不被误删。状态测试分别锁定全空白、0 / 1 / 2 个有效字符、预设追加、补全 / 简化失败不覆盖 canonical Prompt、单层交换撤销、迟到响应和同 dialog 双击;端到端断言 canonical 输入框、BFF、队列载荷、Suno body、记录和响应等值,且不存在隐藏 Prompt。SFX 请求体、默认 Prompt、1500 字限制和时长不变,External v1 契约测试无差异。文档阶段运行 `npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + +## 2026-08-04 BGM Prompt 助手补齐输入、完成状态与埋点边界 + +- 背景:BGM Prompt 助手基线已经固定四字段 envelope、完整 `response.text` JSON 解析和 `180 → 170` 简化轮次,但权威设计仍把所有 201 字以上输入视为可简化,也没有明确 OpenAI Chat 未完成响应、HTTP body 上限及成功路由埋点;这会让超长文本消耗共享模型额度,并可能把 `finish_reason = length / content_filter` 的偶然闭合 JSON 当作完整候选。 +- 决策:本条补充并取代上一条中“总数超过 200 即只允许简化”的无上限表述。一键简化只接受至少含 1 个有效字符且总数为 `201–2000` 的 canonical Prompt,最大值等于正式生成 200 字上限的 10 倍;超过 2000 时保留完整文本但禁用 AI 补全、一键简化和正式生成,服务端以现有 `400 BAD_REQUEST` 和字段 `currentPrompt` 拒绝。两个助手路由分别设置 `32 KiB` HTTP body 上限,body 超限保持 Axum `413 PAYLOAD_TOO_LARGE`,字符上限与 body 上限独立校验。 +- LLM 完成状态:在解析 envelope、canonicalize 候选或提取重试 `prompt` 前,使用 `platform-llm` 现有 API-kind-aware 未完成原因判断检查 OpenAI Chat `finish_reason`;去除外围空白并忽略 ASCII 大小写后的 `length` / `content_filter` 均不可信,即使正文形成合法 JSON 也不得接受或提取。补全遇到两者均直接失败;简化第一轮 `length` 只以冻结的 `originalPrompt` 进入 170 字轮,第一轮 `content_filter` 直接失败,第二轮出现任一未完成原因都最终失败。缺失、空值或未知自定义 reason 不因该字段单独拒绝,继续执行其余门禁,不采用 `stop` 白名单。`platform-llm` 只公开复用 predicate,不改变其它普通文本调用方的降级行为。 +- 限流与埋点:不增加 Prompt 助手专属的用户级、IP 级、时间窗口、令牌桶或本地额度限流,不新增功能级 `429` / `Retry-After`;现有 api-server 全局并发背压、前端防重复操作、Nginx 保护和上游真实 `429` 安全映射不变。两个成功路由进入 `tracking.rs` 静态映射:补全为 `editor_background_music_prompt_completion`,简化为 `editor_background_music_prompt_simplification`,两者均使用 `module_key = editor`、User scope;普通 route tracking 继续只记录成功响应并走现有本机 outbox,助手 handler 不同步写 SpacetimeDB。 +- 影响范围:BGM composer 动作状态与 client、`api-server` Prompt 助手路由和候选验收、`platform-llm` 公共未完成原因 predicate、route tracking 静态映射及相应测试;不修改 SFX、正式 BGM 200 字限制、canonicalization 算法、SpacetimeDB schema、External v1 / OpenAPI 或 LLM 原文日志策略。 +- 验证方式:覆盖 canonical 2000 / 2001、两个路由 `32 KiB` / `413`、补全与两轮简化的 `length` / `content_filter`、缺失和未知 reason 兼容、连续合法请求无功能级 `429`,以及两个成功路由的 event key、`editor` module 和 User scope;运行 `api-server` 与 `platform-llm` 定向测试、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + +--- + +## 2026-08-05 BGM 正式提交保持站内 canonical 载荷与分层回调所有权 + +- 决策:登录态站内 BGM queue / inline 在分流前生成唯一 canonical payload,queue serializer 与 Suno body 只消费其中的 canonical `gptDescriptionPrompt` 并固定 `makeInstrumental=true`。正式 BGM POST 遇到 retryable HTTP 状态或 transport error 不由客户端自动重发,避免一次点击产生重复任务或扣费。 +- 回调所有权:正式任务接受后,钱包刷新只校验账号;任务列表通知同时校验账号与项目;dialog、canvas、asset 和 layer 写回校验账号、项目、scope version 与原 BGM dialog。dialog 删除或同账号切项目不应阻止账号级钱包刷新,账号切换即使暂时保留相同 project ID 也不得触发旧账号的任务列表回调。 +- 外部边界:上述 canonical payload 只属于登录态站内链路。External v1 继续保留调用方原始 BGM payload,并按原始 payload 执行既有 Idempotency-Key 等值语义;不能把 canonical 等价值误判为相同重放。 +- 影响范围:图片画布 BGM 正式提交与回调门禁;不新增持久提交锁、自动重试、计费设计或 External v1 契约变化。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 + +--- + +## 2026-08-04 BGM 撤销按钮按快照存在性显示、按面板锁定禁用 + +- 背景:需求《BGM生成优化需求 V1.0》第三节要求“AI 开始处理”时撤销按钮显示但禁用,权威设计也只要求处理中禁用撤销,但没有写明可见性;T4 界面方案据此把渲染条件收窄为“存在可撤销快照”,而状态模型在发起 AI 操作时会把可撤销快照转为本次临时快照,两者叠加会让撤销按钮在处理期间消失,与需求不一致。 +- 决策:撤销按钮的可见条件为存在可撤销快照或本次 AI 操作的临时快照,启用条件为可见且当前 BGM 面板未处于 `completing`、`simplifying`、`submitting` 或现有 `generating` 锁定状态。因此 AI 处理期间显示并禁用,AI 失败、点击预设和完全无快照时隐藏,AI 成功、手动编辑 AI 结果和连续撤销互换时显示并启用,`submitting` 期间有快照显示并禁用、无快照隐藏,解除锁定后按快照恢复。禁用必须使用真实禁用态并保留按钮在 DOM 与可访问树中的位置,不得用隐藏、透明度或其它视觉伪装代替,也不得因锁定改变按钮占位。发起 AI 操作时仍以当时的 canonical Prompt 替换旧快照,不得为了让处理期间按钮可见而保留旧快照;那会与本日第一条“AI 失败不恢复已被替换的旧快照”冲突,并让失败后的撤销指向不相关内容。视图只按该矩阵决定显示与启用,是否真正执行撤销仍由状态层在非 `idle` 或无快照时拒绝,两处不得各写一套判定。 +- 影响范围:`/editor/canvas` 的 `audio-background-music` 面板撤销按钮与其定向测试;不改变单层交换快照语义、canonicalization、提交锁、Suno 契约或后端 Prompt 助手,也不修改状态模型字段,`temporaryPromptSnapshot` 已在公开 dialog 状态中且只在 `completing` / `simplifying` 期间非空。本条不适用于 SFX,V1.0 不改动 SFX 的一键优化与撤销行为。 +- 验证方式:按矩阵逐行覆盖初始隐藏、首次与再次 AI 处理期间显示并禁用、成功启用、失败隐藏、手动编辑后仍启用、点击预设隐藏、`submitting` 有无快照的两种表现、解除锁定后恢复,以及连续撤销互换保持启用;并断言处理期间按钮仍在可访问树中且为真实禁用态。 +- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 ## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 - 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 @@ -6434,6 +6489,8 @@ - local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。 - schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::)] asset_kind_override: Option`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`。 - 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。 +- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族。前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。 +- 历史恢复:客户端读取到已持久化的跨族 override 时必须保留图层、回退资源默认类型、显示明确提示并自动提交清理后的布局;不得再用 `hydrateLayer() -> null -> filter(Boolean)` 静默隐藏持久层仍存在的图层。修复保存只替换命中图层的规范化布局项,其他尚不能 hydrate 的历史项原样保留,避免修复一个标签时顺带删除无关数据。 ## 2026-08-03 Agent Runtime 原生工具合同本地失败关闭 @@ -6655,6 +6712,7 @@ - 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 - 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。 - 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 + ## 2026-08-05 编辑器生成请求与参考图权威契约 - 主站编辑器生成 POST 不做浏览器自动重试,避免 inline 模式在响应丢失后重复调用 provider;api-server 仍使用独立 namespace + owner + job kind + request id 生成队列 `dedupe_key`,让显式复用同一请求标识的队列重放原子返回已存在任务,并对同键不同 payload 返回 `409`。外部 v1 的 `Idempotency-Key` 保持独立 namespace。 @@ -6662,3 +6720,10 @@ - 图片类最终 `generationInputs.references` 不信任客户端输入;队列 payload、完美像素及直接创建资源 / 素材入口删除客户端 references,worker / inline 路径按本次真实参考源与 owner 范围内的项目资源、账号素材重建 `refType/refId`。只有 owned objectKey 但没有正式行时不生成伪 provenance。完美像素继续使用升级前 canonical 客户端输入计算 operation fingerprint;新操作只持久化权威重建值,历史同 task/resource 重放复用服务端既存 metadata 通过精确比较。 - 升级前 External 幂等任务可能仍在 payload 中保留客户端 references;重放比较只对白名单内已迁移的图片生成、图片修改、去背景、图标图集和 UI 提取任务,在旧侧有 references、当前侧已删除时移除旧字段,其他字段变化仍返回 `409`。音频 / 视频 / 角色动作等未迁移 job kind 始终完整比较,不能扩大兼容面。 - 本次复用既有 `external_generation_job.dedupe_key` 唯一索引和 `spacetime-client` 查询,不改 SpacetimeDB schema、迁移或 bindings。 + +## 2026-08-06 音效与背景音乐恢复共享音频 Composer + +- 决策:图片画布的 `audio-sound-effect` 与 `audio-background-music` 只保留一个 `ImageCanvasAudioGenerationComposerView`,组件内以 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 分流。撤销的是完整 `ImageCanvasBackgroundMusicGenerationComposerView` 这一层视图拆分,不撤销 BGM Prompt 纯模型、助手 controller、预设模型或预设跑马灯的独立职责。 +- 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。 +- 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。 +- 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c253f9ee2..e339c6450 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4048,6 +4048,33 @@ - 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。 - 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 +## 角色动作不能靠素材主图或通用生成输入恢复 + +- 现象:角色动作在整画布导出时正常,但从素材库单项下载只得到第一帧 PNG,拖回画布也成为普通静态图片。 +- 原因:`editor_asset.image_src` 只指向首帧;若 worker 把完整帧集塞进 `generation_inputs_json`,素材 DTO、用户输入清洗或画布布局任一层丢字段,就会退化成 PNG。再增加一个 `mediaType` 只能掩盖结果字段没有落到正式资源的问题。 +- 处理:worker 只把完整帧集与图片序列毫秒时长写入 `editor_project_resource` / `editor_asset` 的 `image_sequence_frames_json`、`image_sequence_duration_ms`;数组位置是唯一帧序,不保存 `frameIndex`,帧数和 FPS 均按需派生。`assetKind=character-animation` 决定序列渲染。素材映射、单项下载和拖回画布只读取这两个正式字段,项目 resource 在保存 / 刷新后继续作为主真相;动作 layout 只保留资源引用和 placement,不再复制正式媒体结果。账号素材提交精选审核时,`editor_showcase_asset` 必须冻结复制相同字段,公开 read model 只返回正式字段。外部 helper 只调用一次动作生成接口并直接使用响应 `resource` / `asset`。 +- 画布回填:角色动作会形成“原角色资源 → 预览视频资源 → 最终序列资源”的血缘链。生成响应必须返回已经持久化的最终 resource,前端图层直接使用其 `resourceId`;不能继续构造 `local-resource-character-animation-*`,否则 `appendCanvasLayersWithResources` 会再次创建重复资源。新图层的 `sourceResourceId` 同时使用最终 resource 的直接来源(预览视频 resource),不能继续沿用请求中的原角色 resource;否则结构化保存会在已生成并计费后因血缘不一致而拒绝。修复时只替换资源关联与血缘字段,不要顺带把动作图层显示尺寸从生成占位尺寸改成原始帧分辨率。 +- 历史处理:不要再在 read mapper 增加 `generationInputs` / layout fallback。使用 migration operator procedure 按 `asset → project-resource → showcase → canvas` 迁移;只有动作身份已由 `assetKind`、正式字段、嵌套 `characterAnimation` 或权威对象证明后,才解释顶层 `frames/durationSeconds`,否则会把无关任意 JSON 误分类。同一 task 可能同时存在误标为动作的预览 MP4 和最终首帧 PNG,候选查找必须先按权威对象类型做计划态分类,排除视频并要求唯一正式图片序列,不能按原始 `assetKind` 计数。账号素材仍有旧帧、但后来拖入画布的 project-resource 只剩清洗后 `fields/references` 时,project-resource dry-run 必须按同 owner / task / 首帧对象精确消费 asset 计划态结果;apply 仍要求前置 asset scope 已物理完成。canvas 判断已有 resource 是否为动作时也必须消费 project-resource 的计划态类型:旧库误标为动作、但权威对象证明为 preview MP4 且 layout 本身是 video 的图层直接跳过动作清理;layout 明确为 `image-sequence` 却指向该视频时继续形成 blocker,资源规划本身有 blocker 时也不得静默跳过。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker;脚本诊断应直接打印 scope、ID、原因、owner/project/task、对象身份和来源资源,不能只报 blocker ID。普通 layer 顶层 `mediaType` 在迁移和响应清洗时删除,但嵌套生成参考的 `mediaType` 保留。 +- 新写入与验证:动作 `generationInputs` 出现 `characterAnimation/frames/previewVideoPath/frameCount/fps/durationSeconds/screenColorHex`,或正式帧出现 `frameIndex`,HTTP 与 storage 双层拒绝;其它 asset kind 的任意 JSON 不受该动作门禁影响。测试覆盖两种历史 JSON、无关顶层同名字段、正式/旧版相等与冲突、可选帧引用合并、screen color 和 frameIndex 清理、预览 MP4 重分类、幂等、blocker/hash apply、画布 placement 清理/资源补建、正式字段缺失失败关闭和 helper 单请求。 + +## 图片序列时长不要复用通用媒体秒数 + +- 现象:把角色动作、视频、音频和上传媒体都写进通用 `duration_seconds`,随后又尝试用持久化 `frame_count/fps/duration_seconds` 互相校验,造成取整口径、生成参数和实际播放时长彼此污染。 +- 原因:角色动作需要的是一组图片完整播放一次的精确时长;视频 / 音频的 `durationSeconds` 是生成请求或临时运行态参数。帧数已经由数组长度唯一确定,FPS 也可按需要推导,无需维护三份可冲突真相。 +- 处理:资源 / 素材只保存 `image_sequence_frames_json` 与 `image_sequence_duration_ms`,精选审核快照只冻结复制这两个正式字段。角色动作要求至少两帧且毫秒时长大于 0;播放器按 `时长毫秒 / 数组长度` 计算间隔,Spine 导出时再换算秒数并推导 FPS。音频 / 视频 `durationSeconds` 不映射到这两个字段。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`。 + +## 精选角色动作显示首帧还要检查前端 renderer 与逐帧授权 + +- 现象:精选接口已经返回 `imageSequenceFrames` 和正确的 5 / 6 秒成本,但创作主页或后台审核仍只显示首帧;接入播放器后又可能只有第一帧成功、后续帧换签返回 404。 +- 原因:快照字段、展示 renderer 和私有对象授权是三道独立边界。公开 `imageSrc/objectKey` 只代表首帧,不能让前端自动获得完整帧集;顶层精选 exact grant 也不会自动覆盖其它帧对象。 +- 处理:公开精选模型必须把 `assetKind=character-animation` 映射到序列 renderer,并携带完整帧与毫秒时长;后台素材查询和精选审核共同透传同一字段并复用 `AdminEditorAssetMedia`。生成端不能在 `ProcessedEditorCharacterAnimationFrame → EditorCharacterAnimationFramePayload` 收口时丢弃逐帧 `assetObjectId/objectKey`,正式序列 JSON 必须保留已确认对象的稳定引用。公开授权在 SpacetimeDB 同一事务快照中只按有效精选动作的同 owner 逐帧 `assetObjectId/objectKey` 匹配,不能放宽 generated 前缀。列表未交互时只读首帧,打开或激活动作预览后也只挂载当前帧和有界预读窗口,避免再次制造换签突发;单帧换签或解码失败时跳过该帧、暂停全帧失败的序列并提供显式重试,不能长期显示空白或旧帧。卡片 hover 与 focus 分别跟踪,只要任一状态仍成立就继续播放,系统请求 `prefers-reduced-motion` 时卡片和弹窗默认暂停,用户仍可在弹窗中手动播放。 +- 验证:模型 / 组件测试覆盖 4 / 5 / 6 秒动作、损坏序列不回退 PNG、后台两页共用播放器和未激活列表不逐帧请求;SpacetimeDB 测试覆盖主对象、每帧对象、无关对象、跨 owner 与取消展示后的授权撤销。真实浏览器和端到端验收由人工单独执行,不把 unit / component 结果写成 E2E PASS。 + +## 可复用资源回填必须保持时间戳单调 + +- 现象:延迟重试携带比既有行更旧的调用方时间,回填图片序列字段时若无条件写入,会使 `updated_at` 倒退,导致基于时间戳的同步看不到更新或排序错误。 +- 处理:同源图片序列字段只允许 `None → Some`,非空冲突失败关闭;发生回填时 `updated_at = max(existing.updated_at, request_timestamp)`。legacy 音频 repair 不派生资源级图片序列或通用时长,重放继续精确匹配。 ## 历史钱包消费不能从最近流水或通用订单快照推算 - 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。 @@ -4297,6 +4324,12 @@ - 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 - 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 +## 异步任务接受后的刷新回调不能统一套用 dialog 所有权(2026-08-05) + +- 现象:正式生成任务已被后端接受,用户随后删除 dialog 或切换项目,任务仍继续并可能扣费,但钱包和任务列表没有刷新;反向问题是账号切换时若 project ID 暂时相同,旧任务可能刷新新账号的任务列表。 +- 原因:把 dialog / canvas 的完整 UI 所有权同时用于账号级钱包和账号内项目级任务列表,或者任务列表只比较 project ID,没有校验账号。 +- 处理:按副作用分层校验。钱包只比较账号;任务列表比较账号加项目;dialog、canvas、asset 和 layer 写回继续比较账号、项目、scope version 与原 dialog。正式请求已接受后,删除 UI 状态不等于取消后端任务。 +- 验证:分别覆盖删除 dialog、同账号切项目、账号 A 切到账号 B 且 project ID 保持相同,以及原账号原项目原 dialog 仍有效的正常回写。 ## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) - 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 @@ -4317,3 +4350,10 @@ - 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。 - 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。 - 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。 + +## 共享音频 Composer 架构冲突不能按单行选边(2026-08-06) + +- 现象:master 的音频 composer 同时承载 SFX 与 BGM,并在组件内定义 `isSoundEffect`;功能分支把 BGM 拆成独立组件后,原组件变成 SFX-only。合并时只把 master 的条件占位表达式带回 SFX-only 组件,没有带回变量定义,最终在测试渲染阶段报 `isSoundEffect is not defined`。 +- 原因:冲突两侧代表不同组件架构,逐行保留看似有用的 JSX 会把一个架构中的局部条件拼进另一个架构。import 排序、格式检查和只覆盖单一 mode 的测试都不能证明这种组合成立。 +- 处理:先确定权威组件边界,再按完整调用链解决冲突。图片画布音频入口当前决策是恢复一个共享 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM/SFX 的 validator、写回、锁和提交契约仍分别保持。不要只补一个常量后继续维持已经废弃的双 composer 边界。 +- 验证:同时渲染 `audio-sound-effect` 与 `audio-background-music`,覆盖两个 mode 的正向控件和互斥负向断言、dialog / mode 切换、BGM 稳定 ID 与 controller 缺失的失败关闭,并运行 `ImageCanvasGenerationComposerView.test.tsx` 与 typecheck。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e5fdfaf35..dd42aa144 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -14,12 +14,12 @@ - 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 - 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。 -- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 +- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。标签覆盖必须与资源默认类型属于同一媒体族:`character-animation` 独立为动作族,`video` 独立为视频族,`audio/sound-effect/background-music` 属于音频族,其余类型和空默认值属于图片族;标签菜单禁用跨族选项,更新回调再次校验。加载到历史跨族 override 时不得静默过滤图层,必须清除内存 override、按资源默认类型恢复图层、向用户提示并排队修复保存。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 +- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中可能存在的后端拼接 Prompt、固定生成模板或模型默认提示词。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -142,7 +142,7 @@ - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、taskId、durationSeconds、resolution 和 `priceMudPoints`,不返回生成 provider。 -- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、taskId、duration、歌词和 `priceMudPoints`,不返回生成 provider。 +- `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、`prompt`、`actualPrompt`、model、taskId、duration、歌词和 `priceMudPoints`,不返回生成 provider。BGM 成功响应必须同时填充 `prompt` 与 `actualPrompt`,两者都和输入框已经写回的 canonical `gpt_description_prompt` 逐 code point 等值;共享响应类型为兼容 SFX 与历史数据仍可保留 `actualPrompt` 可选,SFX 语义不变。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 @@ -197,7 +197,8 @@ - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath`、`objectKey` 和 `assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。 - 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `