fix(canvas): 分镜生成 - 强化模型选择提示并补齐资产引用与角色音频
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}任务已提交`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user