fix(create): 视频创作 - 恢复参考内容添加与按类型校验

This commit is contained in:
Codex
2026-09-21 14:20:17 +08:00
parent c5b81f696c
commit 691073e173
6 changed files with 111 additions and 15 deletions

View File

@@ -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] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。
- [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。

View File

@@ -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);
}

View File

@@ -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) => {

View File

@@ -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<ReturnType<typeof loadCreationRuntime>>;
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<AssetLibraryPickerItem[]>(
() => 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<AssetLibraryPickerItem[]>(() => [
...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();

View File

@@ -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");

View File

@@ -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);