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

This commit is contained in:
ddCat
2026-08-30 19:03:10 +08:00
parent 75bde772b5
commit 6b341e284e
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 {