From 691073e173dd4387e13ea0221c34a46ea585836c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 21 Sep 2026 14:20:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(create):=20=E8=A7=86=E9=A2=91=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=20-=20=E6=81=A2=E5=A4=8D=E5=8F=82=E8=80=83=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=B7=BB=E5=8A=A0=E4=B8=8E=E6=8C=89=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/docs/progress/pending-test.mdx | 3 ++ web/src/pages/create/creation-references.ts | 25 ++++++++++ web/src/pages/create/creation-workspace.tsx | 2 +- web/src/pages/create/index.tsx | 55 ++++++++++++++++----- web/test/create-library-button.test.ts | 10 ++++ web/test/creation-references.test.ts | 31 +++++++++++- 6 files changed, 111 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4c5c98a6..fb942a470 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -1734,7 +1734,10 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束 ## 创作输入区参考内容管理 +- [x] 视频创作按同名模型组汇总图片、视频和音频参考能力;文生视频路由本身不接收参考素材时,仍可先添加参考内容,再由现有兼容路由选择图生视频或全模态参考模型。素材库和提交前按媒体类型分别执行上限校验。 +- [x] 创作参考、素材库入口和模型路由 47 项专项测试、前端 TypeScript 检查与生产构建通过。前端已部署为 `1.5.7-creation-video-references`,容器健康,HTTPS 创作页与 API 健康入口返回 200,线上创作页脚本与本次构建哈希一致;未调用真实视频上游。 - [x] 自动化覆盖首个、中间和末尾参考内容按稳定 ID 独立删除,且删除后其余顺序保持不变。 +- [ ] 在真实创作台分别选择图片、视频和音频参考,确认素材库只允许当前模型组支持的类型,达到各类型上限时提示明确,并检查任务详情中的 `referenceImages`、`referenceVideos`、`referenceAudios` 与所选素材一致。 - [x] 折叠态提供明确的“查看全部”入口,展开态提供图片、视频、音频、文件筛选、清空全部和收起入口。 - [x] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。 - [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。 diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index 675976fa8..636ff8e97 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -7,6 +7,12 @@ export type CreationReference = CanvasResourceReference & { attachmentId?: string; }; +export type CreationReferenceLimits = { + maxImages: number; + maxVideos: number; + maxAudios: number; +}; + export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) { const counts = { image: 0, video: 0, audio: 0, file: 0 }; const attachmentReferences = attachments.map((attachment) => { @@ -34,6 +40,25 @@ export function reconcileCreationAttachmentLimit(attachments: CreationAttachment return { attachments: nextAttachments, removedReferences }; } +export function reconcileCreationAttachmentLimits(attachments: CreationAttachment[], references: CreationReference[], limits: CreationReferenceLimits) { + const remaining = { + image: Math.max(0, Math.floor(limits.maxImages)), + video: Math.max(0, Math.floor(limits.maxVideos)), + audio: Math.max(0, Math.floor(limits.maxAudios)), + }; + const nextAttachments = attachments.filter((attachment) => { + const kind = creationAttachmentKind(attachment); + if (kind === "file" || remaining[kind] <= 0) return false; + remaining[kind] -= 1; + return true; + }); + if (nextAttachments.length === attachments.length) return { attachments, removedReferences: [] as CreationReference[] }; + + const retainedIds = new Set(nextAttachments.map((attachment) => attachment.id)); + const removedReferences = references.filter((reference) => reference.attachmentId && !retainedIds.has(reference.attachmentId)); + return { attachments: nextAttachments, removedReferences }; +} + export function removeCreationReferenceTokens(value: string, references: CreationReference[]) { return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join("").split(`@${reference.label}`).join(""), value); } diff --git a/web/src/pages/create/creation-workspace.tsx b/web/src/pages/create/creation-workspace.tsx index 41dc93480..243329153 100644 --- a/web/src/pages/create/creation-workspace.tsx +++ b/web/src/pages/create/creation-workspace.tsx @@ -415,7 +415,7 @@ export function CreationComposer(props: ComposerProps) { : "描述镜头内容、运动、光线与节奏"; const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; - const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); + const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.maxReferences > 0; const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`; const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => { diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index c588b028d..7d6308232 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -12,7 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope"; import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions } from "@/lib/model-capabilities"; -import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; +import { inferVideoOperation, modelGroupReferenceLimits, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import type { BackendGenerationResult } from "@/services/api/generation-task"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; @@ -27,7 +27,7 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; -import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { buildCreationMentionReferences, expandCreationPrompt, reconcileCreationAttachmentLimit, reconcileCreationAttachmentLimits, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference, type CreationReferenceLimits } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; import { defaultCreationMode, modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; @@ -42,6 +42,18 @@ type CreationRuntime = Awaited>; const TEXT_STREAMING_PREF_KEY = "creation.composer.text-streaming"; const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking"; + +function creationLibraryDisabledReason(mode: CreationMode, kind: string | undefined, videoLimits?: CreationReferenceLimits) { + if (!kind) return undefined; + if (mode === "image") return kind === "image" ? undefined : "图片创作仅支持参考图"; + if (mode !== "video") return undefined; + + const maximum = kind === "image" ? videoLimits?.maxImages : kind === "video" ? videoLimits?.maxVideos : kind === "audio" ? videoLimits?.maxAudios : 0; + if ((maximum || 0) > 0) return undefined; + const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "image" ? "图片" : "此类素材"; + return `当前视频模型不支持参考${label}`; +} + function readComposerPref(key: string, fallback: boolean): boolean { try { const stored = window.localStorage.getItem(key); @@ -152,7 +164,14 @@ export default function CreatePage() { : config, [config, mode, ratio, seconds, selectedModel, videoQuality]); const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); - const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; + // 同名逻辑模型可能把文生视频、图生视频和全模态参考拆到不同路由。 + // 入口需要展示整个模型组的引用能力,添加素材后再由兼容路由选择具体模型。 + const videoReferenceLimits = useMemo(() => mode === "video" + ? modelGroupReferenceLimits(config, preferredModel || selectedModel, "video") || { maxImages: 0, maxVideos: 0, maxAudios: 0 } + : undefined, [config, mode, preferredModel, selectedModel]); + const maxReferences = mode === "video" + ? (videoReferenceLimits?.maxImages || 0) + (videoReferenceLimits?.maxVideos || 0) + (videoReferenceLimits?.maxAudios || 0) + : mode === "image" ? imageProfile.references.maxImages : 6; const referenceImageSize = useMemo(() => { const imageAttachments = attachments.filter(isImageAttachment); if (imageAttachments.length !== 1) return undefined; @@ -225,16 +244,16 @@ export default function CreatePage() { setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); - const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0; - if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences)); }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, videoProfile]); useEffect(() => { - const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); + const reconciled = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits) + : reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); if (reconciled.attachments === attachments) return; setAttachments(reconciled.attachments); if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences)); - }, [attachments, maxReferences, mentionReferences]); + }, [attachments, maxReferences, mentionReferences, mode, videoReferenceLimits]); useEffect(() => { let cancelled = false; @@ -387,9 +406,9 @@ export default function CreatePage() { const externalLibraryItems = useMemo( () => externalAssetSources.items.map((item) => ({ ...item, - disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined, + disabledReason: creationLibraryDisabledReason(mode, item.external?.item.kind, videoReferenceLimits), })), - [externalAssetSources.items, mode], + [externalAssetSources.items, mode, videoReferenceLimits], ); const libraryItems = useMemo(() => [ ...assets @@ -401,10 +420,10 @@ export default function CreatePage() { kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片", asset, searchText: (asset.tags || []).join(" "), - disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined, + disabledReason: creationLibraryDisabledReason(mode, asset.kind, videoReferenceLimits), })), ...externalLibraryItems, - ], [assets, externalLibraryItems, mode]); + ], [assets, externalLibraryItems, mode, videoReferenceLimits]); const uploadCreationAsset = async (file: File) => { const { uploadImage, uploadMediaFile } = await loadCreationRuntime(); if (file.type.startsWith("video/")) { @@ -455,7 +474,14 @@ export default function CreatePage() { return external ? [creationAttachmentFromExternalAsset(external)] : []; }); if (!next.length) return; - setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences)); + setAttachments((current) => { + const candidates = [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next]; + const reconciled = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(candidates, [], videoReferenceLimits) + : reconcileCreationAttachmentLimit(candidates, [], maxReferences); + if (reconciled.attachments.length < candidates.length) toast.warning("部分素材超出当前模型的参考内容上限,未添加到创作中"); + return reconciled.attachments; + }); setLibraryOpen(false); }; @@ -556,7 +582,10 @@ export default function CreatePage() { releaseSubmitGate(); return; } - if (attachments.length > maxReferences) { + const reconciledAttachments = mode === "video" && videoReferenceLimits + ? reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits) + : reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); + if (reconciledAttachments.attachments !== attachments) { toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); releaseRetryLock(); releaseSubmitGate(); diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 46a847e18..1f761af2a 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -53,6 +53,16 @@ describe("creation library button", () => { expect(source).toContain("个素材已上传到素材库并自动选中"); }); + test("视频创作使用同名模型组的全部参考能力开放素材入口", () => { + const source = readCreateSource(); + const workspace = readCreateWorkspaceSource(); + + expect(source).toContain('modelGroupReferenceLimits(config, preferredModel || selectedModel, "video")'); + expect(source).toContain("reconcileCreationAttachmentLimits(attachments, mentionReferences, videoReferenceLimits)"); + expect(workspace).toContain('props.mode !== "video" || props.maxReferences > 0'); + expect(workspace).not.toContain('props.videoProfile.operations.includes("image_to_video")'); + }); + test("previews prompt reference images without removing them", () => { const createSource = readCreateWorkspaceSource(); const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8"); diff --git a/web/test/creation-references.test.ts b/web/test/creation-references.test.ts index a5ad7d25d..3885b2cb7 100644 --- a/web/test/creation-references.test.ts +++ b/web/test/creation-references.test.ts @@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"; import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references"; import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets"; -import { buildCreationMentionReferences, displayCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences } from "../src/pages/create/creation-references"; +import { + buildCreationMentionReferences, + displayCreationPrompt, + reconcileCreationAttachmentLimit, + reconcileCreationAttachmentLimits, + removeCreationReferenceTokens, + replaceCreationAttachmentReference, + selectedCreationReferences, +} from "../src/pages/create/creation-references"; function imageAttachment(id: string): CreationAttachment { return { @@ -14,6 +22,10 @@ function imageAttachment(id: string): CreationAttachment { }; } +function mediaAttachment(id: string, type: string): CreationAttachment { + return { id, name: id, type, url: `blob:${id}`, storageKey: `${id}:key`, previewUrl: "" }; +} + describe("creation references", () => { test("removes attachments and prompt tokens beyond the current model limit", () => { const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")]; @@ -37,6 +49,23 @@ describe("creation references", () => { expect(result.removedReferences).toEqual([]); }); + test("视频参考内容分别遵守图片、视频和音频上限", () => { + const attachments = [imageAttachment("image-1"), mediaAttachment("video-1", "video/mp4"), imageAttachment("image-2"), mediaAttachment("audio-1", "audio/mpeg"), mediaAttachment("video-2", "video/mp4")]; + const references = buildCreationMentionReferences([], attachments); + const result = reconcileCreationAttachmentLimits(attachments, references, { maxImages: 1, maxVideos: 2, maxAudios: 0 }); + + expect(result.attachments.map((attachment) => attachment.id)).toEqual(["image-1", "video-1", "video-2"]); + expect(result.removedReferences.map((reference) => reference.attachmentId)).toEqual(["image-2", "audio-1"]); + }); + + test("视频参考内容在各类型均未超限时保留原数组", () => { + const attachments = [imageAttachment("image"), mediaAttachment("video", "video/mp4"), mediaAttachment("audio", "audio/mpeg")]; + const result = reconcileCreationAttachmentLimits(attachments, buildCreationMentionReferences([], attachments), { maxImages: 1, maxVideos: 1, maxAudios: 1 }); + + expect(result.attachments).toBe(attachments); + expect(result.removedReferences).toEqual([]); + }); + test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => { expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true); expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true);