428 lines
11 KiB
TypeScript
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,
|
|
}));
|
|
}
|