import { MIN_CLIP_DURATION_US, MIN_TEXT_DURATION_US, projectContentEndUs, type ProjectDocument, } from "@web-video-editor/domain"; export { MIN_CLIP_DURATION_US, MIN_TEXT_DURATION_US }; export const MIN_TIMELINE_DISPLAY_DURATION_US = 10_000_000; export function clipDurationUs(clip: ProjectDocument["clips"][number]): number { return clip.sourceEndUs - clip.sourceStartUs; } export type ClipTrimLimit = | "minimum-duration" | "none" | "source-boundary" | "timeline-boundary" | "track-conflict"; export type ClipTrimResult = { limit: ClipTrimLimit; sourceEndUs: number; sourceStartUs: number; timelineStartUs: number; }; export type TextTrimLimit = | "minimum-duration" | "none" | "timeline-boundary" | "track-conflict"; export type TextTrimResult = { endUs: number; limit: TextTrimLimit; startUs: number; }; export function projectDurationUs(project: ProjectDocument): number { return Math.max( MIN_TIMELINE_DISPLAY_DURATION_US, project.timeline.durationUs, projectContentEndUs(project), ); } export function pixelsToTimeUs( pixels: number, pixelsPerSecond: number, ): number { return Math.round((pixels / pixelsPerSecond) * 1_000_000); } export function timeUsToPixels( timeUs: number, pixelsPerSecond: number, ): number { return (timeUs / 1_000_000) * pixelsPerSecond; } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); } function clampStartBetweenSpans( spans: ReadonlyArray<{ endUs: number; startUs: number }>, durationUs: number, proposedStartUs: number, ): number { const proposed = Math.max(0, Math.round(proposedStartUs)); const sorted = [...spans].sort((left, right) => left.startUs - right.startUs); let previousEndUs = 0; let bestStartUs = Math.max(0, proposed); let bestDistance = Number.POSITIVE_INFINITY; for (const span of sorted) { const maximumStartUs = span.startUs - durationUs; if (maximumStartUs >= previousEndUs) { const candidateStartUs = Math.max( previousEndUs, Math.min(maximumStartUs, proposed), ); const distance = Math.abs(candidateStartUs - proposed); if (distance < bestDistance) { bestDistance = distance; bestStartUs = candidateStartUs; } } previousEndUs = Math.max(previousEndUs, span.endUs); } const candidateStartUs = Math.max(previousEndUs, proposed); const distance = Math.abs(candidateStartUs - proposed); if (distance < bestDistance) { bestStartUs = candidateStartUs; } return bestStartUs; } export function appendTimelineStartUs( project: ProjectDocument, trackId?: string, ): number { return project.clips .filter((clip) => !trackId || clip.trackId === trackId) .reduce( (maximum, clip) => Math.max(maximum, clip.timelineStartUs + clipDurationUs(clip)), 0, ); } export function clampClipMove( project: ProjectDocument, clipId: string, proposedStartUs: number, targetTrackId?: string, ): number { const targetClip = project.clips.find((clip) => clip.id === clipId); if (!targetClip) { return 0; } const trackId = targetTrackId ?? targetClip.trackId; if (trackId !== targetClip.trackId) { return clampStartBetweenSpans( project.clips .filter((clip) => clip.trackId === trackId && clip.id !== clipId) .map((clip) => ({ endUs: clip.timelineStartUs + clipDurationUs(clip), startUs: clip.timelineStartUs, })), clipDurationUs(targetClip), proposedStartUs, ); } const sorted = project.clips .filter((clip) => clip.trackId === trackId) .sort((left, right) => left.timelineStartUs - right.timelineStartUs); const index = sorted.findIndex((clip) => clip.id === clipId); const clip = sorted[index]; if (!clip) { return 0; } const previous = sorted[index - 1]; const next = sorted[index + 1]; const minimum = previous ? previous.timelineStartUs + clipDurationUs(previous) : 0; const maximum = next ? next.timelineStartUs - clipDurationUs(clip) : Number.MAX_SAFE_INTEGER; return Math.max(minimum, Math.min(maximum, Math.round(proposedStartUs))); } export function clampTextMove( project: ProjectDocument, textId: string, proposedStartUs: number, targetTrackId?: string, ): number { const targetText = project.texts.find((text) => text.id === textId); if (!targetText) { return 0; } const trackId = targetTrackId ?? targetText.trackId; return clampStartBetweenSpans( project.texts .filter((text) => text.trackId === trackId && text.id !== textId) .map((text) => ({ endUs: text.endUs, startUs: text.startUs, })), targetText.endUs - targetText.startUs, proposedStartUs, ); } function sameTrackTextsExcept( project: ProjectDocument, text: ProjectDocument["texts"][number], ): ProjectDocument["texts"] { return project.texts .filter( (candidate) => candidate.trackId === text.trackId && candidate.id !== text.id, ) .sort((left, right) => left.startUs - right.startUs); } function previousTextEndUs( project: ProjectDocument, text: ProjectDocument["texts"][number], ): number { const previous = sameTrackTextsExcept(project, text) .filter((candidate) => candidate.startUs < text.startUs) .at(-1); return previous ? previous.endUs : 0; } function nextTextStartUs( project: ProjectDocument, text: ProjectDocument["texts"][number], ): number | undefined { return sameTrackTextsExcept(project, text).find( (candidate) => candidate.startUs > text.startUs, )?.startUs; } export function clampTextTrimStart( project: ProjectDocument, text: ProjectDocument["texts"][number], proposedStartUs: number, ): TextTrimResult { const roundedStartUs = Math.round(proposedStartUs); const previousEndUs = previousTextEndUs(project, text); const minimumStartUs = Math.max(0, previousEndUs); const maximumStartUs = text.endUs - MIN_TEXT_DURATION_US; const startUs = clamp(roundedStartUs, minimumStartUs, maximumStartUs); let limit: TextTrimLimit = "none"; if (startUs > roundedStartUs) { limit = previousEndUs > 0 ? "track-conflict" : "timeline-boundary"; } else if (startUs < roundedStartUs) { limit = "minimum-duration"; } return { endUs: text.endUs, limit, startUs, }; } export function clampTextTrimEnd( project: ProjectDocument, text: ProjectDocument["texts"][number], proposedEndUs: number, ): TextTrimResult { const roundedEndUs = Math.round(proposedEndUs); const nextStartUs = nextTextStartUs(project, text); const minimumEndUs = text.startUs + MIN_TEXT_DURATION_US; const maximumEndUs = nextStartUs ?? Number.MAX_SAFE_INTEGER; const endUs = clamp(roundedEndUs, minimumEndUs, maximumEndUs); let limit: TextTrimLimit = "none"; if (endUs > roundedEndUs) { limit = "minimum-duration"; } else if (endUs < roundedEndUs) { limit = "track-conflict"; } return { endUs, limit, startUs: text.startUs, }; } function sameTrackClipsExcept( project: ProjectDocument, clip: ProjectDocument["clips"][number], ): ProjectDocument["clips"] { return project.clips .filter( (candidate) => candidate.trackId === clip.trackId && candidate.id !== clip.id, ) .sort((left, right) => left.timelineStartUs - right.timelineStartUs); } function previousClipEndUs( project: ProjectDocument, clip: ProjectDocument["clips"][number], ): number { const previous = sameTrackClipsExcept(project, clip) .filter((candidate) => candidate.timelineStartUs < clip.timelineStartUs) .at(-1); return previous ? previous.timelineStartUs + clipDurationUs(previous) : 0; } function nextClipStartUs( project: ProjectDocument, clip: ProjectDocument["clips"][number], ): number | undefined { return sameTrackClipsExcept(project, clip).find( (candidate) => candidate.timelineStartUs > clip.timelineStartUs, )?.timelineStartUs; } export function clampClipTrimStart( project: ProjectDocument, clip: ProjectDocument["clips"][number], proposedDeltaUs: number, ): ClipTrimResult { const roundedDeltaUs = Math.round(proposedDeltaUs); const previousEndUs = previousClipEndUs(project, clip); const minimumDeltaUs = Math.max( -clip.sourceStartUs, -clip.timelineStartUs, previousEndUs - clip.timelineStartUs, ); const maximumDeltaUs = clipDurationUs(clip) - MIN_CLIP_DURATION_US; const deltaUs = clamp(roundedDeltaUs, minimumDeltaUs, maximumDeltaUs); let limit: ClipTrimLimit = "none"; if (deltaUs > roundedDeltaUs) { limit = previousEndUs > 0 && deltaUs === previousEndUs - clip.timelineStartUs ? "track-conflict" : deltaUs === -clip.timelineStartUs ? "timeline-boundary" : "source-boundary"; } else if (deltaUs < roundedDeltaUs) { limit = "minimum-duration"; } return { limit, sourceEndUs: clip.sourceEndUs, sourceStartUs: clip.sourceStartUs + deltaUs, timelineStartUs: clip.timelineStartUs + deltaUs, }; } export function clampClipSourceEnd( project: ProjectDocument, clip: ProjectDocument["clips"][number], assetDurationUs: number, proposedSourceEndUs: number, ): ClipTrimResult { const roundedSourceEndUs = Math.round(proposedSourceEndUs); const nextStartUs = nextClipStartUs(project, clip); const maximumByTrackUs = nextStartUs === undefined ? Number.MAX_SAFE_INTEGER : clip.sourceStartUs + nextStartUs - clip.timelineStartUs; const minimumSourceEndUs = clip.sourceStartUs + MIN_CLIP_DURATION_US; const maximumSourceEndUs = Math.min(assetDurationUs, maximumByTrackUs); const sourceEndUs = clamp( roundedSourceEndUs, minimumSourceEndUs, maximumSourceEndUs, ); let limit: ClipTrimLimit = "none"; if (sourceEndUs > roundedSourceEndUs) { limit = "minimum-duration"; } else if (sourceEndUs < roundedSourceEndUs) { limit = maximumByTrackUs < assetDurationUs ? "track-conflict" : "source-boundary"; } return { limit, sourceEndUs, sourceStartUs: clip.sourceStartUs, timelineStartUs: clip.timelineStartUs, }; } export function clampClipSourceStart( project: ProjectDocument, clip: ProjectDocument["clips"][number], proposedSourceStartUs: number, ): ClipTrimResult { const roundedSourceStartUs = Math.round(proposedSourceStartUs); const nextStartUs = nextClipStartUs(project, clip); const maximumDurationUs = nextStartUs === undefined ? Number.MAX_SAFE_INTEGER : nextStartUs - clip.timelineStartUs; const minimumSourceStartUs = Math.max( 0, clip.sourceEndUs - maximumDurationUs, ); const maximumSourceStartUs = clip.sourceEndUs - MIN_CLIP_DURATION_US; const sourceStartUs = clamp( roundedSourceStartUs, minimumSourceStartUs, maximumSourceStartUs, ); let limit: ClipTrimLimit = "none"; if (sourceStartUs < roundedSourceStartUs) { limit = "minimum-duration"; } else if (sourceStartUs > roundedSourceStartUs) { limit = minimumSourceStartUs > 0 ? "track-conflict" : "source-boundary"; } return { limit, sourceEndUs: clip.sourceEndUs, sourceStartUs, timelineStartUs: clip.timelineStartUs, }; }