From 83781d9c1c852f7835676b9bee6017d17cb3fbb9 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 30 Aug 2026 19:03:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=88=86=E9=95=9C=E7=94=9F?= =?UTF-8?q?=E6=88=90=20-=20=E5=BC=BA=E5=8C=96=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E6=8F=90=E7=A4=BA=E5=B9=B6=E8=A1=A5=E9=BD=90=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=BC=95=E7=94=A8=E4=B8=8E=E8=A7=92=E8=89=B2=E9=9F=B3?= =?UTF-8?q?=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/chapter-storyboard-production.ts | 26 ++++++++++++++----- .../detail/workflow-production-workbench.tsx | 13 +++++----- .../detail/workflow-shot-references.ts | 14 ++++++++++ web/src/pages/projects/detail/workflow.css | 22 ++++++++++++++-- web/test/project-chapter-storyboard.test.ts | 2 +- web/test/workflow-model-picker-style.test.ts | 12 +++++++++ web/test/workflow-shot-generation.test.ts | 7 +++++ web/test/workflow-shot-references.test.ts | 11 +++++++- 8 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 web/test/workflow-model-picker-style.test.ts diff --git a/web/src/pages/projects/detail/chapter-storyboard-production.ts b/web/src/pages/projects/detail/chapter-storyboard-production.ts index 7071513ad..5a7f47d06 100644 --- a/web/src/pages/projects/detail/chapter-storyboard-production.ts +++ b/web/src/pages/projects/detail/chapter-storyboard-production.ts @@ -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) { - 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; diff --git a/web/src/pages/projects/detail/workflow-production-workbench.tsx b/web/src/pages/projects/detail/workflow-production-workbench.tsx index 392952886..e3c138d97 100644 --- a/web/src/pages/projects/detail/workflow-production-workbench.tsx +++ b/web/src/pages/projects/detail/workflow-production-workbench.tsx @@ -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 & { 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(() => ({ 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}任务已提交`); diff --git a/web/src/pages/projects/detail/workflow-shot-references.ts b/web/src/pages/projects/detail/workflow-shot-references.ts index 262b036bf..58708a2b2 100644 --- a/web/src/pages/projects/detail/workflow-shot-references.ts +++ b/web/src/pages/projects/detail/workflow-shot-references.ts @@ -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>) { + 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) => { diff --git a/web/src/pages/projects/detail/workflow.css b/web/src/pages/projects/detail/workflow.css index 9c63c3c83..d41747246 100644 --- a/web/src/pages/projects/detail/workflow.css +++ b/web/src/pages/projects/detail/workflow.css @@ -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 { diff --git a/web/test/project-chapter-storyboard.test.ts b/web/test/project-chapter-storyboard.test.ts index 677e8577e..808f38cdc 100644 --- a/web/test/project-chapter-storyboard.test.ts +++ b/web/test/project-chapter-storyboard.test.ts @@ -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 秒环顾" }], }, diff --git a/web/test/workflow-model-picker-style.test.ts b/web/test/workflow-model-picker-style.test.ts new file mode 100644 index 000000000..30e6ac8d0 --- /dev/null +++ b/web/test/workflow-model-picker-style.test.ts @@ -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"); +}); diff --git a/web/test/workflow-shot-generation.test.ts b/web/test/workflow-shot-generation.test.ts index 48cfb7504..f290ce9f2 100644 --- a/web/test/workflow-shot-generation.test.ts +++ b/web/test/workflow-shot-generation.test.ts @@ -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: [ diff --git a/web/test/workflow-shot-references.test.ts b/web/test/workflow-shot-references.test.ts index 7be7b373b..29c03d79f 100644 --- a/web/test/workflow-shot-references.test.ts +++ b/web/test/workflow-shot-references.test.ts @@ -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 {