fix(canvas): 分镜生成 - 强化模型选择提示并补齐资产引用与角色音频

This commit is contained in:
ddCat
2026-08-30 19:03:10 +08:00
parent f2fe163bc7
commit 83781d9c1c
8 changed files with 90 additions and 17 deletions

View File

@@ -1,5 +1,6 @@
import type { StoryboardRow } from "@/types/canvas";
import type { ProjectDetail, ShotRevisionInput } from "@/services/api/projects";
import { ensureShotAssetMentionPrompt } from "./workflow-shot-references";
export type ChapterStoryboardAsset = {
id: string;
@@ -74,10 +75,20 @@ export function chapterStoryboardAssets(detail: ProjectDetail): ChapterStoryboar
export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: ProjectDetail): ProjectShotReplacementInput[] {
const versionByAssetId = new Map((detail?.assets || []).flatMap((asset) => asset.primaryVersionId ? [[asset.id, asset.primaryVersionId] as const] : []));
const mentionReferenceByAssetId = new Map((detail?.assets || []).map((asset) => [asset.id, {
assetId: asset.id,
kind: asset.category === "character" ? "character" as const : "image" as const,
label: asset.title,
title: asset.title,
}]));
return rows.map((row, index) => {
const shotNumber = index + 1;
const description = row.plotDescription.trim() || `镜头 ${shotNumber}`;
const durationMs = Math.max(1000, Math.round((Number(row.durationSeconds) || 1) * 1000));
const mentionReferences = storyboardRowAssetIds(row).flatMap((assetId) => {
const reference = mentionReferenceByAssetId.get(assetId);
return reference ? [reference] : [];
});
return {
title: `SC.${String(shotNumber).padStart(2, "0")}`,
description,
@@ -92,7 +103,7 @@ export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: Pro
cameraMovement: row.motion.trim(),
durationMs,
imagePrompt: row.imageGenerationPrompt.trim(),
videoPrompt: row.videoMotionPrompt.trim(),
videoPrompt: ensureShotAssetMentionPrompt(row.videoMotionPrompt, mentionReferences),
negativePrompt: row.negativePrompt.trim(),
continuityNotes: row.continuityOut.trim(),
actionBeats: row.timeBeats.trim() ? [{ description: row.timeBeats.trim() }] : [],
@@ -114,16 +125,19 @@ export function chapterStoryboardReplaceImpact(detail: ProjectDetail, unitId: st
}
function storyboardRowAssetVersionIds(row: StoryboardRow, versionByAssetId: Map<string, string>) {
const assetIds = [
...row.assetBindings.map((binding) => binding.nodeId),
...row.characters.flatMap((character) => character.characterAssetId ? [character.characterAssetId] : []),
];
return Array.from(new Set(assetIds)).flatMap((assetId) => {
return storyboardRowAssetIds(row).flatMap((assetId) => {
const versionId = versionByAssetId.get(assetId);
return versionId ? [versionId] : [];
}).slice(0, 6);
}
function storyboardRowAssetIds(row: StoryboardRow) {
return Array.from(new Set([
...row.assetBindings.map((binding) => binding.nodeId),
...row.characters.flatMap((character) => character.characterAssetId ? [character.characterAssetId] : []),
])).slice(0, 6);
}
function storyboardAssetType(mediaType: string, category: string): ChapterStoryboardAsset["type"] | null {
if (category === "character") return "character";
if (mediaType === "image" || mediaType === "video" || mediaType === "audio") return mediaType;

View File

@@ -44,7 +44,7 @@ import {
formatDuration,
type ShortDramaWorkflowStage,
} from "./workflow-shared";
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "./workflow-shot-references";
import { buildShotAssetReferenceContext, ensureShotAssetMentionPrompt, resolveShotAssetMentionPrompt } from "./workflow-shot-references";
type ShotEditorValues = Omit<ShotRevisionInput, "durationMs"> & {
title: string;
@@ -127,8 +127,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
}, [detail.shotReferences, selectedShot?.id]);
const currentDurationSeconds = Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000));
const generationSeconds = String(Math.max(1, Math.round(currentDurationSeconds)));
const selectedVideoProfile = generationCapability === "video" && selectedModel ? modelCapabilityConfigFor(effectiveConfig, selectedModel).video : undefined;
const generationReferenceAudios = generationCapability === "video" && (selectedVideoProfile?.references.maxAudios || 0) > 0 ? shotAssetReferenceContext.referenceAudios : [];
const generationReferenceAudios = generationCapability === "video" ? shotAssetReferenceContext.referenceAudios : [];
const videoEditOperation = generationCapability === "video" && shotAssetReferenceContext.referenceImages.length ? "reference_to_video" : undefined;
const modelRequirements = useMemo<ModelRequirements>(() => ({
capability: generationCapability,
@@ -218,6 +217,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
const normalizedDurationSeconds = generationCapability === "video" && currentModel
? Number(normalizeVideoValue(modelCapabilityConfigFor(effectiveConfig, currentModel).video!, { seconds: String(shotDurationSeconds) }).seconds)
: shotDurationSeconds;
const videoPrompt = ensureShotAssetMentionPrompt(revision?.videoPrompt || "", shotAssetReferenceContext.mentionReferences);
form.setFieldsValue({
title: selectedShot?.title || "",
plotDescription: revision?.plotDescription || selectedShot?.description || "",
@@ -228,13 +228,13 @@ export default function WorkflowProductionWorkbench(props: Props) {
cameraMovement: revision?.cameraMovement || "",
durationSeconds: normalizedDurationSeconds,
imagePrompt: revision?.imagePrompt || "",
videoPrompt: revision?.videoPrompt || "",
videoPrompt,
negativePrompt: revision?.negativePrompt || "",
continuityNotes: revision?.continuityNotes || "",
});
setPreviewArtifactId("");
setEditorDirty(!revision);
}, [effectiveConfig, form, generationCapability, initialModel, revision?.id, selectedShot?.id]);
setEditorDirty(!revision || videoPrompt !== revision.videoPrompt);
}, [effectiveConfig, form, generationCapability, initialModel, revision?.id, selectedShot?.id, shotAssetReferenceContext.mentionReferences]);
const changeGenerationModel = (nextModel: string) => {
selectedModelRef.current = nextModel;
@@ -359,7 +359,6 @@ export default function WorkflowProductionWorkbench(props: Props) {
artifactMetadata: { model: routedModel, aspectRatio, resolution, durationSeconds: values.durationSeconds, ...skillExecution.metadata },
},
});
if (shotAssetReferenceContext.referenceAudios.length && !generationReferenceAudios.length) message.warning("当前模型不支持参考音频,已保留角色声音文字说明,但未附加声音样本");
if (activeShotIdRef.current === submittingShot.id) setEditorDirty(false);
await onRefresh();
message.success(`${productionStageCopy[activeStage as "storyboard" | "previz" | "video"].label}任务已提交`);

View File

@@ -105,6 +105,20 @@ export function resolveShotAssetMentionPrompt(prompt: string, context: ShotAsset
return [resolved.trim(), assetBlock, dialogueBlock].filter(Boolean).join("\n\n");
}
export function ensureShotAssetMentionPrompt(prompt: string, references: Array<Pick<CanvasResourceReference, "assetId" | "kind" | "label" | "title">>) {
const value = prompt.trim();
if (!value) return "";
const mentionedAssetIds = new Set(Array.from(value.matchAll(/@\[asset:([^\]]+)\]/g), (match) => match[1]));
const missing = references.filter((reference): reference is typeof reference & { assetId: string } => Boolean(reference.assetId && !mentionedAssetIds.has(reference.assetId)));
if (!missing.length) return value;
const block = (title: string, items: typeof missing) => items.length
? [title, ...items.map((reference) => `${reference.title || reference.label}:@[asset:${reference.assetId}]`)].join("\n")
: "";
const characters = missing.filter((reference) => reference.kind === "character");
const otherAssets = missing.filter((reference) => reference.kind !== "character");
return [value, block("【角色参考】", characters), block("【场景与道具参考】", otherAssets)].filter(Boolean).join("\n\n");
}
export function compileShotAssetReferencePrompt(references: ShotPromptAssetReference[]) {
if (!references.length) return "";
const lines = references.flatMap((reference) => {

View File

@@ -366,11 +366,29 @@
.workflow-model-picker.canvas-composer-model-picker {
width: 100%;
height: 40px;
height: 44px;
justify-content: space-between;
border-color: var(--workspace-border);
border: 1px solid var(--workspace-border-strong);
border-radius: var(--r-md);
background: var(--workspace-surface-strong);
padding-inline: 11px;
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
}
.workflow-model-picker.canvas-composer-model-picker:hover {
border-color: color-mix(in srgb, var(--workspace-accent) 48%, var(--workspace-border-strong));
}
.workflow-model-picker.canvas-composer-model-picker:focus-visible,
.workflow-model-picker.canvas-composer-model-picker[aria-expanded="true"] {
border-color: color-mix(in srgb, var(--workspace-accent) 72%, var(--workspace-border-strong));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 18%, transparent) !important;
}
.workflow-model-picker .canvas-model-picker-chevron {
width: 17px;
height: 17px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
}
.workflow-settings-section + .workflow-settings-section {

View File

@@ -25,7 +25,7 @@ describe("章节分镜写入分镜制作", () => {
cameraAngle: "平视,35mm",
cameraMovement: "缓慢推进",
imagePrompt: "雨夜旧屋中的林默",
videoPrompt: "林默推门,镜头缓慢推进",
videoPrompt: "林默推门,镜头缓慢推进\n\n【角色参考】\n林默:@[asset:character-1]\n\n【场景与道具参考】\n旧信封:@[asset:prop-1]",
continuityNotes: "林默停在门内,右手仍扶着门把",
actionBeats: [{ description: "0-2 秒推门;2-5 秒环顾" }],
},

View File

@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
test("分镜生成模型选择器具有明确的下拉控件状态", async () => {
const css = await Bun.file(new URL("../src/pages/projects/detail/workflow.css", import.meta.url)).text();
const rule = css.match(/\.workflow-model-picker\.canvas-composer-model-picker\s*\{([\s\S]*?)\}/)?.[1] || "";
expect(rule).toContain("border: 1px solid var(--workspace-border-strong)");
expect(rule).toContain("height: 44px");
expect(css).toContain('.workflow-model-picker.canvas-composer-model-picker[aria-expanded="true"]');
expect(css).toContain(".workflow-model-picker .canvas-model-picker-chevron");
expect(css).toContain("box-shadow: 0 0 0 2px");
});

View File

@@ -6,6 +6,13 @@ import type { GenerationTask } from "../src/services/api/task-center";
import type { ProjectDetail } from "../src/services/api/projects";
import { buildShotAssetReferenceContext, resolveShotAssetMentionPrompt } from "../src/pages/projects/detail/workflow-shot-references";
test("production workbench does not silently drop bound voice samples before backend validation", async () => {
const source = await Bun.file(new URL("../src/pages/projects/detail/workflow-production-workbench.tsx", import.meta.url)).text();
expect(source).toContain('const generationReferenceAudios = generationCapability === "video" ? shotAssetReferenceContext.referenceAudios : [];');
expect(source).not.toContain("selectedVideoProfile?.references.maxAudios");
});
test("shot generation submits historical character image, current voice and asset prompt", async () => {
const detail = {
assets: [

View File

@@ -2,7 +2,7 @@ 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";
import { buildShotAssetReferenceContext, ensureShotAssetMentionPrompt, resolveShotAssetMentionPrompt } from "@/pages/projects/detail/workflow-shot-references";
describe("workflow shot asset references", () => {
test("builds ordered image references for bound media and characters", () => {
@@ -26,6 +26,15 @@ describe("workflow shot asset references", () => {
expect(() => resolveShotAssetMentionPrompt("跟随 @[asset:missing]", context)).toThrow("未绑定到当前镜头");
});
test("adds bound assets as editable mention tokens without duplicating existing mentions", () => {
const context = buildShotAssetReferenceContext(detail(), "shot-1");
const prompt = ensureShotAssetMentionPrompt("让 @[asset:asset-character] 走近镜头", context.mentionReferences);
expect(prompt).toContain("@[asset:asset-character]");
expect(prompt.match(/@\[asset:asset-character\]/g)).toHaveLength(1);
expect(prompt).toContain("【场景与道具参考】\n雨夜街道:@[asset:asset-scene]");
});
});
function detail(): ProjectDetail {