合并主线并解决场景生成冲突
保留结构化游戏场景生成链路 合并背景音乐提示词与共享音频编辑能力 保留角色动作数据约定与相关文档索引
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "<assetFolderId>",
|
||||
"assetLabel": "角色呼吸动画"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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(
|
||||
<AdminEditorAssetPreviewDialog
|
||||
entry={{
|
||||
assetId: 'asset-character-animation-cache',
|
||||
label: '缓存回播动作',
|
||||
imageSrc: `/${frameObjectKeys[0]}`,
|
||||
objectKey: frameObjectKeys[0],
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
|
||||
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<HTMLImageElement>(
|
||||
'.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<string, number>();
|
||||
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(
|
||||
<AdminEditorAssetPreviewDialog
|
||||
entry={{
|
||||
assetId: 'asset-character-animation-expiry',
|
||||
label: '过期换签动作',
|
||||
imageSrc: `/${frameObjectKeys[0]}`,
|
||||
objectKey: frameObjectKeys[0],
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
|
||||
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<string, number>();
|
||||
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(
|
||||
<AdminEditorAssetPreviewDialog
|
||||
entry={{
|
||||
assetId: 'asset-character-animation-refresh-failure',
|
||||
label: '换签失败动作',
|
||||
imageSrc: `/${frameObjectKeys[0]}`,
|
||||
objectKey: frameObjectKeys[0],
|
||||
assetKind: 'character-animation',
|
||||
imageSequenceFrames: frameObjectKeys.map((objectKey) => ({
|
||||
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<HTMLImageElement>('.admin-asset-query-sequence-frame')
|
||||
.forEach((frame) => fireEvent.load(frame));
|
||||
}
|
||||
|
||||
function resolveFrameByObjectKey(dialog: HTMLElement, objectKey: string) {
|
||||
return Array.from(
|
||||
dialog.querySelectorAll<HTMLImageElement>(
|
||||
'.admin-asset-query-sequence-frame',
|
||||
),
|
||||
).find((frame) => frame.src.endsWith(objectKey));
|
||||
}
|
||||
|
||||
function resolveVisibleFrameSrc(dialog: HTMLElement) {
|
||||
return Array.from(
|
||||
dialog.querySelectorAll<HTMLImageElement>(
|
||||
'.admin-asset-query-sequence-frame',
|
||||
),
|
||||
).find((frame) => frame.style.opacity === '1')?.src;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,未发现阻断性问题;本轮未创建真实音频生成任务或产生扣费。
|
||||
File diff suppressed because one or more lines are too long
@@ -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。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -56,6 +56,8 @@ v1 只开放以下能力:
|
||||
- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。
|
||||
- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。
|
||||
|
||||
手工调用 `POST /api/external/v1/editor/assets` 或 `POST /api/external/v1/editor/projects/{projectId}/resources` 创建 `assetKind=character-animation` 记录时,`generationInputs` 只保存可重放的生成输入,不接受 `characterAnimation`、`frames`、`previewVideoPath`、`frameCount`、`fps`、`durationSeconds` 等旧运行字段;完整正式帧序列和总时长必须分别写入 `imageSequenceFrames` 与 `imageSequenceDurationMs`。`screenColorHex`、`mattingProvider`、`mattingModel` 属于内部处理审计字段,External v1 会在持久化前移除。角色动作生成接口已经直接返回正式 resource / asset,正常调用方不应再手工复制第一帧创建重复记录。
|
||||
|
||||
provider 原图已保存但透明背景处理最终失败时,worker 保留原图稳定引用,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回。调用方应展示告警,但不得把 completed 任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍使任务失败。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥;风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,compact result 不得丢弃任一条。
|
||||
|
||||
## 异步提交、查询与幂等
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# BGM 生成提示词优化 T6 测试与发布门禁实施记录
|
||||
|
||||
日期:`2026-08-05`
|
||||
|
||||
状态:`实施完成并已提交、推送;本文保留实施时门禁记录`
|
||||
|
||||
## 文档定位
|
||||
|
||||
本文承接 BGM 生成提示词优化 V1.0 的 T6 实施边界、实际测试覆盖和发布门禁记录。产品与技术规则以[画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)为准;长期稳定边界同步记录在 `docs/project-memory/shared-memory/decision-log.md` 和 `pitfalls.md`。
|
||||
|
||||
本文不重新解释原始需求,不把测试便利转化为产品规则,也不作为生产部署授权。
|
||||
|
||||
## 实施基线与边界
|
||||
|
||||
- T0–T5 已完成并提交;T5 提交为 `3b69b4886`。
|
||||
- 用户已于 2026-08-05 完成人工全链路浏览器测试,并确认未发现阻断性问题。
|
||||
- 当前分支通过 merge commit `69426ee61` 包含 `origin/master@0cc257ce6`;实施结束时相对该 ref 为 `0 behind / 12 ahead`。
|
||||
- T6 只补测试、共用 fixture、文档和发布门禁。唯一生产代码调整是把既有 inline BGM 响应字段原样抽为同文件私有构造函数,供生产路径与测试共同调用。
|
||||
|
||||
明确未做:
|
||||
|
||||
- 不修改 BGM / SFX UI、Prompt 业务规则、助手模板、字符或 body 上限、限流、埋点、价格、计费、队列、重试或持久化语义。
|
||||
- 不新增 Idempotency-Key、服务端 dedupe、持久提交锁或跨页面恢复机制。
|
||||
- 不修改 SpacetimeDB schema、migration、bindings 或表目录。
|
||||
- 不修改 External v1 路由、DTO、状态码、异步语义、OpenAPI 或 worker。
|
||||
- 不修改 `platform-llm` 原文日志策略。
|
||||
- 不挂载或清理历史 `vector_engine_audio_generation/tests.rs`。
|
||||
- 不执行生产部署,也不重复创建可能扣费的真实 BGM 任务。
|
||||
|
||||
## 实际实施
|
||||
|
||||
### 共用 canonicalization 向量
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
packages/shared/test-fixtures/background-music-prompt-canonicalization.json
|
||||
```
|
||||
|
||||
共 10 个 case,由 TypeScript 与 Rust 共同消费,覆盖:
|
||||
|
||||
- 完整 Unicode `White_Space` 边界和 U+0085;
|
||||
- 内部 LF、CRLF 与空白保持;
|
||||
- U+200B、U+FEFF 保持;
|
||||
- 组合字符不做 NFC;
|
||||
- ZWJ emoji 按 code point 计数;
|
||||
- 标点、数字、ASCII 与 canonicalization 幂等性。
|
||||
|
||||
代表性 case `representative-complex` 的 canonical `charCount=16`、`effectiveCharCount=13`。同一 case 用于前端正式请求、成功结果 layer、client JSON body、站内 queue serializer、inline 响应和 Suno body 等值断言。
|
||||
|
||||
### 前端与 SFX 回归
|
||||
|
||||
只修改既有测试文件:
|
||||
|
||||
- `ImageCanvasBackgroundMusicPromptModel.test.ts`
|
||||
- `useImageCanvasGenerationSubmissionWorkflow.test.tsx`
|
||||
- `ImageCanvasGenerationSubmissionModel.test.ts`
|
||||
- `editorProjectClient.test.ts`
|
||||
|
||||
新增或收紧的证据:
|
||||
|
||||
- Prompt model 对全部共用 fixture 逐项断言 canonical Prompt、总字符数、有效字符数和幂等性。
|
||||
- BGM 正式请求与成功 layer 的 `prompt`、`actualPrompt`、唯一 `gpt_description_prompt` 均等于代表性 canonical Prompt。
|
||||
- 正式 BGM client JSON body 保持 canonical Prompt 原值。
|
||||
- SFX 全空白 Prompt 继续回退“游戏音效”,并保持 `audio1.0` 与默认 5 秒。
|
||||
|
||||
未修改前端生产实现。
|
||||
|
||||
### 后端与平台回归
|
||||
|
||||
修改:
|
||||
|
||||
- `server-rs/crates/platform-audio/tests/vector_engine_audio.rs`
|
||||
- `server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`
|
||||
|
||||
覆盖:
|
||||
|
||||
- `platform-audio` 消费全部共用 fixture,并保留 BGM 长度、资格和 SFX 回归。
|
||||
- Suno body 继续精确包含 `mv`、`gpt_description_prompt`、`make_instrumental` 三个字段。
|
||||
- 已挂载 generation 模块覆盖 BGM 空字符串、全 Unicode 空白、1 / 200 / 201 code point。
|
||||
- 登录态 queue serializer 与 inline 完成响应保持 canonical Prompt 等值。
|
||||
- 已挂载 SFX 测试覆盖唯一 `audio1.0`、Prompt 规范化、1500 / 1501、2 / 10 / 1 / 11 秒和动态价格。
|
||||
|
||||
生产路径只新增同文件私有 `build_editor_background_music_generate_response` 抽取;字段、DTO、分支和业务语义不变。
|
||||
|
||||
## 验证结果
|
||||
|
||||
|门禁|结果|
|
||||
|---|---|
|
||||
|前端 11 个定向测试文件|`259/259`|
|
||||
|shared-contracts BGM DTO|`1/1`|
|
||||
|`platform-audio`|`29/29`|
|
||||
|`platform-llm` 未完成原因与普通文本降级|两个定向测试均通过|
|
||||
|`api-server background_music`|`36/36`|
|
||||
|已挂载 generation 模块|`6/6`|
|
||||
|`editor_sound_effect`|`2/2`,不再是空过滤器|
|
||||
|External v1|`7/7`|
|
||||
|External BGM|`2/2`|
|
||||
|`cargo check -p api-server --all-targets`|通过|
|
||||
|TypeScript typecheck|通过|
|
||||
|变更文件 ESLint|通过|
|
||||
|Rust 格式|通过|
|
||||
|编码检查|5190 个文件通过|
|
||||
|`git diff --check`|通过|
|
||||
|
||||
Rust 输出只有既有 dead-code warning,没有新增失败。
|
||||
|
||||
## 运行态与人工门禁
|
||||
|
||||
本轮实际验证:
|
||||
|
||||
|服务|地址|门禁|结果|
|
||||
|---|---|---|---|
|
||||
|SpacetimeDB|`http://127.0.0.1:3101`|`GET /v1/ping`|HTTP 200|
|
||||
|BgFilter worker|`http://127.0.0.1:8083`|`GET /readyz`|HTTP 200|
|
||||
|api-server|`http://127.0.0.1:8082`|`GET /healthz`|HTTP 200|
|
||||
|
||||
API 安全响应摘要:
|
||||
|
||||
```json
|
||||
{"ok":true,"service":"genarrative-api-server"}
|
||||
```
|
||||
|
||||
本轮启动的进程树已按归属清理,三个端口均已关闭。
|
||||
|
||||
人工门禁如实记录为:`2026-08-05,用户人工全链路浏览器测试完成,未发现阻断性问题`。T6 没有把该结论扩写为未提供的逐项观察数据,也没有重复创建可能扣费的正式任务。
|
||||
|
||||
## 发布判定与交接
|
||||
|
||||
当前工作树相对已核对的 `origin/master@0cc257ce6` 满足 T6 本地发布门禁:
|
||||
|
||||
- TypeScript 与 Rust 共同消费同一 canonicalization fixture。
|
||||
- 前端、shared-contracts、Prompt 助手、generation、`platform-llm`、`platform-audio`、SFX 和 External v1 定向门禁通过。
|
||||
- 跨层等值证据使用同一代表性 Prompt,没有隐藏字段或二次改写。
|
||||
- API 健康检查与用户人工浏览器门禁通过。
|
||||
- diff 不包含原始需求文件、SpacetimeDB schema、External OpenAPI 或 `platform-llm` 日志策略修改。
|
||||
|
||||
本节记录门禁时,相关改动尚未提交;之后已以 `aeb5894b9` 提交并推送。后续 PR、四个 required jobs 和生产部署是独立动作,不由 T6 自动执行。
|
||||
|
||||
## BGM 助手模型试验
|
||||
|
||||
2026-08-05 起,补全和简化的请求级模型改为 `gpt-5.6-luna`;画布 Agent 其它调用继续使用 `gpt-5.4-mini`。该调整只作用于两个 BGM Prompt 助手路由,不改变共享编辑器 Agent 的默认模型。
|
||||
|
||||
- 本地 API mock 链路 `27/27` 通过,并确认请求 body 的 `model` 为 `gpt-5.6-luna`。
|
||||
- 真实 VectorEngine smoke 已尝试:`GET /v1/models` 与 `POST /v1/chat/completions` 均在建立 HTTP 连接前以 `fetch failed` 结束,没有返回状态码或模型响应;该结果只能说明当前环境网络不可达,不能判定 `gpt-5.6-luna` 被上游拒绝或支持。
|
||||
- 网络恢复后重试成功:补全和简化各发送一条最小 Chat Completions 请求,均返回 HTTP 200、`model=gpt-5.6-luna`、`finish_reason=stop`,并解析出完整四字段 JSON object;补全候选 `126` code points,简化候选 `79` code points。由此确认当前模型和两条助手请求形态可以跑通。
|
||||
|
||||
## 2026-08-06 组件架构后续修订
|
||||
|
||||
本文前述 T6 数字与文件范围是当时实施完成后的历史记录,不因后续组件归并而改写。新的权威方向是让 SFX 与 BGM 恢复使用 `ImageCanvasGenerationComposerView.tsx` 内同一个音频 composer,并由 `isSoundEffect` 分流;详见[音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)。
|
||||
|
||||
该修订已于 2026-08-06 实施:`ImageCanvasGenerationComposerView.tsx` 恢复唯一共享音频 composer 和 `isSoundEffect` 分流,独立完整 BGM composer 及其测试文件已删除,原用例全部迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过。现有 SFX 的 Vidu、2–10 秒、默认 5 秒、Prompt 回退、1500 字、价格与提交路径未改;本轮未实现 SFX V2、未修改后端或需求原文,也未执行浏览器视觉测试或创建真实付费任务。
|
||||
@@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
|
||||
|
||||
外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database <database> --server-url <url> --limit 5 --completed-before-micros <micros>`;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。
|
||||
|
||||
角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <url>`,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。
|
||||
|
||||
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。
|
||||
|
||||
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
@@ -589,7 +591,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.
|
||||
|
||||
同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。
|
||||
|
||||
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
|
||||
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
|
||||
|
||||
三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
### Prompt 与生成契约
|
||||
|
||||
- 前端提交到 `POST /api/editor/character-animations/generations`。
|
||||
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。
|
||||
- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。携带 `canvasCompletion` 时必须同时提供非空 `projectId`,该校验必须在解析来源、调用 provider、持久化和预扣泥点之前完成,禁止无项目画布完成请求在产生费用或产物后才失败。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。
|
||||
- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。
|
||||
- 后端在内联执行和队列入队前都会拒绝 Data URL / Blob URL;路由使用默认 JSON body limit,不再为内联图片请求单独放宽。
|
||||
- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后:
|
||||
@@ -182,9 +182,11 @@
|
||||
- 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。
|
||||
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续父流程只持 object key,并为每帧向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,不直接签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。每帧请求分别携带按父剩余绝对预算派生的 `maxQueueWaitMs` 和公式化 `callBudgetMs`;子 worker 在默认 `Q=2048` admission 保险丝和 `Semaphore(N=16)` 约束下排队,取得 provider permit 后才启动调用预算,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。真实 provider attempt 按 `N × est × 2` 派生,调用预算按 `2 × attempt + 1s` 派生;冻结 `est=5000ms` 时分别为 `160s / 321s`,排队不侵蚀最多两次顺序 attempt 的完整窗口,`32 / 40 / 48` 帧也不再增加单帧 attempt。成功图片由子 worker 以内部 HTTP 二进制 body 返回父流程,不在子侧落 OSS;BgFilter 最终失败且父业务预算仍有效时,由父流程进入 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。
|
||||
- BgFilter、阿里云或本地键色返回透明结果后,父流程继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边;最终帧 OSS 与业务写回仍由父流程完成。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。
|
||||
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按 `frameIndex` 排序;BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
|
||||
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按后端内部 `frame_index` 排序;该索引只服务并发收口、OSS 命名、日志和错误定位,不写入正式 asset / resource 帧对象。BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。
|
||||
- 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `<video>` 预览。
|
||||
- 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`;此时图层的 `sourceResourceId` 继续使用正式账号素材记录的直接来源,不回退成请求中的原角色资源。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。
|
||||
- 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。
|
||||
- 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。
|
||||
- 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。
|
||||
- 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;迁移按权威对象类型排除同 task 的预览视频、只接受唯一最终图片序列并逐帧补齐登记对象身份,且不把 layout 复制的 `sourceResourceId` 当作历史血缘证据。CLI tuple 与 HTTP object 两种 procedure 返回都必须先归一为脚本内部统一的 `batch_sha256`,分页还必须拒绝未推进或重复出现的 cursor,避免安全校验误拒绝或生产迁移无限循环。该宽容只属于迁移;新生成仍严格使用“原角色资源 → 预览视频资源 → 最终序列资源”直接来源链。上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。
|
||||
- `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
日期:`2026-06-18`
|
||||
|
||||
更新时间:`2026-08-06`
|
||||
|
||||
## 范围
|
||||
|
||||
本次只在 `/editor/canvas` 图片画布编辑器内新增底部 `生成音乐` 入口,用于生成完整游戏音效或游戏背景音乐。该入口属于画板生成类工具,不新增平台玩法入口、不进入作品发布链路,也不修改现有视觉小说音频生成开关。
|
||||
|
||||
2026-08-04 起,本文增加 BGM Prompt 优化 V1.0 口径。该切片只修改 `audio-background-music`;`audio-sound-effect` 的 UI、Prompt 回退、Vidu `audio1.0` 请求、`2-10` 秒时长和 1500 字限制全部保持不变。音效与背景音乐使用同一个音频 composer,并由组件内的 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 隔离行为;不得把 BGM 规则扩散到 SFX。
|
||||
|
||||
2026-08-06 的组件架构修订只撤销完整 BGM composer 的独立视图边界。共享音频 composer 的 SFX 分支保留当前 Vidu、时长、默认 Prompt、1500 字和价格行为,BGM 分支保留本文规定的预设、计数、AI 补全 / 简化、撤销、锁定、canonical Prompt 和 Suno 行为。BGM 纯状态模型、助手 controller 与预设跑马灯仍可保持独立职责;本轮不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设等独有能力。具体实施边界见[音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)。
|
||||
|
||||
## 入口与交互
|
||||
|
||||
1. 底部 AI 画布工具栏新增 `生成音乐`。
|
||||
@@ -13,7 +19,7 @@
|
||||
- `生成游戏音效`
|
||||
- `生成游戏背景音乐`
|
||||
3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。
|
||||
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。
|
||||
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐字段区增加字符计数、可展开预设词条、AI 补全、一键简化和单层撤销,底部仍保留现有动态泥点价格、固定 `Suno` 模型胶囊和生成按钮。
|
||||
5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。
|
||||
|
||||
## 面板字段
|
||||
@@ -27,10 +33,176 @@
|
||||
|
||||
### 生成游戏背景音乐
|
||||
|
||||
- `gpt_description_prompt`:用户输入的背景音乐提示词。
|
||||
- `gpt_description_prompt`:用户输入框当前文本按本文规则清理首尾 Unicode 空白后形成的背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。
|
||||
- `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。
|
||||
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`。
|
||||
- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。
|
||||
- 前端请求继续使用 `gptDescriptionPrompt`,Rust DTO 继续使用 `gpt_description_prompt`;不得因“唯一最终 Prompt”语义把请求字段改名为 `actualPrompt`。响应中的 `actualPrompt` / `actual_prompt` 继续保留既有生成审计语义。
|
||||
- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 允许且必须执行同一套首尾 Unicode 空白清理;除此之外不得执行 Unicode 规范化、内部空白折叠、内部换行转换、标点替换或静默截断。
|
||||
|
||||
## BGM Prompt 优化 V1.0
|
||||
|
||||
### 唯一可见最终 Prompt、首尾空白与字符口径
|
||||
|
||||
- 预设和 AI 助手都只修改同一个 BGM 输入框。“唯一最终 Prompt”约束的是语义来源和用户可见性:不得在正式请求中额外拼入用户看不到的前缀、后缀、预设元数据、分组信息、内部模板或系统提示词;它不要求保留编辑缓冲区中的首尾空白。这里的“用户不可见内容”指应用额外注入的语义文本,不指用户输入或粘贴的零宽字符等不可见 Unicode code point。
|
||||
- 从当前字符串两端删除属于 Unicode `White_Space` 属性的 code point 后得到的文本,以下称 canonical Prompt(规范化 Prompt);内部空格、内部换行和其它 code point 保持不变。
|
||||
- 用户编辑期间输入框可以暂时保留首尾空格、Tab 和换行。字符计数与动作可用状态基于 canonical Prompt 的非写入式预览计算,不得在每次键入时改写输入框;点击预设、AI 补全、一键简化、撤销或正式生成时,前端才在同步操作阶段计算 canonical Prompt 并写回输入框。
|
||||
- 正式生成时,写回后的输入框、前端 BFF 请求、队列请求载荷、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应必须逐 code point 完全一致。
|
||||
- 首尾空白清理必须跨语言一致,不能直接混用语义不同的原生函数。TypeScript 按 `\p{White_Space}` 删除两端 code point;Rust 使用 `char::is_whitespace` 删除两端 code point。`U+200B` 和 `U+FEFF` 不属于 Unicode `White_Space`,不得被这一步顺带删除。
|
||||
- 除首尾 Unicode 空白清理外,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠内部空格,不转换或合并内部换行,不替换标点,不静默截断。
|
||||
- 总字符数按规范化后最终 Prompt 的 Unicode code point 数计算。TypeScript 使用 `Array.from(finalPrompt).length`,Rust 使用 `final_prompt.chars().count()`;被删除的首尾 Unicode 空白不计入 200 字。
|
||||
- “有效字符”只用于动作可用性和参数校验,定义为最终 Prompt 中的非 Unicode 空白 code point。标点、数字、字母和不可见但不属于 Unicode `White_Space` 的 code point 均计为有效字符;内部空格、内部换行和全部不可见 code point 仍计入 200 字总数。
|
||||
|
||||
| 规范化后的最终 Prompt | AI 补全 | 一键简化 | 正式生成 |
|
||||
| --- | --- | --- | --- |
|
||||
| 空字符串(总数 0、有效字符数 0) | 禁止 | 禁止 | 禁止 |
|
||||
| 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 |
|
||||
| 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 |
|
||||
| 至少 1 个有效字符且总数为 201–2000 | 禁止 | 允许 | 禁止 |
|
||||
| 总数超过 2000 | 禁止 | 禁止 | 禁止 |
|
||||
|
||||
- canonical Prompt 满足不变量:`有效字符数 = 0` 当且仅当 `总字符数 = 0`。原始输入即使完全由 201 个或更多 Unicode `White_Space` 组成,canonicalization 后仍为空字符串,AI 补全、一键简化和正式生成全部禁止;不得按规范化前的长度把全空白输入归入“可简化”状态。
|
||||
- 一键简化最大输入固定为正式生成合法上限的 10 倍,即 `200 × 10 = 2000` 个 canonical Unicode code point。超过 2000 时必须完整保留当前文本供用户手动编辑,不得截断或丢失,但 AI 补全、一键简化和正式生成都不可用。
|
||||
|
||||
### 预设库与追加规则
|
||||
|
||||
预设分为三组,仅用于颜色和滚动组织;分组、ID、颜色等隐藏元数据不进入 Prompt、Suno 请求或生成记录。写入输入框的是下表右列完整可见文本。
|
||||
|
||||
| 分组 | 预设 | 写入输入框的文本 |
|
||||
| --- | --- | --- |
|
||||
| 用途 | 菜单待机 | 低干扰、适合菜单待机的背景音乐 |
|
||||
| 用途 | 休闲消除 | 轻快可爱的休闲消除背景音乐 |
|
||||
| 用途 | 解谜思考 | 安静专注的解谜思考背景音乐 |
|
||||
| 用途 | 探索冒险 | 温和推进的探索冒险背景音乐 |
|
||||
| 用途 | 对白场景 | 克制柔和、留出对白空间的背景音乐 |
|
||||
| 用途 | 战斗前 | 蓄势待发的战斗前背景音乐 |
|
||||
| 用途 | 胜利结算 | 明亮满足的胜利结算背景音乐 |
|
||||
| 用途 | 失败结算 | 克制低落的失败结算背景音乐 |
|
||||
| 用途 | 日常经营 | 轻松有序的日常经营背景音乐 |
|
||||
| 用途 | 农场经营 | 自然温暖的农场经营背景音乐 |
|
||||
| 用途 | 校园日常 | 青春轻松的校园日常背景音乐 |
|
||||
| 用途 | 美食厨房 | 温暖活泼的美食厨房背景音乐 |
|
||||
| 氛围 | 温暖治愈 | 柔和明亮的治愈背景音乐 |
|
||||
| 氛围 | 神秘悬疑 | 克制神秘的悬疑背景音乐 |
|
||||
| 氛围 | 紧张推进 | 稳定推进、逐渐紧张的背景音乐 |
|
||||
| 场景 | 森林自然 | 清新自然的森林背景音乐 |
|
||||
| 场景 | 雨夜静谧 | 雨夜静谧、略带神秘感的背景音乐 |
|
||||
| 场景 | 海洋漂流 | 开阔舒缓的海洋漂流背景音乐 |
|
||||
| 场景 | 山野远行 | 自由舒展的山野远行背景音乐 |
|
||||
| 场景 | 糖果乐园 | 甜美活泼的糖果乐园背景音乐 |
|
||||
| 场景 | 温馨小屋 | 温暖安静的温馨小屋背景音乐 |
|
||||
| 场景 | 城市夜晚 | 克制迷人的城市夜晚背景音乐 |
|
||||
| 场景 | 太空科幻 | 空灵未来感的太空科幻背景音乐 |
|
||||
| 场景 | 赛博街区 | 冷静律动的赛博街区背景音乐 |
|
||||
| 场景 | 古风幻想 | 空灵雅致的古风幻想背景音乐 |
|
||||
| 场景 | 童话花园 | 梦幻轻盈的童话花园背景音乐 |
|
||||
| 场景 | 海底遗迹 | 深邃神秘的海底遗迹背景音乐 |
|
||||
| 场景 | 沙漠遗迹 | 苍茫神秘的沙漠遗迹背景音乐 |
|
||||
| 场景 | 熔岩洞穴 | 炽热压迫的熔岩洞穴背景音乐 |
|
||||
| 场景 | 奇妙博物馆 | 好奇灵动的奇妙博物馆背景音乐 |
|
||||
|
||||
点击预设时:
|
||||
|
||||
1. 先按统一规则删除输入框文本两端的 Unicode `White_Space` code point,并把结果写回输入框。
|
||||
2. 规范化后的字符串为空,直接写入该预设的完整可见文本。
|
||||
3. 规范化后的字符串非空,检查其最后一个 Unicode code point。
|
||||
4. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。
|
||||
5. 首尾空白在追加前已经删除;内部空格和内部换行保持原位,不得继续清理。写入输入框的规范化文本、句号和预设可见文本共同构成新的唯一最终 Prompt。
|
||||
6. 允许重复点击同一个预设,每次都按同一规则追加。
|
||||
7. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。
|
||||
8. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。
|
||||
|
||||
预设滚动交互:
|
||||
|
||||
- 展开后全部词条横向、连续、缓慢循环;收起后隐藏词条并停止可见滚动。
|
||||
- 桌面端左侧 15% 悬停区快速向左滚动,中间 70% 立即暂停并稳定展示至少 5 个词条,右侧 15% 快速向右滚动;鼠标移出后恢复默认慢速循环。
|
||||
- 左右箭头所在区域也必须触发对应方向的加速滚动,避免箭头可见但悬停无反馈。
|
||||
- 循环使用多份词条队列无缝衔接,末端不跳回、不留白,也不提供“换一批”。
|
||||
- 三组词条使用协调但可区分的颜色;底部细线标记当前悬停控制区。
|
||||
- 组件在触摸环境被渲染时支持横向滚动和箭头操作,不依赖 hover 才能选择预设;本需求不恢复移动端图片画布入口。
|
||||
- AI 处理或正式提交期间暂停滚动并禁用展开、收起、箭头和词条点击。
|
||||
|
||||
### AI 补全
|
||||
|
||||
- 点击 AI 补全时先按统一规则规范化输入框首尾空白并同步写回;至少 2 个有效字符且总字符数不超过 200 时,前端才把这份可见最终 Prompt 传给登录态内部 BFF。
|
||||
- Prompt 助手当前使用专用请求模型 `gpt-5.6-luna`,请求级 `reasoning_effort` 固定为 `medium`;画布 Agent 本身仍使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`。两者复用现有 `LlmClient`,不建立新的平台 LLM 能力。
|
||||
- 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。
|
||||
- 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。
|
||||
- 内部 envelope 的 `prompt` 字段只允许包含一条可直接写回输入框的中文 BGM Prompt;候选文本本身不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。
|
||||
- 补全与简化共用同一份内部结构化 envelope:`prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`。助手显式使用现有 OpenAI Chat 协议,envelope 由完整 `response.text` 中唯一一个 JSON object 承载;服务端只允许 `serde_json` 对完整文本做全量解析,允许 JSON object 外围存在 JSON whitespace,但不接受代码块、前后解释、多个 JSON 值或从字符串中截取 object,也不执行自动修复。助手请求不发送 function tools,不做运行时双协议 fallback;响应出现 tool call 时同样视为结构非法。envelope 只用于服务端解析和判断,不属于候选文本,不写回输入框,也不通过 BFF 暴露给客户端。
|
||||
- 服务端必须在解析 envelope、canonicalize 候选或提取任何 `prompt` 前检查 OpenAI Chat `finish_reason`。去除外围空白并忽略 ASCII 大小写后,`length` 和 `content_filter` 都属于未完成响应;即使正文恰好是合法四字段 JSON,也不得接受、解析或提取候选。`finish_reason` 缺失、为空或为未知自定义值时,不因该字段单独拒绝,继续执行其余结构与候选门禁;不得改为只允许 `stop`。
|
||||
- 补全遇到 `finish_reason = length` 或 `content_filter` 时均直接失败,不写回、不泄漏响应正文或候选,且仍只执行一个业务语义轮。
|
||||
- 补全候选先执行同一套首尾 Unicode 空白规范化,再由程序计算字符数。候选只有在四字段结构有效、canonical `prompt` 包含有效字符且不超过 200 个 Unicode code point,并且三个布尔字段依次为 `true / true / false` 时才通过;程序不使用关键词或未定义正则猜测候选格式、完整性或残句。通过后整段写回输入框并成为唯一最终 Prompt。
|
||||
- 补全只执行一个业务语义轮。`LlmClient` 在该轮内部按现有配置执行的 transport retry 不计为新增业务语义轮;该轮最终发生 transport、超时或上游失败时直接返回失败,不再发起内容修复轮。
|
||||
- 调用失败、结果为空、结构或判断不合格、格式非法或超限时保留请求前已经写回的规范化 Prompt,不写回部分结果;错误响应不得包含未通过候选或内部 envelope。
|
||||
- 补全过程不调用 Suno、不创建正式生成任务、不触发正式音乐生成扣费。
|
||||
|
||||
### 一键简化
|
||||
|
||||
- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 至少含 1 个有效字符且总字符数为 201–2000 时允许调用。超过 2000 时完整保留文本,但不得调用简化 BFF。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。
|
||||
- 服务端在本次简化中冻结 `originalPrompt` 为入站 canonical Prompt,最多两个业务语义轮期间始终不变,作为内容保真参照。第一次业务语义轮使用 `currentPrompt = originalPrompt`,目标为 180 字。这里的 `originalPrompt` / `currentPrompt` 是服务端组装 LLM 简化模板时的内部变量;客户端简化 BFF DTO 仍只提交一个 `currentPrompt`,该入站值经 canonicalization 后同时成为内部 `originalPrompt` 和第一次内部 `currentPrompt`。每个业务语义轮内部由 `LlmClient` 按现有配置执行的 transport retry 不增加业务语义轮数。
|
||||
- 180 不是硬门槛。简化使用与补全相同的四字段内部结构化 envelope;envelope 不属于候选文本,也不得写回输入框或通过 BFF 暴露。响应不能解析为包含上述正确字段类型的对象时,本次候选不通过。
|
||||
- 候选“格式合法”专指 `isDirectWritebackFormat = true`:模型确认 canonical 候选只包含一条可直接写回输入框的中文 BGM Prompt,不包含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明。它与“内部结构可解析”是两个独立校验项;程序不得另用关键词或未定义的正则推断标题、解释等语义格式。
|
||||
- 第一次业务语义轮成功取得上游响应、但候选未通过任一通过条件时,自动进行唯一一个第二业务语义轮,目标为 170 字。只有当完整 `response.text` 已按上述规则全量解析为单个 JSON object 时,才允许从该 object 读取字符串 `prompt`;禁止从未完整解析的文本、代码块、解释或畸形 JSON 中做子串提取。若可提取的字符串 `prompt` canonicalize 后包含有效字符,第二次使用 `currentPrompt = canonicalize(第一次候选)`,即使第一次因其它字段缺失、超限、格式、完整性或残句判断而不通过;若完整响应无法解析为单个 object、object 中没有字符串 `prompt`,或候选 canonicalize 后不含有效字符,第二次回退使用 `currentPrompt = originalPrompt`。第二次业务语义轮中的 `originalPrompt` 始终仍是最初入站 canonical Prompt。
|
||||
- 第一轮 `finish_reason = length` 时整份响应不可信,不得接受或提取其中的 `prompt`;它只按“成功取得响应但候选不合格”进入唯一的 170 字轮,第二轮必须使用 `currentPrompt = originalPrompt`。第一轮 `finish_reason = content_filter` 时直接失败,不进入第二轮。第二轮出现 `length` 或 `content_filter` 时最终失败,不得发起第三轮。该规则优先于上一条的一般候选提取与回退规则。
|
||||
- 第一次业务语义轮最终发生 transport、超时或上游失败时直接返回失败,不进入 170 字内容修复轮;这类失败只应用该轮内部既有的 `LlmClient` transport retry。
|
||||
- 每次候选都先执行同一套首尾 Unicode 空白规范化,再计算实际字符数。第二次仍不合格时返回失败并保留请求前已经写回的规范化 Prompt,提示用户手动精简;错误响应不得包含第一次或第二次未通过候选、可提取的 `prompt` 或内部 envelope。最多两个业务语义轮,任何情况下都不得由程序截断到 200 字。
|
||||
- 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。
|
||||
- 程序不抽取关键词,不对规范化后的输入与候选执行内容硬编码逐项比对。是否为直接写回格式、是否保留重要信息且内容完整、是否形成明显残句由 LLM 在同一次调用的三个布尔字段中分别判断。
|
||||
- BFF 成功时只返回已通过校验的 Prompt 和程序计算的字符数,不暴露三个内部判断字段;失败时使用现有 API 错误 envelope,且不返回任何候选或内部判断字段。
|
||||
- 候选只有同时满足以下条件才通过:内部结构可解析且四个字段类型正确;canonical 候选包含有效字符;canonical 候选实际字符数不超过 200;`isDirectWritebackFormat = true`;`isContentComplete = true`;`hasObviousFragment = false`。程序负责解析和检查字段类型、canonicalization、有效字符与实际字符数,并执行三个布尔判断结果;字符数由程序计算且不采信模型报告,程序不自行推断三个语义判断。
|
||||
- 一键简化不维护或恢复“已选预设”元数据;预设已写入输入框的文本只是当前最终 Prompt 的一部分。
|
||||
|
||||
### 单层撤销
|
||||
|
||||
- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,先把当时输入规范化并写回,再以该 canonical Prompt 建立本次临时快照。
|
||||
- AI 成功写回后,该快照成为可撤销版本;AI 失败时输入框保留请求前已经写回的 canonical Prompt,清除本次临时快照,不生成新的可撤销版本,也不恢复发起本次操作时已经被替换的旧快照。
|
||||
- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,先规范化并写回当时的当前 Prompt,再以该 canonical Prompt 替换旧快照。
|
||||
- 点击预设时先规范化并写回,再清除旧快照;正式提交被后端拒绝时,保留已经写回的 canonical Prompt 和提交前已有快照。
|
||||
- 点击撤销时,先把当前输入规范化并写回,再让该 canonical Prompt 与 canonical 快照互换;按钮继续可用,允许用户在两个规范化版本间来回切换。
|
||||
- 只保存一层,快照只包含 canonical Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。
|
||||
|
||||
撤销按钮的可见性与可用性:
|
||||
|
||||
- 可见条件为存在可撤销快照或本次 AI 操作的临时快照;启用条件为可见且当前 BGM 面板未处于 `completing`、`simplifying`、`submitting` 或现有 `generating` 锁定状态。发起 AI 操作时可撤销快照被本次临时快照取代,因此“处理中没有可撤销快照”不等于“没有快照”,不得据此隐藏按钮。
|
||||
- AI 处理期间按钮显示并禁用,不得隐藏。禁用必须使用真实禁用态并保留按钮在 DOM 与可访问树中的位置,不得用隐藏、透明度或其它视觉伪装代替,也不得在处理前后改变按钮占位。
|
||||
- 完全没有快照时隐藏,不显示灰色占位按钮。
|
||||
|
||||
| 状态 | 可撤销快照 | 本次临时快照 | 撤销按钮 |
|
||||
| --- | --- | --- | --- |
|
||||
| 初始进入面板、没有历史快照 | 无 | 无 | 隐藏 |
|
||||
| AI 开始处理,首次或再次均相同 | 无 | 有 | 显示并禁用 |
|
||||
| AI 处理成功并写回输入框 | 有 | 无 | 显示并启用 |
|
||||
| AI 处理失败、原文未改变 | 无 | 无 | 隐藏 |
|
||||
| 用户手动编辑 AI 结果 | 有 | 无 | 显示并启用 |
|
||||
| 点击预设词条 | 无 | 无 | 隐藏 |
|
||||
| 点击生成提交且已有快照 | 有 | 无 | 显示并禁用 |
|
||||
| 点击生成提交且没有快照 | 无 | 无 | 隐藏 |
|
||||
| 提交成功或失败后解除锁定 | 有或无 | 无 | 有快照时显示并启用,否则隐藏 |
|
||||
| 点击撤销 | 有,与当前文本互换 | 无 | 保持显示并启用 |
|
||||
|
||||
- 再次发起 AI 操作时仍按上面的规则用当时的 canonical Prompt 替换旧快照;不得为了让处理期间按钮可见而保留旧快照,那会与“AI 失败不恢复已被替换的旧快照”冲突,并让失败后的撤销指向不相关内容。
|
||||
- 视图只按上表决定显示与启用;是否真正执行撤销仍由状态层在非 `idle` 或无快照时拒绝,两处不得各写一套判定。
|
||||
|
||||
### AI 操作与方案 A 提交锁
|
||||
|
||||
当前 BGM generation dialog 的 Prompt 助手状态为:
|
||||
|
||||
```text
|
||||
idle
|
||||
├─ completing
|
||||
├─ simplifying
|
||||
└─ submitting
|
||||
```
|
||||
|
||||
- `completing` / `simplifying` 开始时以同步 operation ID 取得当前 dialog 操作权;同一操作的后续双击无效。新操作使旧 operation ID 失效,关闭 dialog 时中止请求,dialog ID 或 operation ID 不匹配的迟到响应必须丢弃。
|
||||
- AI 处理中输入框只读,禁用预设展开/收起、预设词条、滚动箭头、AI 补全、一键简化、撤销和生成。这里的“禁用撤销”指显示并禁用,可见性按“单层撤销”一节的矩阵判定,不得隐藏。
|
||||
- 点击生成的同步事件内必须在任何 `await` 前依次完成:立即把当前 BGM dialog 切换到 `submitting`、计算 canonical Prompt 并写回输入框、按 canonical Prompt 校验 0 个有效字符和 200 字上限、冻结本次请求值。前端校验失败时立即解除锁并保留写回后的 canonical Prompt,不发送请求。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。
|
||||
- 锁只作用于当前 BGM dialog,不锁整个画布、其它 generation dialog、画布拖动、缩放、图层操作或其它编辑能力。
|
||||
- 后端拒绝正式提交时,解除锁并保留已经写回的 canonical Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。原 dialog 仍是当前面板时恢复面板;用户已归档或切走时只记录失败并保持关闭,不自动激活旧面板。
|
||||
- 后端接受请求并创建正式生成任务后,`submitting` 结束;原账号、项目和 dialog 仍匹配时进入现有 `queued/generating` 占位并隐藏输入 composer。若 dialog 已删除或账号 / 项目已切换,正式任务继续,但旧回调不得按同名 dialog ID 写入新 scope。
|
||||
- 正式任务接受后的异步回调按副作用作用域分别校验:钱包刷新只校验原账号仍是当前账号;任务列表通知同时校验原账号和原项目仍是当前账号与项目;dialog、canvas、asset 和 layer 写回继续校验账号、项目、scope version 与原 BGM dialog。删除 dialog 不得阻止同账号钱包刷新或同账号同项目任务列表通知,切换账号不得让旧任务触发新账号页面回调。
|
||||
- “提交成功”仅表示后端已接受请求并创建正式任务,不表示 Suno 已完成音乐生成。
|
||||
- 上述“接受后切占位”以现役默认 queue 模式的任务创建响应为观察点;兼容 inline 模式没有中间接受响应,沿用现有 HTTP 终态响应作为客户端可观察结算点,不为此新增协议。
|
||||
|
||||
## 画布数据
|
||||
|
||||
@@ -51,10 +223,10 @@
|
||||
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
|
||||
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
|
||||
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
|
||||
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。
|
||||
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段;BGM 快照保存输入框已经写回的 canonical Prompt,不保存助手系统模板、内部引导或预设元数据。
|
||||
- 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
|
||||
|
||||
## 前端提交契约
|
||||
## 前端与 BFF 契约
|
||||
|
||||
前端新增两个 BFF client:
|
||||
|
||||
@@ -63,8 +235,7 @@ POST /api/editor/audios/sound-effects/generations
|
||||
{
|
||||
prompt: string,
|
||||
model: "audio1.0",
|
||||
duration: number,
|
||||
priceMudPoints: 10
|
||||
duration: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -72,11 +243,46 @@ POST /api/editor/audios/sound-effects/generations
|
||||
POST /api/editor/audios/background-music/generations
|
||||
{
|
||||
gptDescriptionPrompt: string,
|
||||
makeInstrumental: true,
|
||||
priceMudPoints: 5
|
||||
makeInstrumental: true
|
||||
}
|
||||
```
|
||||
|
||||
BGM Prompt 助手新增两个登录态内部 BFF:
|
||||
|
||||
```ts
|
||||
POST /api/editor/audios/background-music/prompts/completions
|
||||
{
|
||||
currentPrompt: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
POST /api/editor/audios/background-music/prompts/simplifications
|
||||
{
|
||||
currentPrompt: string
|
||||
}
|
||||
```
|
||||
|
||||
统一 Prompt 助手响应:
|
||||
|
||||
```ts
|
||||
{
|
||||
prompt: string,
|
||||
charCount: number
|
||||
}
|
||||
```
|
||||
|
||||
- 客户端不得提交 `maxChars`、`targetChars`、模型名、预设 ID、分组、颜色、内部模板或格式 / 完整性 / 残句判断;这些由服务端固定或由内部 LLM 结果产生。
|
||||
- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库,也不在 handler 中同步执行 SpacetimeDB 业务写入;成功路由的通用 tracking 仍由现有本机 outbox 异步承接。
|
||||
- 两个助手请求中的 `currentPrompt` 必须是前端已经写回输入框的 canonical Prompt;响应 `prompt` 也必须先执行同一 canonicalization,`charCount` 是响应 canonical Prompt 的 Unicode code point 数。
|
||||
- 补全与简化成功响应都只包含上面的 `prompt` / `charCount` 业务字段;内部四字段 envelope、`originalPrompt`、目标字数、业务语义轮信息和未通过候选均不得出现在成功或失败响应中。失败继续使用现有 API 错误 envelope。
|
||||
- 简化 BFF 只接受总字符数为 201–2000 且至少含 1 个有效字符的 canonical `currentPrompt`;超过 2000 返回现有 `400 BAD_REQUEST` 错误 envelope,并标记字段 `currentPrompt`。
|
||||
- 两个助手路由都设置 `32 KiB` HTTP 请求体上限。请求体超过该上限时保留 Axum `413 PAYLOAD_TOO_LARGE`,不得被通用 JSON rejection 降为 `400`;字符上限与原始 body 上限分别校验,不能互相替代。
|
||||
- 不增加 Prompt 助手专属的用户级、IP 级、时间窗口或令牌桶限流,也不新增本功能主动产生的 `429` / `Retry-After`。现有 api-server 全局并发背压、前端防重复操作、Nginx 保护和上游真实 `429` 的安全映射保持不变。
|
||||
- 两个成功路由必须进入 `tracking.rs` 显式静态映射:补全使用 `event_key = editor_background_music_prompt_completion`,简化使用 `event_key = editor_background_music_prompt_simplification`;两者都使用 `module_key = editor`、User scope。普通 route tracking 继续只记录成功响应,不在助手 handler 中新增同步埋点副作用。
|
||||
- BGM 正式提交必须使用输入框已经写回的 canonical Prompt,不得额外拼接用户不可见内容,也不得把空 Prompt 回退为“游戏背景音乐”。
|
||||
- BGM 正式 POST 不使用现有允许 unsafe method 的自动重试,保证一次点击不会由 client 内部重发。本文不新增 Idempotency-Key、持久提交账本或服务端 dedupe;其它生成 mode 的 retry 行为保持不变。
|
||||
|
||||
统一响应:
|
||||
|
||||
```ts
|
||||
@@ -99,11 +305,17 @@ POST /api/editor/audios/background-music/generations
|
||||
}
|
||||
```
|
||||
|
||||
- BGM 成功响应必须同时填充 `prompt` 和 `actualPrompt`,两者都与本次 canonical Prompt 逐 code point 等值,不得携带助手模板、内部引导或隐藏前后缀;共享响应类型为兼容 SFX 与历史数据仍可保留 `actualPrompt` 可选。
|
||||
- 默认 queue 模式的首次生成响应继续只携带现有 `queueState`;上面的完整音频响应是 inline 或 worker 内部完成边界,不要求给 queue 首次响应或普通 metadata-only job result 新增 Prompt 字段。
|
||||
|
||||
## 后端实现
|
||||
|
||||
- 在 `shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。
|
||||
- 在 `shared-contracts` 增加最小 BGM Prompt 助手请求 / 响应 DTO;前后端共享 `currentPrompt`、`prompt` 和 `charCount` 字段,不把内部 LLM 判断暴露给客户端。
|
||||
- 前端、`api-server` 与 `platform-audio` 必须实现同一 BGM canonicalization 语义并复用同一组跨语言测试向量:只删除首尾 Unicode `White_Space`,保留内部空白和全部其它 code point。该操作必须幂等,不得直接混用语义不同的 TypeScript / Rust 原生 `trim`。
|
||||
- 在 `platform-audio` 增加编辑器专用 body builder 和 submit 函数:
|
||||
- 背景音乐 body 使用 `mv`、`gpt_description_prompt`、`make_instrumental`。
|
||||
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
|
||||
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
|
||||
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`sound: prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
|
||||
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`;Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
|
||||
@@ -112,8 +324,17 @@ POST /api/editor/audios/background-music/generations
|
||||
- 在 `api-server` 增加编辑器音频 BFF:
|
||||
- `/api/editor/audios/sound-effects/generations`
|
||||
- `/api/editor/audios/background-music/generations`
|
||||
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。
|
||||
- 在 `api-server` 增加登录态内部 BGM Prompt 助手 BFF:
|
||||
- `POST /api/editor/audios/background-music/prompts/completions`
|
||||
- `POST /api/editor/audios/background-music/prompts/simplifications`
|
||||
- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则、正式生成 200 字限制和简化 201–2000 字资格都基于 canonical Prompt。助手使用现有编辑器专用 LLM client、`gpt-5.6-luna` 请求模型、固定 `reasoning_effort=medium` 和显式 OpenAI Chat 协议;画布 Agent 其它调用仍使用 `gpt-5.4-mini`。补全固定执行一个业务语义轮,简化按 `180 -> 170` 最多两个业务语义轮,并按“一键简化”章节冻结 `originalPrompt`、派生每轮 `currentPrompt`。`LlmClient` 在单轮内部执行的 transport retry 不计入业务语义轮数,简化第一轮 transport、超时或上游失败不进入 170 字轮。服务端负责模板组装、在正文解析或候选提取前检查 `finish_reason`、canonical 字符校验、对完整 `response.text` 中单个 JSON object 的 `serde_json` 全量解析、补全与简化共用的内部 envelope 校验、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断,也不向客户端返回未通过候选。助手不发送 function tools,不接受 tool call,不从代码块或解释中截取 JSON,不自动修复,也不做运行时双协议 fallback。
|
||||
- `finish_reason` 检查复用并公开 `platform-llm` 现有 API-kind-aware 未完成原因 predicate;不得在 `LlmClient` 全局拒绝普通纯文本响应,也不得改变其它调用方既有的长文本降级行为。
|
||||
- 两个助手路由使用各自的 `32 KiB` body limit,并在 `tracking.rs` 中注册上述 User-scope 成功事件;不增加助手专属限流器、本地额度计数或功能级 `429`。
|
||||
- Prompt 助手继续复用 `LlmClient` 现有失败原文日志行为。本需求不增加请求级日志开关、脱敏、metadata-only 模式或相关上线门禁。
|
||||
- BGM generation BFF 在入站时防御性执行同一幂等 canonicalization,规范化后校验有效字符和 200 字限制。登录态站内 handler 在 queue / inline 分流前把 canonical Prompt 和固定 `make_instrumental:true` 写入本次请求值,确保正式 generation queue 请求载荷、持久化记录和内部完成响应等值;删除空 Prompt 默认回退。该站内收口不改变 External v1 的路由、OpenAPI、Idempotency-Key 或 payload 等值语义。助手模板只用于生成输入框可见候选,不得进入正式队列或 Suno 请求。SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。
|
||||
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装。客户端不提交 BGM `priceMudPoints`;服务端按现役动态定价配置解析并在入队时冻结本次价格,后续计费、资产成本和内部完成响应复用该冻结值。T5 不修改价格或计费规则。
|
||||
- 生成音频持久化后返回 OSS `objectKey` 与 `assetObjectId`;前端保存素材库时继续使用 `audioSrc` 作为兼容路径,并把 OSS 身份写入素材记录。
|
||||
- 本切片不修改 SpacetimeDB schema,不新增 Prompt 助手持久化表,不向 `/api/external/v1` 暴露助手,也不修改 External v1 OpenAPI 或 `platform-llm` 日志策略。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -134,3 +355,23 @@ POST /api/editor/audios/background-music/generations
|
||||
- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。
|
||||
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
|
||||
- 刷新后 layout 能恢复音频生成器和音频图层。
|
||||
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,至少含 1 个有效字符且为 201–2000 个 code point 时只能一键简化,超过 2000 时三个动作全部禁止且完整保留文本。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
|
||||
- 三组 30 个 BGM 预设按本文固定文案写入或追加;点击前先删除首尾 Unicode `White_Space` 并写回,再基于规范化结果判断空值和末尾标点。重复点击和追加后超限不得丢失 canonical Prompt,内部空格和内部换行保持原位。
|
||||
- 预设展开后默认无缝慢速循环,桌面端左 / 中 / 右区域分别加速向左、暂停、加速向右,左右箭头同步加速;组件在触摸环境被渲染时可横向滚动并选择词条,本需求不恢复移动端图片画布入口。
|
||||
- AI 补全的输入和候选都先 canonicalize;补全只执行一个业务语义轮,候选必须通过共用四字段 envelope、有效字符、200 字和 `true / true / false` 三个判断后,才写回一条中文 canonical Prompt。失败、空值、结构或判断错误、格式错误或超限结果保留请求前已经写回的 canonical Prompt,错误响应不暴露候选,也不调用 Suno 或扣除正式音乐生成泥点。
|
||||
- 一键简化资格、180 / 170 目标后的实际字符数和候选校验都基于 canonical Prompt;第一次使用冻结的 `originalPrompt`,第二次优先处理第一次成功响应中可提取的非空 canonical 候选,否则回退处理 `originalPrompt`,且两个业务语义轮都以同一 `originalPrompt` 作保真参照。第一轮 transport、超时或上游失败直接失败,不进入第二轮;每轮内部的 `LlmClient` transport retry 不增加业务语义轮数。候选必须同时通过结构、有效字符、200 字、格式、完整性和残句校验;第二次仍失败时保留请求前已经写回的 canonical Prompt,不暴露任一未通过候选,程序不得截断。
|
||||
- OpenAI Chat `finish_reason = length / content_filter` 的正文即使形成合法四字段 JSON 也不能通过或被提取;补全遇到两者均直接失败,简化第一轮 `length` 只以冻结的 `originalPrompt` 进入 170 字轮、第一轮 `content_filter` 直接失败,第二轮遇到任一未完成 reason 都最终失败。缺失、空值和未知自定义 reason 继续执行其余门禁。
|
||||
- Prompt 助手协议测试必须证明请求显式使用 OpenAI Chat 且不发送 function tools;只接受完整 `response.text` 全量解析所得的单个 JSON object。外围 JSON whitespace 可以通过,代码块、前后解释、多个 JSON 值、畸形 JSON、仅能子串提取的 object 和任意 tool call 均失败,不触发自动修复或运行时协议 fallback。
|
||||
- Prompt 助手轮次测试必须区分业务语义轮和单轮内部 transport retry:补全始终只有一个业务语义轮;简化只有第一轮成功返回但候选不合格时才进入 170 字轮,第一轮 transport、超时或上游失败不进入第二轮。
|
||||
- AI 补全和简化成功均产生一层 canonical Prompt 交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销前先规范化当前输入并可在两个 canonical 版本间反复互换。
|
||||
- 撤销按钮按“单层撤销”一节的矩阵逐行验收:初始与无快照时隐藏;AI 处理期间显示并禁用,且仍在可访问树中,不得用隐藏或视觉伪装代替禁用;成功后启用,失败后隐藏,手动编辑后仍启用,点击预设后隐藏;`submitting` 期间有快照显示并禁用、无快照隐藏,解除锁定后按快照恢复启用或隐藏;连续点击撤销在两个版本间互换且保持启用。
|
||||
- AI 操作的旧响应、关闭 dialog 后的响应或其它 dialog 的响应不得覆盖当前 Prompt;同一按钮双击只产生一个有效助手请求。
|
||||
- BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,retryable HTTP 状态或 transport error 也不由 client 自动重发,不锁整个画布或其它 dialog。
|
||||
- BGM 提交期间归档或切走面板不会取消已发出的正式请求;失败时旧面板不抢回焦点。删除 dialog 或切换账号 / 项目后,后端已经创建的正式任务继续处理;钱包回调只允许作用于原账号仍为当前账号的页面,任务列表回调只允许作用于原账号与原项目仍为当前账号与项目的页面,dialog / canvas / asset / layer 写回还必须匹配原 scope version 与原 BGM dialog。本需求不新增跨 scope 的恢复或刷新机制。
|
||||
- BGM 正式提交会删除首尾 Unicode `White_Space` 并同步写回输入框,不回退默认 Prompt;首尾 U+0085 等 `White_Space` 被删除,内部空格和 LF / CRLF 原样保留,U+200B、U+FEFF、组合字符和 ZWJ emoji 不被误删。canonical Prompt 在输入框、BFF、队列载荷、Suno body、生成记录和结果响应中完全一致,且没有用户不可见的前缀、后缀或模板。
|
||||
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
|
||||
- BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。
|
||||
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
|
||||
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;SFX 的 Vidu body、默认 Prompt、时长与 1500 字限制无回归。
|
||||
- `audio-sound-effect` 与 `audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 控件或尚未实施的 SFX V2 控件,BGM 不得渲染 Vidu 与音效时长控件;两个 mode 相互切换时,菜单、预设滚动、锁和助手状态不得跨分支泄漏。
|
||||
- 本切片的定向前端、shared-contracts、`api-server`、`platform-audio` 和端到端 Prompt 等值测试通过,并执行 `npm run typecheck`、对应 Rust 定向测试、`npm run check:encoding` 与 `git diff --check`。
|
||||
|
||||
@@ -153,7 +153,7 @@ pipeline {
|
||||
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ pipeline {
|
||||
copyArtifacts(
|
||||
projectName: params.BUILD_JOB_NAME,
|
||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs",
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs",
|
||||
target: '.',
|
||||
fingerprintArtifacts: true
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user