fix(canvas): 短剧分镜 - 支持多镜头并行生成并正确引用角色图声资产

This commit is contained in:
ddCat
2026-08-30 16:30:51 +08:00
parent f62ea62355
commit 4787c130d0
13 changed files with 393 additions and 55 deletions

View File

@@ -11,7 +11,7 @@ import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, vid
import { modelQuoteRequest } from "@/lib/model-pricing";
import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import { submitBackendGenerationTask } from "@/services/api/generation-task";
import { quoteLogicalModel } from "@/services/api/logical-models";
import { type GenerationTask } from "@/services/api/task-center";
import {
@@ -19,7 +19,6 @@ import {
deleteProjectShot,
linkShotAsset,
listProjectAssetsPage,
registerProjectTaskOutput,
saveProjectShot,
unlinkShotAsset,
type ProjectAsset,
@@ -85,7 +84,10 @@ export default function WorkflowProductionWorkbench(props: Props) {
const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest");
const [previewArtifactId, setPreviewArtifactId] = useState("");
const [editorDirty, setEditorDirty] = useState(false);
const [submittingShotIds, setSubmittingShotIds] = useState<Set<string>>(() => new Set());
const [taskClock, setTaskClock] = useState(() => Date.now());
const activeShotIdRef = useRef(selectedShot?.id || "");
activeShotIdRef.current = selectedShot?.id || "";
const shots = useMemo(() => (detail.shots || []).filter((item) => item.unitId === unitId).slice().sort((left, right) => left.position - right.position), [detail.shots, unitId]);
const shotIndex = selectedShot ? shots.findIndex((item) => item.id === selectedShot.id) : -1;
const revision = currentRevision(detail, selectedShot);
@@ -119,15 +121,19 @@ export default function WorkflowProductionWorkbench(props: Props) {
const referenceByVersionId = useMemo(() => new Map((detail.shotReferences || []).filter((reference) => reference.shotId === selectedShot?.id && reference.role === "reference" && reference.status === "linked").map((reference) => [reference.assetVersionId, reference])), [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 videoEditOperation = generationCapability === "video" && shotAssetReferenceContext.referenceImages.length ? "reference_to_video" : undefined;
const modelRequirements = useMemo<ModelRequirements>(() => ({
capability: generationCapability,
input: { textCount: 1, imageCount: shotAssetReferenceContext.referenceImages.length, videoCount: 0, audioCount: 0, characterCount: 0 },
input: { textCount: 1, imageCount: shotAssetReferenceContext.referenceImages.length, videoCount: 0, audioCount: generationReferenceAudios.length, characterCount: 0 },
videoOperation: videoEditOperation,
videoSeconds: generationCapability === "video" ? generationSeconds : undefined,
imageSize: generationCapability === "image" ? aspectRatio : undefined,
options: generationCapability === "video"
? { size: aspectRatio, vquality: resolution, videoSeconds: Number(generationSeconds) }
: { size: aspectRatio, quality: imageQuality },
}), [aspectRatio, generationCapability, generationSeconds, imageQuality, resolution, shotAssetReferenceContext.referenceImages.length]);
}), [aspectRatio, generationCapability, generationReferenceAudios.length, generationSeconds, imageQuality, resolution, shotAssetReferenceContext.referenceImages.length, videoEditOperation]);
const routedModel = resolveCompatibleModel(effectiveConfig, selectedModel, modelRequirements) || selectedModel;
const activeProfile = useMemo(() => modelCapabilityConfigFor(effectiveConfig, routedModel), [effectiveConfig, routedModel]);
const videoProfile = generationCapability === "video" ? activeProfile.video : undefined;
@@ -285,9 +291,12 @@ export default function WorkflowProductionWorkbench(props: Props) {
onError: (error) => message.error(error instanceof Error ? error.message : "镜头资产更新失败"),
});
const generateArtifact = useMutation({
mutationFn: async () => {
if (!selectedShot) throw new Error("请先选择镜头");
const generateArtifact = async () => {
if (!selectedShot || submittingShotIds.has(selectedShot.id)) return;
const submittingShot = selectedShot;
setSubmittingShotIds((current) => new Set(current).add(submittingShot.id));
try {
const values = await form.validateFields();
let productionStep = workflowStep;
if (!productionStep) {
const initialized = await createUnitWorkflow(projectId, unitId);
@@ -299,22 +308,21 @@ export default function WorkflowProductionWorkbench(props: Props) {
if (routedModel.startsWith("local:dreamina-cli")) throw new Error("本机即梦任务暂不能登记到分镜产物,请选择后端模型渠道");
const compatibilityError = modelCompatibilityError(effectiveConfig, routedModel, modelRequirements);
if (compatibilityError) throw new Error(`当前模型配置不可用:${compatibilityError}`);
const values = await form.validateFields();
const saved = await saveProjectShot(projectId, {
id: selectedShot.id,
id: submittingShot.id,
unitId,
title: values.title,
description: values.plotDescription,
position: selectedShot.position,
position: submittingShot.position,
durationMs: Math.round(values.durationSeconds * 1000),
status: selectedShot.status,
status: submittingShot.status,
revision: revisionInput(values),
});
const mode = generationCapability;
const config = { ...generationConfig, videoSeconds: String(Math.max(1, Math.round(values.durationSeconds))) };
if (!isAiConfigReady(config, routedModel)) throw new Error("当前模型渠道配置不完整,请先到设置中补齐");
const basePrompt = mode === "video"
? [values.videoPrompt || values.plotDescription, values.action, values.continuityNotes].filter(Boolean).join("\n")
? [values.videoPrompt || values.plotDescription, values.action, values.dialogue && `台词:${values.dialogue}`, values.continuityNotes].filter(Boolean).join("\n")
: [values.imagePrompt || values.plotDescription, values.action, "黑白分镜草图,清晰动作节拍,电影构图"].filter(Boolean).join("\n");
const resolvedPrompt = resolveShotAssetMentionPrompt(basePrompt, shotAssetReferenceContext);
const skillExecution = await skillRuntime.prepare({
@@ -323,44 +331,45 @@ export default function WorkflowProductionWorkbench(props: Props) {
skills: availableSkills,
selectedSkillIds,
});
let taskId = "";
const result = await runBackendGenerationTask({
await submitBackendGenerationTask({
projectId,
mode,
prompt: skillExecution.prompt,
config,
referenceImages: shotAssetReferenceContext.referenceImages,
referenceAudios: generationReferenceAudios,
metadata: {
...skillExecution.metadata,
workflowStepId: productionStep.id,
domainProjectId: projectId,
unitId,
shotId: saved.shot.id,
shotRevisionId: saved.shot.currentRevisionId,
artifactType,
role: "output",
source: "short-drama-workflow",
...skillExecution.metadata,
...(mode === "video" && shotAssetReferenceContext.referenceImages.length ? { videoEditOperation: "reference_to_video" } : {}),
resolvedCharacterVersions: shotAssetReferenceContext.resolvedCharacterVersions,
artifactMetadata: { model: routedModel, aspectRatio, resolution, durationSeconds: values.durationSeconds, ...skillExecution.metadata },
},
onTaskUpdate: (task) => { taskId = task.id; },
});
const storageKey = mode === "video" ? result.video?.storageKey : result.images?.[0]?.storageKey;
const resourceId = resourceIdFromStorageKey(storageKey);
if (!taskId || !resourceId) throw new Error("生成已结束,但产物没有可登记的资源标识");
await registerProjectTaskOutput(projectId, productionStep.id, {
taskId,
unitId,
shotId: saved.shot.id,
artifactType,
resourceId,
mediaType: mode,
role: "output",
metadataJson: JSON.stringify({ model: selectedModel, 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}任务已提交`);
} catch (error) {
message.error(error instanceof Error ? error.message : "生成任务提交失败");
} finally {
setSubmittingShotIds((current) => {
const next = new Set(current);
next.delete(submittingShot.id);
return next;
});
},
onSuccess: async () => { setEditorDirty(false); await onRefresh(); message.success(`${productionStageCopy[activeStage as "storyboard" | "previz" | "video"].label}已生成`); },
onError: (error) => message.error(error instanceof Error ? error.message : "生成失败"),
});
}
};
const stageCopy = productionStageCopy[activeStage as "storyboard" | "previz" | "video"];
const selectedShotSubmitting = submittingShotIds.has(selectedShot?.id || "");
if (!selectedShot) {
return <div className="workflow-empty-shot"><Empty description="当前章节还没有分镜"><Button type="primary" icon={<Plus className="size-4" />} loading={addingShot} onClick={onAddShot}>新增第一个分镜</Button></Empty></div>;
@@ -523,7 +532,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
<div className="workflow-generation-cost" aria-live="polite">
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
</div>
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || generateArtifact.isPending || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={generateArtifact.isPending || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => generateArtifact.mutate()}>{shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || selectedShotSubmitting || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={selectedShotSubmitting || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => void generateArtifact()}>{selectedShotSubmitting ? `${stageCopy.action}(正在提交)` : shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
</footer>
</Form>
</section>
@@ -546,13 +555,13 @@ export default function WorkflowProductionWorkbench(props: Props) {
<div className="workflow-preview-scroll thin-scrollbar">
{previewTab === "latest" ? <LatestPreview artifact={previewArtifact} emptyText={stageCopy.empty} /> : <ArtifactHistory artifacts={artifacts} activeId={previewArtifact?.id} onSelect={(artifact) => { setPreviewArtifactId(artifact.id); setPreviewTab("latest"); }} />}
<div className="workflow-preview-summary"><div className="flex items-center justify-between gap-2"><span className="text-xs font-medium">当前产物</span><ArtifactStatus artifact={newestArtifact} compact /></div><div className="mt-1 text-[var(--fs-micro)] text-foreground/45">{newestArtifact ? `${formatDuration(selectedShot.durationMs)} · ${resolution}p · v${newestArtifact.version}` : "当前镜头还没有生成产物"}</div></div>
<div className="workflow-preview-actions"><Button icon={<RefreshCcw className="size-3.5" />} loading={generateArtifact.isPending} onClick={() => generateArtifact.mutate()}>重新生成</Button><Button icon={<Download className="size-3.5" />} disabled={!previewArtifact?.resourceId} onClick={() => previewArtifact?.resourceId && void downloadArtifact(previewArtifact, selectedShot.title, message.error)}>下载{activeStage === "video" ? "视频" : "图片"}</Button></div>
<div className="workflow-preview-actions"><Button icon={<RefreshCcw className="size-3.5" />} loading={selectedShotSubmitting || shotTask?.status === "queued" || shotTask?.status === "running"} onClick={() => void generateArtifact()}>重新生成</Button><Button icon={<Download className="size-3.5" />} disabled={!previewArtifact?.resourceId} onClick={() => previewArtifact?.resourceId && void downloadArtifact(previewArtifact, selectedShot.title, message.error)}>下载{activeStage === "video" ? "视频" : "图片"}</Button></div>
<ArtifactHistory artifacts={artifacts.slice(0, 4)} activeId={previewArtifact?.id} onSelect={(artifact) => setPreviewArtifactId(artifact.id)} compact />
</div>
</aside>
</div>
<ShotTimeline activeStage={activeStage} detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
<ShotTimeline activeStage={activeStage} detail={detail} shots={shots} selectedShotId={selectedShot.id} submittingShotIds={submittingShotIds} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
</div>
);
}
@@ -655,7 +664,7 @@ function ArtifactHistory({ artifacts, activeId, onSelect, compact = false }: { a
return <section className={`workflow-history ${compact ? "is-compact" : ""}`}><div className="workflow-history-title">历史版本</div>{artifacts.map((artifact) => <button key={artifact.id} type="button" className={artifact.id === activeId ? "is-active" : ""} onClick={() => onSelect(artifact)}>{artifact.resourceId ? artifact.type === "video" ? <video src={resourceFileUrl(artifact.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(artifact.resourceId)} alt="" loading="lazy" /> : <span className="workflow-history-placeholder"><Layers3 /></span>}<span className="min-w-0 flex-1"><strong>v{artifact.version}{artifact.selected ? " · 当前" : ""}</strong><small>{new Date(artifact.createdAt).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</small></span><ArtifactStatus artifact={artifact} compact /></button>)}</section>;
}
function ShotTimeline({ activeStage, detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { activeStage: ShortDramaWorkflowStage; detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) {
function ShotTimeline({ activeStage, detail, shots, selectedShotId, submittingShotIds, onSelectShot, onAddShot, addingShot }: { activeStage: ShortDramaWorkflowStage; detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; submittingShotIds: Set<string>; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) {
const artifactType = artifactTypeForStage(activeStage);
const latestTaskByShotId = useMemo(() => {
const tasks = new Map<string, GenerationTask>();
@@ -667,10 +676,10 @@ function ShotTimeline({ activeStage, detail, shots, selectedShotId, onSelectShot
}
return tasks;
}, [artifactType, detail.tasks]);
return <section className="workflow-shot-timeline"><header><div><strong>{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}</strong><span>{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}</span></div><div className="flex items-center gap-1 text-[var(--fs-micro)] text-foreground/40"><List className="size-3.5" /> 共 {shots.length} 镜</div></header><div className="workflow-shot-track thin-scrollbar">{shots.map((shot, index) => <TimelineShot key={shot.id} artifactType={artifactType} detail={detail} shot={shot} task={latestTaskByShotId.get(shot.id)} index={index} selected={shot.id === selectedShotId} onSelect={() => onSelectShot(shot.id)} />)}<button type="button" className="workflow-add-shot-card" disabled={addingShot} onClick={onAddShot}><Plus className="size-5" /><span>新增分镜</span></button></div></section>;
return <section className="workflow-shot-timeline"><header><div><strong>{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}</strong><span>{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}</span></div><div className="flex items-center gap-1 text-[var(--fs-micro)] text-foreground/40"><List className="size-3.5" /> 共 {shots.length} 镜</div></header><div className="workflow-shot-track thin-scrollbar">{shots.map((shot, index) => <TimelineShot key={shot.id} artifactType={artifactType} detail={detail} shot={shot} task={latestTaskByShotId.get(shot.id)} submitting={submittingShotIds.has(shot.id)} index={index} selected={shot.id === selectedShotId} onSelect={() => onSelectShot(shot.id)} />)}<button type="button" className="workflow-add-shot-card" disabled={addingShot} onClick={onAddShot}><Plus className="size-5" /><span>新增分镜</span></button></div></section>;
}
function TimelineShot({ artifactType, detail, shot, task, index, selected, onSelect }: { artifactType: string; detail: ProjectDetail; shot: ProjectShot; task?: GenerationTask; index: number; selected: boolean; onSelect: () => void }) {
function TimelineShot({ artifactType, detail, shot, task, submitting, index, selected, onSelect }: { artifactType: string; detail: ProjectDetail; shot: ProjectShot; task?: GenerationTask; submitting: boolean; index: number; selected: boolean; onSelect: () => void }) {
const video = currentArtifact(detail, shot.id, "video");
const previz = currentArtifact(detail, shot.id, "action_board");
const storyboard = currentArtifact(detail, shot.id, "storyboard");
@@ -679,7 +688,7 @@ function TimelineShot({ artifactType, detail, shot, task, index, selected, onSel
const revision = currentRevision(detail, shot);
const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面";
const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数";
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={task?.status} compact /></span></span></button>;
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
}
function revisionInput(values: ShotEditorValues): ShotRevisionInput {

View File

@@ -3,10 +3,23 @@ import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import type { AssetCategory } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio } from "@/types/media";
export type ShotAssetReferenceContext = {
mentionReferences: CanvasResourceReference[];
referenceImages: ReferenceImage[];
referenceAudios: ReferenceAudio[];
assetReferences: ShotPromptAssetReference[];
resolvedCharacterVersions: Array<{ assetId: string; versionId: string }>;
};
export type ShotPromptAssetReference = {
assetId: string;
title: string;
category: string;
imageIndex: number;
audioIndex?: number;
voiceDescription?: string;
};
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
@@ -24,6 +37,35 @@ export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: st
return [{ asset, image }];
});
const referenceAudios: ReferenceAudio[] = [];
const audioIndexByResourceId = new Map<string, number>();
const assetReferences = entries.map(({ asset }, index) => {
const sampleResourceId = stringValue(asset.character?.voice?.profile.sampleResourceId);
let audioIndex: number | undefined;
if (sampleResourceId) {
audioIndex = audioIndexByResourceId.get(sampleResourceId);
if (!audioIndex) {
audioIndex = referenceAudios.length + 1;
audioIndexByResourceId.set(sampleResourceId, audioIndex);
referenceAudios.push({
id: `project-character-voice:${asset.id}`,
name: `${asset.title}-声音样本`,
type: "audio/*",
url: resourceFileUrl(sampleResourceId),
storageKey: resourceStorageKey(sampleResourceId),
});
}
}
return {
assetId: asset.id,
title: asset.title,
category: asset.category,
imageIndex: index + 1,
...(audioIndex ? { audioIndex } : {}),
...(asset.character ? { voiceDescription: characterVoiceDescription(asset) } : {}),
} satisfies ShotPromptAssetReference;
});
return {
mentionReferences: entries.map(({ asset, image }) => ({
id: `project-asset:${asset.id}`,
@@ -38,6 +80,9 @@ export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: st
category: projectAssetCategory(asset.category),
})),
referenceImages: entries.map(({ image }) => image),
referenceAudios,
assetReferences,
resolvedCharacterVersions: entries.flatMap(({ asset }) => asset.character?.versionId ? [{ assetId: asset.id, versionId: asset.character.versionId }] : []),
};
}
@@ -53,7 +98,21 @@ export function resolveShotAssetMentionPrompt(prompt: string, context: ShotAsset
return label;
});
if (unresolved.size) throw new Error(`提示词中的 ${Array.from(unresolved).join("、")} 未绑定到当前镜头,请重新选择资产或删除引用`);
return resolved;
const assetBlock = compileShotAssetReferencePrompt(context.assetReferences);
return [resolved.trim(), assetBlock].filter(Boolean).join("\n\n");
}
export function compileShotAssetReferencePrompt(references: ShotPromptAssetReference[]) {
if (!references.length) return "";
const lines = references.flatMap((reference) => {
const visualLabel = reference.category === "character" ? "人物参考" : `${projectAssetCategoryLabel(reference.category)}参考`;
const media = [`${visualLabel}:图片${reference.imageIndex}`, reference.audioIndex ? `声音参考:音频${reference.audioIndex}` : ""].filter(Boolean).join(";");
return [
`- ${reference.title}:${media}`,
reference.voiceDescription ? ` 声音说明:${reference.voiceDescription}` : "",
].filter(Boolean);
});
return ["【资产参考】", "", ...lines].join("\n");
}
function projectAssetReferenceImage(asset: ProjectAsset): ReferenceImage | undefined {
@@ -87,3 +146,22 @@ function projectAssetReferenceImage(asset: ProjectAsset): ReferenceImage | undef
function projectAssetCategory(value: string): AssetCategory {
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
}
function projectAssetCategoryLabel(value: string) {
return ({ environment: "场景", wardrobe: "服装", prop: "道具", weapon: "武器", style: "风格", other: "资产" } as Record<string, string>)[value] || "资产";
}
function characterVoiceDescription(asset: ProjectAsset) {
const definition = asset.character?.definition || {};
const voice = asset.character?.voice;
return [
stringValue(definition.voiceLanguage) || stringValue(voice?.profile.language),
stringValue(definition.voiceAge),
stringValue(definition.voiceTimbre) || stringValue(voice?.profile.timbre),
stringValue(voice?.instructions),
].filter(Boolean).join(";");
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -124,6 +124,20 @@ export async function runBackendGenerationTask(
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate }, prepared, dependencies);
}
// 分镜等后台生产流程只需要可靠提交任务;任务状态与产物由项目工作区轮询和
// 后端自动回填负责,不能让页面 mutation 一直等待供应商完成。
export async function submitBackendGenerationTask(
options: BackendGenerationTaskOptions,
dependencies: GenerationTaskDependencies = defaultDependencies,
): Promise<GenerationTask> {
throwIfAborted(options.signal);
assertClientPromptLimit(options.mode, options.prompt, options.config, options.metadata);
if (usesLocalDreamina(options.config)) throw new Error("本机即梦任务暂不支持后台提交");
const prepared = await prepareGenerationReferences(options);
throwIfAborted(options.signal);
return createBackendGenerationTask(options, prepared, dependencies);
}
export async function runBackendToolGenerationTask(options: {
prompt: string;
config: AiConfig;
@@ -401,7 +415,14 @@ async function prepareGenerationReferences({
}
async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, signal, metadata, onTaskUpdate, onTextDelta } = options;
const task = await createBackendGenerationTask(options, prepared, dependencies);
const { signal, onTaskUpdate, onTextDelta } = options;
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate, onTextDelta });
return parseBackendGenerationResult(completed);
}
async function createBackendGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const { projectId, mode, prompt, config, metadata, onTaskUpdate } = options;
const videoOperation = generationOperation(options);
const workflow = resolveGenerationWorkflowExecution(config, mode);
const logicalModelId = workflow ? "" : logicalModelIDForConfig(config);
@@ -428,8 +449,7 @@ async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions
},
});
onTaskUpdate?.(task);
const completed = await dependencies.waitTask(task.id, { signal, initialTask: task, onTaskUpdate, onTextDelta });
return parseBackendGenerationResult(completed);
return task;
}
async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) {

View File

@@ -38,7 +38,12 @@ export type SeedanceTask = {
};
export type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
export type RequestOptions = { signal?: AbortSignal };
export type RequestOptions = {
signal?: AbortSignal;
videoEditOperation?: string;
videoStartFrameNodeId?: string;
videoEndFrameNodeId?: string;
};
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "agnes" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string };

View File

@@ -15,14 +15,16 @@ export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: Re
const videoUrls = await Promise.all(videoReferences.slice(0, 3).map((video) => resolveMiniMaxMediaUrl(video, "参考视频")));
const audioUrls = await Promise.all(audioReferences.slice(0, 3).map((audio) => resolveMiniMaxMediaUrl(audio, "参考音频")));
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt.trim() }];
const explicitFrameMode = options?.videoEditOperation !== "reference_to_video" && Boolean(options?.videoStartFrameNodeId || options?.videoEndFrameNodeId);
imageUrls.forEach((url, index) => {
// 首尾帧只允许成对出现;三张及以上图片统一作为多模态参考图,避免提交非法组合。
const role = videoUrls.length || audioUrls.length || imageUrls.length > 2 ? "reference_image" : index === 0 ? "first_frame" : "last_frame";
const reference = references[index];
const role = miniMaxImageRole(reference?.id, options);
if (explicitFrameMode && role === "reference_image") throw new Error("MiniMax 首尾帧模式不能混合未标记的参考图");
content.push({ type: "image_url", image_url: { url }, role });
});
videoUrls.forEach((url) => content.push({ type: "video_url", video_url: { url }, role: "reference_video" }));
audioUrls.forEach((url) => content.push({ type: "audio_url", audio_url: { url }, role: "reference_audio" }));
const frameMode = imageUrls.length > 0 && imageUrls.length <= 2 && videoUrls.length === 0 && audioUrls.length === 0;
const frameMode = explicitFrameMode;
const payload = {
model: modelOptionName(model),
content,
@@ -41,6 +43,13 @@ export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: Re
}
}
function miniMaxImageRole(imageId: string | undefined, options?: RequestOptions) {
if (options?.videoEditOperation === "reference_to_video") return "reference_image";
if (imageId && imageId === options?.videoStartFrameNodeId) return "first_frame";
if (imageId && imageId === options?.videoEndFrameNodeId) return "last_frame";
return "reference_image";
}
export async function pollMiniMaxVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const response = await deps.transport.get<{ task?: MiniMaxVideoTask }>(miniMaxVideoUrl(config, `/query/video_generation/${encodeURIComponent(task.id)}`), options);