refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate

This commit is contained in:
Maze Winther 2026-04-07 01:09:13 +02:00
parent 79df736431
commit e4b67094e7
102 changed files with 4977 additions and 3707 deletions

26
Cargo.lock generated
View File

@ -1427,6 +1427,16 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "effects"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"thiserror 2.0.18",
"wgpu",
]
[[package]]
name = "either"
version = "1.15.0"
@ -3217,6 +3227,15 @@ dependencies = [
"libc",
]
[[package]]
name = "masks"
version = "0.1.0"
dependencies = [
"bytemuck",
"gpu",
"wgpu",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@ -3777,10 +3796,14 @@ dependencies = [
[[package]]
name = "opencut-wasm"
version = "0.1.3"
version = "0.2.3"
dependencies = [
"bridge",
"effects",
"gpu",
"js-sys",
"masks",
"num-traits",
"serde",
"serde-wasm-bindgen",
"time",
@ -5581,6 +5604,7 @@ name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"num-traits",
"serde",
"tsify-next",
"wasm-bindgen",

View File

@ -4,5 +4,8 @@ members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm", "rust/crates/gpu",
"rust/crates/effects",
"rust/crates/gpu",
"rust/crates/masks",
"rust/wasm",
]

View File

@ -79,5 +79,7 @@ changes:
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
- type: new
text: "You can now import transcript files to generate captions instead of running auto-transcription."
- type: new
text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles."
---

View File

@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.1.3",
"opencut-wasm": "^0.2.3",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",

View File

@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -76,8 +76,9 @@ const formatProjectDuration = ({
return null;
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
const durationSeconds = mediaTimeToSeconds({ time: duration });
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimecode({ time: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [

View File

@ -1,14 +1,14 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TimeCodeFormat;
fps: number;
fps: FrameRate;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
@ -28,7 +28,7 @@ export function EditableTimecode({
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const formattedTime = formatTimecode({ time, format, rate: fps }) ?? "";
const startEditing = () => {
if (disabled) return;
@ -46,17 +46,16 @@ export function EditableTimecode({
};
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
if (parsedTime == null) {
setHasError(true);
return;
}
const clampedTime = Math.max(
0,
duration ? Math.min(duration, parsedTime) : parsedTime,
);
const clampedTime = duration
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
: parsedTime;
onTimeChange?.({ time: clampedTime });
setIsEditing(false);

View File

@ -8,7 +8,7 @@ import {
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
import { Button } from "@/components/ui/button";
function InfoRow({
@ -35,9 +35,10 @@ export function ProjectInfoDialog({
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationSeconds = mediaTimeToSeconds({ time: project.duration });
const durationFormatted =
project.duration > 0
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
: "0:00";
return (

View File

@ -25,7 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { invokeAction } from "@/lib/actions";
@ -257,7 +257,7 @@ function MediaAssetDraggable({
startTime: number;
}) => {
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
mediaType: asset.type,

View File

@ -10,6 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/lib/fps/constants";
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
@ -232,12 +233,12 @@ export function SettingsView() {
<Section showTopBorder={false}>
<SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={(value) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
}}
<Select
value={String(Math.round(frameRateToFloat(activeProject.settings.fps)))}
onValueChange={(value) => {
const fps = floatToFrameRate(parseFloat(value));
editor.project.updateSettings({ settings: { fps } });
}}
>
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" />

View File

@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
import { useContainerSize } from "@/hooks/use-container-size";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
@ -129,12 +130,16 @@ function PreviewCanvas({
height: nativeHeight,
fps: activeProject.settings.fps,
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const renderTime = editor.playback.getCurrentTime();
const frame = Math.floor(renderTime * renderer.fps);
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
const frame = Math.floor(renderTime / ticksPerFrame);
if (
frame !== lastFrameRef.current ||

View File

@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
@ -93,7 +93,7 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
{formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
</span>
</div>
);

View File

@ -1,23 +1,22 @@
import { useEditor } from "@/hooks/use-editor";
import { getElementLocalTime } from "@/lib/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
export function useElementPlayhead({
startTime,
duration,
}: {
startTime: number;
duration: number;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
});
const isPlayheadWithinElementRange =
playheadTime >= startTime - TIME_EPSILON_SECONDS &&
playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
return { localTime, isPlayheadWithinElementRange };
}
import { useEditor } from "@/hooks/use-editor";
import { getElementLocalTime } from "@/lib/animation";
export function useElementPlayhead({
startTime,
duration,
}: {
startTime: number;
duration: number;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: startTime,
elementDuration: duration,
});
const isPlayheadWithinElementRange =
playheadTime >= startTime &&
playheadTime <= startTime + duration;
return { localTime, isPlayheadWithinElementRange };
}

View File

@ -15,7 +15,6 @@ import {
PlusSignIcon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
@ -102,7 +101,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
const elementBounds = useMemo(() => {
const clampedTime = Math.min(
Math.max(currentTime, element.startTime),
element.startTime + element.duration - TIME_EPSILON_SECONDS,
element.startTime + element.duration - 1,
);
return (

View File

@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import {
DEFAULT_TIMELINE_BOOKMARK_COLOR,
} from "./theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@ -49,8 +48,8 @@ function seekToBookmarkTime({
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
editor.playback.seek({ time: snappedTime });
}
@ -139,7 +138,7 @@ function TimelineBookmark({
const isDragging =
dragState.isDragging &&
dragState.bookmarkTime !== null &&
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
dragState.bookmarkTime === bookmark.time;
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;

View File

@ -1,5 +1,5 @@
import type { TimelineTrack } from "@/lib/timeline";
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import {
TIMELINE_CONTENT_TOP_PADDING_PX,
} from "./layout";
@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({
startPos,
endPos: currentPos,
});
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
const selectedElements: TimelineElementRef[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({
const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
const elementRight = elementLeft + element.duration * pixelsPerSecond;
const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
const elementRectangle = {
left: elementLeft,
top: elementTop,

View File

@ -7,6 +7,7 @@ import {
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "@/hooks/use-editor";
import {
TIMELINE_SCROLLBAR_SIZE_PX,
@ -72,10 +73,11 @@ export function TimelinePlayhead({
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime();
const nextTime = Math.max(0, Math.min(duration, now + direction * step));
const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame));
editor.playback.seek({ time: nextTime });
};

View File

@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react";
import {
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { mediaTimeToSeconds } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
import { DEFAULT_FPS } from "@/lib/fps/constants";
import { useEditor } from "@/hooks/use-editor";
@ -30,17 +32,18 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
const duration = useEditor((e) => e.timeline.getTotalDuration());
const durationTicks = useEditor((e) => e.timeline.getTotalDuration());
const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
const fps =
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
});
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
const { scrollLeft, viewportWidth } = useScrollPosition({
scrollRef: tracksScrollRef,
@ -61,20 +64,20 @@ export function TimelineRuler({
prevZoomRef.current = zoomLevel;
}, [zoomLevel]);
const visibleStartTime = Math.max(
const visibleStartTimeSeconds = Math.max(
0,
(scrollLeft - bufferPx) / pixelsPerSecond,
);
const visibleEndTime =
const visibleEndTimeSeconds =
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
const startTickIndex = Math.max(
0,
Math.floor(visibleStartTime / tickIntervalSeconds),
Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
);
const endTickIndex = Math.min(
tickCount - 1,
Math.ceil(visibleEndTime / tickIntervalSeconds),
Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
);
const timelineTicks: Array<JSX.Element> = [];
@ -83,14 +86,16 @@ export function TimelineRuler({
tickIndex <= endTickIndex;
tickIndex += 1
) {
const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break;
const timeSeconds = tickIndex * tickIntervalSeconds;
if (timeSeconds > effectiveDurationSeconds) break;
const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds });
timelineTicks.push(
<TimelineTick
key={tickIndex}
time={time}
time={timeTicks}
timeInSeconds={timeSeconds}
zoomLevel={zoomLevel}
fps={fps}
showLabel={showLabel}
@ -104,7 +109,7 @@ export function TimelineRuler({
tabIndex={0}
aria-label="Timeline ruler"
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuemax={effectiveDurationSeconds}
aria-valuenow={0}
className="relative flex-1 overflow-x-visible"
style={{ height: TIMELINE_RULER_HEIGHT_PX }}

View File

@ -1,39 +1,42 @@
"use client";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
time: number;
zoomLevel: number;
fps: number;
showLabel: boolean;
}
export function TimelineTick({
time,
zoomLevel,
fps,
showLabel,
}: TimelineTickProps) {
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds: time, fps });
return (
<span
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
</span>
);
}
return (
<div
className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
}
"use client";
import type { FrameRate } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
time: number;
timeInSeconds: number;
zoomLevel: number;
fps: FrameRate;
showLabel: boolean;
}
export function TimelineTick({
time,
timeInSeconds,
zoomLevel,
fps,
showLabel,
}: TimelineTickProps) {
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds, fps });
return (
<span
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
</span>
);
}
return (
<div
className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
}

View File

@ -1,2 +1 @@
export const TIME_EPSILON_SECONDS = 1 / 1000;
export const MIN_TRANSFORM_SCALE = 0.01;
export const MIN_TRANSFORM_SCALE = 0.01;

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
import type { EditorCore } from "@/core";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
export class PlaybackManager {
private isPlaying = false;
@ -9,11 +11,12 @@ export class PlaybackManager {
private isScrubbing = false;
private listeners = new Set<() => void>();
private playbackTimer: number | null = null;
private lastUpdate = 0;
private playbackStartWallTime = 0;
private playbackStartTime = 0;
constructor(private editor: EditorCore) {
this.editor.timeline.subscribe(() => {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
if (this.currentTime > maxTime && maxTime > 0) {
this.currentTime = maxTime;
this.notify();
@ -22,7 +25,7 @@ export class PlaybackManager {
}
play(): void {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime > 0) {
if (this.currentTime >= maxTime) {
@ -50,8 +53,12 @@ export class PlaybackManager {
}
seek({ time }: { time: number }): void {
const maxTime = this.editor.timeline.getLastSeekableTime();
const maxTime = this.editor.timeline.getTotalDuration();
this.currentTime = Math.max(0, Math.min(maxTime, time));
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime;
}
this.notify();
window.dispatchEvent(
@ -135,7 +142,8 @@ export class PlaybackManager {
cancelAnimationFrame(this.playbackTimer);
}
this.lastUpdate = performance.now();
this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime;
this.updateTime();
}
@ -149,12 +157,11 @@ export class PlaybackManager {
private updateTime = (): void => {
if (!this.isPlaying) return;
const now = performance.now();
const delta = (now - this.lastUpdate) / 1000;
this.lastUpdate = now;
const newTime = this.currentTime + delta;
const maxTime = this.editor.timeline.getLastSeekableTime();
const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000;
const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime > 0 && newTime >= maxTime) {
this.pause();

View File

@ -496,7 +496,7 @@ export class ProjectManager {
importedAssets,
}: {
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
}): number | null {
}): import("opencut-wasm").FrameRate | null {
if (!this.active) return null;
const nextFps = getRaisedProjectFpsForImportedMedia({

View File

@ -5,7 +5,8 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode } from "opencut-wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@ -81,7 +82,10 @@ export class RendererManager {
}
const { canvasSize, fps } = activeProject.settings;
const renderTime = this.editor.playback.getCurrentTime();
const renderTime = Math.min(
this.editor.playback.getCurrentTime(),
this.editor.timeline.getLastFrameTime(),
);
const renderer = new CanvasRenderer({
width: canvasSize.width,
@ -107,7 +111,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
@ -148,7 +152,7 @@ export class RendererManager {
return { success: false, error: "Project is empty" };
}
const exportFps = fps || activeProject.settings.fps;
const exportFps = fps ?? activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
let audioBuffer: AudioBuffer | null = null;

View File

@ -18,7 +18,7 @@ import type {
AnimationValue,
ScalarCurveKeyframePatch,
} from "@/lib/animation/types";
import { getLastFrameTime } from "opencut-wasm";
import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/lib/commands";
import {
AddTrackCommand,
@ -196,11 +196,11 @@ export class TimelineManager {
return calculateTotalDuration({ tracks: this.getTracks() });
}
getLastSeekableTime(): number {
getLastFrameTime(): number {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return getLastFrameTime({ duration, fps });
return lastFrameTime({ duration, rate: fps }) ?? duration;
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {

View File

@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import {
getElementsAtTime,
@ -110,10 +111,11 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + 1 / fps,
editor.playback.getCurrentTime() + ticksPerFrame,
),
});
},
@ -124,8 +126,9 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
});
},
undefined,

View File

@ -10,8 +10,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@ -67,7 +68,8 @@ function getClickOffsetTime({
zoomLevel: number;
}): number {
const clickOffsetX = clientX - elementRect.left;
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
return Math.round(seconds * TICKS_PER_SECOND);
}
function getVerticalDragDirection({
@ -297,15 +299,17 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime =
mouseTime - pendingDragRef.current.clickOffsetTime;
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
const adjustedTime = Math.max(
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = roundToFrame({
time: adjustedTime,
rate: activeProject.settings.fps,
}) ?? adjustedTime;
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
initialCurrentMouseY: clientY,
});
startedDragThisEvent = true;
@ -343,9 +347,9 @@ export function useElementInteraction({
zoomLevel,
scrollLeft,
});
const adjustedTime = mouseTime - dragState.clickOffsetTime;
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const fps = activeProject.settings.fps;
const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps });
const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
const sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { roundToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@ -185,13 +186,14 @@ export function useTimelineElementResize({
({ clientX }: { clientX: number }) => {
if (!resizing) return;
const deltaX = clientX - resizing.startX;
let deltaTime =
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
const deltaX = clientX - resizing.startX;
let deltaTime = Math.round(
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
);
let resizeSnapPoint: SnapPoint | null = null;
const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const projectFps = editor.project.getActive().settings.fps;
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
const tracks = editor.timeline.getTracks();
@ -277,19 +279,19 @@ export function useTimelineElementResize({
const maxAllowed =
sourceDuration -
resizing.initialTrimEnd -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const calculated =
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated));
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta;
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@ -311,8 +313,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@ -338,8 +340,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@ -366,7 +368,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@ -376,7 +378,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration);
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@ -395,17 +397,17 @@ export function useTimelineElementResize({
const maxTrimEnd =
sourceDuration -
resizing.initialTrimStart -
getVisibleSourceSpanForDuration(minDurationSeconds);
getVisibleSourceSpanForDuration(minDuration);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);

View File

@ -8,9 +8,10 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { getKeyframeById } from "@/lib/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
@ -144,9 +145,9 @@ export function useKeyframeDrag({
if (!dragState.isDragging) return;
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = (clientX - startX) / pixelsPerSecond;
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
const startX = mouseDownXRef.current ?? clientX;
const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
};
@ -255,7 +256,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime;
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {

View File

@ -8,7 +8,7 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@ -148,7 +148,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration));
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@ -176,9 +176,9 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});

View File

@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { snapTimeToFrame } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@ -47,7 +47,7 @@ export function useTimelineDragDrop({
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
return roundToFrame({ time, rate: projectFps }) ?? time;
},
[editor],
);
@ -83,14 +83,14 @@ export function useTimelineDragDrop({
elementType === "sticker" ||
elementType === "effect"
) {
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
}
if (mediaId) {
const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
}
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
return DEFAULT_NEW_ELEMENT_DURATION;
},
[editor],
);
@ -331,7 +331,7 @@ export function useTimelineDragDrop({
dragData.mediaType === "audio" ? "audio" : "video";
const duration =
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: mediaAsset.id,
mediaType: mediaAsset.type,
@ -446,7 +446,7 @@ export function useTimelineDragDrop({
const duration =
createdAsset.duration ??
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
DEFAULT_NEW_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];

View File

@ -1,4 +1,5 @@
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@ -6,6 +7,7 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
getCenteredLineLeft,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
@ -66,24 +68,24 @@ export function useTimelinePlayhead({
const rulerRect = ruler.getBoundingClientRect();
const relativeMouseX = event.clientX - rulerRect.left;
const timelineContentWidth =
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const clampedMouseX = Math.max(
0,
Math.min(timelineContentWidth, relativeMouseX),
);
const rawTime = Math.max(
0,
Math.min(
duration,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const rate = activeProject.settings.fps;
const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
@ -161,7 +163,7 @@ export function useTimelinePlayhead({
getMouseClientX: () => lastMouseXRef.current,
rulerScrollRef,
tracksScrollRef,
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
});
useEffect(() => {
@ -247,8 +249,10 @@ export function useTimelinePlayhead({
const tracksViewport = tracksScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
const playheadPixels =
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
const playheadPixels = timelineTimeToPixels({
time,
zoomLevel: zoomLevelRef.current,
});
const viewportWidth = rulerViewport.clientWidth;
const scrollMinimum = 0;
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;

View File

@ -1,7 +1,8 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getSnappedSeekTime } from "opencut-wasm";
import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@ -127,17 +128,18 @@ export function useTimelineSeek({
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTimeSeconds = Math.max(
0,
Math.min(
duration / TICKS_PER_SECOND,
(mouseX + scrollLeft) /
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
const rate = activeProject?.settings.fps;
const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
seek(time);
editor.project.setTimelineViewState({
viewState: {
@ -154,7 +156,8 @@ export function useTimelineSeek({
tracksScrollRef,
seek,
editor,
activeProject?.settings.fps,
activeProject?.settings.fps.numerator,
activeProject?.settings.fps.denominator,
],
);

View File

@ -13,8 +13,8 @@ import {
import {
TIMELINE_ZOOM_MAX,
TIMELINE_ZOOM_MIN,
BASE_TIMELINE_PIXELS_PER_SECOND,
} from "@/lib/timeline/scale";
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
import { useEditor } from "@/hooks/use-editor";
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
@ -186,10 +186,8 @@ export function useTimelineZoom({
}
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
const playheadPixelsAfter =
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;

View File

@ -1,114 +1,114 @@
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}
import { useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
function isMediaMimeType({ type }: { type: string }): boolean {
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
}
function extractMediaFilesFromClipboard({
clipboardData,
}: {
clipboardData: DataTransfer | null;
}): File[] {
if (!clipboardData?.items) return [];
const files: File[] = [];
for (const item of clipboardData.items) {
if (item.kind !== "file") continue;
if (!isMediaMimeType({ type: item.type })) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
export function usePasteMedia() {
const editor = useEditor();
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
if (files.length === 0) {
event.preventDefault();
invokeAction("paste-copied");
return;
}
event.preventDefault();
const activeProject = editor.project.getActive();
if (!activeProject) return;
try {
await showMediaUploadToast({
filesCount: files.length,
promise: async () => {
const processedAssets = await processMediaAssets({ files });
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand(
activeProject.metadata.id,
asset,
);
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
const element = buildElementFromMedia({
mediaId: assetId,
mediaType: asset.type,
name: asset.name,
duration,
startTime,
buffer:
asset.type === "audio"
? new AudioBuffer({ length: 1, sampleRate: 44100 })
: undefined,
});
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "auto", trackType },
});
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
editor.command.execute({ command: batchCmd });
}
return {
uploadedCount: processedAssets.length,
assetNames: processedAssets.map((asset) => asset.name),
};
},
});
} catch (error) {
console.error("Failed to paste media:", error);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor]);
}

View File

@ -1,4 +1,3 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import {
getDefaultLeftHandle,
getDefaultRightHandle,
@ -25,7 +24,7 @@ export function getNormalizedCubicBezierForScalarSegment({
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;
@ -59,7 +58,7 @@ export function getCurveHandlesForNormalizedCubicBezier({
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;

View File

@ -8,7 +8,6 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/lib/animation/types";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { clamp } from "@/utils/math";
import {
getBezierPoint,
@ -36,10 +35,7 @@ function isWithinTimePair({
leftTime: number;
rightTime: number;
}): boolean {
return (
time >= leftTime - TIME_EPSILON_SECONDS &&
time <= rightTime + TIME_EPSILON_SECONDS
);
return time >= leftTime && time <= rightTime;
}
function lerpNumber({
@ -67,7 +63,7 @@ function normalizeRightHandle({
return undefined;
}
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.min(span, Math.max(0, handle.dt)),
dv: handle.dv,
@ -87,7 +83,7 @@ function normalizeLeftHandle({
return undefined;
}
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.max(-span, Math.min(0, handle.dt)),
dv: handle.dv,
@ -187,7 +183,7 @@ function extrapolateScalarEdge({
}
const span = neighborKey.time - edgeKey.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
if (span === 0) {
return edgeKey.value;
}
@ -228,8 +224,8 @@ export function getScalarChannelValueAtTime({
return fallbackValue;
}
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
if (time <= firstKey.time) {
if (time < firstKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.before ?? "hold",
edgeKey: firstKey,
@ -241,8 +237,8 @@ export function getScalarChannelValueAtTime({
return firstKey.value;
}
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
if (time >= lastKey.time) {
if (time > lastKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.after ?? "hold",
edgeKey: lastKey,
@ -261,7 +257,7 @@ export function getScalarChannelValueAtTime({
) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
if (time === rightKey.time) {
return rightKey.value;
}
@ -280,7 +276,7 @@ export function getScalarChannelValueAtTime({
}
const span = rightKey.time - leftKey.time;
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
if (span === 0) {
return rightKey.value;
}
@ -336,7 +332,7 @@ export function getDiscreteChannelValueAtTime({
});
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time + TIME_EPSILON_SECONDS < key.time) {
if (time < key.time) {
break;
}
currentValue = key.value;

View File

@ -1,4 +1,3 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationBindingInstance,
AnimationChannel,
@ -76,8 +75,7 @@ function getUniqueBindingKeyframeMatches({
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
if (
!previousMatch ||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
TIME_EPSILON_SECONDS
previousMatch.keyframe.time !== match.keyframe.time
) {
uniqueMatches.push(match);
continue;
@ -249,9 +247,7 @@ export function getKeyframeAtTime({
matches: getBindingKeyframeMatches({
animations,
binding,
}).filter(({ keyframe }) =>
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
),
}).filter(({ keyframe }) => keyframe.time === time),
});
if (!keyframeMatch) {
return null;

View File

@ -14,7 +14,6 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/lib/animation/types";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
@ -44,7 +43,7 @@ function isNearlySameTime({
leftTime: number;
rightTime: number;
}): boolean {
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
return leftTime === rightTime;
}
function hasChannelKeys({
@ -1032,8 +1031,8 @@ function splitScalarChannelAtTime({
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (
!(
splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
splitTime < rightKey.time - TIME_EPSILON_SECONDS
splitTime > leftKey.time &&
splitTime < rightKey.time
)
) {
continue;

View File

@ -4,16 +4,17 @@ import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
import type { FrameRate } from "opencut-wasm";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
private previousProjectFps: number | null = null;
private appliedProjectFps: number | null = null;
private previousProjectFps: FrameRate | null = null;
private appliedProjectFps: FrameRate | null = null;
constructor(
private projectId: string,
@ -112,14 +113,15 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
if (activeProject.settings.fps !== this.appliedProjectFps) return;
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
});
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
if (
highestRemainingVideoFps !== null &&
highestRemainingVideoFps >= this.appliedProjectFps
highestRemainingVideoFps >= appliedFpsFloat
) {
return;
}

View File

@ -1,264 +1,265 @@
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
TrackType,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/lib/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
export interface InsertElementParams {
element: CreateTimelineElement;
placement: InsertElementPlacement;
}
export class InsertElementCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = generateUUID();
this.element = element;
this.placement = placement;
}
private element: CreateTimelineElement;
private placement: InsertElementPlacement;
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
if (!this.savedState) {
console.error("Tracks not available");
return;
}
if (!this.validateElementBasics({ element: this.element })) {
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
if (!updateResult) {
return;
}
const { updatedTracks, targetTrackId } = updateResult;
this.targetTrackId = targetTrackId;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaAssets = editor.media.getAssets();
const activeProject = editor.project.getActive();
const asset = mediaAssets.find(
(item: MediaAsset) => item.id === newElement.mediaId,
);
if (asset?.width && asset?.height) {
const nextCanvasSize = { width: asset.width, height: asset.height };
const shouldSetOriginalCanvasSize =
!activeProject?.settings.originalCanvasSize;
editor.project.updateSettings({
settings: {
canvasSize: nextCanvasSize,
...(shouldSetOriginalCanvasSize
? { originalCanvasSize: nextCanvasSize }
: {}),
},
pushHistory: false,
});
}
if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({
settings: { fps: asset.fps },
pushHistory: false,
});
}
}
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
getTrackId(): string | null {
return this.targetTrackId;
}
private buildElement({
element,
}: {
element: CreateTimelineElement;
}): TimelineElement {
return {
...element,
id: this.elementId,
startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
} as TimelineElement;
}
private validateElementBasics({
element,
}: {
element: CreateTimelineElement;
}): boolean {
if (requiresMediaId({ element }) && !("mediaId" in element)) {
console.error("Element requires mediaId");
return false;
}
if (
element.type === "audio" &&
element.sourceType === "library" &&
!element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return false;
}
if (element.type === "sticker" && !element.stickerId) {
console.error("Sticker element must have stickerId");
return false;
}
if (element.type === "graphic") {
registerDefaultGraphics();
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
console.error("Graphic element must have a valid definitionId");
return false;
}
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
}
if (element.type === "effect" && !element.effectType) {
console.error("Effect element must have effectType");
return false;
}
return true;
}
private applyPlacementResult({
tracks,
element,
}: {
tracks: TimelineTrack[];
element: TimelineElement;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const placementResult = resolveTrackPlacement({
tracks,
...(placement.mode === "auto" && placement.trackType
? { trackType: placement.trackType }
: { elementType: element.type }),
timeSpans: [
{
startTime: element.startTime,
duration: element.duration,
},
],
strategy:
placement.mode === "explicit"
? { type: "explicit", trackId: placement.trackId }
: { type: "firstAvailable" },
});
if (!placementResult) {
if (placement.mode === "explicit") {
const targetTrack = tracks.find((track) => track.id === placement.trackId);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
console.error(validation.errorMessage);
}
return null;
}
const elementToPlace =
placementResult.kind === "existingTrack"
? {
...element,
startTime:
placementResult.adjustedStartTime ?? element.startTime,
}
: element;
return applyPlacement({
tracks,
placementResult,
elements: [elementToPlace],
newTrackInsertIndexOverride:
placement.mode === "auto" && typeof placement.insertIndex === "number"
? placement.insertIndex
: undefined,
});
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
TimelineTrack,
TimelineElement,
TrackType,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import { floatToFrameRate } from "@/lib/fps/utils";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/lib/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
export interface InsertElementParams {
element: CreateTimelineElement;
placement: InsertElementPlacement;
}
export class InsertElementCommand extends Command {
private elementId: string;
private savedState: TimelineTrack[] | null = null;
private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) {
super();
this.elementId = generateUUID();
this.element = element;
this.placement = placement;
}
private element: CreateTimelineElement;
private placement: InsertElementPlacement;
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
if (!this.savedState) {
console.error("Tracks not available");
return;
}
if (!this.validateElementBasics({ element: this.element })) {
return;
}
const totalElementsInTimeline = this.savedState.reduce(
(total, t) => total + t.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
if (!updateResult) {
return;
}
const { updatedTracks, targetTrackId } = updateResult;
this.targetTrackId = targetTrackId;
const isVisualMedia =
newElement.type === "video" || newElement.type === "image";
if (isFirstElement && isVisualMedia) {
const mediaAssets = editor.media.getAssets();
const activeProject = editor.project.getActive();
const asset = mediaAssets.find(
(item: MediaAsset) => item.id === newElement.mediaId,
);
if (asset?.width && asset?.height) {
const nextCanvasSize = { width: asset.width, height: asset.height };
const shouldSetOriginalCanvasSize =
!activeProject?.settings.originalCanvasSize;
editor.project.updateSettings({
settings: {
canvasSize: nextCanvasSize,
...(shouldSetOriginalCanvasSize
? { originalCanvasSize: nextCanvasSize }
: {}),
},
pushHistory: false,
});
}
if (asset?.type === "video" && asset?.fps) {
editor.project.updateSettings({
settings: { fps: floatToFrameRate(asset.fps) },
pushHistory: false,
});
}
}
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getElementId(): string {
return this.elementId;
}
getTrackId(): string | null {
return this.targetTrackId;
}
private buildElement({
element,
}: {
element: CreateTimelineElement;
}): TimelineElement {
return {
...element,
id: this.elementId,
startTime: element.startTime,
trimStart: element.trimStart ?? 0,
trimEnd: element.trimEnd ?? 0,
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
} as TimelineElement;
}
private validateElementBasics({
element,
}: {
element: CreateTimelineElement;
}): boolean {
if (requiresMediaId({ element }) && !("mediaId" in element)) {
console.error("Element requires mediaId");
return false;
}
if (
element.type === "audio" &&
element.sourceType === "library" &&
!element.sourceUrl
) {
console.error("Library audio element must have sourceUrl");
return false;
}
if (element.type === "sticker" && !element.stickerId) {
console.error("Sticker element must have stickerId");
return false;
}
if (element.type === "graphic") {
registerDefaultGraphics();
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
console.error("Graphic element must have a valid definitionId");
return false;
}
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
}
if (element.type === "effect" && !element.effectType) {
console.error("Effect element must have effectType");
return false;
}
return true;
}
private applyPlacementResult({
tracks,
element,
}: {
tracks: TimelineTrack[];
element: TimelineElement;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const placementResult = resolveTrackPlacement({
tracks,
...(placement.mode === "auto" && placement.trackType
? { trackType: placement.trackType }
: { elementType: element.type }),
timeSpans: [
{
startTime: element.startTime,
duration: element.duration,
},
],
strategy:
placement.mode === "explicit"
? { type: "explicit", trackId: placement.trackId }
: { type: "firstAvailable" },
});
if (!placementResult) {
if (placement.mode === "explicit") {
const targetTrack = tracks.find((track) => track.id === placement.trackId);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
console.error(validation.errorMessage);
}
return null;
}
const elementToPlace =
placementResult.kind === "existingTrack"
? {
...element,
startTime:
placementResult.adjustedStartTime ?? element.startTime,
}
: element;
return applyPlacement({
tracks,
placementResult,
elements: [elementToPlace],
newTrackInsertIndexOverride:
placement.mode === "auto" && typeof placement.insertIndex === "number"
? placement.insertIndex
: undefined,
});
}
}

View File

@ -1,3 +1,4 @@
import type { FrameRate } from "opencut-wasm";
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
export const EXPORT_QUALITY_VALUES = [
@ -15,7 +16,7 @@ export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
export interface ExportOptions {
format: ExportFormat;
quality: ExportQuality;
fps?: number;
fps?: FrameRate;
includeAudio?: boolean;
}

View File

@ -36,16 +36,16 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
currentFps: { numerator: 30, denominator: 1 },
importedAssets: [{ type: "video", fps: 60 }],
}),
).toBe(60);
).toEqual({ numerator: 60, denominator: 1 });
});
test("does not lower the project fps for lower-fps imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 60,
currentFps: { numerator: 60, denominator: 1 },
importedAssets: [{ type: "video", fps: 10 }],
}),
).toBeNull();
@ -54,7 +54,7 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
test("ignores non-video imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
currentFps: 30,
currentFps: { numerator: 30, denominator: 1 },
importedAssets: [
{ type: "image", fps: 60 },
{ type: "audio", fps: 120 },

View File

@ -1,3 +1,5 @@
import type { FrameRate } from "opencut-wasm";
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
@ -6,4 +8,4 @@ export const FPS_PRESETS = [
{ value: "120", label: "120 fps" },
] as const;
export const DEFAULT_FPS = 30;
export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 };

View File

@ -1,7 +1,61 @@
import type { FrameRate } from "opencut-wasm";
import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
const STANDARD_FRAME_RATES: Array<{ value: number; rate: FrameRate }> = [
{ value: 24_000 / 1_001, rate: { numerator: 24_000, denominator: 1_001 } },
{ value: 24, rate: { numerator: 24, denominator: 1 } },
{ value: 25, rate: { numerator: 25, denominator: 1 } },
{ value: 30_000 / 1_001, rate: { numerator: 30_000, denominator: 1_001 } },
{ value: 30, rate: { numerator: 30, denominator: 1 } },
{ value: 48, rate: { numerator: 48, denominator: 1 } },
{ value: 50, rate: { numerator: 50, denominator: 1 } },
{ value: 60_000 / 1_001, rate: { numerator: 60_000, denominator: 1_001 } },
{ value: 60, rate: { numerator: 60, denominator: 1 } },
{ value: 120, rate: { numerator: 120, denominator: 1 } },
];
const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
export function frameRateToFloat(rate: FrameRate): number {
return rate.numerator / rate.denominator;
}
export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean {
return a.numerator === b.numerator && a.denominator === b.denominator;
}
export function floatToFrameRate(fps: number): FrameRate {
const standard = STANDARD_FRAME_RATES.find(
(candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
);
if (standard) return standard.rate;
if (Number.isInteger(fps)) {
return { numerator: fps, denominator: 1 };
}
const ARBITRARY_DENOMINATOR = 1_000_000;
const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR);
return {
numerator: scaledNumerator / divisor,
denominator: ARBITRARY_DENOMINATOR / divisor,
};
}
function gcd(left: number, right: number): number {
let a = Math.abs(left);
let b = Math.abs(right);
while (b !== 0) {
const remainder = a % b;
a = b;
b = remainder;
}
return a || 1;
}
export function getHighestImportedVideoFps({
mediaAssets,
}: {
@ -24,16 +78,18 @@ export function getRaisedProjectFpsForImportedMedia({
currentFps,
importedAssets,
}: {
currentFps: number;
currentFps: FrameRate;
importedAssets: MediaAssetFpsInput[];
}): number | null {
}): FrameRate | null {
const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets,
});
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
const currentFpsFloat = frameRateToFloat(currentFps);
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) {
return null;
}
return highestImportedVideoFps;
return floatToFrameRate(highestImportedVideoFps);
}

View File

@ -21,6 +21,8 @@ import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
import { TICKS_PER_SECOND } from "@/lib/wasm";
const MAX_AUDIO_CHANNELS = 2;
const EXPORT_SAMPLE_RATE = 44100;
const COARSE_SAMPLE_COUNT = 2048;
@ -122,10 +124,10 @@ export async function collectAudioElements({
return {
timelineElement: element,
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
startTime: element.startTime / TICKS_PER_SECOND,
duration: element.duration / TICKS_PER_SECOND,
trimStart: element.trimStart / TICKS_PER_SECOND,
trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume: resolveEffectiveAudioGain({
element,
trackMuted: isTrackMuted,
@ -152,10 +154,10 @@ export async function collectAudioElements({
return {
timelineElement: element,
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
startTime: element.startTime / TICKS_PER_SECOND,
duration: element.duration / TICKS_PER_SECOND,
trimStart: element.trimStart / TICKS_PER_SECOND,
trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume: resolveEffectiveAudioGain({
element,
trackMuted: isTrackMuted,
@ -337,16 +339,16 @@ async function fetchLibraryAudioSource({
type: "audio/mpeg",
});
return {
timelineElement: element,
file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
volume,
retime: element.retime,
};
return {
timelineElement: element,
file,
startTime: element.startTime / TICKS_PER_SECOND,
duration: element.duration / TICKS_PER_SECOND,
trimStart: element.trimStart / TICKS_PER_SECOND,
trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume,
retime: element.retime,
};
} catch (error) {
console.warn("Failed to fetch library audio:", error);
return null;
@ -404,10 +406,10 @@ function collectMediaAudioSource({
return {
timelineElement: element,
file: mediaAsset.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
startTime: element.startTime / TICKS_PER_SECOND,
duration: element.duration / TICKS_PER_SECOND,
trimStart: element.trimStart / TICKS_PER_SECOND,
trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume,
retime: element.retime,
};
@ -429,10 +431,10 @@ function collectMediaAudioClip({
id: element.id,
sourceKey: mediaAsset.id,
file: mediaAsset.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
startTime: element.startTime / TICKS_PER_SECOND,
duration: element.duration / TICKS_PER_SECOND,
trimStart: element.trimStart / TICKS_PER_SECOND,
trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume,
muted,
retime: element.retime,
@ -602,7 +604,8 @@ export async function createTimelineAudioBuffer({
if (audioElements.length === 0) return null;
const outputChannels = 2;
const outputLength = Math.ceil(duration * sampleRate);
const durationSeconds = duration / TICKS_PER_SECOND;
const outputLength = Math.ceil(durationSeconds * sampleRate);
const outputBuffer = context.createBuffer(
outputChannels,
outputLength,

View File

@ -1,53 +1,54 @@
import type { TScene } from "@/lib/timeline/types";
export type TBackground =
| {
type: "color";
color: string;
}
| {
type: "blur";
blurIntensity: number;
};
export interface TCanvasSize {
width: number;
height: number;
}
export interface TProjectMetadata {
id: string;
name: string;
thumbnail?: string;
duration: number;
createdAt: Date;
updatedAt: Date;
}
export interface TProjectSettings {
fps: number;
canvasSize: TCanvasSize;
canvasSizeMode?: "preset" | "custom";
lastCustomCanvasSize?: TCanvasSize | null;
originalCanvasSize?: TCanvasSize | null;
background: TBackground;
}
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
playheadTime: number;
}
export interface TProject {
metadata: TProjectMetadata;
scenes: TScene[];
currentSceneId: string;
settings: TProjectSettings;
version: number;
timelineViewState?: TTimelineViewState;
}
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
export type TSortOrder = "asc" | "desc";
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
import type { FrameRate } from "opencut-wasm";
import type { TScene } from "@/lib/timeline/types";
export type TBackground =
| {
type: "color";
color: string;
}
| {
type: "blur";
blurIntensity: number;
};
export interface TCanvasSize {
width: number;
height: number;
}
export interface TProjectMetadata {
id: string;
name: string;
thumbnail?: string;
duration: number;
createdAt: Date;
updatedAt: Date;
}
export interface TProjectSettings {
fps: FrameRate;
canvasSize: TCanvasSize;
canvasSizeMode?: "preset" | "custom";
lastCustomCanvasSize?: TCanvasSize | null;
originalCanvasSize?: TCanvasSize | null;
background: TBackground;
}
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
playheadTime: number;
}
export interface TProject {
metadata: TProjectMetadata;
scenes: TScene[];
currentSceneId: string;
settings: TProjectSettings;
version: number;
timelineViewState?: TTimelineViewState;
}
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
export type TSortOrder = "asc" | "desc";
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;

View File

@ -1,4 +1,4 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
import type { RippleAdjustment } from "./apply";
@ -105,7 +105,7 @@ function collectTrackIntervals({
continue;
}
if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) {
if (beforeElement.endTime > afterElement.endTime) {
pushInterval({
intervals: vacatedIntervals,
startTime: afterElement.endTime,
@ -141,7 +141,7 @@ function buildAdjustments({
}): RippleAdjustment[] {
return intervals.flatMap((interval): RippleAdjustment[] => {
const shiftAmount = interval.endTime - interval.startTime;
if (shiftAmount <= TIME_EPSILON_SECONDS) {
if (shiftAmount <= 0) {
return [];
}
@ -203,7 +203,7 @@ function normalizeIntervals({
const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
for (const interval of sortedIntervals.slice(1)) {
const previousInterval = mergedIntervals[mergedIntervals.length - 1];
if (interval.startTime <= previousInterval.endTime + TIME_EPSILON_SECONDS) {
if (interval.startTime <= previousInterval.endTime) {
previousInterval.endTime = Math.max(
previousInterval.endTime,
interval.endTime,
@ -228,12 +228,10 @@ function subtractSingleInterval({
for (const overlappingInterval of overlappingIntervals) {
remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
if (
overlappingInterval.endTime <=
remainingInterval.startTime + TIME_EPSILON_SECONDS ||
overlappingInterval.startTime >=
remainingInterval.endTime - TIME_EPSILON_SECONDS
) {
if (
overlappingInterval.endTime <= remainingInterval.startTime ||
overlappingInterval.startTime >= remainingInterval.endTime
) {
return [remainingInterval];
}
@ -264,7 +262,7 @@ function pushInterval({
startTime,
endTime,
}: { intervals: Interval[]; startTime: number; endTime: number }): void {
if (endTime - startTime <= TIME_EPSILON_SECONDS) {
if (endTime <= startTime) {
return;
}

View File

@ -1,100 +1,102 @@
import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
import { resolveNumberAtTime } from "@/lib/animation/resolve";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
import type { TimelineElement } from "./types";
const DEFAULT_STEP_SECONDS = 1 / 60;
export type AudioCapableElement = Extract<
TimelineElement,
{ type: "audio" | "video" }
>;
export function clampDb(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
}
export function dBToLinear(db: number): number {
return 10 ** (clampDb(db) / 20);
}
export function hasAnimatedVolume({
element,
}: {
element: AudioCapableElement;
}): boolean {
return hasKeyframesForPath({
animations: element.animations,
propertyPath: "volume",
});
}
export function resolveEffectiveAudioGain({
element,
trackMuted = false,
localTime,
}: {
element: AudioCapableElement;
trackMuted?: boolean;
localTime: number;
}): number {
if (trackMuted || element.muted === true) {
return 0;
}
const resolvedDb = resolveNumberAtTime({
baseValue: element.volume ?? 0,
animations: element.animations,
propertyPath: "volume",
localTime,
});
return dBToLinear(resolvedDb);
}
export function buildAudioGainAutomation({
element,
trackMuted = false,
fromLocalTime,
toLocalTime,
stepSeconds = DEFAULT_STEP_SECONDS,
}: {
element: AudioCapableElement;
trackMuted?: boolean;
fromLocalTime: number;
toLocalTime: number;
stepSeconds?: number;
}): Array<{ localTime: number; gain: number }> {
const startTime = Math.max(0, fromLocalTime);
const endTime = Math.max(startTime, toLocalTime);
const safeStep =
Number.isFinite(stepSeconds) && stepSeconds > 0
? stepSeconds
: DEFAULT_STEP_SECONDS;
const points: Array<{ localTime: number; gain: number }> = [];
for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
points.push({
localTime,
gain: resolveEffectiveAudioGain({
element,
trackMuted,
localTime,
}),
});
}
points.push({
localTime: endTime,
gain: resolveEffectiveAudioGain({
element,
trackMuted,
localTime: endTime,
}),
});
return points;
}
import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
import { resolveNumberAtTime } from "@/lib/animation/resolve";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
import type { TimelineElement } from "./types";
const DEFAULT_STEP_SECONDS = 1 / 60;
export type AudioCapableElement = Extract<
TimelineElement,
{ type: "audio" | "video" }
>;
export function clampDb(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
}
export function dBToLinear(db: number): number {
return 10 ** (clampDb(db) / 20);
}
export function hasAnimatedVolume({
element,
}: {
element: AudioCapableElement;
}): boolean {
return hasKeyframesForPath({
animations: element.animations,
propertyPath: "volume",
});
}
import { TICKS_PER_SECOND } from "@/lib/wasm";
export function resolveEffectiveAudioGain({
element,
trackMuted = false,
localTime,
}: {
element: AudioCapableElement;
trackMuted?: boolean;
localTime: number;
}): number {
if (trackMuted || element.muted === true) {
return 0;
}
const resolvedDb = resolveNumberAtTime({
baseValue: element.volume ?? 0,
animations: element.animations,
propertyPath: "volume",
localTime: Math.round(localTime * TICKS_PER_SECOND),
});
return dBToLinear(resolvedDb);
}
export function buildAudioGainAutomation({
element,
trackMuted = false,
fromLocalTime,
toLocalTime,
stepSeconds = DEFAULT_STEP_SECONDS,
}: {
element: AudioCapableElement;
trackMuted?: boolean;
fromLocalTime: number;
toLocalTime: number;
stepSeconds?: number;
}): Array<{ localTime: number; gain: number }> {
const startTime = Math.max(0, fromLocalTime);
const endTime = Math.max(startTime, toLocalTime);
const safeStep =
Number.isFinite(stepSeconds) && stepSeconds > 0
? stepSeconds
: DEFAULT_STEP_SECONDS;
const points: Array<{ localTime: number; gain: number }> = [];
for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
points.push({
localTime,
gain: resolveEffectiveAudioGain({
element,
trackMuted,
localTime,
}),
});
}
points.push({
localTime: endTime,
gain: resolveEffectiveAudioGain({
element,
trackMuted,
localTime: endTime,
}),
});
return points;
}

View File

@ -1,8 +1,7 @@
import type { Bookmark } from "@/lib/timeline";
import type { FrameRate } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
export const BOOKMARK_TIME_EPSILON = 0.001;
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
@ -10,7 +9,7 @@ function bookmarkTimeEqual({
bookmarkTime: number;
frameTime: number;
}): boolean {
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
return bookmarkTime === frameTime;
}
export function findBookmarkIndex({
@ -112,9 +111,9 @@ export function getFrameTime({
fps,
}: {
time: number;
fps: number;
fps: FrameRate;
}): number {
return roundToFrame({ time, fps });
return roundToFrame({ time, rate: fps }) ?? time;
}
export function getBookmarkAtTime({
@ -141,9 +140,6 @@ export function getBookmarksActiveAtTime({
bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration
: start;
return (
time >= start - BOOKMARK_TIME_EPSILON &&
time <= end + BOOKMARK_TIME_EPSILON
);
return time >= start && time <= end;
});
}

View File

@ -1 +1,3 @@
export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
import { TICKS_PER_SECOND } from "@/lib/wasm";
export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;

View File

@ -1,77 +1,77 @@
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import type { TTimelineViewState } from "@/lib/project/types";
import type { BlendMode, Transform } from "@/lib/rendering";
import type { TextElement } from "./types";
const defaultTransform: Transform = {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
const defaultOpacity = 1;
const defaultBlendMode: BlendMode = "normal";
const defaultVolume = 0;
const defaultTextLetterSpacing = 0;
const defaultTextLineHeight = 1.2;
const defaultTextBackground = {
enabled: false,
color: "#000000",
cornerRadius: 0,
paddingX: 30,
paddingY: 42,
offsetX: 0,
offsetY: 0,
};
const defaultTextElement: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default text",
fontSize: 15,
fontFamily: "Arial",
color: "#ffffff",
background: { ...defaultTextBackground },
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
...defaultTransform,
position: { ...defaultTransform.position },
},
opacity: defaultOpacity,
};
const defaultTimelineViewState: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
playheadTime: 0,
};
export const DEFAULTS = {
element: {
transform: defaultTransform,
opacity: defaultOpacity,
blendMode: defaultBlendMode,
volume: defaultVolume,
},
text: {
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
background: defaultTextBackground,
element: defaultTextElement,
},
timeline: {
viewState: defaultTimelineViewState,
},
};
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import type { TTimelineViewState } from "@/lib/project/types";
import type { BlendMode, Transform } from "@/lib/rendering";
import type { TextElement } from "./types";
const defaultTransform: Transform = {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
const defaultOpacity = 1;
const defaultBlendMode: BlendMode = "normal";
const defaultVolume = 0;
const defaultTextLetterSpacing = 0;
const defaultTextLineHeight = 1.2;
const defaultTextBackground = {
enabled: false,
color: "#000000",
cornerRadius: 0,
paddingX: 30,
paddingY: 42,
offsetX: 0,
offsetY: 0,
};
const defaultTextElement: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default text",
fontSize: 15,
fontFamily: "Arial",
color: "#ffffff",
background: { ...defaultTextBackground },
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
...defaultTransform,
position: { ...defaultTransform.position },
},
opacity: defaultOpacity,
};
const defaultTimelineViewState: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
playheadTime: 0,
};
export const DEFAULTS = {
element: {
transform: defaultTransform,
opacity: defaultOpacity,
blendMode: defaultBlendMode,
volume: defaultVolume,
},
text: {
letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight,
background: defaultTextBackground,
element: defaultTextElement,
},
timeline: {
viewState: defaultTimelineViewState,
},
};

View File

@ -1,19 +1,21 @@
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export function getMouseTimeFromClientX({
clientX,
containerRect,
zoomLevel,
scrollLeft,
}: {
clientX: number;
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
}): number {
const mouseX = clientX - containerRect.left + scrollLeft;
return Math.max(
0,
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
}
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
export function getMouseTimeFromClientX({
clientX,
containerRect,
zoomLevel,
scrollLeft,
}: {
clientX: number;
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
}): number {
const mouseX = clientX - containerRect.left + scrollLeft;
const seconds = Math.max(
0,
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
return Math.round(seconds * TICKS_PER_SECOND);
}

View File

@ -1,418 +1,418 @@
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
import {
MASKABLE_ELEMENT_TYPES,
RETIMABLE_ELEMENT_TYPES,
VISUAL_ELEMENT_TYPES,
type CreateEffectElement,
type CreateGraphicElement,
type CreateTimelineElement,
type CreateVideoElement,
type CreateImageElement,
type CreateStickerElement,
type CreateUploadAudioElement,
type CreateLibraryAudioElement,
type TextBackground,
type TextElement,
type TimelineElement,
type TimelineTrack,
type AudioElement,
type VideoElement,
type ImageElement,
type MaskableElement,
type RetimableElement,
type VisualElement,
type UploadAudioElement,
} from "@/lib/timeline";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { MediaType } from "@/lib/media/types";
import { buildDefaultEffectInstance } from "@/lib/effects";
import { buildDefaultGraphicInstance } from "@/lib/graphics";
import type { ParamValues } from "@/lib/params";
import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
): element is AudioElement | VideoElement {
return element.type === "audio" || element.type === "video";
}
export function isVisualElement(
element: TimelineElement,
): element is VisualElement {
return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function isMaskableElement(
element: TimelineElement,
): element is MaskableElement {
return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function isRetimableElement(
element: TimelineElement,
): element is RetimableElement {
return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function canElementBeHidden(
element: TimelineElement,
): element is VisualElement {
return isVisualElement(element);
}
export function hasElementEffects({
element,
}: {
element: TimelineElement;
}): boolean {
return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
}
export function hasMediaId(
element: TimelineElement,
): element is UploadAudioElement | VideoElement | ImageElement {
return "mediaId" in element;
}
export function requiresMediaId({
element,
}: {
element: CreateTimelineElement;
}): boolean {
return (
element.type === "video" ||
element.type === "image" ||
(element.type === "audio" && element.sourceType === "upload")
);
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
const color = raw?.color ?? DEFAULTS.text.element.background.color;
const enabled = raw?.enabled ?? color !== "transparent";
return {
enabled,
color,
cornerRadius: raw?.cornerRadius,
paddingX: raw?.paddingX,
paddingY: raw?.paddingY,
offsetX: raw?.offsetX,
offsetY: raw?.offsetY,
};
}
export function buildTextElement({
raw,
startTime,
}: {
raw: Partial<Omit<TextElement, "type" | "id">>;
startTime: number;
}): CreateTimelineElement {
const t = raw as Partial<TextElement>;
return {
type: "text",
name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
color: t.color ?? DEFAULTS.text.element.color,
background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
transform: t.transform ?? DEFAULTS.text.element.transform,
opacity: t.opacity ?? DEFAULTS.text.element.opacity,
blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
};
}
export function buildEffectElement({
effectType,
startTime,
duration,
}: {
effectType: string;
startTime: number;
duration?: number;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
type: "effect",
name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
};
}
export function buildStickerElement({
stickerId,
name,
startTime,
intrinsicWidth,
intrinsicHeight,
}: {
stickerId: string;
name?: string;
startTime: number;
intrinsicWidth?: number;
intrinsicHeight?: number;
}): CreateStickerElement {
const stickerNameFromId =
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
return {
type: "sticker",
name: name ?? stickerNameFromId,
stickerId,
intrinsicWidth,
intrinsicHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
export function buildGraphicElement({
definitionId,
name,
startTime,
params,
}: {
definitionId: string;
name?: string;
startTime: number;
params?: Partial<ParamValues>;
}): CreateGraphicElement {
const instance = buildDefaultGraphicInstance({ definitionId });
return {
type: "graphic",
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
startTime,
trimStart: 0,
trimEnd: 0,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
function buildVideoElement({
mediaId,
name,
duration,
startTime,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
}): CreateVideoElement {
return {
type: "video",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true,
hidden: false,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
volume: DEFAULTS.element.volume,
};
}
function buildImageElement({
mediaId,
name,
duration,
startTime,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
}): CreateImageElement {
return {
type: "image",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
hidden: false,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
function buildUploadAudioElement({
mediaId,
name,
duration,
startTime,
buffer,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateUploadAudioElement {
const element: CreateUploadAudioElement = {
type: "audio",
sourceType: "upload",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}
export function buildElementFromMedia({
mediaId,
mediaType,
name,
duration,
startTime,
buffer,
}: {
mediaId: string;
mediaType: MediaType;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateTimelineElement {
switch (mediaType) {
case "audio":
return buildUploadAudioElement({
mediaId,
name,
duration,
startTime,
buffer,
});
case "video":
return buildVideoElement({ mediaId, name, duration, startTime });
case "image":
return buildImageElement({ mediaId, name, duration, startTime });
}
}
export function buildLibraryAudioElement({
sourceUrl,
name,
duration,
startTime,
buffer,
}: {
sourceUrl: string;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement {
const element: CreateLibraryAudioElement = {
type: "audio",
sourceType: "library",
sourceUrl,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}
export function getElementsAtTime({
tracks,
time,
}: {
tracks: TimelineTrack[];
time: number;
}): { trackId: string; elementId: string }[] {
const result: { trackId: string; elementId: string }[] = [];
for (const track of tracks) {
for (const element of track.elements) {
const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration;
if (time > elementStart && time < elementEnd) {
result.push({ trackId: track.id, elementId: element.id });
}
}
}
return result;
}
export function getElementFontFamilies({
tracks,
}: {
tracks: TimelineTrack[];
}): string[] {
const families = new Set<string>();
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily);
}
}
}
return [...families];
}
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
import {
MASKABLE_ELEMENT_TYPES,
RETIMABLE_ELEMENT_TYPES,
VISUAL_ELEMENT_TYPES,
type CreateEffectElement,
type CreateGraphicElement,
type CreateTimelineElement,
type CreateVideoElement,
type CreateImageElement,
type CreateStickerElement,
type CreateUploadAudioElement,
type CreateLibraryAudioElement,
type TextBackground,
type TextElement,
type TimelineElement,
type TimelineTrack,
type AudioElement,
type VideoElement,
type ImageElement,
type MaskableElement,
type RetimableElement,
type VisualElement,
type UploadAudioElement,
} from "@/lib/timeline";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { MediaType } from "@/lib/media/types";
import { buildDefaultEffectInstance } from "@/lib/effects";
import { buildDefaultGraphicInstance } from "@/lib/graphics";
import type { ParamValues } from "@/lib/params";
import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
): element is AudioElement | VideoElement {
return element.type === "audio" || element.type === "video";
}
export function isVisualElement(
element: TimelineElement,
): element is VisualElement {
return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function isMaskableElement(
element: TimelineElement,
): element is MaskableElement {
return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function isRetimableElement(
element: TimelineElement,
): element is RetimableElement {
return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function canElementBeHidden(
element: TimelineElement,
): element is VisualElement {
return isVisualElement(element);
}
export function hasElementEffects({
element,
}: {
element: TimelineElement;
}): boolean {
return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
}
export function hasMediaId(
element: TimelineElement,
): element is UploadAudioElement | VideoElement | ImageElement {
return "mediaId" in element;
}
export function requiresMediaId({
element,
}: {
element: CreateTimelineElement;
}): boolean {
return (
element.type === "video" ||
element.type === "image" ||
(element.type === "audio" && element.sourceType === "upload")
);
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
const color = raw?.color ?? DEFAULTS.text.element.background.color;
const enabled = raw?.enabled ?? color !== "transparent";
return {
enabled,
color,
cornerRadius: raw?.cornerRadius,
paddingX: raw?.paddingX,
paddingY: raw?.paddingY,
offsetX: raw?.offsetX,
offsetY: raw?.offsetY,
};
}
export function buildTextElement({
raw,
startTime,
}: {
raw: Partial<Omit<TextElement, "type" | "id">>;
startTime: number;
}): CreateTimelineElement {
const t = raw as Partial<TextElement>;
return {
type: "text",
name: t.name ?? DEFAULTS.text.element.name,
content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
color: t.color ?? DEFAULTS.text.element.color,
background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
transform: t.transform ?? DEFAULTS.text.element.transform,
opacity: t.opacity ?? DEFAULTS.text.element.opacity,
blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
};
}
export function buildEffectElement({
effectType,
startTime,
duration,
}: {
effectType: string;
startTime: number;
duration?: number;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
type: "effect",
name: capitalizeFirstLetter({ string: instance.type }),
effectType,
params: instance.params,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
};
}
export function buildStickerElement({
stickerId,
name,
startTime,
intrinsicWidth,
intrinsicHeight,
}: {
stickerId: string;
name?: string;
startTime: number;
intrinsicWidth?: number;
intrinsicHeight?: number;
}): CreateStickerElement {
const stickerNameFromId =
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
return {
type: "sticker",
name: name ?? stickerNameFromId,
stickerId,
intrinsicWidth,
intrinsicHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
export function buildGraphicElement({
definitionId,
name,
startTime,
params,
}: {
definitionId: string;
name?: string;
startTime: number;
params?: Partial<ParamValues>;
}): CreateGraphicElement {
const instance = buildDefaultGraphicInstance({ definitionId });
return {
type: "graphic",
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
function buildVideoElement({
mediaId,
name,
duration,
startTime,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
}): CreateVideoElement {
return {
type: "video",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true,
hidden: false,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
volume: DEFAULTS.element.volume,
};
}
function buildImageElement({
mediaId,
name,
duration,
startTime,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
}): CreateImageElement {
return {
type: "image",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
hidden: false,
transform: {
...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position },
},
opacity: DEFAULTS.element.opacity,
blendMode: DEFAULTS.element.blendMode,
};
}
function buildUploadAudioElement({
mediaId,
name,
duration,
startTime,
buffer,
}: {
mediaId: string;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateUploadAudioElement {
const element: CreateUploadAudioElement = {
type: "audio",
sourceType: "upload",
mediaId,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}
export function buildElementFromMedia({
mediaId,
mediaType,
name,
duration,
startTime,
buffer,
}: {
mediaId: string;
mediaType: MediaType;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateTimelineElement {
switch (mediaType) {
case "audio":
return buildUploadAudioElement({
mediaId,
name,
duration,
startTime,
buffer,
});
case "video":
return buildVideoElement({ mediaId, name, duration, startTime });
case "image":
return buildImageElement({ mediaId, name, duration, startTime });
}
}
export function buildLibraryAudioElement({
sourceUrl,
name,
duration,
startTime,
buffer,
}: {
sourceUrl: string;
name: string;
duration: number;
startTime: number;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement {
const element: CreateLibraryAudioElement = {
type: "audio",
sourceType: "library",
sourceUrl,
name,
duration,
startTime,
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
element.buffer = buffer;
}
return element;
}
export function getElementsAtTime({
tracks,
time,
}: {
tracks: TimelineTrack[];
time: number;
}): { trackId: string; elementId: string }[] {
const result: { trackId: string; elementId: string }[] = [];
for (const track of tracks) {
for (const element of track.elements) {
const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration;
if (time > elementStart && time < elementEnd) {
result.push({ trackId: track.id, elementId: element.id });
}
}
}
return result;
}
export function getElementFontFamilies({
tracks,
}: {
tracks: TimelineTrack[];
}): string[] {
const families = new Set<string>();
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily);
}
}
}
return [...families];
}

View File

@ -1,79 +1,80 @@
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
function getDevicePixelRatio({
devicePixelRatio,
}: {
devicePixelRatio?: number;
}): number {
if (
typeof devicePixelRatio === "number" &&
Number.isFinite(devicePixelRatio) &&
devicePixelRatio > 0
) {
return devicePixelRatio;
}
if (typeof window === "undefined") {
return 1;
}
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
return window.devicePixelRatio;
}
return 1;
}
export function getTimelinePixelsPerSecond({
zoomLevel,
}: {
zoomLevel: number;
}): number {
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
}
export function timelineTimeToPixels({
time,
zoomLevel,
}: {
time: number;
zoomLevel: number;
}): number {
return time * getTimelinePixelsPerSecond({ zoomLevel });
}
export function snapPixelToDeviceGrid({
pixel,
devicePixelRatio,
}: {
pixel: number;
devicePixelRatio?: number;
}): number {
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
}
export function timelineTimeToSnappedPixels({
time,
zoomLevel,
devicePixelRatio,
}: {
time: number;
zoomLevel: number;
devicePixelRatio?: number;
}): number {
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
}
export function getCenteredLineLeft({
centerPixel,
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
}: {
centerPixel: number;
lineWidthPx?: number;
}): number {
return centerPixel - lineWidthPx / 2;
}
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
function getDevicePixelRatio({
devicePixelRatio,
}: {
devicePixelRatio?: number;
}): number {
if (
typeof devicePixelRatio === "number" &&
Number.isFinite(devicePixelRatio) &&
devicePixelRatio > 0
) {
return devicePixelRatio;
}
if (typeof window === "undefined") {
return 1;
}
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
return window.devicePixelRatio;
}
return 1;
}
export function getTimelinePixelsPerSecond({
zoomLevel,
}: {
zoomLevel: number;
}): number {
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
}
export function timelineTimeToPixels({
time,
zoomLevel,
}: {
time: number;
zoomLevel: number;
}): number {
return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel });
}
export function snapPixelToDeviceGrid({
pixel,
devicePixelRatio,
}: {
pixel: number;
devicePixelRatio?: number;
}): number {
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
}
export function timelineTimeToSnappedPixels({
time,
zoomLevel,
devicePixelRatio,
}: {
time: number;
zoomLevel: number;
devicePixelRatio?: number;
}): number {
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
}
export function getCenteredLineLeft({
centerPixel,
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
}: {
centerPixel: number;
lineWidthPx?: number;
}): number {
return centerPixel - lineWidthPx / 2;
}

View File

@ -1,4 +1,6 @@
import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { frameRateToFloat } from "@/lib/fps/utils";
/**
* frame intervals for labels - starts at 2 so there's always at least
@ -54,15 +56,16 @@ export function getRulerConfig({
fps,
}: {
zoomLevel: number;
fps: number;
fps: FrameRate;
}): RulerConfig {
const fpsFloat = frameRateToFloat(fps);
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const pixelsPerFrame = pixelsPerSecond / fps;
const pixelsPerFrame = pixelsPerSecond / fpsFloat;
const labelIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
fps,
fps: fpsFloat,
minSpacingPx: MIN_LABEL_SPACING_PX,
frameIntervals: LABEL_FRAME_INTERVALS,
});
@ -70,7 +73,7 @@ export function getRulerConfig({
const rawTickIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
fps,
fps: fpsFloat,
minSpacingPx: MIN_TICK_SPACING_PX,
frameIntervals: TICK_FRAME_INTERVALS,
});
@ -81,7 +84,7 @@ export function getRulerConfig({
labelIntervalSeconds,
pixelsPerFrame,
pixelsPerSecond,
fps,
fps: fpsFloat,
});
return { labelIntervalSeconds, tickIntervalSeconds };
@ -197,13 +200,13 @@ export function formatRulerLabel({
fps,
}: {
timeInSeconds: number;
fps: number;
fps: FrameRate;
}): string {
if (isSecondBoundary({ timeInSeconds })) {
return formatTimestamp({ timeInSeconds });
}
const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps });
const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
return `${frameWithinSecond}f`;
}

View File

@ -1,172 +1,172 @@
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { getElementKeyframes } from "@/lib/animation";
export interface SnapPoint {
time: number;
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
elementId?: string;
trackId?: string;
}
export interface SnapResult {
snappedTime: number;
snapPoint: SnapPoint | null;
snapDistance: number;
}
const DEFAULT_SNAP_THRESHOLD_PX = 10;
export function findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks = [],
excludeBookmarkTime,
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
enableKeyframeSnapping = true,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
enableKeyframeSnapping?: boolean;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
for (const track of tracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
if (enableElementSnapping) {
snapPoints.push(
{
time: element.startTime,
type: "element-start",
elementId: element.id,
trackId: track.id,
},
{
time: element.startTime + element.duration,
type: "element-end",
elementId: element.id,
trackId: track.id,
},
);
}
if (enableKeyframeSnapping) {
for (const keyframe of getElementKeyframes({
animations: element.animations,
})) {
snapPoints.push({
time: element.startTime + keyframe.time,
type: "keyframe",
elementId: element.id,
trackId: track.id,
});
}
}
}
}
if (enablePlayheadSnapping) {
snapPoints.push({ time: playheadTime, type: "playhead" });
}
if (enableBookmarkSnapping) {
for (const bookmark of bookmarks) {
if (
excludeBookmarkTime != null &&
Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
) {
continue;
}
snapPoints.push({ time: bookmark.time, type: "bookmark" });
}
}
return snapPoints;
}
export function snapToNearestPoint({
targetTime,
snapPoints,
zoomLevel,
snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
}: {
targetTime: number;
snapPoints: Array<SnapPoint>;
zoomLevel: number;
snapThreshold?: number;
}): SnapResult {
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
let closestSnapPoint: SnapPoint | null = null;
let closestDistance = Infinity;
for (const snapPoint of snapPoints) {
const distance = Math.abs(targetTime - snapPoint.time);
if (distance < thresholdInSeconds && distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint;
}
}
return {
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
snapPoint: closestSnapPoint,
snapDistance: closestDistance,
};
}
export function snapElementEdge({
targetTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId,
snapToStart = true,
bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
tracks: Array<TimelineTrack>;
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks,
});
const effectiveTargetTime = snapToStart
? targetTime
: targetTime + elementDuration;
const snapResult = snapToNearestPoint({
targetTime: effectiveTargetTime,
snapPoints,
zoomLevel,
});
if (!snapToStart && snapResult.snapPoint) {
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
}
return snapResult;
}
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getElementKeyframes } from "@/lib/animation";
import { TICKS_PER_SECOND } from "@/lib/wasm";
export interface SnapPoint {
time: number;
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
elementId?: string;
trackId?: string;
}
export interface SnapResult {
snappedTime: number;
snapPoint: SnapPoint | null;
snapDistance: number;
}
const DEFAULT_SNAP_THRESHOLD_PX = 10;
export function findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks = [],
excludeBookmarkTime,
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
enableKeyframeSnapping = true,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
excludeBookmarkTime?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
enableKeyframeSnapping?: boolean;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
for (const track of tracks) {
for (const element of track.elements) {
if (element.id === excludeElementId) continue;
if (enableElementSnapping) {
snapPoints.push(
{
time: element.startTime,
type: "element-start",
elementId: element.id,
trackId: track.id,
},
{
time: element.startTime + element.duration,
type: "element-end",
elementId: element.id,
trackId: track.id,
},
);
}
if (enableKeyframeSnapping) {
for (const keyframe of getElementKeyframes({
animations: element.animations,
})) {
snapPoints.push({
time: element.startTime + keyframe.time,
type: "keyframe",
elementId: element.id,
trackId: track.id,
});
}
}
}
}
if (enablePlayheadSnapping) {
snapPoints.push({ time: playheadTime, type: "playhead" });
}
if (enableBookmarkSnapping) {
for (const bookmark of bookmarks) {
if (
excludeBookmarkTime != null &&
bookmark.time === excludeBookmarkTime
) {
continue;
}
snapPoints.push({ time: bookmark.time, type: "bookmark" });
}
}
return snapPoints;
}
export function snapToNearestPoint({
targetTime,
snapPoints,
zoomLevel,
snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
}: {
targetTime: number;
snapPoints: Array<SnapPoint>;
zoomLevel: number;
snapThreshold?: number;
}): SnapResult {
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
const thresholdInTicks = (snapThreshold / pixelsPerSecond) * TICKS_PER_SECOND;
let closestSnapPoint: SnapPoint | null = null;
let closestDistance = Infinity;
for (const snapPoint of snapPoints) {
const distance = Math.abs(targetTime - snapPoint.time);
if (distance < thresholdInTicks && distance < closestDistance) {
closestDistance = distance;
closestSnapPoint = snapPoint;
}
}
return {
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
snapPoint: closestSnapPoint,
snapDistance: closestDistance,
};
}
export function snapElementEdge({
targetTime,
elementDuration,
tracks,
playheadTime,
zoomLevel,
excludeElementId,
snapToStart = true,
bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
tracks: Array<TimelineTrack>;
playheadTime: number;
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
bookmarks?: Array<Bookmark>;
}): SnapResult {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
bookmarks,
});
const effectiveTargetTime = snapToStart
? targetTime
: targetTime + elementDuration;
const snapResult = snapToNearestPoint({
targetTime: effectiveTargetTime,
snapPoints,
zoomLevel,
});
if (!snapToStart && snapResult.snapPoint) {
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
}
return snapResult;
}

View File

@ -8,7 +8,7 @@ import { enforceMainTrackStart } from "@/lib/timeline/placement";
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
import { isRetimableElement } from "@/lib/timeline";
type ElementUpdateField = keyof TimelineElement;
type ElementUpdateField = keyof TimelineElement | string;
export interface ElementUpdateContext {
tracks: TimelineTrack[];

View File

@ -2,6 +2,7 @@ import {
BASE_TIMELINE_PIXELS_PER_SECOND,
TIMELINE_ZOOM_MAX,
} from "@/lib/timeline/scale";
import { TICKS_PER_SECOND } from "@/lib/wasm";
const PADDING_MAX_RATIO = 0.75;
const PADDING_MIN_RATIO = 0.15;
@ -14,12 +15,12 @@ export function getTimelineZoomMin({
duration: number;
containerWidth: number | null | undefined;
}): number {
const safeDuration = Math.max(duration, 1);
const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1);
const safeContainerWidth = containerWidth ?? 1000;
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
const zoomToFit =
availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND);
availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
}

View File

@ -0,0 +1,3 @@
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();

View File

@ -0,0 +1 @@
export * from "./constants";

View File

@ -1,9 +1,11 @@
import type { FrameRate } from "opencut-wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import type { BaseNode } from "./nodes/base-node";
export type CanvasRendererParams = {
width: number;
height: number;
fps: number;
fps: FrameRate;
};
export class CanvasRenderer {
@ -11,7 +13,7 @@ export class CanvasRenderer {
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
width: number;
height: number;
fps: number;
fps: FrameRate;
constructor({ width, height, fps }: CanvasRendererParams) {
this.width = width;

View File

@ -1,5 +1,5 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
import { mediaTimeToSeconds } from "opencut-wasm";
import { getSourceTimeAtClipTime } from "@/lib/retime";
import { videoCache } from "@/services/video-cache/service";
import type { RetimeConfig } from "@/lib/timeline";
@ -39,9 +39,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
private isInRange({ time }: { time: number }): boolean {
const localTime = time - this.params.timeOffset;
return (
localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration
);
return localTime >= 0 && localTime < this.params.duration;
}
private getSourceLocalTime({ time }: { time: number }): number {
@ -61,10 +59,11 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
time: number;
}): Promise<BackdropSource | null> {
if (this.params.mediaType === "video") {
const sourceTimeTicks = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
time: this.getSourceLocalTime({ time }),
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
});
if (!frame) {

View File

@ -1,37 +1,39 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { VisualNode, type VisualNodeParams } from "./visual-node";
import { videoCache } from "@/services/video-cache/service";
export interface VideoNodeParams extends VisualNodeParams {
url: string;
file: File;
mediaId: string;
}
export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
if (!this.isInRange({ time })) {
return;
}
const videoTime = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
time: videoTime,
});
if (frame) {
this.renderVisual({
renderer,
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
timelineTime: time,
});
}
}
}
import type { CanvasRenderer } from "../canvas-renderer";
import { mediaTimeToSeconds } from "opencut-wasm";
import { VisualNode, type VisualNodeParams } from "./visual-node";
import { videoCache } from "@/services/video-cache/service";
export interface VideoNodeParams extends VisualNodeParams {
url: string;
file: File;
mediaId: string;
}
export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
if (!this.isInRange({ time })) {
return;
}
const videoTimeTicks = this.getSourceLocalTime({ time });
const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
time: videoTimeSeconds,
});
if (frame) {
this.renderVisual({
renderer,
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
timelineTime: time,
});
}
}
}

View File

@ -12,7 +12,6 @@ import {
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
import { masksRegistry } from "@/lib/masks";
import { getSourceTimeAtClipTime } from "@/lib/retime";
@ -58,7 +57,7 @@ export abstract class VisualNode<
protected isInRange({ time }: { time: number }): boolean {
const localTime = time - this.params.timeOffset;
return (
localTime >= -TIME_EPSILON_SECONDS &&
localTime >= 0 &&
localTime < this.params.duration
);
}

View File

@ -1,163 +1,170 @@
import EventEmitter from "eventemitter3";
import {
Output,
Mp4OutputFormat,
WebMOutputFormat,
BufferTarget,
CanvasSource,
AudioBufferSource,
QUALITY_LOW,
QUALITY_MEDIUM,
QUALITY_HIGH,
QUALITY_VERY_HIGH,
} from "mediabunny";
import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/lib/export";
import { CanvasRenderer } from "./canvas-renderer";
type ExportParams = {
width: number;
height: number;
fps: number;
format: ExportFormat;
quality: ExportQuality;
shouldIncludeAudio?: boolean;
audioBuffer?: AudioBuffer;
};
const qualityMap = {
low: QUALITY_LOW,
medium: QUALITY_MEDIUM,
high: QUALITY_HIGH,
very_high: QUALITY_VERY_HIGH,
};
export type SceneExporterEvents = {
progress: [progress: number];
complete: [buffer: ArrayBuffer];
error: [error: Error];
cancelled: [];
};
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
private renderer: CanvasRenderer;
private format: ExportFormat;
private quality: ExportQuality;
private shouldIncludeAudio: boolean;
private audioBuffer?: AudioBuffer;
private isCancelled = false;
constructor({
width,
height,
fps,
format,
quality,
shouldIncludeAudio,
audioBuffer,
}: ExportParams) {
super();
this.renderer = new CanvasRenderer({
width,
height,
fps,
});
this.format = format;
this.quality = quality;
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
this.audioBuffer = audioBuffer;
}
cancel(): void {
this.isCancelled = true;
}
async export({
rootNode,
}: {
rootNode: RootNode;
}): Promise<ArrayBuffer | null> {
const { fps } = this.renderer;
const frameCount = Math.ceil(rootNode.duration * fps);
const outputFormat =
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
const output = new Output({
format: outputFormat,
target: new BufferTarget(),
});
const videoSource = new CanvasSource(this.renderer.canvas, {
codec: this.format === "webm" ? "vp9" : "avc",
bitrate: qualityMap[this.quality],
});
output.addVideoTrack(videoSource, { frameRate: fps });
let audioSource: AudioBufferSource | null = null;
if (this.shouldIncludeAudio && this.audioBuffer) {
let audioCodec: "aac" | "opus" =
this.format === "webm" ? "opus" : "aac";
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
const { supported } = await AudioEncoder.isConfigSupported({
codec: "mp4a.40.2",
sampleRate: this.audioBuffer.sampleRate,
numberOfChannels: this.audioBuffer.numberOfChannels,
bitrate: 192000,
});
if (!supported) audioCodec = "opus";
}
audioSource = new AudioBufferSource({
codec: audioCodec,
bitrate: qualityMap[this.quality],
});
output.addAudioTrack(audioSource);
}
await output.start();
if (audioSource && this.audioBuffer) {
await audioSource.add(this.audioBuffer);
audioSource.close();
}
for (let i = 0; i < frameCount; i++) {
if (this.isCancelled) {
await output.cancel();
this.emit("cancelled");
return null;
}
const time = i / fps;
await this.renderer.render({ node: rootNode, time });
await videoSource.add(time, 1 / fps);
this.emit("progress", i / frameCount);
}
if (this.isCancelled) {
await output.cancel();
this.emit("cancelled");
return null;
}
videoSource.close();
await output.finalize();
this.emit("progress", 1);
const buffer = output.target.buffer;
if (!buffer) {
this.emit("error", new Error("Failed to export video"));
return null;
}
this.emit("complete", buffer);
return buffer;
}
}
import EventEmitter from "eventemitter3";
import {
Output,
Mp4OutputFormat,
WebMOutputFormat,
BufferTarget,
CanvasSource,
AudioBufferSource,
QUALITY_LOW,
QUALITY_MEDIUM,
QUALITY_HIGH,
QUALITY_VERY_HIGH,
} from "mediabunny";
import type { FrameRate } from "opencut-wasm";
import { mediaTimeToSeconds } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/lib/export";
import { CanvasRenderer } from "./canvas-renderer";
type ExportParams = {
width: number;
height: number;
fps: FrameRate;
format: ExportFormat;
quality: ExportQuality;
shouldIncludeAudio?: boolean;
audioBuffer?: AudioBuffer;
};
const qualityMap = {
low: QUALITY_LOW,
medium: QUALITY_MEDIUM,
high: QUALITY_HIGH,
very_high: QUALITY_VERY_HIGH,
};
export type SceneExporterEvents = {
progress: [progress: number];
complete: [buffer: ArrayBuffer];
error: [error: Error];
cancelled: [];
};
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
private renderer: CanvasRenderer;
private format: ExportFormat;
private quality: ExportQuality;
private shouldIncludeAudio: boolean;
private audioBuffer?: AudioBuffer;
private isCancelled = false;
constructor({
width,
height,
fps,
format,
quality,
shouldIncludeAudio,
audioBuffer,
}: ExportParams) {
super();
this.renderer = new CanvasRenderer({
width,
height,
fps,
});
this.format = format;
this.quality = quality;
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
this.audioBuffer = audioBuffer;
}
cancel(): void {
this.isCancelled = true;
}
async export({
rootNode,
}: {
rootNode: RootNode;
}): Promise<ArrayBuffer | null> {
const fps = this.renderer.fps;
const fpsFloat = frameRateToFloat(fps);
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
const frameCount = Math.floor(rootNode.duration / ticksPerFrame);
const outputFormat =
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
const output = new Output({
format: outputFormat,
target: new BufferTarget(),
});
const videoSource = new CanvasSource(this.renderer.canvas, {
codec: this.format === "webm" ? "vp9" : "avc",
bitrate: qualityMap[this.quality],
});
output.addVideoTrack(videoSource, { frameRate: fpsFloat });
let audioSource: AudioBufferSource | null = null;
if (this.shouldIncludeAudio && this.audioBuffer) {
let audioCodec: "aac" | "opus" =
this.format === "webm" ? "opus" : "aac";
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
const { supported } = await AudioEncoder.isConfigSupported({
codec: "mp4a.40.2",
sampleRate: this.audioBuffer.sampleRate,
numberOfChannels: this.audioBuffer.numberOfChannels,
bitrate: 192000,
});
if (!supported) audioCodec = "opus";
}
audioSource = new AudioBufferSource({
codec: audioCodec,
bitrate: qualityMap[this.quality],
});
output.addAudioTrack(audioSource);
}
await output.start();
if (audioSource && this.audioBuffer) {
await audioSource.add(this.audioBuffer);
audioSource.close();
}
for (let i = 0; i < frameCount; i++) {
if (this.isCancelled) {
await output.cancel();
this.emit("cancelled");
return null;
}
const timeTicks = i * ticksPerFrame;
const timeSeconds = mediaTimeToSeconds({ time: timeTicks });
await this.renderer.render({ node: rootNode, time: timeTicks });
await videoSource.add(timeSeconds, 1 / fpsFloat);
this.emit("progress", i / frameCount);
}
if (this.isCancelled) {
await output.cancel();
this.emit("cancelled");
return null;
}
videoSource.close();
await output.finalize();
this.emit("progress", 1);
const buffer = output.target.buffer;
if (!buffer) {
this.emit("error", new Error("Failed to export video"));
return null;
}
this.emit("complete", buffer);
return buffer;
}
}

View File

@ -4,7 +4,7 @@ import {
DEFAULT_BACKGROUND_COLOR,
} from "@/lib/background/constants";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
import { DEFAULT_FPS } from "@/lib/fps/constants";
const DEFAULT_FPS = 30;
import type { MediaAssetData } from "@/services/storage/types";
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
import {

View File

@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV22ToV23 } from "../transformers/v22-to-v23";
describe("V22 to V23 Migration", () => {
test("converts project time values from seconds to ticks and fps to a frame-rate object", () => {
const result = transformProjectV22ToV23({
project: {
id: "project-v22-time",
version: 22,
metadata: {
id: "project-v22-time",
name: "Project",
duration: 15.5,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
settings: {
fps: 29.97,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
timelineViewState: {
zoomLevel: 1,
scrollLeft: 120,
playheadTime: 1.25,
},
scenes: [
{
id: "scene-1",
bookmarks: [
{ time: 2.5, duration: 0.75, note: "Marker", color: "#ff0000" },
{ time: 4.5 },
],
tracks: [
{
id: "track-1",
type: "video",
elements: [
{
id: "element-1",
type: "video",
startTime: 1.25,
duration: 5.5,
trimStart: 0.25,
trimEnd: 0.5,
sourceDuration: 6.25,
animations: {
bindings: {
opacity: {
path: "opacity",
kind: "number",
components: [
{
key: "value",
channelId: "opacity:value",
},
],
},
},
channels: {
"opacity:value": {
kind: "scalar",
keys: [
{
id: "key-1",
time: 0.5,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
rightHandle: {
dt: 0.25,
dv: 0.2,
},
},
{
id: "key-2",
time: 1.0,
value: 0.4,
segmentToNext: "linear",
tangentMode: "flat",
leftHandle: {
dt: -0.125,
dv: -0.1,
},
},
],
},
},
},
},
],
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(23);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(1_860_000);
const settings = result.project.settings as Record<string, unknown>;
expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 });
const timelineViewState = result.project.timelineViewState as Record<
string,
unknown
>;
expect(timelineViewState.playheadTime).toBe(150_000);
expect(timelineViewState.scrollLeft).toBe(120);
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const scene = scenes[0];
expect(scene.bookmarks).toEqual([
{
time: 300_000,
duration: 90_000,
note: "Marker",
color: "#ff0000",
},
{ time: 540_000 },
]);
const tracks = scene.tracks as Array<Record<string, unknown>>;
const elements = tracks[0].elements as Array<Record<string, unknown>>;
const element = elements[0];
expect(element.startTime).toBe(150_000);
expect(element.duration).toBe(660_000);
expect(element.trimStart).toBe(30_000);
expect(element.trimEnd).toBe(60_000);
expect(element.sourceDuration).toBe(750_000);
const animations = element.animations as Record<string, unknown>;
const channels = animations.channels as Record<string, Record<string, unknown>>;
expect(channels["opacity:value"]).toEqual({
kind: "scalar",
keys: [
{
id: "key-1",
time: 60_000,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
rightHandle: {
dt: 30_000,
dv: 0.2,
},
},
{
id: "key-2",
time: 120_000,
value: 0.4,
segmentToNext: "linear",
tangentMode: "flat",
leftHandle: {
dt: -15_000,
dv: -0.1,
},
},
],
});
});
test("skips projects already on v23", () => {
const result = transformProjectV22ToV23({
project: {
id: "project-v23",
version: 23,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v23");
});
test("skips projects not on v22", () => {
const result = transformProjectV22ToV23({
project: {
id: "project-v21",
version: 21,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("not v22");
});
});

View File

@ -21,10 +21,11 @@ import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
import { V22toV23Migration } from "./v22-to-v23";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 22;
export const CURRENT_PROJECT_VERSION = 23;
export const migrations = [
new V0toV1Migration(),
@ -49,4 +50,5 @@ export const migrations = [
new V19toV20Migration(),
new V20toV21Migration(),
new V21toV22Migration(),
new V22toV23Migration(),
];

View File

@ -3,7 +3,7 @@ import {
DEFAULT_BACKGROUND_COLOR,
} from "@/lib/background/constants";
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
import { DEFAULT_FPS } from "@/lib/fps/constants";
const DEFAULT_FPS = 30;
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types";

View File

@ -0,0 +1,335 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
import { TICKS_PER_SECOND } from "@/lib/wasm";
const ARBITRARY_FPS_DENOMINATOR = 1_000_000;
const STANDARD_FRAME_RATES = [
{ value: 24_000 / 1_001, numerator: 24_000, denominator: 1_001 },
{ value: 24, numerator: 24, denominator: 1 },
{ value: 25, numerator: 25, denominator: 1 },
{ value: 30_000 / 1_001, numerator: 30_000, denominator: 1_001 },
{ value: 30, numerator: 30, denominator: 1 },
{ value: 48, numerator: 48, denominator: 1 },
{ value: 50, numerator: 50, denominator: 1 },
{ value: 60_000 / 1_001, numerator: 60_000, denominator: 1_001 },
{ value: 60, numerator: 60, denominator: 1 },
{ value: 120, numerator: 120, denominator: 1 },
] as const;
const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
export function transformProjectV22ToV23({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
const version = project.version;
if (typeof version !== "number") {
return { project, skipped: true, reason: "invalid version" };
}
if (version >= 23) {
return { project, skipped: true, reason: "already v23" };
}
if (version !== 22) {
return { project, skipped: true, reason: "not v22" };
}
return {
project: {
...migrateProject({ project }),
version: 23,
},
skipped: false,
};
}
function migrateProject({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const nextProject = { ...project };
if (isRecord(project.metadata)) {
nextProject.metadata = migrateMetadata({ metadata: project.metadata });
}
if (isRecord(project.settings)) {
nextProject.settings = migrateSettings({ settings: project.settings });
}
if (isRecord(project.timelineViewState)) {
nextProject.timelineViewState = migrateTimelineViewState({
timelineViewState: project.timelineViewState,
});
}
if (Array.isArray(project.scenes)) {
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
}
return nextProject;
}
function migrateMetadata({
metadata,
}: {
metadata: ProjectRecord;
}): ProjectRecord {
return migrateTimeFields({
record: metadata,
keys: ["duration"],
});
}
function migrateSettings({
settings,
}: {
settings: ProjectRecord;
}): ProjectRecord {
const nextSettings = { ...settings };
if ("fps" in settings) {
nextSettings.fps = migrateFrameRate({ fps: settings.fps });
}
return nextSettings;
}
function migrateTimelineViewState({
timelineViewState,
}: {
timelineViewState: ProjectRecord;
}): ProjectRecord {
return migrateTimeFields({
record: timelineViewState,
keys: ["playheadTime"],
});
}
function migrateScene({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) {
return scene;
}
const nextScene = { ...scene };
if (Array.isArray(scene.bookmarks)) {
nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
migrateBookmark({ bookmark }),
);
}
if (Array.isArray(scene.tracks)) {
nextScene.tracks = scene.tracks.map((track) => migrateTrack({ track }));
}
return nextScene;
}
function migrateTrack({ track }: { track: unknown }): unknown {
if (!isRecord(track)) {
return track;
}
if (!Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) {
return element;
}
const nextElement = migrateTimeFields({
record: element,
keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
});
if (isRecord(element.animations)) {
nextElement.animations = migrateAnimations({
animations: element.animations,
});
}
return nextElement;
}
function migrateAnimations({
animations,
}: {
animations: ProjectRecord;
}): ProjectRecord {
if (!isRecord(animations.channels)) {
return animations;
}
return {
...animations,
channels: Object.fromEntries(
Object.entries(animations.channels).map(([channelId, channel]) => [
channelId,
migrateAnimationChannel({ channel }),
]),
),
};
}
function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
if (!isRecord(channel)) {
return channel;
}
if (!Array.isArray(channel.keys)) {
return channel;
}
return {
...channel,
keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
};
}
function migrateAnimationKeyframe({
keyframe,
}: {
keyframe: unknown;
}): unknown {
if (!isRecord(keyframe)) {
return keyframe;
}
const nextKeyframe = migrateTimeFields({
record: keyframe,
keys: ["time"],
});
if (isRecord(keyframe.leftHandle)) {
nextKeyframe.leftHandle = migrateCurveHandle({
handle: keyframe.leftHandle,
});
}
if (isRecord(keyframe.rightHandle)) {
nextKeyframe.rightHandle = migrateCurveHandle({
handle: keyframe.rightHandle,
});
}
return nextKeyframe;
}
function migrateCurveHandle({
handle,
}: {
handle: ProjectRecord;
}): ProjectRecord {
return migrateTimeFields({
record: handle,
keys: ["dt"],
});
}
function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
if (!isRecord(bookmark)) {
return bookmark;
}
return migrateTimeFields({
record: bookmark,
keys: ["time", "duration"],
});
}
function migrateTimeFields({
record,
keys,
}: {
record: ProjectRecord;
keys: string[];
}): ProjectRecord {
const nextRecord = { ...record };
for (const key of keys) {
if (!(key in record)) {
continue;
}
nextRecord[key] = migrateTimeValue({ value: record[key] });
}
return nextRecord;
}
function migrateTimeValue({ value }: { value: unknown }): unknown {
if (typeof value !== "number" || !Number.isFinite(value)) {
return value;
}
return secondsToTicks({ value });
}
function secondsToTicks({ value }: { value: number }): number {
return Math.round(value * TICKS_PER_SECOND);
}
function migrateFrameRate({ fps }: { fps: unknown }): unknown {
if (isRecord(fps)) {
return fps;
}
if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0) {
return fps;
}
const standardFrameRate = STANDARD_FRAME_RATES.find(
(candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
);
if (standardFrameRate) {
return {
numerator: standardFrameRate.numerator,
denominator: standardFrameRate.denominator,
};
}
if (Number.isInteger(fps)) {
return { numerator: fps, denominator: 1 };
}
const scaledNumerator = Math.round(fps * ARBITRARY_FPS_DENOMINATOR);
const divisor = greatestCommonDivisor({
left: scaledNumerator,
right: ARBITRARY_FPS_DENOMINATOR,
});
return {
numerator: scaledNumerator / divisor,
denominator: ARBITRARY_FPS_DENOMINATOR / divisor,
};
}
function greatestCommonDivisor({
left,
right,
}: {
left: number;
right: number;
}): number {
let nextLeft = Math.abs(left);
let nextRight = Math.abs(right);
while (nextRight !== 0) {
const remainder = nextLeft % nextRight;
nextLeft = nextRight;
nextRight = remainder;
}
return nextLeft || 1;
}

View File

@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV22ToV23 } from "./transformers/v22-to-v23";
export class V22toV23Migration extends StorageMigration {
from = 22;
to = 23;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV22ToV23({ project });
}
}

View File

@ -54,7 +54,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.1.3",
"opencut-wasm": "^0.2.3",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@ -1359,7 +1359,7 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
"opencut-wasm": ["opencut-wasm@0.1.3", "", {}, "sha512-3MlWL8J8NCRBfm/6LrdvW08rgIPqyhk5dCG3j/zokKDOf5mwULox9Vpqi2jgZ+dSKIWReHuUCZq2b0cGCFuVHQ=="],
"opencut-wasm": ["opencut-wasm@0.2.3", "", {}, "sha512-SaLe2fgvLK+EcW7qqggmcfXaQwo6SOh83/h3dtEEbdqEka7gn1PGJECKnTZq4OWhkNEgf8SKJy/HiJOQvyZC+Q=="],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],

View File

@ -1,10 +1,36 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, parse_macro_input};
use syn::{FnArg, Item, ItemConst, ItemFn, parse_macro_input};
#[proc_macro_attribute]
pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
let function = parse_macro_input!(item as ItemFn);
match parse_macro_input!(item as Item) {
Item::Fn(function) => export_fn(function),
Item::Const(constant) => export_const(constant),
other => syn::Error::new_spanned(other, "#[export] only supports fn and const items")
.to_compile_error()
.into(),
}
}
fn export_fn(function: ItemFn) -> TokenStream {
let param_count = function
.sig
.inputs
.iter()
.filter(|arg| matches!(arg, FnArg::Typed(_)))
.count();
if param_count > 1 {
return syn::Error::new_spanned(
&function.sig.inputs,
"#[export] functions must accept a single options struct, not positional arguments. \
Wrap parameters in a struct: `fn foo(FooOptions { a, b }: FooOptions)`",
)
.to_compile_error()
.into();
}
let js_name = snake_to_camel(&function.sig.ident.to_string());
quote! {
@ -14,6 +40,26 @@ pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
.into()
}
fn export_const(constant: ItemConst) -> TokenStream {
let js_name = constant.ident.to_string();
let const_ident = &constant.ident;
let getter_ident = syn::Ident::new(
&format!("__const_{}", constant.ident.to_string().to_lowercase()),
constant.ident.span(),
);
quote! {
#constant
#[cfg(feature = "wasm")]
#[::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)]
pub fn #getter_ident() -> f64 {
#const_ident as f64
}
}
.into()
}
fn snake_to_camel(name: &str) -> String {
let mut camel = String::with_capacity(name.len());
let mut should_uppercase_next = false;

View File

@ -0,0 +1,14 @@
[package]
name = "effects"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/effects.rs"
crate-type = ["rlib"]
[dependencies]
bytemuck = { version = "1.25.0", features = ["derive"] }
gpu = { version = "0.1.0", path = "../gpu" }
thiserror = "2.0.18"
wgpu = "29.0.1"

View File

@ -0,0 +1,5 @@
mod pipeline;
mod types;
pub use pipeline::{ApplyEffectsOptions, EffectPipeline, EffectsError};
pub use types::{EffectPass, UniformValue};

View File

@ -0,0 +1,306 @@
use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
use thiserror::Error;
use wgpu::util::DeviceExt;
use crate::{EffectPass, UniformValue};
const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur";
const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl");
pub struct ApplyEffectsOptions<'a> {
pub source: &'a wgpu::Texture,
pub width: u32,
pub height: u32,
pub passes: &'a [EffectPass],
}
pub struct EffectPipeline {
uniform_bind_group_layout: wgpu::BindGroupLayout,
pipelines: HashMap<String, wgpu::RenderPipeline>,
}
#[derive(Debug, Error)]
pub enum EffectsError {
#[error("At least one effect pass is required")]
MissingEffectPasses,
#[error("Unknown effect shader '{shader}'")]
UnknownEffectShader { shader: String },
#[error("Missing uniform '{uniform}' for shader '{shader}'")]
MissingUniform { shader: String, uniform: String },
#[error("Uniform '{uniform}' for shader '{shader}' must be a number")]
InvalidNumberUniform { shader: String, uniform: String },
#[error(
"Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}"
)]
InvalidVectorUniform {
shader: String,
uniform: String,
expected_length: usize,
},
#[error("Shader '{shader}' does not support uniform '{uniform}'")]
UnsupportedUniform { shader: String, uniform: String },
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct EffectUniformBuffer {
resolution: [f32; 2],
direction: [f32; 2],
scalars: [f32; 4],
}
impl EffectPipeline {
pub fn new(context: &GpuContext) -> Self {
let uniform_bind_group_layout =
context
.device()
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effects-uniform-bind-group-layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let vertex_shader_module =
context
.device()
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effects-fullscreen-shader"),
source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
});
let gaussian_blur_shader_module =
context
.device()
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effects-gaussian-blur-shader"),
source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()),
});
let pipeline_layout =
context
.device()
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effects-pipeline-layout"),
bind_group_layouts: &[
Some(context.texture_sampler_bind_group_layout()),
Some(&uniform_bind_group_layout),
],
immediate_size: 0,
});
let gaussian_blur_pipeline =
context
.device()
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("effects-gaussian-blur-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &gaussian_blur_shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: GPU_TEXTURE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let pipelines =
HashMap::from([(GAUSSIAN_BLUR_SHADER_ID.to_string(), gaussian_blur_pipeline)]);
Self {
uniform_bind_group_layout,
pipelines,
}
}
pub fn apply(
&self,
context: &GpuContext,
ApplyEffectsOptions {
source,
width,
height,
passes,
}: ApplyEffectsOptions<'_>,
) -> Result<wgpu::Texture, EffectsError> {
let mut current_texture: Option<wgpu::Texture> = None;
for pass in passes {
let input_texture = current_texture.as_ref().unwrap_or(source);
let output_texture =
context.create_render_texture(width, height, "effects-pass-output");
let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default());
let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
let texture_bind_group =
context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("effects-texture-bind-group"),
layout: context.texture_sampler_bind_group_layout(),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&input_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(context.linear_sampler()),
},
],
});
let uniform_buffer =
context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("effects-uniform-buffer"),
contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group =
context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("effects-uniform-bind-group"),
layout: &self.uniform_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
let pipeline = self.pipelines.get(&pass.shader).ok_or_else(|| {
EffectsError::UnknownEffectShader {
shader: pass.shader.clone(),
}
})?;
let mut encoder =
context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("effects-command-encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("effects-render-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &output_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
render_pass.set_pipeline(pipeline);
render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..));
render_pass.set_bind_group(0, &texture_bind_group, &[]);
render_pass.set_bind_group(1, &uniform_bind_group, &[]);
render_pass.draw(0..6, 0..1);
}
context.queue().submit([encoder.finish()]);
current_texture = Some(output_texture);
}
current_texture.ok_or(EffectsError::MissingEffectPasses)
}
}
fn pack_effect_uniforms(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<EffectUniformBuffer, EffectsError> {
let shader = pass.shader.as_str();
let sigma = read_number_uniform(pass, "u_sigma")?;
let step = read_number_uniform(pass, "u_step")?;
let direction = read_vec2_uniform(pass, "u_direction")?;
for uniform in pass.uniforms.keys() {
if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" {
continue;
}
return Err(EffectsError::UnsupportedUniform {
shader: shader.to_string(),
uniform: uniform.clone(),
});
}
Ok(EffectUniformBuffer {
resolution: [width as f32, height as f32],
direction,
scalars: [sigma, step, 0.0, 0.0],
})
}
fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result<f32, EffectsError> {
let Some(value) = pass.uniforms.get(uniform) else {
return Err(EffectsError::MissingUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
});
};
match value {
UniformValue::Number(value) => Ok(*value),
UniformValue::Vector(_) => Err(EffectsError::InvalidNumberUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
}),
}
}
fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], EffectsError> {
let Some(value) = pass.uniforms.get(uniform) else {
return Err(EffectsError::MissingUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
});
};
let UniformValue::Vector(values) = value else {
return Err(EffectsError::InvalidVectorUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
expected_length: 2,
});
};
if values.len() != 2 {
return Err(EffectsError::InvalidVectorUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
expected_length: 2,
});
}
Ok([values[0], values[1]])
}

View File

@ -0,0 +1,13 @@
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct EffectPass {
pub shader: String,
pub uniforms: HashMap<String, UniformValue>,
}
#[derive(Clone, Debug)]
pub enum UniformValue {
Number(f32),
Vector(Vec<f32>),
}

View File

@ -7,3 +7,7 @@ edition = "2024"
bytemuck = { version = "1.25.0", features = ["derive"] }
thiserror = "2.0.18"
wgpu = "29.0.1"
[features]
default = []
wasm = []

View File

@ -1,12 +1,8 @@
use wgpu::util::DeviceExt;
use crate::{
GPU_TEXTURE_FORMAT, GpuError,
effect_pipeline::{ApplyEffectsOptions, apply_effects},
mask_feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline},
sdf_pipeline::SdfPipeline,
shader_registry::ShaderRegistry,
};
use crate::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuError};
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
[-1.0, -1.0],
@ -25,9 +21,8 @@ pub struct GpuContext {
fullscreen_quad: wgpu::Buffer,
linear_sampler: wgpu::Sampler,
nearest_sampler: wgpu::Sampler,
shader_registry: ShaderRegistry,
sdf_pipeline: SdfPipeline,
mask_feather_pipeline: MaskFeatherPipeline,
texture_sampler_bind_group_layout: wgpu::BindGroupLayout,
blit_pipeline: wgpu::RenderPipeline,
}
impl GpuContext {
@ -77,9 +72,74 @@ impl GpuContext {
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let shader_registry = ShaderRegistry::new(&device);
let sdf_pipeline = SdfPipeline::new(&device);
let mask_feather_pipeline = MaskFeatherPipeline::new(&device);
let texture_sampler_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-texture-sampler-bind-group-layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-fullscreen-shader"),
source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
});
let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-blit-shader"),
source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()),
});
let blit_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu-blit-pipeline-layout"),
bind_group_layouts: &[Some(&texture_sampler_bind_group_layout)],
immediate_size: 0,
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("gpu-blit-pipeline"),
layout: Some(&blit_pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blit_shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: GPU_TEXTURE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
Ok(Self {
instance,
@ -89,26 +149,11 @@ impl GpuContext {
fullscreen_quad,
linear_sampler,
nearest_sampler,
shader_registry,
sdf_pipeline,
mask_feather_pipeline,
texture_sampler_bind_group_layout,
blit_pipeline,
})
}
pub fn apply_effects(
&self,
options: ApplyEffectsOptions<'_>,
) -> Result<wgpu::Texture, GpuError> {
apply_effects(self, options)
}
pub fn apply_mask_feather(
&self,
options: ApplyMaskFeatherOptions<'_>,
) -> wgpu::Texture {
self.mask_feather_pipeline.apply_mask_feather(self, options)
}
pub fn create_render_texture(
&self,
width: u32,
@ -162,12 +207,8 @@ impl GpuContext {
&self.nearest_sampler
}
pub fn shader_registry(&self) -> &ShaderRegistry {
&self.shader_registry
}
pub fn sdf_pipeline(&self) -> &SdfPipeline {
&self.sdf_pipeline
pub fn texture_sampler_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.texture_sampler_bind_group_layout
}
pub fn render_texture_to_surface(
@ -202,7 +243,7 @@ impl GpuContext {
.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu-blit-bind-group"),
layout: self.shader_registry.effect_texture_bind_group_layout(),
layout: &self.texture_sampler_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@ -237,7 +278,7 @@ impl GpuContext {
timestamp_writes: None,
multiview_mask: None,
});
render_pass.set_pipeline(self.shader_registry.blit_pipeline());
render_pass.set_pipeline(&self.blit_pipeline);
render_pass.set_vertex_buffer(0, self.fullscreen_quad.slice(..));
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..6, 0..1);
@ -247,4 +288,50 @@ impl GpuContext {
surface_texture.present();
Ok(())
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub fn import_offscreen_canvas_texture(
&self,
canvas: &wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
label: &'static str,
) -> wgpu::Texture {
let texture = self.create_render_texture(width, height, label);
self.queue.copy_external_image_to_texture(
&wgpu::CopyExternalImageSourceInfo {
source: wgpu::ExternalImageSource::OffscreenCanvas(canvas.clone()),
origin: wgpu::Origin2d::ZERO,
flip_y: true,
},
wgpu::CopyExternalImageDestInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
color_space: wgpu::PredefinedColorSpace::Srgb,
premultiplied_alpha: false,
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
texture
}
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub fn render_texture_to_offscreen_canvas(
&self,
texture: &wgpu::Texture,
canvas: &wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
) -> Result<(), GpuError> {
let surface = self
.instance
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas.clone()))?;
self.render_texture_to_surface(texture, &surface, width, height)
}
}

View File

@ -1,194 +0,0 @@
use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::{GpuError, context::GpuContext};
pub struct ApplyEffectsOptions<'a> {
pub source: &'a wgpu::Texture,
pub width: u32,
pub height: u32,
pub passes: &'a [EffectPass],
}
#[derive(Clone, Debug)]
pub struct EffectPass {
pub shader: String,
pub uniforms: HashMap<String, UniformValue>,
}
#[derive(Clone, Debug)]
pub enum UniformValue {
Number(f32),
Vector(Vec<f32>),
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct EffectUniformBuffer {
resolution: [f32; 2],
direction: [f32; 2],
scalars: [f32; 4],
}
pub fn apply_effects(
context: &GpuContext,
ApplyEffectsOptions {
source,
width,
height,
passes,
}: ApplyEffectsOptions<'_>,
) -> Result<wgpu::Texture, GpuError> {
let mut current_texture: Option<wgpu::Texture> = None;
for pass in passes {
let input_texture = current_texture.as_ref().unwrap_or(source);
let output_texture =
context.create_render_texture(width, height, "gpu-effect-pass-output");
let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default());
let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
let texture_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu-effect-texture-bind-group"),
layout: context
.shader_registry()
.effect_texture_bind_group_layout(),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&input_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(context.linear_sampler()),
},
],
});
let uniform_buffer = context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gpu-effect-uniform-buffer"),
contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("gpu-effect-uniform-bind-group"),
layout: context
.shader_registry()
.effect_uniform_bind_group_layout(),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
let pipeline = context.shader_registry().get_effect_pipeline(&pass.shader)?;
let mut encoder = context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-effect-command-encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("gpu-effect-render-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &output_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
render_pass.set_pipeline(pipeline);
render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..));
render_pass.set_bind_group(0, &texture_bind_group, &[]);
render_pass.set_bind_group(1, &uniform_bind_group, &[]);
render_pass.draw(0..6, 0..1);
}
context.queue().submit([encoder.finish()]);
current_texture = Some(output_texture);
}
current_texture.ok_or_else(|| GpuError::UnknownEffectShader {
shader: "missing-effect-pass".to_string(),
})
}
fn pack_effect_uniforms(
pass: &EffectPass,
width: u32,
height: u32,
) -> Result<EffectUniformBuffer, GpuError> {
let shader = pass.shader.as_str();
let sigma = read_number_uniform(pass, "u_sigma")?;
let step = read_number_uniform(pass, "u_step")?;
let direction = read_vec2_uniform(pass, "u_direction")?;
for uniform in pass.uniforms.keys() {
if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" {
continue;
}
return Err(GpuError::UnsupportedUniform {
shader: shader.to_string(),
uniform: uniform.clone(),
});
}
Ok(EffectUniformBuffer {
resolution: [width as f32, height as f32],
direction,
scalars: [sigma, step, 0.0, 0.0],
})
}
fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result<f32, GpuError> {
let Some(value) = pass.uniforms.get(uniform) else {
return Err(GpuError::MissingUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
});
};
match value {
UniformValue::Number(value) => Ok(*value),
UniformValue::Vector(_) => Err(GpuError::InvalidNumberUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
}),
}
}
fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], GpuError> {
let Some(value) = pass.uniforms.get(uniform) else {
return Err(GpuError::MissingUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
});
};
let UniformValue::Vector(values) = value else {
return Err(GpuError::InvalidVectorUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
expected_length: 2,
});
};
if values.len() != 2 {
return Err(GpuError::InvalidVectorUniform {
shader: pass.shader.clone(),
uniform: uniform.to_string(),
expected_length: 2,
});
}
Ok([values[0], values[1]])
}

View File

@ -1,17 +1,12 @@
mod context;
mod effect_pipeline;
mod mask_feather;
mod sdf_pipeline;
mod shader_registry;
use thiserror::Error;
pub use wgpu;
pub use context::GpuContext;
pub use effect_pipeline::{ApplyEffectsOptions, EffectPass, UniformValue};
pub use mask_feather::ApplyMaskFeatherOptions;
pub use wgpu;
pub const GPU_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
pub const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
#[derive(Debug, Error)]
pub enum GpuError {
@ -23,18 +18,4 @@ pub enum GpuError {
CreateSurface(#[from] wgpu::CreateSurfaceError),
#[error("The output surface does not support the required texture format")]
UnsupportedSurfaceFormat,
#[error("Unknown effect shader '{shader}'")]
UnknownEffectShader { shader: String },
#[error("Missing uniform '{uniform}' for shader '{shader}'")]
MissingUniform { shader: String, uniform: String },
#[error("Uniform '{uniform}' for shader '{shader}' must be a number")]
InvalidNumberUniform { shader: String, uniform: String },
#[error("Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}")]
InvalidVectorUniform {
shader: String,
uniform: String,
expected_length: usize,
},
#[error("Shader '{shader}' does not support uniform '{uniform}'")]
UnsupportedUniform { shader: String, uniform: String },
}

View File

@ -1,183 +0,0 @@
use std::collections::HashMap;
use crate::{GPU_TEXTURE_FORMAT, GpuError};
const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl");
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
pub const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur";
pub struct ShaderRegistry {
effect_texture_bind_group_layout: wgpu::BindGroupLayout,
effect_uniform_bind_group_layout: wgpu::BindGroupLayout,
effect_pipelines: HashMap<String, wgpu::RenderPipeline>,
blit_pipeline: wgpu::RenderPipeline,
}
impl ShaderRegistry {
pub fn new(device: &wgpu::Device) -> Self {
let effect_texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-effect-texture-bind-group-layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let effect_uniform_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-effect-uniform-bind-group-layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-fullscreen-shader"),
source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()),
});
let gaussian_blur_shader_module =
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-gaussian-blur-shader"),
source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()),
});
let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("gpu-blit-shader"),
source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu-effect-pipeline-layout"),
bind_group_layouts: &[
Some(&effect_texture_bind_group_layout),
Some(&effect_uniform_bind_group_layout),
],
immediate_size: 0,
});
let blit_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("gpu-blit-pipeline-layout"),
bind_group_layouts: &[Some(&effect_texture_bind_group_layout)],
immediate_size: 0,
});
let gaussian_blur_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("gpu-gaussian-blur-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &gaussian_blur_shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: GPU_TEXTURE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("gpu-blit-pipeline"),
layout: Some(&blit_pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex_shader_module,
entry_point: Some("vertex_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 2]>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &blit_shader_module,
entry_point: Some("fragment_main"),
targets: &[Some(wgpu::ColorTargetState {
format: GPU_TEXTURE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let effect_pipelines = HashMap::from([(
GAUSSIAN_BLUR_SHADER_ID.to_string(),
gaussian_blur_pipeline,
)]);
Self {
effect_texture_bind_group_layout,
effect_uniform_bind_group_layout,
effect_pipelines,
blit_pipeline,
}
}
pub fn get_effect_pipeline(&self, shader: &str) -> Result<&wgpu::RenderPipeline, GpuError> {
self.effect_pipelines
.get(shader)
.ok_or_else(|| GpuError::UnknownEffectShader {
shader: shader.to_string(),
})
}
pub fn effect_texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.effect_texture_bind_group_layout
}
pub fn effect_uniform_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.effect_uniform_bind_group_layout
}
pub fn blit_pipeline(&self) -> &wgpu::RenderPipeline {
&self.blit_pipeline
}
}

View File

@ -0,0 +1,13 @@
[package]
name = "masks"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/masks.rs"
crate-type = ["rlib"]
[dependencies]
bytemuck = { version = "1.25.0", features = ["derive"] }
gpu = { version = "0.1.0", path = "../gpu" }
wgpu = "29.0.1"

View File

@ -1,9 +1,9 @@
use bytemuck::{Pod, Zeroable};
use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
use wgpu::util::DeviceExt;
use crate::{GPU_TEXTURE_FORMAT, context::GpuContext};
use crate::SdfPipeline;
const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
const JFA_DISTANCE_SHADER_SOURCE: &str = include_str!("shaders/jfa_distance.wgsl");
pub struct ApplyMaskFeatherOptions<'a> {
@ -14,6 +14,7 @@ pub struct ApplyMaskFeatherOptions<'a> {
}
pub struct MaskFeatherPipeline {
sdf_pipeline: SdfPipeline,
inside_texture_bind_group_layout: wgpu::BindGroupLayout,
outside_texture_bind_group_layout: wgpu::BindGroupLayout,
uniform_bind_group_layout: wgpu::BindGroupLayout,
@ -29,7 +30,8 @@ struct DistanceUniformBuffer {
}
impl MaskFeatherPipeline {
pub fn new(device: &wgpu::Device) -> Self {
pub fn new(context: &GpuContext) -> Self {
let device = context.device();
let inside_texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-mask-distance-inside-layout"),
@ -140,6 +142,7 @@ impl MaskFeatherPipeline {
});
Self {
sdf_pipeline: SdfPipeline::new(context),
inside_texture_bind_group_layout,
outside_texture_bind_group_layout,
uniform_bind_group_layout,
@ -157,11 +160,10 @@ impl MaskFeatherPipeline {
feather,
}: ApplyMaskFeatherOptions<'_>,
) -> wgpu::Texture {
let sdf = context
.sdf_pipeline()
let sdf = self
.sdf_pipeline
.compute_signed_distance_field(context, mask, width, height);
let output_texture =
context.create_render_texture(width, height, "gpu-mask-feather-output");
let output_texture = context.create_render_texture(width, height, "masks-feather-output");
let inside_view = sdf
.inside_texture
.create_view(&wgpu::TextureViewDescriptor::default());
@ -201,17 +203,18 @@ impl MaskFeatherPipeline {
},
],
});
let uniform_buffer = context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gpu-mask-distance-uniform-buffer"),
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
resolution: [width as f32, height as f32],
feather_half: feather / 2.0,
_padding: 0.0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_buffer =
context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gpu-mask-distance-uniform-buffer"),
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
resolution: [width as f32, height as f32],
feather_half: feather / 2.0,
_padding: 0.0,
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
@ -222,11 +225,12 @@ impl MaskFeatherPipeline {
resource: uniform_buffer.as_entire_binding(),
}],
});
let mut encoder = context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-mask-distance-command-encoder"),
});
let mut encoder =
context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-mask-distance-command-encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {

View File

@ -0,0 +1,5 @@
mod feather;
mod sdf;
pub use feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
pub use sdf::{SdfPipeline, SignedDistanceFieldTextures};

View File

@ -1,9 +1,7 @@
use bytemuck::{Pod, Zeroable};
use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
use wgpu::util::DeviceExt;
use crate::{GPU_TEXTURE_FORMAT, context::GpuContext};
const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
@ -36,7 +34,8 @@ struct JfaStepUniformBuffer {
}
impl SdfPipeline {
pub fn new(device: &wgpu::Device) -> Self {
pub fn new(context: &GpuContext) -> Self {
let device = context.device();
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("gpu-sdf-texture-bind-group-layout"),
@ -213,13 +212,14 @@ impl SdfPipeline {
},
],
});
let uniform_buffer = context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gpu-sdf-uniform-buffer"),
contents: uniform_buffer_bytes,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_buffer =
context
.device()
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("gpu-sdf-uniform-buffer"),
contents: uniform_buffer_bytes,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let uniform_bind_group = context
.device()
.create_bind_group(&wgpu::BindGroupDescriptor {
@ -230,11 +230,12 @@ impl SdfPipeline {
resource: uniform_buffer.as_entire_binding(),
}],
});
let mut encoder = context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-sdf-command-encoder"),
});
let mut encoder =
context
.device()
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-sdf-command-encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {

View File

@ -5,13 +5,14 @@ edition = "2024"
[lib]
path = "src/time.rs"
crate-type = ["rlib", "cdylib"]
crate-type = ["rlib"]
[dependencies]
bridge = { version = "0.1.0", path = "../bridge" }
num-traits = "0.2.19"
serde = { version = "1", features = ["derive"] }
tsify-next = { version = "0.5", optional = true }
wasm-bindgen = { version = "0.2.115", optional = true }
[features]
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]

View File

@ -0,0 +1,121 @@
use serde::{Deserialize, Serialize};
use crate::media_time::TICKS_PER_SECOND;
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
pub struct FrameRate {
pub numerator: u32,
pub denominator: u32,
}
impl FrameRate {
pub const FPS_23_976: Self = Self {
numerator: 24_000,
denominator: 1_001,
};
pub const FPS_24: Self = Self {
numerator: 24,
denominator: 1,
};
pub const FPS_25: Self = Self {
numerator: 25,
denominator: 1,
};
pub const FPS_29_97: Self = Self {
numerator: 30_000,
denominator: 1_001,
};
pub const FPS_30: Self = Self {
numerator: 30,
denominator: 1,
};
pub const FPS_48: Self = Self {
numerator: 48,
denominator: 1,
};
pub const FPS_50: Self = Self {
numerator: 50,
denominator: 1,
};
pub const FPS_59_94: Self = Self {
numerator: 60_000,
denominator: 1_001,
};
pub const FPS_60: Self = Self {
numerator: 60,
denominator: 1,
};
pub const FPS_120: Self = Self {
numerator: 120,
denominator: 1,
};
pub const fn new(numerator: u32, denominator: u32) -> Self {
Self {
numerator,
denominator,
}
}
pub const fn is_valid(self) -> bool {
self.numerator > 0 && self.denominator > 0
}
pub fn as_f64(self) -> Option<f64> {
if !self.is_valid() {
return None;
}
Some(f64::from(self.numerator) / f64::from(self.denominator))
}
pub fn frame_number_upper_bound(self) -> Option<u32> {
if !self.is_valid() {
return None;
}
Some(self.numerator.div_ceil(self.denominator))
}
pub fn ticks_per_frame(self) -> Option<i64> {
if !self.is_valid() {
return None;
}
let tick_numerator = TICKS_PER_SECOND.checked_mul(i64::from(self.denominator))?;
let tick_denominator = i64::from(self.numerator);
if tick_numerator % tick_denominator != 0 {
return None;
}
Some(tick_numerator / tick_denominator)
}
}
#[cfg(test)]
mod tests {
use super::FrameRate;
#[test]
fn resolves_ticks_per_standard_frame_rate() {
assert_eq!(FrameRate::FPS_23_976.ticks_per_frame(), Some(5_005));
assert_eq!(FrameRate::FPS_24.ticks_per_frame(), Some(5_000));
assert_eq!(FrameRate::FPS_25.ticks_per_frame(), Some(4_800));
assert_eq!(FrameRate::FPS_29_97.ticks_per_frame(), Some(4_004));
assert_eq!(FrameRate::FPS_30.ticks_per_frame(), Some(4_000));
assert_eq!(FrameRate::FPS_48.ticks_per_frame(), Some(2_500));
assert_eq!(FrameRate::FPS_50.ticks_per_frame(), Some(2_400));
assert_eq!(FrameRate::FPS_59_94.ticks_per_frame(), Some(2_002));
assert_eq!(FrameRate::FPS_60.ticks_per_frame(), Some(2_000));
assert_eq!(FrameRate::FPS_120.ticks_per_frame(), Some(1_000));
}
#[test]
fn rejects_invalid_or_unsupported_rates() {
assert_eq!(FrameRate::new(0, 1).ticks_per_frame(), None);
assert_eq!(FrameRate::new(1, 0).ticks_per_frame(), None);
assert_eq!(FrameRate::new(7, 3).ticks_per_frame(), None);
}
}

View File

@ -0,0 +1,428 @@
use std::ops::{Add, Div, Mul, Neg, Sub};
use bridge::export;
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
use crate::frame_rate::FrameRate;
#[export]
pub const TICKS_PER_SECOND: i64 = 120_000;
const TICKS_PER_SECOND_F64: f64 = TICKS_PER_SECOND as f64;
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct MediaTime(i64);
impl MediaTime {
pub const ZERO: Self = Self(0);
pub const ONE_TICK: Self = Self(1);
pub const fn from_ticks(ticks: i64) -> Self {
Self(ticks)
}
pub const fn as_ticks(self) -> i64 {
self.0
}
pub fn from_seconds_f64(seconds: f64) -> Option<Self> {
if !seconds.is_finite() {
return None;
}
let ticks = (seconds * TICKS_PER_SECOND_F64).round().to_i64()?;
Some(Self(ticks))
}
pub fn to_seconds_f64(self) -> f64 {
self.0.to_f64().unwrap_or(0.0) / TICKS_PER_SECOND_F64
}
pub fn from_frame(frame: i64, rate: FrameRate) -> Option<Self> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(Self(frame.checked_mul(ticks_per_frame)?))
}
pub fn to_frame_round(self, rate: FrameRate) -> Option<i64> {
let ticks_per_frame = rate.ticks_per_frame()?;
let remainder = self.0.rem_euclid(ticks_per_frame);
let floor = self.0.div_euclid(ticks_per_frame);
if remainder * 2 >= ticks_per_frame {
Some(floor + 1)
} else {
Some(floor)
}
}
pub fn to_frame_floor(self, rate: FrameRate) -> Option<i64> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(self.0.div_euclid(ticks_per_frame))
}
pub fn round_to_frame(self, rate: FrameRate) -> Option<Self> {
Self::from_frame(self.to_frame_round(rate)?, rate)
}
pub fn floor_to_frame(self, rate: FrameRate) -> Option<Self> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(Self(self.0.div_euclid(ticks_per_frame) * ticks_per_frame))
}
pub fn is_frame_aligned(self, rate: FrameRate) -> Option<bool> {
let ticks_per_frame = rate.ticks_per_frame()?;
Some(self.0.rem_euclid(ticks_per_frame) == 0)
}
pub fn last_frame_time(self, rate: FrameRate) -> Option<Self> {
if self <= Self::ZERO {
return Some(Self::ZERO);
}
let last_inclusive_tick = self.0.checked_sub(1).unwrap_or(0);
Self::from_ticks(last_inclusive_tick).floor_to_frame(rate)
}
pub fn snapped_seek_time(self, duration: Self, rate: FrameRate) -> Option<Self> {
let snapped = self.round_to_frame(rate)?;
Some(snapped.clamp(Self::ZERO, duration))
}
pub fn clamp(self, min: Self, max: Self) -> Self {
Self(self.0.clamp(min.0, max.0))
}
pub fn min(self, other: Self) -> Self {
Self(self.0.min(other.0))
}
pub fn max(self, other: Self) -> Self {
Self(self.0.max(other.0))
}
}
impl Add for MediaTime {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub for MediaTime {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl Neg for MediaTime {
type Output = Self;
fn neg(self) -> Self::Output {
Self(-self.0)
}
}
impl Mul<i64> for MediaTime {
type Output = Self;
fn mul(self, rhs: i64) -> Self::Output {
Self(self.0 * rhs)
}
}
impl Div<i64> for MediaTime {
type Output = Self;
fn div(self, rhs: i64) -> Self::Output {
Self(self.0 / rhs)
}
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeFromSecondsOptions {
pub seconds: f64,
}
#[export]
pub fn media_time_from_seconds(
MediaTimeFromSecondsOptions { seconds }: MediaTimeFromSecondsOptions,
) -> Option<MediaTime> {
MediaTime::from_seconds_f64(seconds)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeToSecondsOptions {
pub time: MediaTime,
}
#[export]
pub fn media_time_to_seconds(MediaTimeToSecondsOptions { time }: MediaTimeToSecondsOptions) -> f64 {
time.to_seconds_f64()
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeFromFrameOptions {
pub frame: i64,
pub rate: FrameRate,
}
#[export]
pub fn media_time_from_frame(
MediaTimeFromFrameOptions { frame, rate }: MediaTimeFromFrameOptions,
) -> Option<MediaTime> {
MediaTime::from_frame(frame, rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn media_time_to_frame(
MediaTimeToFrameOptions { time, rate }: MediaTimeToFrameOptions,
) -> Option<i64> {
time.to_frame_round(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoundToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn round_to_frame(
RoundToFrameOptions { time, rate }: RoundToFrameOptions,
) -> Option<MediaTime> {
time.round_to_frame(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FloorToFrameOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn floor_to_frame(
FloorToFrameOptions { time, rate }: FloorToFrameOptions,
) -> Option<MediaTime> {
time.floor_to_frame(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IsFrameAlignedOptions {
pub time: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn is_frame_aligned(
IsFrameAlignedOptions { time, rate }: IsFrameAlignedOptions,
) -> Option<bool> {
time.is_frame_aligned(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastFrameTimeOptions {
pub duration: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn last_frame_time(
LastFrameTimeOptions { duration, rate }: LastFrameTimeOptions,
) -> Option<MediaTime> {
duration.last_frame_time(rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnappedSeekTimeOptions {
pub time: MediaTime,
pub duration: MediaTime,
pub rate: FrameRate,
}
#[export]
pub fn snapped_seek_time(
SnappedSeekTimeOptions {
time,
duration,
rate,
}: SnappedSeekTimeOptions,
) -> Option<MediaTime> {
time.snapped_seek_time(duration, rate)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeAddOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_add(MediaTimeAddOptions { lhs, rhs }: MediaTimeAddOptions) -> MediaTime {
lhs + rhs
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeSubOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_sub(MediaTimeSubOptions { lhs, rhs }: MediaTimeSubOptions) -> MediaTime {
lhs - rhs
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeMinOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_min(MediaTimeMinOptions { lhs, rhs }: MediaTimeMinOptions) -> MediaTime {
lhs.min(rhs)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeMaxOptions {
pub lhs: MediaTime,
pub rhs: MediaTime,
}
#[export]
pub fn media_time_max(MediaTimeMaxOptions { lhs, rhs }: MediaTimeMaxOptions) -> MediaTime {
lhs.max(rhs)
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTimeClampOptions {
pub time: MediaTime,
pub min: MediaTime,
pub max: MediaTime,
}
#[export]
pub fn media_time_clamp(
MediaTimeClampOptions { time, min, max }: MediaTimeClampOptions,
) -> MediaTime {
time.clamp(min, max)
}
#[cfg(test)]
mod tests {
use crate::frame_rate::FrameRate;
use super::{MediaTime, TICKS_PER_SECOND};
#[test]
fn converts_between_seconds_and_ticks() {
assert_eq!(
MediaTime::from_seconds_f64(1.5),
Some(MediaTime::from_ticks(180_000))
);
assert_eq!(MediaTime::from_ticks(180_000).to_seconds_f64(), 1.5);
assert_eq!(TICKS_PER_SECOND, 120_000);
}
#[test]
fn rejects_non_finite_seconds() {
assert_eq!(MediaTime::from_seconds_f64(f64::NAN), None);
assert_eq!(MediaTime::from_seconds_f64(f64::INFINITY), None);
assert_eq!(MediaTime::from_seconds_f64(f64::NEG_INFINITY), None);
}
#[test]
fn snaps_to_the_nearest_frame() {
let rate = FrameRate::FPS_30;
let time = MediaTime::from_seconds_f64(1.26).unwrap();
assert_eq!(time.to_frame_round(rate), Some(38));
assert_eq!(
time.round_to_frame(rate),
Some(MediaTime::from_ticks(152_000))
);
}
#[test]
fn floors_to_frame() {
let rate = FrameRate::FPS_30;
let ticks_per_frame = 4_000;
let time = MediaTime::from_ticks(ticks_per_frame * 5 + 1);
assert_eq!(time.to_frame_floor(rate), Some(5));
assert_eq!(time.to_frame_round(rate), Some(5));
let almost_next = MediaTime::from_ticks(ticks_per_frame * 5 + ticks_per_frame / 2);
assert_eq!(almost_next.to_frame_floor(rate), Some(5));
assert_eq!(almost_next.to_frame_round(rate), Some(6));
}
#[test]
fn computes_last_frame_time_and_snapped_seek_time() {
let rate = FrameRate::new(5, 1);
let duration = MediaTime::from_seconds_f64(10.0).unwrap();
assert_eq!(
duration.last_frame_time(rate),
Some(MediaTime::from_seconds_f64(9.8).unwrap()),
);
assert_eq!(
MediaTime::from_seconds_f64(10.0)
.unwrap()
.snapped_seek_time(duration, rate),
Some(MediaTime::from_seconds_f64(10.0).unwrap()),
);
}
}

View File

@ -1,422 +1,19 @@
use bridge::export;
use serde::{Deserialize, Serialize};
mod frame_rate;
mod media_time;
mod timecode;
const SECONDS_PER_HOUR: f64 = 3600.0;
const SECONDS_PER_MINUTE: f64 = 60.0;
const CENTISECONDS_PER_SECOND: f64 = 100.0;
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
pub enum TimeCodeFormat {
#[serde(rename = "MM:SS")]
MmSs,
#[serde(rename = "HH:MM:SS")]
HhMmSs,
#[serde(rename = "HH:MM:SS:CS")]
HhMmSsCs,
#[serde(rename = "HH:MM:SS:FF")]
HhMmSsFf,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoundToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatTimeCodeOptions {
pub time_in_seconds: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParseTimeCodeOptions {
pub time_code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuessTimeCodeFormatOptions {
pub time_code: String,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameToTimeOptions {
pub frame: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapTimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSnappedSeekTimeOptions {
pub raw_time: f64,
pub duration: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLastFrameTimeOptions {
pub duration: f64,
pub fps: f64,
}
#[export]
pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps
}
#[export]
pub fn format_time_code(
FormatTimeCodeOptions {
time_in_seconds,
format,
fps,
}: FormatTimeCodeOptions,
) -> Option<String> {
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64;
let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64;
let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64;
match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSsCs => Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}",
)),
TimeCodeFormat::HhMmSsFf => {
let fps = fps?;
if fps <= 0.0 {
return None;
}
let frames = ((time_in_seconds % 1.0) * fps).floor() as u64;
Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}",
))
}
}
}
#[export]
pub fn parse_time_code(
ParseTimeCodeOptions {
time_code,
format,
fps,
}: ParseTimeCodeOptions,
) -> Option<f64> {
if time_code.trim().is_empty() {
return None;
}
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code
.trim()
.split(':')
.map(|part| part.parse::<u32>().ok())
.collect::<Option<Vec<_>>>()?;
match format {
TimeCodeFormat::MmSs => {
let [minutes, seconds] = parts.as_slice() else {
return None;
};
if *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64,
)
}
TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *centiseconds >= CENTISECONDS_PER_SECOND as u32
{
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
)
}
TimeCodeFormat::HhMmSsFf => {
let fps = fps?;
if fps <= 0.0 {
return None;
}
let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None;
};
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *frames as f64 >= fps
{
return None;
}
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*frames as f64 / fps),
)
}
}
}
#[export]
pub fn guess_time_code_format(
GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() {
return None;
}
let part_count = time_code
.split(':')
.try_fold(0usize, |count, part| {
part.parse::<u32>().ok().map(|_| count + 1)
})?;
match part_count {
2 => Some(TimeCodeFormat::MmSs),
3 => Some(TimeCodeFormat::HhMmSs),
4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None,
}
}
#[export]
pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round()
}
#[export]
pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps
}
#[export]
pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 {
if fps <= 0.0 {
return time;
}
frame_to_time(FrameToTimeOptions {
frame: time_to_frame(TimeToFrameOptions { time, fps }),
fps,
})
}
#[export]
pub fn get_snapped_seek_time(
GetSnappedSeekTimeOptions {
raw_time,
duration,
fps,
}: GetSnappedSeekTimeOptions,
) -> f64 {
let snapped_time = snap_time_to_frame(SnapTimeToFrameOptions { time: raw_time, fps });
let last_frame = get_last_frame_time(GetLastFrameTimeOptions { duration, fps });
snapped_time.clamp(0.0, last_frame)
}
#[export]
pub fn get_last_frame_time(
GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
) -> f64 {
if duration <= 0.0 {
return 0.0;
}
if fps <= 0.0 {
return duration;
}
let frame_offset = 1.0 / fps;
(duration - frame_offset).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2);
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
}
#[test]
fn formats_default_time_codes() {
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 3723.45,
format: None,
fps: None,
}),
Some("01:02:03:44".to_string()),
);
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 65.0,
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some("01:05".to_string()),
);
}
#[test]
fn formats_frame_based_time_codes() {
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some("00:00:01:15".to_string()),
);
assert_eq!(
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: None,
}),
None,
);
}
#[test]
fn parses_time_codes() {
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some(65.0),
);
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some(1.5),
);
assert_eq!(
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
None,
);
}
#[test]
fn guesses_time_code_formats() {
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }),
Some(TimeCodeFormat::MmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01".to_string(),
}),
Some(TimeCodeFormat::HhMmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01:15".to_string(),
}),
Some(TimeCodeFormat::HhMmSsFf),
);
}
#[test]
fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0);
assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3);
assert_eq!(snap_time_to_frame(SnapTimeToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8);
assert_eq!(
get_snapped_seek_time(GetSnappedSeekTimeOptions {
raw_time: 10.0,
duration: 10.0,
fps: 5.0,
}),
9.8,
);
}
}
pub use frame_rate::FrameRate;
pub use media_time::{
FloorToFrameOptions, IsFrameAlignedOptions, LastFrameTimeOptions, MediaTime,
MediaTimeAddOptions, MediaTimeClampOptions, MediaTimeFromFrameOptions,
MediaTimeFromSecondsOptions, MediaTimeMaxOptions, MediaTimeMinOptions, MediaTimeSubOptions,
MediaTimeToFrameOptions, MediaTimeToSecondsOptions, RoundToFrameOptions,
SnappedSeekTimeOptions, TICKS_PER_SECOND, floor_to_frame, is_frame_aligned, last_frame_time,
media_time_add, media_time_clamp, media_time_from_frame, media_time_from_seconds,
media_time_max, media_time_min, media_time_sub, media_time_to_frame, media_time_to_seconds,
round_to_frame, snapped_seek_time,
};
pub use timecode::{
FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions, TimeCodeFormat,
format_timecode, guess_timecode_format, parse_timecode,
};

View File

@ -0,0 +1,287 @@
use bridge::export;
use serde::{Deserialize, Serialize};
use crate::{
frame_rate::FrameRate,
media_time::{MediaTime, TICKS_PER_SECOND},
};
const SECONDS_PER_HOUR: i64 = 3_600;
const SECONDS_PER_MINUTE: i64 = 60;
const CENTISECONDS_PER_SECOND: i64 = 100;
const TICKS_PER_CENTISECOND: i64 = TICKS_PER_SECOND / CENTISECONDS_PER_SECOND;
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
pub enum TimeCodeFormat {
#[serde(rename = "MM:SS")]
MmSs,
#[serde(rename = "HH:MM:SS")]
HhMmSs,
#[serde(rename = "HH:MM:SS:CS")]
HhMmSsCs,
#[serde(rename = "HH:MM:SS:FF")]
HhMmSsFf,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatTimecodeOptions {
pub time: MediaTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<FrameRate>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParseTimecodeOptions {
pub time_code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<FrameRate>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuessTimecodeFormatOptions {
pub time_code: String,
}
#[export]
pub fn guess_timecode_format(
GuessTimecodeFormatOptions { time_code }: GuessTimecodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() {
return None;
}
let part_count = time_code
.trim()
.split(':')
.try_fold(0usize, |count, part| {
part.parse::<u32>().ok().map(|_| count + 1)
})?;
match part_count {
2 => Some(TimeCodeFormat::MmSs),
3 => Some(TimeCodeFormat::HhMmSs),
4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None,
}
}
#[export]
pub fn format_timecode(
FormatTimecodeOptions { time, format, rate }: FormatTimecodeOptions,
) -> Option<String> {
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let total_ticks = u64::try_from(time.as_ticks().max(0)).ok()?;
let ticks_per_second = u64::try_from(TICKS_PER_SECOND).ok()?;
let total_seconds = total_ticks / ticks_per_second;
let hour_ticks = u64::try_from(SECONDS_PER_HOUR).ok()? * ticks_per_second;
let minute_ticks = u64::try_from(SECONDS_PER_MINUTE).ok()? * ticks_per_second;
let seconds_per_minute = u64::try_from(SECONDS_PER_MINUTE).ok()?;
let ticks_per_centisecond = u64::try_from(TICKS_PER_CENTISECOND).ok()?;
let hours = total_ticks / hour_ticks;
let minutes = (total_ticks % hour_ticks) / minute_ticks;
let seconds = total_seconds % seconds_per_minute;
let second_ticks = total_ticks % ticks_per_second;
let centiseconds = second_ticks / ticks_per_centisecond;
match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSsCs => Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}"
)),
TimeCodeFormat::HhMmSsFf => {
let rate = rate?;
let ticks_per_frame = rate.ticks_per_frame()?;
let frames = second_ticks / u64::try_from(ticks_per_frame).ok()?;
Some(format!("{hours:02}:{minutes:02}:{seconds:02}:{frames:02}"))
}
}
}
#[export]
pub fn parse_timecode(
ParseTimecodeOptions {
time_code,
format,
rate,
}: ParseTimecodeOptions,
) -> Option<MediaTime> {
if time_code.trim().is_empty() {
return None;
}
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code
.trim()
.split(':')
.map(|part| part.parse::<u32>().ok())
.collect::<Option<Vec<_>>>()?;
match format {
TimeCodeFormat::MmSs => {
let [minutes, seconds] = parts.as_slice() else {
return None;
};
if i64::from(*seconds) >= SECONDS_PER_MINUTE {
return None;
}
Some(MediaTime::from_ticks(
(i64::from(*minutes) * SECONDS_PER_MINUTE + i64::from(*seconds)) * TICKS_PER_SECOND,
))
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
if i64::from(*minutes) >= SECONDS_PER_MINUTE
|| i64::from(*seconds) >= SECONDS_PER_MINUTE
{
return None;
}
Some(MediaTime::from_ticks(
(i64::from(*hours) * SECONDS_PER_HOUR
+ i64::from(*minutes) * SECONDS_PER_MINUTE
+ i64::from(*seconds))
* TICKS_PER_SECOND,
))
}
TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None;
};
if i64::from(*minutes) >= SECONDS_PER_MINUTE
|| i64::from(*seconds) >= SECONDS_PER_MINUTE
|| i64::from(*centiseconds) >= CENTISECONDS_PER_SECOND
{
return None;
}
Some(MediaTime::from_ticks(
(i64::from(*hours) * SECONDS_PER_HOUR
+ i64::from(*minutes) * SECONDS_PER_MINUTE
+ i64::from(*seconds))
* TICKS_PER_SECOND
+ i64::from(*centiseconds) * TICKS_PER_CENTISECOND,
))
}
TimeCodeFormat::HhMmSsFf => {
let rate = rate?;
let frame_upper_bound = rate.frame_number_upper_bound()?;
let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None;
};
if i64::from(*minutes) >= SECONDS_PER_MINUTE
|| i64::from(*seconds) >= SECONDS_PER_MINUTE
|| *frames >= frame_upper_bound
{
return None;
}
Some(
MediaTime::from_ticks(
(i64::from(*hours) * SECONDS_PER_HOUR
+ i64::from(*minutes) * SECONDS_PER_MINUTE
+ i64::from(*seconds))
* TICKS_PER_SECOND,
) + MediaTime::from_frame(i64::from(*frames), rate)?,
)
}
}
}
#[cfg(test)]
mod tests {
use crate::frame_rate::FrameRate;
use crate::media_time::MediaTime;
use super::{FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions};
use super::{TimeCodeFormat, format_timecode, guess_timecode_format, parse_timecode};
#[test]
fn formats_default_and_frame_timecodes() {
assert_eq!(
format_timecode(FormatTimecodeOptions {
time: MediaTime::from_seconds_f64(3723.45).unwrap(),
format: None,
rate: None,
}),
Some("01:02:03:45".to_string()),
);
assert_eq!(
format_timecode(FormatTimecodeOptions {
time: MediaTime::from_seconds_f64(1.5).unwrap(),
format: Some(TimeCodeFormat::HhMmSsFf),
rate: Some(FrameRate::FPS_30),
}),
Some("00:00:01:15".to_string()),
);
}
#[test]
fn parses_timecodes() {
assert_eq!(
parse_timecode(ParseTimecodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
rate: None,
}),
Some(MediaTime::from_seconds_f64(65.0).unwrap()),
);
assert_eq!(
parse_timecode(ParseTimecodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
rate: Some(FrameRate::FPS_30),
}),
Some(MediaTime::from_seconds_f64(1.5).unwrap()),
);
assert_eq!(
parse_timecode(ParseTimecodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
rate: Some(FrameRate::FPS_30),
}),
None,
);
}
#[test]
fn guesses_timecode_formats() {
assert_eq!(
guess_timecode_format(GuessTimecodeFormatOptions {
time_code: "01:05".to_string(),
}),
Some(TimeCodeFormat::MmSs),
);
assert_eq!(
guess_timecode_format(GuessTimecodeFormatOptions {
time_code: "00:00:01".to_string(),
}),
Some(TimeCodeFormat::HhMmSs),
);
assert_eq!(
guess_timecode_format(GuessTimecodeFormatOptions {
time_code: "00:00:01:15".to_string(),
}),
Some(TimeCodeFormat::HhMmSsFf),
);
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "opencut-wasm"
version = "0.1.3"
version = "0.2.3"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"
@ -11,11 +11,19 @@ path = "src/wasm.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
gpu = { version = "0.1.0", path = "../crates/gpu" }
bridge = { version = "0.1.0", path = "../crates/bridge" }
effects = { version = "0.1.0", path = "../crates/effects" }
gpu = { version = "0.1.0", path = "../crates/gpu", features = ["wasm"] }
js-sys = "0.3.93"
masks = { version = "0.1.0", path = "../crates/masks" }
num-traits = "0.2.19"
serde = { version = "1.0.228", features = ["derive"] }
serde-wasm-bindgen = "0.6.5"
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
wasm-bindgen = "0.2.116"
wasm-bindgen-futures = "0.4.66"
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
[features]
default = ["wasm"]
wasm = []

View File

@ -11,7 +11,10 @@ npm install opencut-wasm
## Usage
```ts
import { formatTimeCode } from "opencut-wasm";
import { formatTimecode, mediaTimeFromSeconds } from "opencut-wasm";
const ticks = mediaTimeFromSeconds(1.5);
const label = formatTimecode({ ticks });
```
All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts).

101
rust/wasm/src/effects.rs Normal file
View File

@ -0,0 +1,101 @@
#![cfg(target_arch = "wasm32")]
use effects::{ApplyEffectsOptions, EffectPass, UniformValue};
use gpu::wgpu;
use js_sys::Object;
use serde::Deserialize;
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
use crate::gpu::{
import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property,
render_texture_to_canvas, with_gpu_runtime,
};
struct ApplyEffectPassesOptions {
source: wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
passes: Vec<EffectPassInput>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EffectPassInput {
shader: String,
uniforms: Vec<EffectUniformInput>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EffectUniformInput {
name: String,
value: Vec<f32>,
}
#[wasm_bindgen(js_name = applyEffectPasses)]
pub fn apply_effect_passes(options: JsValue) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
let ApplyEffectPassesOptions {
source,
width,
height,
passes,
} = parse_apply_effect_passes_options(options)?;
with_gpu_runtime(|runtime| {
let source_texture = import_canvas_texture(
&runtime.context,
&source,
width,
height,
"effects-input-texture",
);
let effect_passes = map_effect_passes(passes);
let result_texture = runtime
.effects
.apply(
&runtime.context,
ApplyEffectsOptions {
source: &source_texture,
width,
height,
passes: &effect_passes,
},
)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
render_texture_to_canvas(&runtime.context, &result_texture, width, height)
})
}
fn map_effect_passes(effect_passes: Vec<EffectPassInput>) -> Vec<EffectPass> {
effect_passes
.into_iter()
.map(|pass| EffectPass {
shader: pass.shader,
uniforms: pass
.uniforms
.into_iter()
.map(|uniform| {
let value = if uniform.value.len() == 1 {
UniformValue::Number(uniform.value[0])
} else {
UniformValue::Vector(uniform.value)
};
(uniform.name, value)
})
.collect(),
})
.collect()
}
fn parse_apply_effect_passes_options(value: JsValue) -> Result<ApplyEffectPassesOptions, JsValue> {
let object: Object = value
.dyn_into()
.map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?;
Ok(ApplyEffectPassesOptions {
source: read_offscreen_canvas_property(&object, "source")?,
width: read_u32_property(&object, "width")?,
height: read_u32_property(&object, "height")?,
passes: read_serde_property(&object, "passes")?,
})
}

123
rust/wasm/src/gpu.rs Normal file
View File

@ -0,0 +1,123 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use effects::EffectPipeline;
use gpu::{GpuContext, wgpu};
use js_sys::{Object, Reflect};
use masks::MaskFeatherPipeline;
use serde::Deserialize;
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
pub(crate) struct GpuRuntime {
pub(crate) context: GpuContext,
pub(crate) effects: EffectPipeline,
pub(crate) masks: MaskFeatherPipeline,
}
thread_local! {
static GPU_RUNTIME: RefCell<Option<GpuRuntime>> = const { RefCell::new(None) };
}
#[wasm_bindgen(js_name = initializeGpu)]
pub async fn initialize_gpu() -> Result<(), JsValue> {
if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) {
return Ok(());
}
let context = GpuContext::new()
.await
.map_err(|error| JsValue::from_str(&error.to_string()))?;
let effects = EffectPipeline::new(&context);
let masks = MaskFeatherPipeline::new(&context);
GPU_RUNTIME.with(|runtime| {
runtime.replace(Some(GpuRuntime {
context,
effects,
masks,
}));
});
Ok(())
}
pub(crate) fn with_gpu_runtime<T>(
action: impl FnOnce(&GpuRuntime) -> Result<T, JsValue>,
) -> Result<T, JsValue> {
GPU_RUNTIME.with(|runtime| {
let borrow = runtime.borrow();
let Some(gpu_runtime) = borrow.as_ref() else {
return Err(JsValue::from_str(
"GPU context not initialized. Call initializeGpu() first.",
));
};
action(gpu_runtime)
})
}
pub(crate) fn import_canvas_texture(
context: &GpuContext,
canvas: &wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
label: &'static str,
) -> wgpu::Texture {
context.import_offscreen_canvas_texture(canvas, width, height, label)
}
pub(crate) fn render_texture_to_canvas(
context: &GpuContext,
texture: &wgpu::Texture,
width: u32,
height: u32,
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
let canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
context
.render_texture_to_offscreen_canvas(texture, &canvas, width, height)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
Ok(canvas)
}
pub(crate) fn read_property(object: &Object, name: &str) -> Result<JsValue, JsValue> {
Reflect::get(object, &JsValue::from_str(name))
.map_err(|_| JsValue::from_str(&format!("Missing property '{name}'")))
}
pub(crate) fn read_offscreen_canvas_property(
object: &Object,
name: &str,
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
read_property(object, name)?
.dyn_into::<wgpu::web_sys::OffscreenCanvas>()
.map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas")))
}
pub(crate) fn read_u32_property(object: &Object, name: &str) -> Result<u32, JsValue> {
let value = read_property(object, name)?;
let Some(number) = value.as_f64() else {
return Err(JsValue::from_str(&format!(
"Property '{name}' must be a number"
)));
};
Ok(number as u32)
}
pub(crate) fn read_f32_property(object: &Object, name: &str) -> Result<f32, JsValue> {
let value = read_property(object, name)?;
let Some(number) = value.as_f64() else {
return Err(JsValue::from_str(&format!(
"Property '{name}' must be a number"
)));
};
Ok(number as f32)
}
pub(crate) fn read_serde_property<T>(object: &Object, name: &str) -> Result<T, JsValue>
where
T: for<'de> Deserialize<'de>,
{
let value = read_property(object, name)?;
serde_wasm_bindgen::from_value(value)
.map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}")))
}

View File

@ -1,256 +0,0 @@
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use gpu::{EffectPass, GpuContext, UniformValue, wgpu};
use js_sys::{Object, Reflect};
use serde::Deserialize;
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
thread_local! {
static GPU_CONTEXT: RefCell<Option<GpuContext>> = const { RefCell::new(None) };
}
struct ApplyEffectPassesOptions {
source: wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
passes: Vec<EffectPassInput>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EffectPassInput {
shader: String,
uniforms: Vec<EffectUniformInput>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EffectUniformInput {
name: String,
value: Vec<f32>,
}
struct ApplyMaskFeatherOptions {
mask: wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
feather: f32,
}
#[wasm_bindgen(js_name = initializeGpu)]
pub async fn initialize_gpu() -> Result<(), JsValue> {
if GPU_CONTEXT.with(|context| context.borrow().is_some()) {
return Ok(());
}
let context = GpuContext::new()
.await
.map_err(|error| JsValue::from_str(&error.to_string()))?;
GPU_CONTEXT.with(|gpu_context| {
gpu_context.replace(Some(context));
});
Ok(())
}
#[wasm_bindgen(js_name = applyEffectPasses)]
pub fn apply_effect_passes(
options: JsValue,
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
let ApplyEffectPassesOptions {
source,
width,
height,
passes,
} = parse_apply_effect_passes_options(options)?;
with_gpu_context(|context| {
let source_texture = import_canvas_texture(context, &source, width, height)?;
let effect_passes = map_effect_passes(passes);
let result_texture = context
.apply_effects(gpu::ApplyEffectsOptions {
source: &source_texture,
width,
height,
passes: &effect_passes,
})
.map_err(|error| JsValue::from_str(&error.to_string()))?;
let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
let surface = context
.instance()
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone()))
.map_err(|error| JsValue::from_str(&error.to_string()))?;
context
.render_texture_to_surface(&result_texture, &surface, width, height)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
Ok(output_canvas)
})
}
#[wasm_bindgen(js_name = applyMaskFeather)]
pub fn apply_mask_feather(
options: JsValue,
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
let ApplyMaskFeatherOptions {
mask,
width,
height,
feather,
} = parse_apply_mask_feather_options(options)?;
with_gpu_context(|context| {
let mask_texture = import_canvas_texture(context, &mask, width, height)?;
let result_texture = context.apply_mask_feather(gpu::ApplyMaskFeatherOptions {
mask: &mask_texture,
width,
height,
feather,
});
let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?;
let surface = context
.instance()
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone()))
.map_err(|error| JsValue::from_str(&error.to_string()))?;
context
.render_texture_to_surface(&result_texture, &surface, width, height)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
Ok(output_canvas)
})
}
fn with_gpu_context<T>(
action: impl FnOnce(&GpuContext) -> Result<T, JsValue>,
) -> Result<T, JsValue> {
GPU_CONTEXT.with(|context| {
let borrow = context.borrow();
let Some(gpu_context) = borrow.as_ref() else {
return Err(JsValue::from_str(
"GPU context not initialized. Call initializeGpu() first.",
));
};
action(gpu_context)
})
}
fn import_canvas_texture(
context: &GpuContext,
canvas: &wgpu::web_sys::OffscreenCanvas,
width: u32,
height: u32,
) -> Result<wgpu::Texture, JsValue> {
let texture = context.create_render_texture(width, height, "gpu-bridge-input-texture");
context.queue().copy_external_image_to_texture(
&wgpu::CopyExternalImageSourceInfo {
source: wgpu::ExternalImageSource::OffscreenCanvas(canvas.clone()),
origin: wgpu::Origin2d::ZERO,
flip_y: true,
},
wgpu::CopyExternalImageDestInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
color_space: wgpu::PredefinedColorSpace::Srgb,
premultiplied_alpha: false,
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
Ok(texture)
}
fn map_effect_passes(effect_passes: Vec<EffectPassInput>) -> Vec<EffectPass> {
effect_passes
.into_iter()
.map(|pass| EffectPass {
shader: pass.shader,
uniforms: pass
.uniforms
.into_iter()
.map(|uniform| {
let value = if uniform.value.len() == 1 {
UniformValue::Number(uniform.value[0])
} else {
UniformValue::Vector(uniform.value)
};
(uniform.name, value)
})
.collect(),
})
.collect()
}
fn parse_apply_effect_passes_options(value: JsValue) -> Result<ApplyEffectPassesOptions, JsValue> {
let object: Object = value
.dyn_into()
.map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?;
Ok(ApplyEffectPassesOptions {
source: read_offscreen_canvas_property(&object, "source")?,
width: read_u32_property(&object, "width")?,
height: read_u32_property(&object, "height")?,
passes: read_serde_property(&object, "passes")?,
})
}
fn parse_apply_mask_feather_options(value: JsValue) -> Result<ApplyMaskFeatherOptions, JsValue> {
let object: Object = value
.dyn_into()
.map_err(|_| JsValue::from_str("applyMaskFeather expects an options object"))?;
Ok(ApplyMaskFeatherOptions {
mask: read_offscreen_canvas_property(&object, "mask")?,
width: read_u32_property(&object, "width")?,
height: read_u32_property(&object, "height")?,
feather: read_f32_property(&object, "feather")?,
})
}
fn read_property(object: &Object, name: &str) -> Result<JsValue, JsValue> {
Reflect::get(object, &JsValue::from_str(name))
.map_err(|_| JsValue::from_str(&format!("Missing property '{name}'")))
}
fn read_offscreen_canvas_property(
object: &Object,
name: &str,
) -> Result<wgpu::web_sys::OffscreenCanvas, JsValue> {
read_property(object, name)?
.dyn_into::<wgpu::web_sys::OffscreenCanvas>()
.map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas")))
}
fn read_u32_property(object: &Object, name: &str) -> Result<u32, JsValue> {
let value = read_property(object, name)?;
let Some(number) = value.as_f64() else {
return Err(JsValue::from_str(&format!(
"Property '{name}' must be a number"
)));
};
Ok(number as u32)
}
fn read_f32_property(object: &Object, name: &str) -> Result<f32, JsValue> {
let value = read_property(object, name)?;
let Some(number) = value.as_f64() else {
return Err(JsValue::from_str(&format!(
"Property '{name}' must be a number"
)));
};
Ok(number as f32)
}
fn read_serde_property<T>(object: &Object, name: &str) -> Result<T, JsValue>
where
T: for<'de> Deserialize<'de>,
{
let value = read_property(object, name)?;
serde_wasm_bindgen::from_value(value)
.map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}")))
}

Some files were not shown because too many files have changed in this diff Show More