feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat
2026-08-29 23:34:46 +08:00
parent 4f07daae9e
commit 2f6832f99c
113 changed files with 7016 additions and 723 deletions

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import { normalizeAssetRecord, parseAssetStorageDocument } from "@/lib/asset-storage-revision";
import type { Asset } from "@/stores/use-asset-store";
const legacyAsset = {
id: "legacy-image",
kind: "image",
title: "旧素材",
coverUrl: "",
createdAt: "2026-08-29T00:00:00.000Z",
updatedAt: "2026-08-29T00:00:00.000Z",
data: { dataUrl: "opaque://legacy", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
} as Asset;
describe("asset storage revision", () => {
test("normalizes missing and malformed tags on legacy assets", () => {
expect(normalizeAssetRecord(legacyAsset).tags).toEqual([]);
expect(normalizeAssetRecord({ ...legacyAsset, tags: ["角色", 1, null] } as unknown as Asset).tags).toEqual(["角色"]);
});
test("normalizes legacy assets while parsing persisted storage", () => {
const document = parseAssetStorageDocument(JSON.stringify({
state: { assets: [legacyAsset] },
version: 1,
storageRevision: 3,
tombstones: { assets: {} },
}));
expect(document.state.assets[0]?.tags).toEqual([]);
expect(() => document.state.assets[0]?.tags.join(" ")).not.toThrow();
});
});

View File

@@ -54,4 +54,26 @@ describe("canvas resource mention editor", () => {
expect(configComposer).not.toContain("result += `@[node:");
expect(project).not.toContain("removeCanvasResourceMention");
});
test("anchors the mention menu to the caret instead of the textarea edge", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
expect(component).toContain("cursorOffset={mention.end}");
expect(component).toContain("mentionCaretRect(anchor, cursorOffset)");
expect(component).toContain("textareaCaretRect(anchor, cursorOffset)");
expect(component).toContain('transform: position.showAbove ? "translateY(-100%)" : undefined');
expect(component).toContain('window.addEventListener("scroll", updatePosition, true)');
});
test("renders skill references as descriptive workflow rows", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const css = source("../src/styles/globals.css");
expect(component).toContain('reference.kind === "skill" ? "is-skill" : ""');
expect(component).toContain("reference.skill?.description");
expect(component).toContain("reference.skill?.file_count");
expect(component).toContain("<Workflow aria-hidden />");
expect(css).toContain(".canvas-resource-mention-item.is-skill");
expect(css).toContain(".canvas-resource-mention-meta");
});
});

View File

@@ -128,6 +128,11 @@ test("task display does not claim a submitted Dreamina receipt is actively gener
expect(generationTaskStageLabel(officialCompleted)).toBe("官方返回状态:completed");
expect(generationTaskStatusLabel(generating)).toBe("生成中");
expect(generationTaskShowsProgress(generating)).toBe(true);
for (const stage of ["等待队列调度", "后端接管任务", "正在连接上游", "调用生成模型"]) {
expect(generationTaskShowsProgress({ ...generating, provider: "managed", stage })).toBe(false);
}
expect(generationTaskShowsProgress({ ...generating, provider: "managed", stage: "上游生成中" })).toBe(true);
expect(generationTaskStatusLabel(remoteRunning)).toBe("生成中");
});
@@ -185,6 +190,14 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
expect(createSource).not.toContain('generationStage === "submission_unknown" ? "cancelled"');
});
test("Canvas fullscreen video restores large controls instead of keeping the compact node layout", async () => {
const playerCSS = await Bun.file(new URL("../src/components/video-player.css", import.meta.url)).text();
expect(playerCSS).toContain(".canvas-video-player-compact:not([data-fullscreen])");
expect(playerCSS).toContain("--media-fullscreen-button-size: 52px");
expect(playerCSS).toContain("--media-slider-track-height: 7px");
expect(playerCSS).toContain("min-height: 64px");
});
test("task center deletion accepts only local Dreamina records", async () => {
await expect(deleteGenerationTask("backend-task-0001")).rejects.toThrow("当前任务不支持删除");
const source = await Bun.file(new URL("../src/pages/tasks/index.tsx", import.meta.url)).text();

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { linkSelectedProjectAssets } from "@/pages/projects/detail/project-asset-linking";
describe("project asset batch linking", () => {
test("links every selected asset instead of only the first one", async () => {
const visited: string[] = [];
const result = await linkSelectedProjectAssets(["asset-1", "asset-2", "asset-3"], async (id) => {
visited.push(id);
return { id };
});
expect(visited).toEqual(["asset-1", "asset-2", "asset-3"]);
expect(result.linked.map((item) => item.id)).toEqual(["asset-1", "asset-2", "asset-3"]);
expect(result.failedCount).toBe(0);
});
test("keeps successful links and reports partial failures", async () => {
const result = await linkSelectedProjectAssets(["asset-1", "asset-2"], async (id) => {
if (id === "asset-2") throw new Error("不可用");
return id;
});
expect(result).toEqual({ linked: ["asset-1"], failedCount: 1 });
});
test("surfaces the failure when no asset can be linked", async () => {
await expect(linkSelectedProjectAssets(["asset-1"], async () => {
throw new Error("素材已失效");
})).rejects.toThrow("素材已失效");
});
});

View File

@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
test("章节页直接生成到分镜制作,并通过共享选择器执行技能", async () => {
const source = await Bun.file(new URL("../src/pages/projects/detail/chapters.tsx", import.meta.url)).text();
expect(source).not.toContain("导入分镜");
expect(source).not.toContain("importStoryboardToCanvas");
expect(source).not.toContain("在画布中分镜");
expect(source).toContain("生成到分镜制作");
expect(source).toContain("replaceProjectUnitShots");
expect(source).toContain("/storyboard`");
expect(source).toContain('<SkillRuntimePicker profile="shortDrama"');
expect(source.match(/<ModelPicker config=\{effectiveConfig\} capability="text"/g)).toHaveLength(2);
expect(source).toContain("chapterAnalysisInput(selectedTextModel)");
expect(source).toContain("const textModel = selectedTextModel");
expect(source).toContain("generateChapterStoryboard");
});
test("镜头画面使用已绑定资产的 @ 引用编辑器", async () => {
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
expect(source).toContain('name="plotDescription"');
expect(source).toContain('<ShotAssetMentionTextarea variant="scene"');
expect(source).toContain("resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext)");
expect(source).toContain("<Image.PreviewGroup>");
});
test("技能选择器占满表单宽度", async () => {
const source = await Bun.file(new URL("../src/components/skills/skill-runtime-picker.tsx", import.meta.url)).text();
expect(source).toContain('style={{ width: "100%" }}');
});
test("画布分镜生成统一经过 Skill Runtime", async () => {
const source = await Bun.file(new URL("../src/pages/canvas/use-canvas-storyboard.ts", import.meta.url)).text();
expect(source).toContain('profile: "shortDrama"');
expect(source).toContain("skillRuntime.prepare");
expect(source).toContain("...skillExecution.metadata");
expect(source).not.toContain("getSkillFile");
expect(source).not.toContain("getSkillBundle");
});

View File

@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import {
chapterStoryboardAssets,
chapterStoryboardCharacters,
chapterStoryboardReplaceImpact,
storyboardRowsToProjectShots,
} from "@/pages/projects/detail/chapter-storyboard-production";
import type { ProjectDetail } from "@/services/api/projects";
import type { StoryboardRow } from "@/types/canvas";
describe("章节分镜写入分镜制作", () => {
test("将分镜任务结果完整映射为镜头脚本", () => {
const shots = storyboardRowsToProjectShots([storyboardRow()], projectDetail());
expect(shots).toHaveLength(1);
expect(shots[0]).toMatchObject({
title: "SC.01",
description: "林默推门进入雨夜的旧屋。",
durationMs: 5000,
revision: {
plotDescription: "林默推门进入雨夜的旧屋。",
dialogue: "林默:有人吗?",
shotSize: "中景",
cameraAngle: "平视,35mm",
cameraMovement: "缓慢推进",
imagePrompt: "雨夜旧屋中的林默",
videoPrompt: "林默推门,镜头缓慢推进",
continuityNotes: "林默停在门内,右手仍扶着门把",
actionBeats: [{ description: "0-2 秒推门;2-5 秒环顾" }],
},
});
expect(shots[0].assetVersionIds).toEqual(expect.arrayContaining(["asset-version-1", "asset-version-2"]));
expect(shots[0].revision.action).toContain("表演调度:林默谨慎推门并环顾室内");
expect(shots[0].revision.action).toContain("声音:雨声与木门摩擦声");
});
test("把已确认角色和本章待确认角色交给分镜任务", () => {
const detail = projectDetail();
expect(chapterStoryboardCharacters(detail, "chapter-1")).toEqual([
{ assetId: "character-1", versionId: "character-version-1", name: "林默", definition: { appearance: "黑色风衣" } },
{ name: "张天昊", definition: { role: "主角" } },
]);
expect(chapterStoryboardAssets(detail).map((asset) => ({ id: asset.id, type: asset.type, characterVersionId: asset.characterVersionId }))).toEqual([
{ id: "character-1", type: "character", characterVersionId: "character-version-1" },
{ id: "prop-1", type: "image", characterVersionId: undefined },
]);
});
test("替换确认统计该章会被删除的关联数据", () => {
expect(chapterStoryboardReplaceImpact(projectDetail(), "chapter-1")).toEqual({
shotCount: 1,
revisionCount: 1,
referenceCount: 1,
artifactCount: 1,
candidateCount: 1,
});
});
});
function storyboardRow(): StoryboardRow {
return {
id: "row-1", shotNumber: 1, durationSeconds: 5,
plotDescription: "林默推门进入雨夜的旧屋。", dialogue: "林默:有人吗?", characters: [{ characterName: "林默", characterAssetId: "character-1", characterVersionId: "character-version-1" }],
narrativeIntent: "建立悬念", viewerPOV: "跟随林默", performanceBlocking: "林默谨慎推门并环顾室内",
shotSize: "中景", emotion: "戒备", lightingAndAtmosphere: "冷色月光与室内暖光交界", audioEffects: "雨声与木门摩擦声",
camera: "平视,35mm", motion: "缓慢推进", timeBeats: "0-2 秒推门;2-5 秒环顾",
imageGenerationPrompt: "雨夜旧屋中的林默", videoMotionPrompt: "林默推门,镜头缓慢推进",
mustHave: [], optionalDetails: [], continuityOut: "林默停在门内,右手仍扶着门把", negativePrompt: "画面文字", assetBindings: [{ nodeId: "prop-1", role: "prop", priority: 80 }],
};
}
function projectDetail(): ProjectDetail {
return {
project: { id: "project-1", userId: "user-1", name: "雨夜", type: "short_drama", aspectRatio: "16:9", sourceType: "novel", description: "", stylePresetId: "urban-live-action", status: "active", revision: 1, createdAt: "", updatedAt: "" },
units: [], canvases: [], canvasUnitLinks: [], assetFolders: [], workflows: [],
assets: [
{ id: "character-1", title: "林默", mediaType: "image", category: "character", status: "ready", primaryVersionId: "asset-version-1", versionCount: 1, usages: [], position: 0, updatedAt: "", character: { versionId: "character-version-1", version: 1, definition: { appearance: "黑色风衣" }, representations: [], visualStatus: "ready", voiceStatus: "missing" } },
{ id: "prop-1", title: "旧信封", mediaType: "image", category: "prop", status: "ready", primaryVersionId: "asset-version-2", versionCount: 1, usages: [], position: 1, previewText: "泛黄的旧信封", updatedAt: "" },
{ id: "draft-prop", title: "未完成道具", mediaType: "image", category: "prop", status: "draft", versionCount: 0, usages: [], position: 2, updatedAt: "" },
],
shots: [{ id: "shot-1", projectId: "project-1", unitId: "chapter-1", currentRevisionId: "revision-1", title: "SC.01", description: "旧分镜", position: 0, durationMs: 3000, status: "draft", createdAt: "", updatedAt: "" }],
shotRevisions: [{ id: "revision-1", shotId: "shot-1", version: 1, plotDescription: "旧分镜", action: "", dialogue: "", shotSize: "", cameraAngle: "", cameraMovement: "", durationMs: 3000, imagePrompt: "", videoPrompt: "", negativePrompt: "", continuityNotes: "", actionBeatsJson: "[]", createdAt: "" }],
shotArtifacts: [{ id: "artifact-1", projectId: "project-1", unitId: "chapter-1", shotId: "shot-1", type: "storyboard", version: 1, status: "ready", selected: true, metadataJson: "{}", createdAt: "", updatedAt: "" }],
shotReferences: [{ id: "reference-1", shotId: "shot-1", assetVersionId: "asset-version-1", role: "reference", status: "linked", createdAt: "" }],
assetCandidates: [
{ id: "candidate-1", projectId: "project-1", unitId: "chapter-1", shotId: "shot-1", name: "雨伞", category: "prop", status: "pending_confirmation", detailsJson: "{}", createdAt: "", updatedAt: "" },
{ id: "candidate-character-1", projectId: "project-1", unitId: "chapter-1", name: "张天昊", category: "character", status: "pending_confirmation", detailsJson: JSON.stringify({ role: "主角" }), createdAt: "", updatedAt: "" },
{ id: "candidate-character-other", projectId: "project-1", unitId: "chapter-2", name: "另一章角色", category: "character", status: "pending_confirmation", detailsJson: "{}", createdAt: "", updatedAt: "" },
],
};
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { chapterTaskIdentity } from "@/pages/projects/detail/project-chapter-ai";
import type { GenerationTask } from "@/services/api/task-center";
describe("章节生成任务刷新恢复", () => {
test("优先从任务列表的安全客户端上下文识别章节与操作", () => {
expect(chapterTaskIdentity(task({
clientContext: {
domainProjectId: "project-1",
chapterId: "chapter-1",
chapterOperation: "characters",
},
}))).toEqual({ chapterId: "chapter-1", kind: "characters" });
});
test("任务详情可从脱敏输入 metadata 恢复分镜操作", () => {
expect(chapterTaskIdentity(task({
inputJson: JSON.stringify({
metadata: {
domainProjectId: "project-1",
chapterId: "chapter-2",
source: "short-drama-chapter-storyboard",
},
}),
}))).toEqual({ chapterId: "chapter-2", kind: "storyboard" });
});
test("无关、缺少章节或损坏的任务输入不会关联章节按钮", () => {
expect(chapterTaskIdentity(task({ inputJson: "{" }))).toBeNull();
expect(chapterTaskIdentity(task({ inputJson: JSON.stringify({ metadata: { operation: "chapter_character_breakdown" } }) }))).toBeNull();
expect(chapterTaskIdentity(task({ inputJson: JSON.stringify({ metadata: { chapterId: "chapter-1", operation: "other" } }) }))).toBeNull();
});
});
function task(overrides: Partial<GenerationTask>): GenerationTask {
return {
id: "task-1",
type: "text",
status: "running",
prompt: "",
attempts: 1,
createdAt: "2026-08-29T00:00:00.000Z",
updatedAt: "2026-08-29T00:00:00.000Z",
...overrides,
};
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { audioFileExtension, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
describe("角色声音格式", () => {
test("接受浏览器可试听的常用音频格式", () => {
for (const [name, type] of [
["voice.mp3", "audio/mpeg"],
["voice.wav", "audio/x-wav"],
["voice.m4a", "audio/mp4"],
["voice.aac", "audio/aac"],
["voice.flac", "audio/flac"],
["voice.ogg", "audio/ogg"],
["voice.opus", "audio/opus"],
["voice.weba", "audio/webm"],
]) expect(isSupportedCharacterVoiceFile({ name, type })).toBe(true);
});
test("仅在 MIME 未知时使用扩展名兜底", () => {
expect(isSupportedCharacterVoiceFile({ name: "voice.wav", type: "" })).toBe(true);
expect(isSupportedCharacterVoiceFile({ name: "video.webm", type: "video/webm" })).toBe(false);
expect(isSupportedCharacterVoiceFile({ name: "voice.wma", type: "audio/x-ms-wma" })).toBe(false);
});
test("生成正确的标题和参考音频扩展名", () => {
expect(characterVoiceTitleFromFileName("张振天原声.FLAC")).toBe("张振天原声");
expect(audioFileExtension("audio/mp4", "sample.m4a")).toBe("m4a");
expect(audioFileExtension("audio/ogg", "sample.opus")).toBe("opus");
expect(audioFileExtension("audio/wav")).toBe("wav");
});
});

View File

@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import type { Skill, SkillPackageFile, SkillPackageFileContent } from "../src/services/api/skills";
import { createSkillRuntime, resolveSkillMentions } from "../src/services/skill-runtime";
function skill(overrides: Partial<Skill> = {}): Skill {
return {
skill_id: "director",
skill_name: "AI导演",
description: "导演工作流",
version_id: "version-2",
version: "2.0.0",
content_hash: "hash",
file_count: 5,
total_bytes: 1024,
source_type: "zip",
source_url: "",
source_ref: "",
source_subdir: "",
source_commit: "",
sync_status: "synced",
auto_update: false,
last_checked_at: 0,
last_synced_at: 0,
status: 1,
markdown_url: "",
create_time: 0,
update_time: 0,
source: 0,
tag: "影视",
sort_weight: 0,
is_private: true,
like_count: 0,
is_like: false,
owner_uid: "user",
effective_user: { name: "用户", avatar_url: "", uid: "user" },
original_skill_id: null,
showcase_media: [],
added_count: 1,
is_test: false,
extra_info: "",
is_added: true,
is_owner: true,
...overrides,
};
}
function file(path: string, content: string, kind: SkillPackageFile["kind"] = "markdown"): SkillPackageFileContent {
return {
file: { path, kind, mime_type: "text/markdown", size: content.length, sha256: `sha-${path}` },
content,
binary: false,
};
}
describe("skill runtime", () => {
test("技能引用解析由统一规则同时支持稳定 token 和自然提及", () => {
const director = skill();
const storyboard = skill({ skill_id: "storyboard", skill_name: "小说转分镜" });
expect(resolveSkillMentions("用 @[skill:director] 处理", [director, storyboard]).map((item) => item.skill_id)).toEqual(["director"]);
expect(resolveSkillMentions("请用 @小说转分镜。", [director, storyboard]).map((item) => item.skill_id)).toEqual(["storyboard"]);
expect(resolveSkillMentions("@AI导演增强版", [director])).toEqual([]);
});
test("普通生成只加载入口和与当前任务最相关的直接引用文本", async () => {
const entry = [
"# AI导演",
"视频提示词读取 `references/prompt_templates.md`。",
"角色资产读取 [角色规则](references/character_assets.md)。",
"维护脚本见 `scripts/audit_skill.py`。",
"项目模板见 `assets/project-ledger-template.md`。",
].join("\n");
const files: SkillPackageFile[] = [
file("SKILL.md", entry).file,
file("references/prompt_templates.md", "视频提示词模板正文").file,
file("references/character_assets.md", "角色资产正文").file,
file("scripts/audit_skill.py", "print('audit')", "code").file,
file("assets/project-ledger-template.md", "台账模板").file,
];
const readPaths: string[] = [];
const runtime = createSkillRuntime({
getFile: async (_id, path) => {
readPaths.push(path);
if (path === "SKILL.md") return { file: file(path, entry) };
return { file: file(path, path.includes("prompt_templates") ? "视频提示词模板正文" : "角色资产正文") };
},
listFiles: async () => ({ files }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => { throw new Error("不应读取完整包"); },
});
const result = await runtime.prepare({ profile: "canvas", prompt: "@[skill:director] 帮我生成视频提示词", skills: [skill()] });
expect(result.prompt).toContain('<skill-file path="SKILL.md">');
expect(result.prompt).toContain('<skill-file path="references/prompt_templates.md">');
expect(readPaths).not.toContain("references/character_assets.md");
expect(readPaths).not.toContain("scripts/audit_skill.py");
expect(readPaths).not.toContain("assets/project-ledger-template.md");
expect(result.prompt).toContain("【用户任务】\n@AI导演 帮我生成视频提示词");
expect(result.metadata.skillIds).toEqual(["director"]);
});
test("本地 Agent 通过同一 Runtime 投递完整原生技能包", async () => {
const runtime = createSkillRuntime({
getFile: async () => { throw new Error("不应读取单文件"); },
listFiles: async () => ({ files: [] }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => ({
bundle: {
skill_id: "director",
name: "AI导演",
description: "导演工作流",
version_id: "version-2",
version: "2.0.0",
content_hash: "hash",
files: [{ path: "SKILL.md", mime_type: "text/markdown", content_base64: "IyBBSuWvv+a8lA==" }],
},
}),
});
const result = await runtime.prepare({ profile: "localAgent", prompt: "@[skill:director] 开始", skills: [skill()] });
expect(result.prompt).toBe("@AI导演 开始");
expect(result.skills).toEqual([{ skillId: "director", name: "AI导演", description: "导演工作流", version: "2.0.0", files: [{ path: "SKILL.md", mimeType: "text/markdown", contentBase64: "IyBBSuWvv+a8lA==" }] }]);
});
test("在线 Agent 的技能工具由 Runtime 注册表统一执行", async () => {
const runtime = createSkillRuntime({
getFile: async () => ({ file: file("SKILL.md", "# AI导演") }),
listFiles: async () => ({ files: [] }),
searchFiles: async () => ({ results: [] }),
getBundle: async () => { throw new Error("不应读取完整包"); },
});
expect(runtime.agentToolNames("onlineAgent").has("canvas_get_skill")).toBe(true);
const result = await runtime.executeAgentTool("onlineAgent", "canvas_get_skill", { skillId: "director" }, [skill()]);
expect(result?.ok).toBe(true);
expect(result && "data" in result ? result.data : null).toMatchObject({ skillId: "director", version: "2.0.0" });
});
});

View File

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
test("镜头编辑器在保存按钮左侧提供带确认的删除操作", async () => {
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
const deleteButton = source.indexOf(">删除镜头</Button>");
const saveButton = source.indexOf(">保存脚本</Button>");
expect(source).toContain("deleteProjectShot(projectId, shotId)");
expect(source).toContain("脚本版本、资产引用和生成产物都会被删除");
expect(source).toContain("okButtonProps: { danger: true }");
expect(deleteButton).toBeGreaterThan(-1);
expect(saveButton).toBeGreaterThan(deleteButton);
});
test("镜头删除 API 使用项目和镜头双重路径作用域", async () => {
const source = await Bun.file(new URL("../src/services/api/projects.ts", import.meta.url)).text();
expect(source).toContain("api.delete(`/projects/${encodeURIComponent(projectId)}/shots/${encodeURIComponent(shotId)}`)");
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "@/pages/projects/detail/workflow-shot-references";
describe("workflow shot asset references", () => {
test("builds ordered image references for bound media and characters", () => {
const context = buildShotAssetReferenceContext(detail(), "shot-1");
expect(context.mentionReferences.map((reference) => reference.label)).toEqual(["雨夜街道", "林默"]);
expect(context.referenceImages.map((image) => image.id)).toEqual(["asset-scene", "asset-character"]);
expect(context.referenceImages[1]?.storageKey).toBe("resource:character-cover");
});
test("resolves asset tokens to the submitted reference image order", () => {
const context = buildShotAssetReferenceContext(detail(), "shot-1");
const [scene, character] = context.mentionReferences;
const prompt = `让 ${canvasResourceMentionToken(character)} 从 ${canvasResourceMentionToken(scene)} 走近镜头`;
expect(resolveShotAssetMentionPrompt(prompt, context)).toBe("让 图片2 从 图片1 走近镜头");
});
test("rejects stale mentions after an asset is unbound", () => {
const context = buildShotAssetReferenceContext(detail(), "shot-1");
expect(() => resolveShotAssetMentionPrompt("跟随 @[asset:missing]", context)).toThrow("未绑定到当前镜头");
});
});
function detail(): ProjectDetail {
const scene = {
id: "asset-scene",
title: "雨夜街道",
mediaType: "image",
category: "environment",
status: "confirmed",
primaryVersionId: "version-scene",
versionCount: 1,
usages: [],
position: 0,
storageKey: "resource:scene-image",
updatedAt: "2026-08-29T00:00:00Z",
} satisfies ProjectAsset;
const character = {
id: "asset-character",
title: "林默",
mediaType: "entity",
category: "character",
status: "confirmed",
primaryVersionId: "version-character",
versionCount: 1,
usages: [],
position: 1,
updatedAt: "2026-08-29T00:00:00Z",
character: {
versionId: "version-character",
version: 1,
definition: {},
representations: [{ id: "representation-1", resourceId: "character-cover", mediaType: "image", role: "primary" }],
visualStatus: "ready",
voiceStatus: "missing",
},
} satisfies ProjectAsset;
const audio = {
id: "asset-audio",
title: "雨声",
mediaType: "audio",
category: "other",
status: "confirmed",
primaryVersionId: "version-audio",
versionCount: 1,
usages: [],
position: 2,
storageKey: "resource:rain-audio",
updatedAt: "2026-08-29T00:00:00Z",
} satisfies ProjectAsset;
return {
assets: [scene, character, audio],
shotReferences: [
{ id: "reference-scene", shotId: "shot-1", assetVersionId: "version-scene", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:00Z" },
{ id: "reference-character", shotId: "shot-1", assetVersionId: "version-character", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:01Z" },
{ id: "reference-audio", shotId: "shot-1", assetVersionId: "version-audio", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:02Z" },
{ id: "reference-other-shot", shotId: "shot-2", assetVersionId: "version-scene", role: "reference", status: "linked", createdAt: "2026-08-29T00:00:03Z" },
],
} as ProjectDetail;
}