325 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> {
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<string>();
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, string> = {
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,
}));
}