import type { VideoCanvasSubjectLibraryConfig } from "@/lib/video-canvas/config"; export const VIDEO_CANVAS_SUBJECT_SOURCE_TYPE = "video_canvas_subject"; export const VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE = "video_canvas_subject_upload"; export const VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE = "video_canvas_subject_voice"; export type VideoCanvasSubjectMediaMode = "images" | "video"; export type VideoCanvasSubjectDraft = { assetCount: number; category: string; description: string; durationSeconds?: number | null; mediaMode: VideoCanvasSubjectMediaMode; name: string; voiceId: string; }; export type VideoCanvasSubjectRecord = { description?: unknown; displayName?: string | null; id?: string; metadata?: unknown; originalUrl?: string | null; previewUrl?: string | null; thumbnailUrl?: string | null; }; export type VideoCanvasAttachedSubject = { category: string; description: string; id: string; mediaMode: VideoCanvasSubjectMediaMode; mediaUrls: string[]; name: string; thumbnailUrl: string; voiceId: string; voiceName: string; }; export type VideoCanvasSubjectProviderReference = { category: string; description: string; name: string; referenceImages: string[]; referenceVideo?: string; subjectId: string; voiceId?: string; }; export function normalizeVideoCanvasSubjectText(value: unknown, maxLength: number) { return String(value || "").trim().slice(0, Math.max(0, maxLength)); } export function normalizeGeneratedSubjectDescription( value: unknown, maxLength: number, ) { return normalizeVideoCanvasSubjectText( String(value || "") .replace(/^```(?:text|markdown|md)?\s*/i, "") .replace(/\s*```$/i, "") .replace(/^(?:主体描述|描述|description)\s*[::]\s*/i, "") .replace(/^['"“”]+|['"“”]+$/g, ""), maxLength, ); } export function resolveVideoCanvasSubjectCategory( value: unknown, config: VideoCanvasSubjectLibraryConfig, ) { const category = String(value || "").trim(); if (config.labels.includes(category)) return category; if (config.labels.includes(config.unselectedCategory)) { return config.unselectedCategory; } return config.labels.includes("其他") ? "其他" : config.labels.includes("无标签") ? "无标签" : config.labels[0] || "其他"; } export function validateVideoCanvasSubjectDraft( draft: VideoCanvasSubjectDraft, config: VideoCanvasSubjectLibraryConfig, ) { if (!config.enabled) return "主体库已停用"; const name = String(draft.name || "").trim(); const description = String(draft.description || "").trim(); if (!name) return "请输入主体名称"; if (name.length > config.nameMaxLength) { return `主体名称不能超过${config.nameMaxLength}字`; } if (!description) return "请输入主体描述"; if (description.length > config.descriptionMaxLength) { return `主体描述不能超过${config.descriptionMaxLength}字`; } if (!config.labels.includes(draft.category)) return "请选择有效标签"; if ( !config.voices.some((voice) => voice.id === draft.voiceId) && !draft.voiceId.startsWith("custom:") ) { return "请选择有效音色"; } if (draft.mediaMode === "images") { if (draft.assetCount < config.imageMinCount) { return `请至少添加${config.imageMinCount}张角色图片`; } if (draft.assetCount > config.imageMaxCount) { return `角色图片不能超过${config.imageMaxCount}张`; } return null; } if (draft.assetCount !== 1) return "请添加一个角色视频"; const duration = Number(draft.durationSeconds); if (!Number.isFinite(duration)) return "无法读取视频时长"; if ( duration < config.videoMinDurationSeconds || duration > config.videoMaxDurationSeconds ) { return `角色视频时长需为${config.videoMinDurationSeconds}-${config.videoMaxDurationSeconds}秒`; } return null; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function readSubjectDescription(value: unknown) { if (isRecord(value)) return String(value.description || "").trim(); return String(value || "").trim(); } function readStringArray(value: unknown) { return Array.isArray(value) ? Array.from( new Set(value.map((item) => String(item || "").trim()).filter(Boolean)), ) : []; } export function normalizeVideoCanvasAttachedSubject( value: unknown, ): VideoCanvasAttachedSubject | null { if (!isRecord(value)) return null; const id = String(value.id || "").trim(); const name = String(value.name || value.displayName || "").trim(); const thumbnailUrl = String( value.thumbnailUrl || value.previewUrl || value.originalUrl || "", ).trim(); if (!id || !name || !thumbnailUrl) return null; const mediaUrls = readStringArray(value.mediaUrls); const mediaMode: VideoCanvasSubjectMediaMode = value.mediaMode === "video" ? "video" : "images"; return { category: String(value.category || "其他").trim() || "其他", description: readSubjectDescription(value.description), id, mediaMode, mediaUrls: mediaUrls.length > 0 ? mediaUrls : [thumbnailUrl], name, thumbnailUrl, voiceId: String(value.voiceId || "none").trim() || "none", voiceName: String(value.voiceName || "无配音").trim() || "无配音", }; } export function toVideoCanvasAttachedSubject( subject: VideoCanvasSubjectRecord, ): VideoCanvasAttachedSubject | null { const metadata = isRecord(subject.metadata) ? subject.metadata : {}; return normalizeVideoCanvasAttachedSubject({ category: metadata.category, description: readSubjectDescription(subject.description), id: subject.id, mediaMode: metadata.mediaMode, mediaUrls: metadata.mediaUrls, name: subject.displayName, originalUrl: subject.originalUrl, previewUrl: subject.previewUrl, thumbnailUrl: subject.thumbnailUrl, voiceId: metadata.voiceId, voiceName: metadata.voiceName, }); } export function normalizeVideoCanvasAttachedSubjects( value: unknown, maxSubjects = 12, ) { if (!Array.isArray(value)) return []; const subjects: VideoCanvasAttachedSubject[] = []; const seen = new Set(); for (const item of value) { const subject = normalizeVideoCanvasAttachedSubject(item); if (!subject || seen.has(subject.id)) continue; seen.add(subject.id); subjects.push(subject); if (subjects.length >= Math.max(1, Math.round(maxSubjects))) break; } return subjects; } export function getVideoCanvasAttachedSubjects( draftValues: Record | null | undefined, maxSubjects = 12, ) { if (!draftValues) return []; const params = isRecord(draftValues.params) ? draftValues.params : {}; return normalizeVideoCanvasAttachedSubjects( Array.isArray(draftValues.videoSubjects) ? draftValues.videoSubjects : params.subjects, maxSubjects, ); } export function addVideoCanvasAttachedSubject( draftValues: Record | null | undefined, subject: VideoCanvasAttachedSubject, maxSubjects = 4, ) { const current = { ...(draftValues || {}) }; const params = isRecord(current.params) ? current.params : {}; const existing = getVideoCanvasAttachedSubjects(current, maxSubjects); const subjects = normalizeVideoCanvasAttachedSubjects( [...existing.filter((item) => item.id !== subject.id), subject], maxSubjects, ); return { ...current, params: { ...params, subjects }, videoSubjects: subjects, }; } export function removeVideoCanvasAttachedSubject( draftValues: Record | null | undefined, subjectId: string, ) { const current = { ...(draftValues || {}) }; const params = isRecord(current.params) ? current.params : {}; const subjects = getVideoCanvasAttachedSubjects(current).filter( (subject) => subject.id !== subjectId, ); const { subjects: _removedSubjects, ...nextParams } = params; const { videoSubjects: _removedVideoSubjects, ...nextDraftValues } = current; return subjects.length > 0 ? { ...nextDraftValues, params: { ...nextParams, subjects }, videoSubjects: subjects, } : { ...nextDraftValues, params: nextParams }; } export function supportsVideoCanvasSubjectUsage( config: VideoCanvasSubjectLibraryConfig, modelIdentity: unknown, ) { if (!config.enabled || !config.videoUsage.enabled) return false; const normalizedModel = String(modelIdentity || "").trim().toLowerCase(); if (!normalizedModel) return false; return config.videoUsage.modelPatterns.some((pattern) => normalizedModel.includes(pattern.trim().toLowerCase()), ); } function renderSubjectPromptTemplate( template: string, subject: VideoCanvasAttachedSubject, index: number, ) { const values: Record = { category: subject.category, description: subject.description, index: String(index + 1), name: subject.name, voiceId: subject.voiceId, voiceName: subject.voiceName, }; return template.replace(/\{\{(category|description|index|name|voiceId|voiceName)\}\}/g, ( _match, key: string, ) => values[key] || ""); } export function composeVideoCanvasSubjectPrompt( prompt: string, subjects: VideoCanvasAttachedSubject[], promptTemplate: string, ) { return [ prompt.trim(), ...subjects.map((subject, index) => renderSubjectPromptTemplate(promptTemplate, subject, index).trim(), ), ] .filter(Boolean) .join(" "); } export function buildVideoCanvasSubjectProviderReferences( subjects: VideoCanvasAttachedSubject[], ): VideoCanvasSubjectProviderReference[] { return subjects.map((subject) => ({ category: subject.category, description: subject.description, name: subject.name, referenceImages: subject.mediaMode === "images" ? subject.mediaUrls.slice(0, 4) : [], referenceVideo: subject.mediaMode === "video" ? subject.mediaUrls[0] : undefined, subjectId: subject.id, voiceId: subject.voiceId === "none" ? undefined : subject.voiceId, })); }