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; 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; 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; 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), ], }; }