ai-chatbot/lib/video-canvas/history-assets.ts

428 lines
11 KiB
TypeScript

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<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 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<string>();
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<T>(
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<string, T[]>();
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,
}));
}