325 lines
9.9 KiB
TypeScript
325 lines
9.9 KiB
TypeScript
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,
|
||
}));
|
||
}
|