export const VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY = "videoCanvasHistoryHiddenAt"; export const VIDEO_CANVAS_HISTORY_MEDIA_TYPES = [ "image", "video", "audio", ] as const; export type VideoCanvasHistoryMediaType = (typeof VIDEO_CANVAS_HISTORY_MEDIA_TYPES)[number]; export const VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES = [ "image", "video", "audio", "av", ] as const; export type VideoCanvasHistoryPickerMediaMode = (typeof VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES)[number]; export type VideoCanvasHistoryPickerSource = { enabled: boolean; id: string; label: string; mediaModes: VideoCanvasHistoryPickerMediaMode[]; usesLocalAssets: boolean; }; export type VideoCanvasHistoryPickerConfig = { maxSelection: number; mediaShortSide: number; pageSize: number; placementGap: number; sources: VideoCanvasHistoryPickerSource[]; }; export const VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS: Record< VideoCanvasHistoryPickerMediaMode, string > = { audio: "音频", av: "音视频", image: "图片", video: "视频", }; export const defaultVideoCanvasHistoryPickerConfig: VideoCanvasHistoryPickerConfig = { maxSelection: 10, mediaShortSide: 350, pageSize: 15, placementGap: 450, sources: [ { enabled: true, id: "libtv", label: "LibTV", mediaModes: ["image", "video", "audio"], usesLocalAssets: true, }, { enabled: true, id: "lib-generator", label: "Lib生成器", mediaModes: ["image", "video"], usesLocalAssets: false, }, { enabled: true, id: "webui", label: "WebUI", mediaModes: ["image", "video"], usesLocalAssets: false, }, { enabled: true, id: "comfyui", label: "ComfyUI", mediaModes: ["image", "av"], usesLocalAssets: false, }, { enabled: true, id: "ai-app", label: "AI应用", mediaModes: ["image", "av"], usesLocalAssets: false, }, ], }; export const VIDEO_CANVAS_HISTORY_ZOOM_LEVELS = [ { cardSize: 130, percent: 75 }, { cardSize: 144, percent: 100 }, { cardSize: 182, percent: 125 }, { cardSize: 248, percent: 150 }, ] as const; export type VideoCanvasHistoryZoomPercent = (typeof VIDEO_CANVAS_HISTORY_ZOOM_LEVELS)[number]["percent"]; export function isVideoCanvasHistoryMediaType( value: unknown, ): value is VideoCanvasHistoryMediaType { return VIDEO_CANVAS_HISTORY_MEDIA_TYPES.includes( value as VideoCanvasHistoryMediaType, ); } export function isVideoCanvasHistoryPickerMediaMode( value: unknown, ): value is VideoCanvasHistoryPickerMediaMode { return VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES.includes( value as VideoCanvasHistoryPickerMediaMode, ); } function isRecord(value: unknown): value is Record { 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 normalizeSourceId(value: unknown, fallback: string) { return ( String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48) || fallback ); } export function normalizeVideoCanvasHistoryPickerConfig( value: unknown, ): VideoCanvasHistoryPickerConfig { const fallback = defaultVideoCanvasHistoryPickerConfig; const incoming = isRecord(value) ? value : {}; const incomingSources = Array.isArray(incoming.sources) ? incoming.sources : []; const seenIds = new Set(); const sources = incomingSources .map((item, index) => { if (!isRecord(item)) return null; const fallbackSource = fallback.sources[index] || { enabled: true, id: `source-${index + 1}`, label: `来源 ${index + 1}`, mediaModes: ["image"] as VideoCanvasHistoryPickerMediaMode[], usesLocalAssets: false, }; const id = normalizeSourceId(item.id, fallbackSource.id); if (seenIds.has(id)) return null; seenIds.add(id); const mediaModes = Array.from( new Set( (Array.isArray(item.mediaModes) ? item.mediaModes : []) .filter(isVideoCanvasHistoryPickerMediaMode) .slice(0, VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES.length), ), ); return { enabled: typeof item.enabled === "boolean" ? item.enabled : fallbackSource.enabled, id, label: String(item.label || "").trim().slice(0, 40) || fallbackSource.label, mediaModes: mediaModes.length > 0 ? mediaModes : fallbackSource.mediaModes, usesLocalAssets: typeof item.usesLocalAssets === "boolean" ? item.usesLocalAssets : fallbackSource.usesLocalAssets, } satisfies VideoCanvasHistoryPickerSource; }) .filter((item): item is VideoCanvasHistoryPickerSource => Boolean(item)); return { maxSelection: normalizeInteger( incoming.maxSelection, fallback.maxSelection, 1, 50, ), mediaShortSide: normalizeInteger( incoming.mediaShortSide, fallback.mediaShortSide, 120, 1000, ), pageSize: normalizeInteger(incoming.pageSize, fallback.pageSize, 1, 50), placementGap: normalizeInteger( incoming.placementGap, fallback.placementGap, 40, 2000, ), sources: sources.length > 0 ? sources : fallback.sources, }; } export function getVideoCanvasHistoryPickerMediaTypes( mode: VideoCanvasHistoryPickerMediaMode, ): VideoCanvasHistoryMediaType[] { return mode === "av" ? ["video", "audio"] : [mode]; } export function getVideoCanvasHistoryPickerTitle( mode: VideoCanvasHistoryPickerMediaMode, ) { return `选择${VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS[mode]}`; } export function paginateVideoCanvasHistoryItems( items: T[], page: number, pageSize: number, ) { const normalizedPageSize = Math.max(1, Math.round(pageSize)); const pageCount = Math.max(1, Math.ceil(items.length / normalizedPageSize)); const normalizedPage = Math.max(1, Math.min(pageCount, Math.round(page))); const start = (normalizedPage - 1) * normalizedPageSize; return { items: items.slice(start, start + normalizedPageSize), page: normalizedPage, pageCount, }; } export function getVideoCanvasHistoryMediaNodeDimensions({ height, mediaShortSide, mediaType, width, }: { height: number | null | undefined; mediaShortSide: number; mediaType: VideoCanvasHistoryMediaType; width: number | null | undefined; }) { if (mediaType === "video") { return { height: mediaShortSide, width: 622 }; } if (mediaType === "audio") { return { height: 148, width: mediaShortSide }; } const aspectRatio = Number(width) > 0 && Number(height) > 0 ? Number(width) / Number(height) : 1; return aspectRatio >= 1 ? { height: mediaShortSide, width: Math.max(mediaShortSide, Math.round(mediaShortSide * aspectRatio)), } : { height: Math.max(mediaShortSide, Math.round(mediaShortSide / aspectRatio)), width: mediaShortSide, }; } export function getVideoCanvasHistoryMediaNodePosition({ center, index, placementGap, }: { center: { x: number; y: number }; index: number; placementGap: number; }) { return { x: Math.round(center.x + index * placementGap), y: Math.round(center.y), }; } const VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS: Record< VideoCanvasHistoryMediaType, string > = { audio: "音频", image: "图片", video: "视频", }; export type VideoCanvasHistoryMediaOrdinals = Record< VideoCanvasHistoryMediaType, number >; export function getInitialVideoCanvasHistoryMediaOrdinals( nodes: Array<{ inputType?: string | null; title?: string | null; }>, ): VideoCanvasHistoryMediaOrdinals { const ordinals: VideoCanvasHistoryMediaOrdinals = { audio: 1, image: 1, video: 1, }; for (const node of nodes) { if (!isVideoCanvasHistoryMediaType(node.inputType)) continue; const label = VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS[node.inputType]; const match = String(node.title || "") .trim() .match(new RegExp(`^${label}节点\\s+(\\d+)$`)); if (!match) continue; ordinals[node.inputType] = Math.max( ordinals[node.inputType], Number.parseInt(match[1], 10) + 1, ); } return ordinals; } export function allocateVideoCanvasHistoryMediaNodeTitle({ mediaType, nextOrdinal, sourceTitle, }: { mediaType: VideoCanvasHistoryMediaType; nextOrdinal: number; sourceTitle: string; }) { const label = VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS[mediaType]; const trimmedTitle = sourceTitle.trim(); const numberedTitle = trimmedTitle.match( new RegExp(`^${label}节点\\s+(\\d+)$`), ); if (!numberedTitle && trimmedTitle) { return { nextOrdinal, title: trimmedTitle }; } const sourceOrdinal = numberedTitle ? Number.parseInt(numberedTitle[1], 10) : 1; const ordinal = Math.max(1, nextOrdinal, sourceOrdinal); return { nextOrdinal: ordinal + 1, title: `${label}节点 ${ordinal}`, }; } export function isVideoCanvasHistoryZoomPercent( value: unknown, ): value is VideoCanvasHistoryZoomPercent { return VIDEO_CANVAS_HISTORY_ZOOM_LEVELS.some( (level) => level.percent === Number(value), ); } export function getVideoCanvasHistoryZoomIndex( percent: VideoCanvasHistoryZoomPercent, ) { const index = VIDEO_CANVAS_HISTORY_ZOOM_LEVELS.findIndex( (level) => level.percent === percent, ); return index >= 0 ? index : 1; } export function normalizeVideoCanvasHistoryAssetIds( value: unknown, limit = 50, ) { if (!Array.isArray(value)) return []; return Array.from( new Set( value .map((item) => String(item || "").trim()) .filter(Boolean) .slice(0, Math.max(1, limit)), ), ); } export function formatVideoCanvasHistoryDate( value: string | Date, timeZone?: string, ) { const date = value instanceof Date ? value : new Date(value); if (!Number.isFinite(date.getTime())) return "日期未知"; const formatter = new Intl.DateTimeFormat("en-CA", { day: "2-digit", month: "2-digit", timeZone, year: "numeric", }); const parts = Object.fromEntries( formatter .formatToParts(date) .filter((part) => part.type !== "literal") .map((part) => [part.type, part.value]), ); return `${parts.year}-${parts.month}-${parts.day}`; } export function groupVideoCanvasHistoryItems< T extends { createdAt: string | Date }, >(items: T[], timeZone?: string) { const groups = new Map(); for (const item of items) { const date = formatVideoCanvasHistoryDate(item.createdAt, timeZone); const group = groups.get(date); if (group) group.push(item); else groups.set(date, [item]); } return Array.from(groups, ([date, groupedItems]) => ({ date, items: groupedItems, })); }