224 lines
6.4 KiB
TypeScript
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),
|
|
],
|
|
};
|
|
}
|