Merge remote-tracking branch 'origin/pr-573' into codex/pr-557-589-security-integration
This commit is contained in:
@@ -1778,7 +1778,10 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束
|
||||
|
||||
## 创作输入区参考内容管理
|
||||
|
||||
- [x] 视频创作按同名模型组汇总图片、视频和音频参考能力;文生视频路由本身不接收参考素材时,仍可先添加参考内容,再由现有兼容路由选择图生视频或全模态参考模型。素材库和提交前按媒体类型分别执行上限校验。
|
||||
- [x] 创作参考、素材库入口和模型路由 47 项专项测试、前端 TypeScript 检查与生产构建通过。前端已部署为 `1.5.7-creation-video-references`,容器健康,HTTPS 创作页与 API 健康入口返回 200,线上创作页脚本与本次构建哈希一致;未调用真实视频上游。
|
||||
- [x] 自动化覆盖首个、中间和末尾参考内容按稳定 ID 独立删除,且删除后其余顺序保持不变。
|
||||
- [ ] 在真实创作台分别选择图片、视频和音频参考,确认素材库只允许当前模型组支持的类型,达到各类型上限时提示明确,并检查任务详情中的 `referenceImages`、`referenceVideos`、`referenceAudios` 与所选素材一致。
|
||||
- [x] 折叠态提供明确的“查看全部”入口,展开态提供图片、视频、音频、文件筛选、清空全部和收起入口。
|
||||
- [x] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。
|
||||
- [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user