合并最新主线并统一画布素材合同
合并主线背景音乐与角色动作素材能力 保留无限画布共享渲染和历史恢复合同 统一正式序列帧与素材类型字段 补齐后端、SpacetimeDB 与 CI 回归验证
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;
|
||||
|
||||
+1
-1
@@ -32,11 +32,11 @@ import {
|
||||
projectRuntimePlanProgress,
|
||||
projectRuntimeVisibleCurrentWork,
|
||||
projectRuntimeVisibleError,
|
||||
projectWorkspaceStatusForDisplay,
|
||||
projectSupervisorChatRuntimeStatus,
|
||||
projectSupervisorCollaboratingAgentRuntimes,
|
||||
ProjectSupervisorRuntimeControls,
|
||||
projectSupervisorVisibleConversationText,
|
||||
projectWorkspaceStatusForDisplay,
|
||||
} from '../agent-runtime';
|
||||
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
|
||||
import { GameChatImageViewer } from './GameChatImageViewer';
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
- [客户端素材创作无限画布阶段一合同](./technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md)
|
||||
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
|
||||
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.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
@@ -62,6 +62,14 @@
|
||||
- 验证:使用 deferred commit/layout Promise,依次在请求后切项目、切 run/overview、新开 session、改选择和改筛选;断言 manifest 只更新对应项目,新资源仍进入投影/布局,但所有失效守卫都不切中央状态、不改选择、不清查询。条件未变化且资源可见时才自动定位。
|
||||
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。
|
||||
|
||||
## Chat 生成预算字段不能按模型名猜测或失败后自动重放
|
||||
|
||||
- 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。
|
||||
- 原因:内部生成预算语义与上游 wire dialect 被混在一起。Chat 当前字段是 `max_completion_tokens`,旧兼容层仍只接受 `max_tokens`;Responses 和 Anthropic 又分别使用自己的字段。模型名、base URL 和 `OpenAiCompatible` 标签都不能证明 endpoint 能力,收到 `400` 后重发还可能重复计费。
|
||||
- 处理:在 `LlmConfig` 上显式声明 Chat token budget field capability;通用兼容配置默认 legacy,已验证的 VectorEngine 专用 client opt-in `max_completion_tokens`,每次只发送一个字段。内部 `max_output_tokens` 与 AGC 持久指纹键 `maxOutputTokens` 保持不变。
|
||||
- 验证:序列化测试分别断言 modern / legacy Chat 只出现选定字段,请求级 model override 不改变字段;Responses 继续只发 `max_output_tokens`,Anthropic 继续只发 `max_tokens`;AppState 测试断言 VectorEngine client 已显式启用 modern capability。
|
||||
- 关联:`server-rs/crates/platform-llm/src/lib.rs`、`server-rs/crates/api-server/src/state.rs`、`scripts/test-ve-llm.mjs`、Issue #143。
|
||||
|
||||
## Runtime 状态写失败不能发生在公开失败消息之前
|
||||
|
||||
- 现象:用户提交长任务后只看到运行失败或任务直接消失,聊天里一条有用消息都没有;另一些失败又同时出现 Runtime event 和 conversation 两条近似提示。
|
||||
@@ -4078,6 +4086,35 @@
|
||||
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
|
||||
- 验证:`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`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
|
||||
@@ -4340,6 +4377,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 不再重试。
|
||||
@@ -4389,3 +4432,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
@@ -872,7 +872,7 @@ V1.21 对标 Codex CLI 的 `model_context_window`、`model_auto_compact_token_li
|
||||
|
||||
### 配置与预算
|
||||
|
||||
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000`;`agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的 `maxOutputTokens` 与固定安全余量留下空间。
|
||||
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000`;`agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的生成 token 预算与固定安全余量留下空间。既有配置和持久协议键 `maxOutputTokens` 保持冻结以兼容恢复;它表示包含可见输出与隐藏 reasoning token 的生成侧预算,不表示输入加输出总量,也不保证可见正文长度。
|
||||
- Runtime 在发送 tool-plan、context-compaction 或 final-reply 前,按消息、multimodal 文本和 function schema 的规范序列化字符数做保守 token 估算;Provider 返回 usage 时再记录真实 `prompt/completion/total`。估算只用于提前门禁,不能伪装成 Provider 计费事实。
|
||||
- 单条 observation 进入模型上下文前按 `toolOutputTokenLimit` 收紧;完整命令输出仍留在 owning Agent 的私有 sidecar,通过既有分页工具读取。公共状态只显示估算 token、最近真实 usage、阈值、压缩次数和时间,不显示被压缩正文。
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ Agent Runtime 负责:
|
||||
- v4 在压缩状态校验通过后迁移为当前格式;v3 在原身份、任务、project revision、verification gate 和结构化计划校验通过后,从当前 Runtime/Goal sidecar 补齐 Goal 快照并继续;v2 先按 V1.17 规则补齐结构化计划,再补 Goal;后续 checkpoint 统一写 v5。v1、缺失既有 gate 关联或无法证明 Goal 快照的记录不自动迁移。Provider pause 中断/返回边界先持久化 continuation;该恢复快照把 `goalStatus` 设为恢复后的 `active`,避免 resume 后用 paused 上下文自相矛盾。
|
||||
- stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置、窗口进度和结构化计划;`contextStalled` 一旦成立,同 run 重规划和重启不得清除。动态 revision 数字、时间戳与验证命令输出不构成独立进展;重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 128 KiB、最多 12 条 observation;写入前统一限长并过滤敏感内容和项目绝对路径。revision 与验证资格仍以锁内独立文件为准,bundle 只是 Runtime 私有恢复上下文,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。
|
||||
- 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。
|
||||
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。
|
||||
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新,2026-08-06 仅澄清 token 口径:后台 planning 使用 4,000 生成 token 预算,最终回复使用 2,400;预算包含可见输出与 Provider 可能使用的隐藏 reasoning token,不等于可见正文长度。既有配置与持久协议键 `maxOutputTokens` 保持冻结,Provider adapter 再按协议映射为 Chat endpoint capability 选定的 `max_completion_tokens` 或 legacy `max_tokens`、Responses `max_output_tokens`、Anthropic `max_tokens`;AGC 通用自定义网关当前保持 legacy 默认。本条原有“最多 3 次 EmptyResponse 重试”已由本文后续 2026-07-15 的 V1.18 收口条目取代。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。
|
||||
- 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。
|
||||
- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新,2026-08-03 收紧 strict 边界:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`)。`strict` 能力不从 `apiKind` 推断:AGC 只对无凭据 / 自定义端口 / 路径的官方 HTTPS endpoint 和 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成 Anthropic 专用传输 schema,已知不支持约束只从传输副本剔除,调用方原 schema 保持不变;未知关键词、不可解析 / 递归 `$ref` 和 strict 工具 / optional / union 请求级复杂度超限时该工具保持 non-strict,不能因完整 AGC 工具集超限让整次请求被上游拒绝。工具数组最后一项携带 `cache_control: {"type":"ephemeral"}` 作为 prompt cache breakpoint;非流式和流式 usage 都将 `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` 合并为 prompt tokens。`tool_choice` 使用对象形态(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。
|
||||
- 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step` 与 `action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。
|
||||
|
||||
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,不得原地改类型或删除。
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
|
||||
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。
|
||||
- 用户的当前消息确实在确认或取消一条已存在且仍为 pending 的工具调用时,画布 Agent 只引导使用该卡片的确认 / 取消按钮,本条确认 / 取消意图不产生新 tool call。这条边界必须使用“匹配 pending 调用时如何处理”的正向、条件化描述,不得改写成“不得重新发起相同工具调用”一类全局否定话术:实测中模型会把这类否定句过度泛化为拒绝后续新请求。已 cancelled 的卡片不再处理;用户明确要求修改、重做或发起新任务时必须允许新 tool call,pending 卡片也不阻塞无关的新请求。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 生成 token 预算;VectorEngine 专用 client 发送 `max_completion_tokens`,预算包含可见输出与隐藏 reasoning token,不等于可见正文长度。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
|
||||
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。
|
||||
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind`、`retryable`、`fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user