ai-chatbot/lib/video-canvas/storyboard-composition.ts

224 lines
6.4 KiB
TypeScript

export type VideoCanvasStoryboardCompositionClip = {
durationSeconds: number;
id: string;
inPointSeconds: number;
mediaUrl: string;
nodeId: string;
outPointSeconds: number;
speed: number;
title: string;
transition: VideoCanvasStoryboardCompositionTransition | null;
};
export type VideoCanvasStoryboardCompositionSubtitle = {
endSeconds: number;
id: string;
startSeconds: number;
text: string;
};
export type VideoCanvasStoryboardCompositionTransition =
| "fade"
| "fade-black"
| "fade-white";
export type VideoCanvasStoryboardCompositionProject = {
clips: VideoCanvasStoryboardCompositionClip[];
subtitles: VideoCanvasStoryboardCompositionSubtitle[];
};
export const EMPTY_VIDEO_CANVAS_STORYBOARD_COMPOSITION: VideoCanvasStoryboardCompositionProject = {
clips: [],
subtitles: [],
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const asString = (value: unknown) =>
typeof value === "string" ? value.trim() : "";
const asFiniteNumber = (value: unknown, fallback: number) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
export function normalizeVideoCanvasStoryboardComposition(
value: unknown,
): VideoCanvasStoryboardCompositionProject {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return structuredClone(EMPTY_VIDEO_CANVAS_STORYBOARD_COMPOSITION);
}
const source = value as Record<string, unknown>;
const clips = Array.isArray(source.clips)
? source.clips.flatMap((candidate, index) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return [];
}
const clip = candidate as Record<string, unknown>;
const nodeId = asString(clip.nodeId);
const mediaUrl = asString(clip.mediaUrl);
if (!(nodeId && mediaUrl)) return [];
const durationSeconds = Math.max(
0.1,
asFiniteNumber(clip.durationSeconds, 5),
);
const inPointSeconds = clamp(
asFiniteNumber(clip.inPointSeconds, 0),
0,
Math.max(0, durationSeconds - 0.1),
);
const outPointSeconds = clamp(
asFiniteNumber(clip.outPointSeconds, durationSeconds),
inPointSeconds + 0.1,
durationSeconds,
);
const transition = asString(clip.transition);
return [{
durationSeconds,
id: asString(clip.id) || `${nodeId}-${index + 1}`,
inPointSeconds,
mediaUrl,
nodeId,
outPointSeconds,
speed: clamp(asFiniteNumber(clip.speed, 1), 0.25, 4),
title: asString(clip.title) || "视频片段",
transition:
transition === "fade" ||
transition === "fade-black" ||
transition === "fade-white"
? transition
: null,
} satisfies VideoCanvasStoryboardCompositionClip];
})
: [];
const subtitles = Array.isArray(source.subtitles)
? source.subtitles.flatMap((candidate, index) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return [];
}
const subtitle = candidate as Record<string, unknown>;
const startSeconds = Math.max(
0,
asFiniteNumber(subtitle.startSeconds, 0),
);
const endSeconds = Math.max(
startSeconds + 0.1,
asFiniteNumber(subtitle.endSeconds, startSeconds + 2),
);
return [{
endSeconds,
id: asString(subtitle.id) || `subtitle-${index + 1}`,
startSeconds,
text: asString(subtitle.text) || "新建字幕",
} satisfies VideoCanvasStoryboardCompositionSubtitle];
})
: [];
return { clips, subtitles };
}
export function getVideoCanvasCompositionClipDuration(
clip: VideoCanvasStoryboardCompositionClip,
) {
return Math.max(0.1, clip.outPointSeconds - clip.inPointSeconds) / clip.speed;
}
export function getVideoCanvasCompositionDuration(
project: VideoCanvasStoryboardCompositionProject,
) {
return project.clips.reduce(
(total, clip) => total + getVideoCanvasCompositionClipDuration(clip),
0,
);
}
export function getVideoCanvasCompositionClipStart(
project: VideoCanvasStoryboardCompositionProject,
clipId: string,
) {
let start = 0;
for (const clip of project.clips) {
if (clip.id === clipId) return start;
start += getVideoCanvasCompositionClipDuration(clip);
}
return null;
}
export function createVideoCanvasCompositionClip({
durationSeconds,
existingIds,
mediaUrl,
nodeId,
title,
}: {
durationSeconds: number;
existingIds: string[];
mediaUrl: string;
nodeId: string;
title: string;
}): VideoCanvasStoryboardCompositionClip {
const normalizedDuration = Math.max(0.1, durationSeconds || 5);
const baseId = `${nodeId}-clip`;
let suffix = 1;
let id = `${baseId}-${suffix}`;
while (existingIds.includes(id)) {
suffix += 1;
id = `${baseId}-${suffix}`;
}
return {
durationSeconds: normalizedDuration,
id,
inPointSeconds: 0,
mediaUrl,
nodeId,
outPointSeconds: normalizedDuration,
speed: 1,
title: title.trim() || "视频片段",
transition: null,
};
}
export function splitVideoCanvasCompositionClip(
project: VideoCanvasStoryboardCompositionProject,
clipId: string,
playheadSeconds: number,
): VideoCanvasStoryboardCompositionProject {
const clipIndex = project.clips.findIndex((clip) => clip.id === clipId);
const clipStart = getVideoCanvasCompositionClipStart(project, clipId);
if (clipIndex < 0 || clipStart === null) return project;
const clip = project.clips[clipIndex];
const relativeTimelineSeconds = playheadSeconds - clipStart;
const sourceSplitSeconds =
clip.inPointSeconds + relativeTimelineSeconds * clip.speed;
if (
sourceSplitSeconds <= clip.inPointSeconds + 0.1 ||
sourceSplitSeconds >= clip.outPointSeconds - 0.1
) {
return project;
}
const suffix = project.clips.filter((item) => item.id.startsWith(`${clip.id}-split`)).length + 1;
const left = { ...clip, outPointSeconds: sourceSplitSeconds };
const right = {
...clip,
id: `${clip.id}-split-${suffix}`,
inPointSeconds: sourceSplitSeconds,
transition: null,
};
return {
...project,
clips: [
...project.clips.slice(0, clipIndex),
left,
right,
...project.clips.slice(clipIndex + 1),
],
};
}