ai-chatbot/lib/video-canvas/node-creation.ts

227 lines
6.8 KiB
TypeScript

import type { VideoCanvasModelCatalogEntry } from "@/lib/video-canvas/video-model-catalog";
export type VideoCanvasNodeCreationConfig = {
audio: {
advancedSettingsOpen: boolean;
};
image: {
count: number;
height: number;
modelLabel: string;
quality: string;
ratio: string;
size: string;
width: number;
};
upload: {
accept: string;
maxFileSizeMb: number;
maxFiles: number;
placementGap: number;
};
video: {
count: number;
durationSeconds: number;
generateAudio: boolean;
height: number;
modelCatalogId: string;
ratio: string;
resolution: string;
width: number;
};
};
export const defaultVideoCanvasNodeCreationConfig: VideoCanvasNodeCreationConfig = {
audio: {
advancedSettingsOpen: true,
},
image: {
count: 1,
height: 350,
modelLabel: "Lib Image",
quality: "标准画质",
ratio: "16:9",
size: "2K",
width: 622,
},
upload: {
accept: "image/*,video/*,audio/*",
maxFileSizeMb: 50,
maxFiles: 10,
placementGap: 450,
},
video: {
count: 1,
durationSeconds: 5,
generateAudio: true,
height: 350,
modelCatalogId: "seedance-1-5-pro",
ratio: "16:9",
resolution: "1080P",
width: 622,
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeInteger(value: unknown, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeLabel(value: unknown, fallback: string, maximumLength = 80) {
return String(value || "").trim().slice(0, maximumLength) || fallback;
}
export function normalizeVideoCanvasNodeCreationConfig(
value: unknown,
): VideoCanvasNodeCreationConfig {
const fallback = defaultVideoCanvasNodeCreationConfig;
const incoming = isRecord(value) ? value : {};
const incomingAudio = isRecord(incoming.audio) ? incoming.audio : {};
const incomingImage = isRecord(incoming.image) ? incoming.image : {};
const incomingUpload = isRecord(incoming.upload) ? incoming.upload : {};
const incomingVideo = isRecord(incoming.video) ? incoming.video : {};
return {
audio: {
advancedSettingsOpen:
typeof incomingAudio.advancedSettingsOpen === "boolean"
? incomingAudio.advancedSettingsOpen
: fallback.audio.advancedSettingsOpen,
},
image: {
count: normalizeInteger(incomingImage.count, fallback.image.count, 1, 4),
height: normalizeInteger(incomingImage.height, fallback.image.height, 240, 1200),
modelLabel: normalizeLabel(incomingImage.modelLabel, fallback.image.modelLabel),
quality: normalizeLabel(incomingImage.quality, fallback.image.quality, 40),
ratio: normalizeLabel(incomingImage.ratio, fallback.image.ratio, 30),
size: normalizeLabel(incomingImage.size, fallback.image.size, 30),
width: normalizeInteger(incomingImage.width, fallback.image.width, 240, 1600),
},
upload: {
accept: normalizeLabel(incomingUpload.accept, fallback.upload.accept, 240),
maxFileSizeMb: normalizeInteger(
incomingUpload.maxFileSizeMb,
fallback.upload.maxFileSizeMb,
1,
500,
),
maxFiles: normalizeInteger(
incomingUpload.maxFiles,
fallback.upload.maxFiles,
1,
50,
),
placementGap: normalizeInteger(
incomingUpload.placementGap,
fallback.upload.placementGap,
40,
2000,
),
},
video: {
count: normalizeInteger(incomingVideo.count, fallback.video.count, 1, 4),
durationSeconds: normalizeInteger(
incomingVideo.durationSeconds,
fallback.video.durationSeconds,
1,
60,
),
generateAudio:
typeof incomingVideo.generateAudio === "boolean"
? incomingVideo.generateAudio
: fallback.video.generateAudio,
height: normalizeInteger(incomingVideo.height, fallback.video.height, 240, 1200),
modelCatalogId: normalizeLabel(
incomingVideo.modelCatalogId,
fallback.video.modelCatalogId,
120,
),
ratio: normalizeLabel(incomingVideo.ratio, fallback.video.ratio, 30),
resolution: normalizeLabel(
incomingVideo.resolution,
fallback.video.resolution,
30,
).toUpperCase(),
width: normalizeInteger(incomingVideo.width, fallback.video.width, 240, 1600),
},
};
}
export function getInitialVideoCanvasNodeCreationOrdinal(
nodes: Array<{ title?: string | null }>,
) {
const highestSuffix = nodes.reduce((maximum, node) => {
const match = String(node.title || "").trim().match(/(?:^|\s)(\d+)$/);
return match ? Math.max(maximum, Number.parseInt(match[1], 10)) : maximum;
}, 0);
return Math.max(1, nodes.length, highestSuffix + 1);
}
export function resolveVideoCanvasCreationModel(
config: VideoCanvasNodeCreationConfig,
catalog: VideoCanvasModelCatalogEntry[],
) {
const selected =
catalog.find((entry) => entry.enabled && entry.id === config.video.modelCatalogId) ||
catalog.find((entry) => entry.enabled) ||
catalog[0];
return {
catalogId: selected?.id || config.video.modelCatalogId,
label: selected?.label || "自动模型",
modelRef: selected?.modelRef || "auto",
};
}
export function buildVideoCanvasImageCreationParams(
config: VideoCanvasNodeCreationConfig,
): Record<string, unknown> {
return {
count: config.image.count,
imageCount: config.image.count,
imageQuality: config.image.quality,
imageSize: config.image.size,
modelLabel: config.image.modelLabel,
ratio: config.image.ratio,
settings: {
count: config.image.count,
imageQuality: config.image.quality,
imageSize: config.image.size,
modelLabel: config.image.modelLabel,
ratio: config.image.ratio,
},
};
}
export function buildVideoCanvasVideoCreationParams(
config: VideoCanvasNodeCreationConfig,
catalog: VideoCanvasModelCatalogEntry[],
): Record<string, unknown> {
const model = resolveVideoCanvasCreationModel(config, catalog);
const audio = config.video.generateAudio ? "开启" : "关闭";
return {
audio: config.video.generateAudio,
count: config.video.count,
duration: config.video.durationSeconds,
mode: "text-to-video",
model: model.modelRef,
ratio: config.video.ratio,
resolution: config.video.resolution.toLowerCase(),
settings: {
audio,
count: `${config.video.count}个`,
duration: `${config.video.durationSeconds}s`,
mode: "text-to-video",
model: model.modelRef,
modelCatalogId: model.catalogId,
modelLabel: model.label,
ratio: config.video.ratio,
resolution: config.video.resolution,
},
};
}