refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
This commit is contained in:
parent
79df736431
commit
e4b67094e7
|
|
@ -1427,6 +1427,16 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "effects"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -3217,6 +3227,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "masks"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gpu",
|
||||||
|
"wgpu",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -3777,10 +3796,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.1.3"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bridge",
|
||||||
|
"effects",
|
||||||
"gpu",
|
"gpu",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"masks",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"time",
|
"time",
|
||||||
|
|
@ -5581,6 +5604,7 @@ name = "time"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bridge",
|
"bridge",
|
||||||
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"tsify-next",
|
"tsify-next",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,8 @@ members = [
|
||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
"rust/crates/time",
|
"rust/crates/time",
|
||||||
"rust/crates/bridge",
|
"rust/crates/bridge",
|
||||||
"rust/wasm", "rust/crates/gpu",
|
"rust/crates/effects",
|
||||||
|
"rust/crates/gpu",
|
||||||
|
"rust/crates/masks",
|
||||||
|
"rust/wasm",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -79,5 +79,7 @@ changes:
|
||||||
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
||||||
- type: new
|
- type: new
|
||||||
text: "You can now import transcript files to generate captions instead of running auto-transcription."
|
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."
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.1.3",
|
"opencut-wasm": "^0.2.3",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import type {
|
||||||
TProjectSortKey,
|
TProjectSortKey,
|
||||||
TProjectSortOption,
|
TProjectSortOption,
|
||||||
} from "@/lib/project/types";
|
} from "@/lib/project/types";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -76,8 +76,9 @@ const formatProjectDuration = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
|
const durationSeconds = mediaTimeToSeconds({ time: duration });
|
||||||
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
|
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
|
||||||
|
return formatTimecode({ time: duration, format }) ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEW_MODE_OPTIONS = [
|
const VIEW_MODE_OPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
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";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
interface EditableTimecodeProps {
|
interface EditableTimecodeProps {
|
||||||
time: number;
|
time: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
format?: TimeCodeFormat;
|
format?: TimeCodeFormat;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
onTimeChange?: ({ time }: { time: number }) => void;
|
onTimeChange?: ({ time }: { time: number }) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -28,7 +28,7 @@ export function EditableTimecode({
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const enterPressedRef = useRef(false);
|
const enterPressedRef = useRef(false);
|
||||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
|
const formattedTime = formatTimecode({ time, format, rate: fps }) ?? "";
|
||||||
|
|
||||||
const startEditing = () => {
|
const startEditing = () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
@ -46,17 +46,16 @@ export function EditableTimecode({
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEdit = () => {
|
const applyEdit = () => {
|
||||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||||
|
|
||||||
if (parsedTime == null) {
|
if (parsedTime == null) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.max(
|
const clampedTime = duration
|
||||||
0,
|
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
: parsedTime;
|
||||||
);
|
|
||||||
|
|
||||||
onTimeChange?.({ time: clampedTime });
|
onTimeChange?.({ time: clampedTime });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { TProjectMetadata } from "@/lib/project/types";
|
import type { TProjectMetadata } from "@/lib/project/types";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate } from "@/utils/date";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({
|
||||||
|
|
@ -35,9 +35,10 @@ export function ProjectInfoDialog({
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
project: TProjectMetadata;
|
project: TProjectMetadata;
|
||||||
}) {
|
}) {
|
||||||
|
const durationSeconds = mediaTimeToSeconds({ time: project.duration });
|
||||||
const durationFormatted =
|
const durationFormatted =
|
||||||
project.duration > 0
|
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";
|
: "0:00";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} 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 { useEditor } from "@/hooks/use-editor";
|
||||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
|
|
@ -257,7 +257,7 @@ function MediaAssetDraggable({
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}) => {
|
}) => {
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: asset.id,
|
mediaId: asset.id,
|
||||||
mediaType: asset.type,
|
mediaType: asset.type,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { FPS_PRESETS } from "@/lib/fps/constants";
|
import { FPS_PRESETS } from "@/lib/fps/constants";
|
||||||
|
import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
|
|
@ -232,12 +233,12 @@ export function SettingsView() {
|
||||||
<Section showTopBorder={false}>
|
<Section showTopBorder={false}>
|
||||||
<SectionHeader className="justify-between">
|
<SectionHeader className="justify-between">
|
||||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||||
<Select
|
<Select
|
||||||
value={activeProject.settings.fps.toString()}
|
value={String(Math.round(frameRateToFloat(activeProject.settings.fps)))}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const fps = parseFloat(value);
|
const fps = floatToFrameRate(parseFloat(value));
|
||||||
editor.project.updateSettings({ settings: { fps } });
|
editor.project.updateSettings({ settings: { fps } });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||||
<SelectValue placeholder="Select a frame rate" />
|
<SelectValue placeholder="Select a frame rate" />
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||||
import { useContainerSize } from "@/hooks/use-container-size";
|
import { useContainerSize } from "@/hooks/use-container-size";
|
||||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||||
|
|
@ -129,12 +130,16 @@ function PreviewCanvas({
|
||||||
height: nativeHeight,
|
height: nativeHeight,
|
||||||
fps: activeProject.settings.fps,
|
fps: activeProject.settings.fps,
|
||||||
});
|
});
|
||||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
}, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]);
|
||||||
|
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||||
const renderTime = editor.playback.getCurrentTime();
|
const renderTime = Math.min(
|
||||||
const frame = Math.floor(renderTime * renderer.fps);
|
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 (
|
if (
|
||||||
frame !== lastFrameRef.current ||
|
frame !== lastFrameRef.current ||
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { formatTimeCode } from "opencut-wasm";
|
import { formatTimecode } from "opencut-wasm";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
import { EditableTimecode } from "@/components/editable-timecode";
|
import { EditableTimecode } from "@/components/editable-timecode";
|
||||||
import { Button } from "@/components/ui/button";
|
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 px-2 font-mono text-xs">/</span>
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { getElementLocalTime } from "@/lib/animation";
|
import { getElementLocalTime } from "@/lib/animation";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
|
export function useElementPlayhead({
|
||||||
export function useElementPlayhead({
|
startTime,
|
||||||
startTime,
|
duration,
|
||||||
duration,
|
}: {
|
||||||
}: {
|
startTime: number;
|
||||||
startTime: number;
|
duration: number;
|
||||||
duration: number;
|
}) {
|
||||||
}) {
|
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
const localTime = getElementLocalTime({
|
||||||
const localTime = getElementLocalTime({
|
timelineTime: playheadTime,
|
||||||
timelineTime: playheadTime,
|
elementStartTime: startTime,
|
||||||
elementStartTime: startTime,
|
elementDuration: duration,
|
||||||
elementDuration: duration,
|
});
|
||||||
});
|
const isPlayheadWithinElementRange =
|
||||||
const isPlayheadWithinElementRange =
|
playheadTime >= startTime &&
|
||||||
playheadTime >= startTime - TIME_EPSILON_SECONDS &&
|
playheadTime <= startTime + duration;
|
||||||
playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
|
|
||||||
|
return { localTime, isPlayheadWithinElementRange };
|
||||||
return { localTime, isPlayheadWithinElementRange };
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
PlusSignIcon,
|
PlusSignIcon,
|
||||||
RotateClockwiseIcon,
|
RotateClockwiseIcon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
|
|
@ -102,7 +101,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
|
||||||
const elementBounds = useMemo(() => {
|
const elementBounds = useMemo(() => {
|
||||||
const clampedTime = Math.min(
|
const clampedTime = Math.min(
|
||||||
Math.max(currentTime, element.startTime),
|
Math.max(currentTime, element.startTime),
|
||||||
element.startTime + element.duration - TIME_EPSILON_SECONDS,
|
element.startTime + element.duration - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ import { useEffect, useState } from "react";
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
||||||
} from "./theme";
|
} from "./theme";
|
||||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
||||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||||
import { getSnappedSeekTime } from "opencut-wasm";
|
import { snappedSeekTime } from "opencut-wasm";
|
||||||
import {
|
import {
|
||||||
ArrowTurnBackwardIcon,
|
ArrowTurnBackwardIcon,
|
||||||
Delete02Icon,
|
Delete02Icon,
|
||||||
|
|
@ -49,8 +48,8 @@ function seekToBookmarkTime({
|
||||||
}) {
|
}) {
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
|
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||||
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
|
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
|
||||||
editor.playback.seek({ time: snappedTime });
|
editor.playback.seek({ time: snappedTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +138,7 @@ function TimelineBookmark({
|
||||||
const isDragging =
|
const isDragging =
|
||||||
dragState.isDragging &&
|
dragState.isDragging &&
|
||||||
dragState.bookmarkTime !== null &&
|
dragState.bookmarkTime !== null &&
|
||||||
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
|
dragState.bookmarkTime === bookmark.time;
|
||||||
|
|
||||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||||
const time = bookmark.time;
|
const time = bookmark.time;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
|
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||||
import {
|
import {
|
||||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||||
} from "./layout";
|
} from "./layout";
|
||||||
|
|
@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({
|
||||||
startPos,
|
startPos,
|
||||||
endPos: currentPos,
|
endPos: currentPos,
|
||||||
});
|
});
|
||||||
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
|
|
||||||
const selectedElements: TimelineElementRef[] = [];
|
const selectedElements: TimelineElementRef[] = [];
|
||||||
|
|
||||||
for (const [trackIndex, track] of tracks.entries()) {
|
for (const [trackIndex, track] of tracks.entries()) {
|
||||||
|
|
@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({
|
||||||
const elementBottom = elementTop + trackHeight;
|
const elementBottom = elementTop + trackHeight;
|
||||||
|
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
const elementLeft = element.startTime * pixelsPerSecond;
|
const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
|
||||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
|
||||||
const elementRectangle = {
|
const elementRectangle = {
|
||||||
left: elementLeft,
|
left: elementLeft,
|
||||||
top: elementTop,
|
top: elementTop,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||||
|
|
@ -72,10 +73,11 @@ export function TimelinePlayhead({
|
||||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||||
|
|
||||||
event.preventDefault();
|
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 direction = event.key === "ArrowRight" ? 1 : -1;
|
||||||
const now = editor.playback.getCurrentTime();
|
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 });
|
editor.playback.seek({ time: nextTime });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||||
} from "@/lib/timeline/scale";
|
} from "@/lib/timeline/scale";
|
||||||
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
||||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
|
@ -30,17 +32,18 @@ export function TimelineRuler({
|
||||||
handleRulerTrackingMouseDown,
|
handleRulerTrackingMouseDown,
|
||||||
handleRulerMouseDown,
|
handleRulerMouseDown,
|
||||||
}: TimelineRulerProps) {
|
}: 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 pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
|
||||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds);
|
||||||
const fps =
|
const fps =
|
||||||
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
|
useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
|
||||||
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
|
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
fps,
|
fps,
|
||||||
});
|
});
|
||||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
|
||||||
|
|
||||||
const { scrollLeft, viewportWidth } = useScrollPosition({
|
const { scrollLeft, viewportWidth } = useScrollPosition({
|
||||||
scrollRef: tracksScrollRef,
|
scrollRef: tracksScrollRef,
|
||||||
|
|
@ -61,20 +64,20 @@ export function TimelineRuler({
|
||||||
prevZoomRef.current = zoomLevel;
|
prevZoomRef.current = zoomLevel;
|
||||||
}, [zoomLevel]);
|
}, [zoomLevel]);
|
||||||
|
|
||||||
const visibleStartTime = Math.max(
|
const visibleStartTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
(scrollLeft - bufferPx) / pixelsPerSecond,
|
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||||
);
|
);
|
||||||
const visibleEndTime =
|
const visibleEndTimeSeconds =
|
||||||
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
||||||
|
|
||||||
const startTickIndex = Math.max(
|
const startTickIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(visibleStartTime / tickIntervalSeconds),
|
Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
|
||||||
);
|
);
|
||||||
const endTickIndex = Math.min(
|
const endTickIndex = Math.min(
|
||||||
tickCount - 1,
|
tickCount - 1,
|
||||||
Math.ceil(visibleEndTime / tickIntervalSeconds),
|
Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
|
||||||
);
|
);
|
||||||
|
|
||||||
const timelineTicks: Array<JSX.Element> = [];
|
const timelineTicks: Array<JSX.Element> = [];
|
||||||
|
|
@ -83,14 +86,16 @@ export function TimelineRuler({
|
||||||
tickIndex <= endTickIndex;
|
tickIndex <= endTickIndex;
|
||||||
tickIndex += 1
|
tickIndex += 1
|
||||||
) {
|
) {
|
||||||
const time = tickIndex * tickIntervalSeconds;
|
const timeSeconds = tickIndex * tickIntervalSeconds;
|
||||||
if (time > effectiveDuration) break;
|
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(
|
timelineTicks.push(
|
||||||
<TimelineTick
|
<TimelineTick
|
||||||
key={tickIndex}
|
key={tickIndex}
|
||||||
time={time}
|
time={timeTicks}
|
||||||
|
timeInSeconds={timeSeconds}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
fps={fps}
|
fps={fps}
|
||||||
showLabel={showLabel}
|
showLabel={showLabel}
|
||||||
|
|
@ -104,7 +109,7 @@ export function TimelineRuler({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Timeline ruler"
|
aria-label="Timeline ruler"
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={effectiveDuration}
|
aria-valuemax={effectiveDurationSeconds}
|
||||||
aria-valuenow={0}
|
aria-valuenow={0}
|
||||||
className="relative flex-1 overflow-x-visible"
|
className="relative flex-1 overflow-x-visible"
|
||||||
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||||
|
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
|
||||||
interface TimelineTickProps {
|
|
||||||
time: number;
|
interface TimelineTickProps {
|
||||||
zoomLevel: number;
|
time: number;
|
||||||
fps: number;
|
timeInSeconds: number;
|
||||||
showLabel: boolean;
|
zoomLevel: number;
|
||||||
}
|
fps: FrameRate;
|
||||||
|
showLabel: boolean;
|
||||||
export function TimelineTick({
|
}
|
||||||
time,
|
|
||||||
zoomLevel,
|
export function TimelineTick({
|
||||||
fps,
|
time,
|
||||||
showLabel,
|
timeInSeconds,
|
||||||
}: TimelineTickProps) {
|
zoomLevel,
|
||||||
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
|
fps,
|
||||||
|
showLabel,
|
||||||
if (showLabel) {
|
}: TimelineTickProps) {
|
||||||
const label = formatRulerLabel({ timeInSeconds: time, fps });
|
const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
|
||||||
return (
|
|
||||||
<span
|
if (showLabel) {
|
||||||
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
|
const label = formatRulerLabel({ timeInSeconds, fps });
|
||||||
style={{ left: `${leftPosition}px` }}
|
return (
|
||||||
>
|
<span
|
||||||
{label}
|
className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
|
||||||
</span>
|
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` }}
|
return (
|
||||||
/>
|
<div
|
||||||
);
|
className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
|
||||||
}
|
style={{ left: `${leftPosition}px` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -1,4 +1,6 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
import { roundToFrame } from "opencut-wasm";
|
||||||
|
|
||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
|
|
@ -9,11 +11,12 @@ export class PlaybackManager {
|
||||||
private isScrubbing = false;
|
private isScrubbing = false;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private playbackTimer: number | null = null;
|
private playbackTimer: number | null = null;
|
||||||
private lastUpdate = 0;
|
private playbackStartWallTime = 0;
|
||||||
|
private playbackStartTime = 0;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {
|
constructor(private editor: EditorCore) {
|
||||||
this.editor.timeline.subscribe(() => {
|
this.editor.timeline.subscribe(() => {
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
if (this.currentTime > maxTime && maxTime > 0) {
|
if (this.currentTime > maxTime && maxTime > 0) {
|
||||||
this.currentTime = maxTime;
|
this.currentTime = maxTime;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
@ -22,7 +25,7 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
play(): void {
|
play(): void {
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (maxTime > 0) {
|
if (maxTime > 0) {
|
||||||
if (this.currentTime >= maxTime) {
|
if (this.currentTime >= maxTime) {
|
||||||
|
|
@ -50,8 +53,12 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
seek({ time }: { time: number }): void {
|
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));
|
this.currentTime = Math.max(0, Math.min(maxTime, time));
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.playbackStartWallTime = performance.now();
|
||||||
|
this.playbackStartTime = this.currentTime;
|
||||||
|
}
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
|
|
@ -135,7 +142,8 @@ export class PlaybackManager {
|
||||||
cancelAnimationFrame(this.playbackTimer);
|
cancelAnimationFrame(this.playbackTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastUpdate = performance.now();
|
this.playbackStartWallTime = performance.now();
|
||||||
|
this.playbackStartTime = this.currentTime;
|
||||||
this.updateTime();
|
this.updateTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,12 +157,11 @@ export class PlaybackManager {
|
||||||
private updateTime = (): void => {
|
private updateTime = (): void => {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
const now = performance.now();
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
const delta = (now - this.lastUpdate) / 1000;
|
const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000;
|
||||||
this.lastUpdate = now;
|
const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
||||||
|
const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime;
|
||||||
const newTime = this.currentTime + delta;
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
const maxTime = this.editor.timeline.getLastSeekableTime();
|
|
||||||
|
|
||||||
if (maxTime > 0 && newTime >= maxTime) {
|
if (maxTime > 0 && newTime >= maxTime) {
|
||||||
this.pause();
|
this.pause();
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ export class ProjectManager {
|
||||||
importedAssets,
|
importedAssets,
|
||||||
}: {
|
}: {
|
||||||
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
|
importedAssets: Array<Pick<MediaAsset, "type" | "fps">>;
|
||||||
}): number | null {
|
}): import("opencut-wasm").FrameRate | null {
|
||||||
if (!this.active) return null;
|
if (!this.active) return null;
|
||||||
|
|
||||||
const nextFps = getRaisedProjectFpsForImportedMedia({
|
const nextFps = getRaisedProjectFpsForImportedMedia({
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
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";
|
import { downloadBlob } from "@/utils/browser";
|
||||||
|
|
||||||
type SnapshotResult =
|
type SnapshotResult =
|
||||||
|
|
@ -81,7 +82,10 @@ export class RendererManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canvasSize, fps } = activeProject.settings;
|
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({
|
const renderer = new CanvasRenderer({
|
||||||
width: canvasSize.width,
|
width: canvasSize.width,
|
||||||
|
|
@ -107,7 +111,7 @@ export class RendererManager {
|
||||||
return { success: false, error: "Failed to create image" };
|
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 =
|
const safeName =
|
||||||
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
||||||
"snapshot";
|
"snapshot";
|
||||||
|
|
@ -148,7 +152,7 @@ export class RendererManager {
|
||||||
return { success: false, error: "Project is empty" };
|
return { success: false, error: "Project is empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportFps = fps || activeProject.settings.fps;
|
const exportFps = fps ?? activeProject.settings.fps;
|
||||||
const canvasSize = activeProject.settings.canvasSize;
|
const canvasSize = activeProject.settings.canvasSize;
|
||||||
|
|
||||||
let audioBuffer: AudioBuffer | null = null;
|
let audioBuffer: AudioBuffer | null = null;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type {
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { getLastFrameTime } from "opencut-wasm";
|
import { lastFrameTime } from "opencut-wasm";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import {
|
import {
|
||||||
AddTrackCommand,
|
AddTrackCommand,
|
||||||
|
|
@ -196,11 +196,11 @@ export class TimelineManager {
|
||||||
return calculateTotalDuration({ tracks: this.getTracks() });
|
return calculateTotalDuration({ tracks: this.getTracks() });
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastSeekableTime(): number {
|
getLastFrameTime(): number {
|
||||||
const duration = this.getTotalDuration();
|
const duration = this.getTotalDuration();
|
||||||
const fps = this.editor.project.getActive()?.settings.fps;
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
if (!fps || duration <= 0) return duration;
|
if (!fps || duration <= 0) return duration;
|
||||||
return getLastFrameTime({ duration, fps });
|
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||||
import { useEditor } from "../use-editor";
|
import { useEditor } from "../use-editor";
|
||||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||||
import {
|
import {
|
||||||
getElementsAtTime,
|
getElementsAtTime,
|
||||||
|
|
@ -110,10 +111,11 @@ export function useEditorActions() {
|
||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: Math.min(
|
||||||
editor.timeline.getTotalDuration(),
|
editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + 1 / fps,
|
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -124,8 +126,9 @@ export function useEditorActions() {
|
||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
|
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 { 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 { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
|
@ -67,7 +68,8 @@ function getClickOffsetTime({
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}): number {
|
}): number {
|
||||||
const clickOffsetX = clientX - elementRect.left;
|
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({
|
function getVerticalDragDirection({
|
||||||
|
|
@ -297,15 +299,17 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime =
|
const adjustedTime = Math.max(
|
||||||
mouseTime - pendingDragRef.current.clickOffsetTime;
|
0,
|
||||||
const snappedTime = snapTimeToFrame({
|
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||||
time: adjustedTime,
|
);
|
||||||
fps: activeProject.settings.fps,
|
const snappedTime = roundToFrame({
|
||||||
});
|
time: adjustedTime,
|
||||||
startDrag({
|
rate: activeProject.settings.fps,
|
||||||
...pendingDragRef.current,
|
}) ?? adjustedTime;
|
||||||
initialCurrentTime: snappedTime,
|
startDrag({
|
||||||
|
...pendingDragRef.current,
|
||||||
|
initialCurrentTime: snappedTime,
|
||||||
initialCurrentMouseY: clientY,
|
initialCurrentMouseY: clientY,
|
||||||
});
|
});
|
||||||
startedDragThisEvent = true;
|
startedDragThisEvent = true;
|
||||||
|
|
@ -343,9 +347,9 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime = mouseTime - dragState.clickOffsetTime;
|
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||||
const fps = activeProject.settings.fps;
|
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 sourceTrack = tracks.find(({ id }) => id === dragState.trackId);
|
||||||
const movingElement = sourceTrack?.elements.find(
|
const movingElement = sourceTrack?.elements.find(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
|
|
@ -185,13 +186,14 @@ export function useTimelineElementResize({
|
||||||
({ clientX }: { clientX: number }) => {
|
({ clientX }: { clientX: number }) => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
||||||
const deltaX = clientX - resizing.startX;
|
const deltaX = clientX - resizing.startX;
|
||||||
let deltaTime =
|
let deltaTime = Math.round(
|
||||||
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND,
|
||||||
let resizeSnapPoint: SnapPoint | null = null;
|
);
|
||||||
|
let resizeSnapPoint: SnapPoint | null = null;
|
||||||
|
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
const projectFps = editor.project.getActive().settings.fps;
|
||||||
const minDurationSeconds = 1 / projectFps;
|
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
if (shouldSnap) {
|
if (shouldSnap) {
|
||||||
const tracks = editor.timeline.getTracks();
|
const tracks = editor.timeline.getTracks();
|
||||||
|
|
@ -277,19 +279,19 @@ export function useTimelineElementResize({
|
||||||
const maxAllowed =
|
const maxAllowed =
|
||||||
sourceDuration -
|
sourceDuration -
|
||||||
resizing.initialTrimEnd -
|
resizing.initialTrimEnd -
|
||||||
getVisibleSourceSpanForDuration(minDurationSeconds);
|
getVisibleSourceSpanForDuration(minDuration);
|
||||||
const calculated =
|
const calculated =
|
||||||
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
||||||
|
|
||||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
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(
|
const visibleSourceSpan = Math.max(
|
||||||
0,
|
0,
|
||||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
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 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);
|
setCurrentTrimStart(newTrimStart);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -311,8 +313,8 @@ export function useTimelineElementResize({
|
||||||
)
|
)
|
||||||
: Math.min(extensionAmount, maxExtension),
|
: Math.min(extensionAmount, maxExtension),
|
||||||
);
|
);
|
||||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
|
const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension;
|
||||||
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
|
const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension;
|
||||||
|
|
||||||
setCurrentTrimStart(0);
|
setCurrentTrimStart(0);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -338,8 +340,8 @@ export function useTimelineElementResize({
|
||||||
0,
|
0,
|
||||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||||
);
|
);
|
||||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
|
const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration);
|
||||||
|
|
||||||
setCurrentTrimStart(newTrimStart);
|
setCurrentTrimStart(newTrimStart);
|
||||||
setCurrentStartTime(newStartTime);
|
setCurrentStartTime(newStartTime);
|
||||||
|
|
@ -366,7 +368,7 @@ export function useTimelineElementResize({
|
||||||
const extensionNeeded = Math.abs(newTrimEnd);
|
const extensionNeeded = Math.abs(newTrimEnd);
|
||||||
const baseDuration =
|
const baseDuration =
|
||||||
resizing.initialDuration + resizing.initialTrimEnd;
|
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);
|
setCurrentDuration(newDuration);
|
||||||
setCurrentTrimEnd(0);
|
setCurrentTrimEnd(0);
|
||||||
|
|
@ -376,7 +378,7 @@ export function useTimelineElementResize({
|
||||||
const unclampedDuration = getDurationForVisibleSourceSpan(
|
const unclampedDuration = getDurationForVisibleSourceSpan(
|
||||||
Math.max(0, sourceDuration - resizing.initialTrimStart),
|
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);
|
setCurrentDuration(newDuration);
|
||||||
setCurrentTrimEnd(0);
|
setCurrentTrimEnd(0);
|
||||||
|
|
@ -395,17 +397,17 @@ export function useTimelineElementResize({
|
||||||
const maxTrimEnd =
|
const maxTrimEnd =
|
||||||
sourceDuration -
|
sourceDuration -
|
||||||
resizing.initialTrimStart -
|
resizing.initialTrimStart -
|
||||||
getVisibleSourceSpanForDuration(minDurationSeconds);
|
getVisibleSourceSpanForDuration(minDuration);
|
||||||
const clampedTrimEnd = Math.min(
|
const clampedTrimEnd = Math.min(
|
||||||
maxTrimEnd,
|
maxTrimEnd,
|
||||||
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
||||||
);
|
);
|
||||||
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
|
const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd;
|
||||||
const visibleSourceSpan = Math.max(
|
const visibleSourceSpan = Math.max(
|
||||||
0,
|
0,
|
||||||
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
||||||
);
|
);
|
||||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan);
|
||||||
|
|
||||||
setCurrentTrimEnd(finalTrimEnd);
|
setCurrentTrimEnd(finalTrimEnd);
|
||||||
setCurrentDuration(newDuration);
|
setCurrentDuration(newDuration);
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ import {
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { getKeyframeById } from "@/lib/animation";
|
import { getKeyframeById } from "@/lib/animation";
|
||||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
||||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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 { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
|
|
@ -144,9 +145,9 @@ export function useKeyframeDrag({
|
||||||
|
|
||||||
if (!dragState.isDragging) return;
|
if (!dragState.isDragging) return;
|
||||||
|
|
||||||
const startX = mouseDownXRef.current ?? clientX;
|
const startX = mouseDownXRef.current ?? clientX;
|
||||||
const rawDelta = (clientX - startX) / pixelsPerSecond;
|
const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND);
|
||||||
const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
|
const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
|
||||||
|
|
||||||
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
||||||
};
|
};
|
||||||
|
|
@ -255,7 +256,7 @@ export function useKeyframeDrag({
|
||||||
if (wasDrag) return;
|
if (wasDrag) return;
|
||||||
|
|
||||||
const duration = editor.timeline.getTotalDuration();
|
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 });
|
editor.playback.seek({ time: seekTime });
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
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 { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||||
import {
|
import {
|
||||||
findSnapPoints,
|
findSnapPoints,
|
||||||
|
|
@ -148,7 +148,7 @@ export function useBookmarkDrag({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
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({
|
const { snappedTime: initialTime } = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: bookmarkTime,
|
excludeBookmarkTime: bookmarkTime,
|
||||||
|
|
@ -176,9 +176,9 @@ export function useBookmarkDrag({
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||||
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
|
const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime;
|
||||||
const snapResult = getSnapResult({
|
const snapResult = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: dragState.bookmarkTime,
|
excludeBookmarkTime: dragState.bookmarkTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor";
|
||||||
import { processMediaAssets } from "@/lib/media/processing";
|
import { processMediaAssets } from "@/lib/media/processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { snapTimeToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
import {
|
import {
|
||||||
buildTextElement,
|
buildTextElement,
|
||||||
buildGraphicElement,
|
buildGraphicElement,
|
||||||
|
|
@ -47,7 +47,7 @@ export function useTimelineDragDrop({
|
||||||
const getSnappedTime = useCallback(
|
const getSnappedTime = useCallback(
|
||||||
({ time }: { time: number }) => {
|
({ time }: { time: number }) => {
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
const projectFps = editor.project.getActive().settings.fps;
|
||||||
return snapTimeToFrame({ time, fps: projectFps });
|
return roundToFrame({ time, rate: projectFps }) ?? time;
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
@ -83,14 +83,14 @@ export function useTimelineDragDrop({
|
||||||
elementType === "sticker" ||
|
elementType === "sticker" ||
|
||||||
elementType === "effect"
|
elementType === "effect"
|
||||||
) {
|
) {
|
||||||
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
}
|
}
|
||||||
if (mediaId) {
|
if (mediaId) {
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
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],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
@ -331,7 +331,7 @@ export function useTimelineDragDrop({
|
||||||
dragData.mediaType === "audio" ? "audio" : "video";
|
dragData.mediaType === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: mediaAsset.id,
|
mediaId: mediaAsset.id,
|
||||||
mediaType: mediaAsset.type,
|
mediaType: mediaAsset.type,
|
||||||
|
|
@ -446,7 +446,7 @@ export function useTimelineDragDrop({
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
createdAsset.duration ??
|
createdAsset.duration ??
|
||||||
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const currentTracks = editor.timeline.getTracks();
|
const currentTracks = editor.timeline.getTracks();
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
const onlyTrack = currentTracks[0];
|
const onlyTrack = currentTracks[0];
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect, useCallback, useRef } from "react";
|
||||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||||
import { useEditor } from "../use-editor";
|
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 { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
|
||||||
import {
|
import {
|
||||||
getCenteredLineLeft,
|
getCenteredLineLeft,
|
||||||
|
timelineTimeToPixels,
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
|
|
@ -66,24 +68,24 @@ export function useTimelinePlayhead({
|
||||||
const rulerRect = ruler.getBoundingClientRect();
|
const rulerRect = ruler.getBoundingClientRect();
|
||||||
const relativeMouseX = event.clientX - rulerRect.left;
|
const relativeMouseX = event.clientX - rulerRect.left;
|
||||||
|
|
||||||
const timelineContentWidth =
|
const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
|
||||||
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
|
|
||||||
const clampedMouseX = Math.max(
|
const clampedMouseX = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(timelineContentWidth, relativeMouseX),
|
Math.min(timelineContentWidth, relativeMouseX),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawTime = Math.max(
|
const rawTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration / TICKS_PER_SECOND,
|
||||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||||
|
|
||||||
const framesPerSecond = activeProject.settings.fps;
|
const rate = activeProject.settings.fps;
|
||||||
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
|
const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
|
||||||
|
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
const time = (() => {
|
const time = (() => {
|
||||||
|
|
@ -161,7 +163,7 @@ export function useTimelinePlayhead({
|
||||||
getMouseClientX: () => lastMouseXRef.current,
|
getMouseClientX: () => lastMouseXRef.current,
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -247,8 +249,10 @@ export function useTimelinePlayhead({
|
||||||
const tracksViewport = tracksScrollRef.current;
|
const tracksViewport = tracksScrollRef.current;
|
||||||
if (!rulerViewport || !tracksViewport) return;
|
if (!rulerViewport || !tracksViewport) return;
|
||||||
|
|
||||||
const playheadPixels =
|
const playheadPixels = timelineTimeToPixels({
|
||||||
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
|
time,
|
||||||
|
zoomLevel: zoomLevelRef.current,
|
||||||
|
});
|
||||||
const viewportWidth = rulerViewport.clientWidth;
|
const viewportWidth = rulerViewport.clientWidth;
|
||||||
const scrollMinimum = 0;
|
const scrollMinimum = 0;
|
||||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import type { MutableRefObject, RefObject } from "react";
|
import type { MutableRefObject, RefObject } from "react";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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";
|
import { useEditor } from "../use-editor";
|
||||||
|
|
||||||
interface UseTimelineSeekProps {
|
interface UseTimelineSeekProps {
|
||||||
|
|
@ -127,17 +128,18 @@ export function useTimelineSeek({
|
||||||
const mouseX = event.clientX - rect.left;
|
const mouseX = event.clientX - rect.left;
|
||||||
const scrollLeft = scrollContainer.scrollLeft;
|
const scrollLeft = scrollContainer.scrollLeft;
|
||||||
|
|
||||||
const rawTime = Math.max(
|
const rawTimeSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
duration,
|
duration / TICKS_PER_SECOND,
|
||||||
(mouseX + scrollLeft) /
|
(mouseX + scrollLeft) /
|
||||||
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||||
|
|
||||||
const projectFps = activeProject?.settings.fps || 30;
|
const rate = activeProject?.settings.fps;
|
||||||
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
|
const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime;
|
||||||
seek(time);
|
seek(time);
|
||||||
editor.project.setTimelineViewState({
|
editor.project.setTimelineViewState({
|
||||||
viewState: {
|
viewState: {
|
||||||
|
|
@ -154,7 +156,8 @@ export function useTimelineSeek({
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
seek,
|
seek,
|
||||||
editor,
|
editor,
|
||||||
activeProject?.settings.fps,
|
activeProject?.settings.fps.numerator,
|
||||||
|
activeProject?.settings.fps.denominator,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
import {
|
import {
|
||||||
TIMELINE_ZOOM_MAX,
|
TIMELINE_ZOOM_MAX,
|
||||||
TIMELINE_ZOOM_MIN,
|
TIMELINE_ZOOM_MIN,
|
||||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
|
||||||
} from "@/lib/timeline/scale";
|
} from "@/lib/timeline/scale";
|
||||||
|
import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||||
|
|
||||||
|
|
@ -186,10 +186,8 @@ export function useTimelineZoom({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||||
const playheadPixelsBefore =
|
const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom });
|
||||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
|
const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel });
|
||||||
const playheadPixelsAfter =
|
|
||||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
|
|
||||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,114 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { processMediaAssets } from "@/lib/media/processing";
|
import { processMediaAssets } from "@/lib/media/processing";
|
||||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
||||||
import { invokeAction } from "@/lib/actions";
|
import { invokeAction } from "@/lib/actions";
|
||||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||||
import { InsertElementCommand } from "@/lib/commands/timeline";
|
import { InsertElementCommand } from "@/lib/commands/timeline";
|
||||||
import { BatchCommand } from "@/lib/commands";
|
import { BatchCommand } from "@/lib/commands";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import type { MediaType } from "@/lib/media/types";
|
import type { MediaType } from "@/lib/media/types";
|
||||||
|
|
||||||
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
|
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
|
||||||
|
|
||||||
function isMediaMimeType({ type }: { type: string }): boolean {
|
function isMediaMimeType({ type }: { type: string }): boolean {
|
||||||
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
|
return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMediaFilesFromClipboard({
|
function extractMediaFilesFromClipboard({
|
||||||
clipboardData,
|
clipboardData,
|
||||||
}: {
|
}: {
|
||||||
clipboardData: DataTransfer | null;
|
clipboardData: DataTransfer | null;
|
||||||
}): File[] {
|
}): File[] {
|
||||||
if (!clipboardData?.items) return [];
|
if (!clipboardData?.items) return [];
|
||||||
|
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
for (const item of clipboardData.items) {
|
for (const item of clipboardData.items) {
|
||||||
if (item.kind !== "file") continue;
|
if (item.kind !== "file") continue;
|
||||||
if (!isMediaMimeType({ type: item.type })) continue;
|
if (!isMediaMimeType({ type: item.type })) continue;
|
||||||
|
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) files.push(file);
|
if (file) files.push(file);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePasteMedia() {
|
export function usePasteMedia() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
const activeElement = document.activeElement as HTMLElement;
|
const activeElement = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
if (activeElement && isTypableDOMElement({ element: activeElement })) {
|
if (activeElement && isTypableDOMElement({ element: activeElement })) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = extractMediaFilesFromClipboard({
|
const files = extractMediaFilesFromClipboard({
|
||||||
clipboardData: event.clipboardData,
|
clipboardData: event.clipboardData,
|
||||||
});
|
});
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
invokeAction("paste-copied");
|
invokeAction("paste-copied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await showMediaUploadToast({
|
await showMediaUploadToast({
|
||||||
filesCount: files.length,
|
filesCount: files.length,
|
||||||
promise: async () => {
|
promise: async () => {
|
||||||
const processedAssets = await processMediaAssets({ files });
|
const processedAssets = await processMediaAssets({ files });
|
||||||
const startTime = editor.playback.getCurrentTime();
|
const startTime = editor.playback.getCurrentTime();
|
||||||
|
|
||||||
for (const asset of processedAssets) {
|
for (const asset of processedAssets) {
|
||||||
const addMediaCmd = new AddMediaAssetCommand(
|
const addMediaCmd = new AddMediaAssetCommand(
|
||||||
activeProject.metadata.id,
|
activeProject.metadata.id,
|
||||||
asset,
|
asset,
|
||||||
);
|
);
|
||||||
const assetId = addMediaCmd.getAssetId();
|
const assetId = addMediaCmd.getAssetId();
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: assetId,
|
mediaId: assetId,
|
||||||
mediaType: asset.type,
|
mediaType: asset.type,
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer:
|
buffer:
|
||||||
asset.type === "audio"
|
asset.type === "audio"
|
||||||
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
? new AudioBuffer({ length: 1, sampleRate: 44100 })
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const insertCmd = new InsertElementCommand({
|
const insertCmd = new InsertElementCommand({
|
||||||
element,
|
element,
|
||||||
placement: { mode: "auto", trackType },
|
placement: { mode: "auto", trackType },
|
||||||
});
|
});
|
||||||
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
|
const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
|
||||||
editor.command.execute({ command: batchCmd });
|
editor.command.execute({ command: batchCmd });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadedCount: processedAssets.length,
|
uploadedCount: processedAssets.length,
|
||||||
assetNames: processedAssets.map((asset) => asset.name),
|
assetNames: processedAssets.map((asset) => asset.name),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to paste media:", error);
|
console.error("Failed to paste media:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("paste", handlePaste);
|
window.addEventListener("paste", handlePaste);
|
||||||
return () => window.removeEventListener("paste", handlePaste);
|
return () => window.removeEventListener("paste", handlePaste);
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import {
|
import {
|
||||||
getDefaultLeftHandle,
|
getDefaultLeftHandle,
|
||||||
getDefaultRightHandle,
|
getDefaultRightHandle,
|
||||||
|
|
@ -25,7 +24,7 @@ export function getNormalizedCubicBezierForScalarSegment({
|
||||||
const spanTime = rightKey.time - leftKey.time;
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
const spanValue = rightKey.value - leftKey.value;
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
if (
|
if (
|
||||||
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
spanTime === 0 ||
|
||||||
Math.abs(spanValue) <= VALUE_EPSILON
|
Math.abs(spanValue) <= VALUE_EPSILON
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -59,7 +58,7 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
||||||
const spanTime = rightKey.time - leftKey.time;
|
const spanTime = rightKey.time - leftKey.time;
|
||||||
const spanValue = rightKey.value - leftKey.value;
|
const spanValue = rightKey.value - leftKey.value;
|
||||||
if (
|
if (
|
||||||
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
spanTime === 0 ||
|
||||||
Math.abs(spanValue) <= VALUE_EPSILON
|
Math.abs(spanValue) <= VALUE_EPSILON
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import type {
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { clamp } from "@/utils/math";
|
import { clamp } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
getBezierPoint,
|
getBezierPoint,
|
||||||
|
|
@ -36,10 +35,7 @@ function isWithinTimePair({
|
||||||
leftTime: number;
|
leftTime: number;
|
||||||
rightTime: number;
|
rightTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return time >= leftTime && time <= rightTime;
|
||||||
time >= leftTime - TIME_EPSILON_SECONDS &&
|
|
||||||
time <= rightTime + TIME_EPSILON_SECONDS
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpNumber({
|
function lerpNumber({
|
||||||
|
|
@ -67,7 +63,7 @@ function normalizeRightHandle({
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||||
return {
|
return {
|
||||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
|
|
@ -87,7 +83,7 @@ function normalizeLeftHandle({
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||||
return {
|
return {
|
||||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
|
|
@ -187,7 +183,7 @@ function extrapolateScalarEdge({
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = neighborKey.time - edgeKey.time;
|
const span = neighborKey.time - edgeKey.time;
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
if (span === 0) {
|
||||||
return edgeKey.value;
|
return edgeKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,8 +224,8 @@ export function getScalarChannelValueAtTime({
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
if (time <= firstKey.time) {
|
||||||
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
if (time < firstKey.time) {
|
||||||
return extrapolateScalarEdge({
|
return extrapolateScalarEdge({
|
||||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||||
edgeKey: firstKey,
|
edgeKey: firstKey,
|
||||||
|
|
@ -241,8 +237,8 @@ export function getScalarChannelValueAtTime({
|
||||||
return firstKey.value;
|
return firstKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
if (time >= lastKey.time) {
|
||||||
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
if (time > lastKey.time) {
|
||||||
return extrapolateScalarEdge({
|
return extrapolateScalarEdge({
|
||||||
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||||
edgeKey: lastKey,
|
edgeKey: lastKey,
|
||||||
|
|
@ -261,7 +257,7 @@ export function getScalarChannelValueAtTime({
|
||||||
) {
|
) {
|
||||||
const leftKey = normalizedChannel.keys[keyIndex];
|
const leftKey = normalizedChannel.keys[keyIndex];
|
||||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
if (time === rightKey.time) {
|
||||||
return rightKey.value;
|
return rightKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,7 +276,7 @@ export function getScalarChannelValueAtTime({
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = rightKey.time - leftKey.time;
|
const span = rightKey.time - leftKey.time;
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
if (span === 0) {
|
||||||
return rightKey.value;
|
return rightKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +332,7 @@ export function getDiscreteChannelValueAtTime({
|
||||||
});
|
});
|
||||||
let currentValue = fallbackValue;
|
let currentValue = fallbackValue;
|
||||||
for (const key of normalizedChannel.keys) {
|
for (const key of normalizedChannel.keys) {
|
||||||
if (time + TIME_EPSILON_SECONDS < key.time) {
|
if (time < key.time) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
currentValue = key.value;
|
currentValue = key.value;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import type {
|
import type {
|
||||||
AnimationBindingInstance,
|
AnimationBindingInstance,
|
||||||
AnimationChannel,
|
AnimationChannel,
|
||||||
|
|
@ -76,8 +75,7 @@ function getUniqueBindingKeyframeMatches({
|
||||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||||
if (
|
if (
|
||||||
!previousMatch ||
|
!previousMatch ||
|
||||||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
previousMatch.keyframe.time !== match.keyframe.time
|
||||||
TIME_EPSILON_SECONDS
|
|
||||||
) {
|
) {
|
||||||
uniqueMatches.push(match);
|
uniqueMatches.push(match);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -249,9 +247,7 @@ export function getKeyframeAtTime({
|
||||||
matches: getBindingKeyframeMatches({
|
matches: getBindingKeyframeMatches({
|
||||||
animations,
|
animations,
|
||||||
binding,
|
binding,
|
||||||
}).filter(({ keyframe }) =>
|
}).filter(({ keyframe }) => keyframe.time === time),
|
||||||
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (!keyframeMatch) {
|
if (!keyframeMatch) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type {
|
||||||
ScalarCurveKeyframePatch,
|
ScalarCurveKeyframePatch,
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import {
|
import {
|
||||||
cloneAnimationBinding,
|
cloneAnimationBinding,
|
||||||
|
|
@ -44,7 +43,7 @@ function isNearlySameTime({
|
||||||
leftTime: number;
|
leftTime: number;
|
||||||
rightTime: number;
|
rightTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
|
return leftTime === rightTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChannelKeys({
|
function hasChannelKeys({
|
||||||
|
|
@ -1032,8 +1031,8 @@ function splitScalarChannelAtTime({
|
||||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
|
splitTime > leftKey.time &&
|
||||||
splitTime < rightKey.time - TIME_EPSILON_SECONDS
|
splitTime < rightKey.time
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,17 @@ import { toast } from "sonner";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
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";
|
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||||
|
|
||||||
export class AddMediaAssetCommand extends Command {
|
export class AddMediaAssetCommand extends Command {
|
||||||
private assetId: string;
|
private assetId: string;
|
||||||
private savedAssets: MediaAsset[] | null = null;
|
private savedAssets: MediaAsset[] | null = null;
|
||||||
private createdAsset: MediaAsset | null = null;
|
private createdAsset: MediaAsset | null = null;
|
||||||
private previousProjectFps: number | null = null;
|
private previousProjectFps: FrameRate | null = null;
|
||||||
private appliedProjectFps: number | null = null;
|
private appliedProjectFps: FrameRate | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private projectId: string,
|
private projectId: string,
|
||||||
|
|
@ -112,14 +113,15 @@ export class AddMediaAssetCommand extends Command {
|
||||||
|
|
||||||
const activeProject = editor.project.getActiveOrNull();
|
const activeProject = editor.project.getActiveOrNull();
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
if (activeProject.settings.fps !== this.appliedProjectFps) return;
|
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
|
||||||
|
|
||||||
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||||
mediaAssets: editor.media.getAssets(),
|
mediaAssets: editor.media.getAssets(),
|
||||||
});
|
});
|
||||||
|
const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
|
||||||
if (
|
if (
|
||||||
highestRemainingVideoFps !== null &&
|
highestRemainingVideoFps !== null &&
|
||||||
highestRemainingVideoFps >= this.appliedProjectFps
|
highestRemainingVideoFps >= appliedFpsFloat
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,264 +1,265 @@
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import type {
|
import type {
|
||||||
CreateTimelineElement,
|
CreateTimelineElement,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
TrackType,
|
TrackType,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
import { floatToFrameRate } from "@/lib/fps/utils";
|
||||||
import {
|
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||||
applyPlacement,
|
import {
|
||||||
canElementGoOnTrack,
|
applyPlacement,
|
||||||
resolveTrackPlacement,
|
canElementGoOnTrack,
|
||||||
validateElementTrackCompatibility,
|
resolveTrackPlacement,
|
||||||
} from "@/lib/timeline/placement";
|
validateElementTrackCompatibility,
|
||||||
|
} from "@/lib/timeline/placement";
|
||||||
type InsertElementPlacement =
|
|
||||||
| { mode: "explicit"; trackId: string }
|
type InsertElementPlacement =
|
||||||
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
|
| { mode: "explicit"; trackId: string }
|
||||||
|
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
|
||||||
export interface InsertElementParams {
|
|
||||||
element: CreateTimelineElement;
|
export interface InsertElementParams {
|
||||||
placement: InsertElementPlacement;
|
element: CreateTimelineElement;
|
||||||
}
|
placement: InsertElementPlacement;
|
||||||
|
}
|
||||||
export class InsertElementCommand extends Command {
|
|
||||||
private elementId: string;
|
export class InsertElementCommand extends Command {
|
||||||
private savedState: TimelineTrack[] | null = null;
|
private elementId: string;
|
||||||
private targetTrackId: string | null = null;
|
private savedState: TimelineTrack[] | null = null;
|
||||||
|
private targetTrackId: string | null = null;
|
||||||
constructor({ element, placement }: InsertElementParams) {
|
|
||||||
super();
|
constructor({ element, placement }: InsertElementParams) {
|
||||||
this.elementId = generateUUID();
|
super();
|
||||||
this.element = element;
|
this.elementId = generateUUID();
|
||||||
this.placement = placement;
|
this.element = element;
|
||||||
}
|
this.placement = placement;
|
||||||
|
}
|
||||||
private element: CreateTimelineElement;
|
|
||||||
private placement: InsertElementPlacement;
|
private element: CreateTimelineElement;
|
||||||
|
private placement: InsertElementPlacement;
|
||||||
execute(): CommandResult | undefined {
|
|
||||||
const editor = EditorCore.getInstance();
|
execute(): CommandResult | undefined {
|
||||||
this.savedState = editor.timeline.getTracks();
|
const editor = EditorCore.getInstance();
|
||||||
|
this.savedState = editor.timeline.getTracks();
|
||||||
if (!this.savedState) {
|
|
||||||
console.error("Tracks not available");
|
if (!this.savedState) {
|
||||||
return;
|
console.error("Tracks not available");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (!this.validateElementBasics({ element: this.element })) {
|
|
||||||
return;
|
if (!this.validateElementBasics({ element: this.element })) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const totalElementsInTimeline = this.savedState.reduce(
|
|
||||||
(total, t) => total + t.elements.length,
|
const totalElementsInTimeline = this.savedState.reduce(
|
||||||
0,
|
(total, t) => total + t.elements.length,
|
||||||
);
|
0,
|
||||||
const isFirstElement = totalElementsInTimeline === 0;
|
);
|
||||||
|
const isFirstElement = totalElementsInTimeline === 0;
|
||||||
const newElement = this.buildElement({ element: this.element });
|
|
||||||
const updateResult = this.applyPlacementResult({
|
const newElement = this.buildElement({ element: this.element });
|
||||||
tracks: this.savedState,
|
const updateResult = this.applyPlacementResult({
|
||||||
element: newElement,
|
tracks: this.savedState,
|
||||||
});
|
element: newElement,
|
||||||
|
});
|
||||||
if (!updateResult) {
|
|
||||||
return;
|
if (!updateResult) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const { updatedTracks, targetTrackId } = updateResult;
|
|
||||||
this.targetTrackId = targetTrackId;
|
const { updatedTracks, targetTrackId } = updateResult;
|
||||||
|
this.targetTrackId = targetTrackId;
|
||||||
const isVisualMedia =
|
|
||||||
newElement.type === "video" || newElement.type === "image";
|
const isVisualMedia =
|
||||||
|
newElement.type === "video" || newElement.type === "image";
|
||||||
if (isFirstElement && isVisualMedia) {
|
|
||||||
const mediaAssets = editor.media.getAssets();
|
if (isFirstElement && isVisualMedia) {
|
||||||
const activeProject = editor.project.getActive();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const asset = mediaAssets.find(
|
const activeProject = editor.project.getActive();
|
||||||
(item: MediaAsset) => item.id === newElement.mediaId,
|
const asset = mediaAssets.find(
|
||||||
);
|
(item: MediaAsset) => item.id === newElement.mediaId,
|
||||||
|
);
|
||||||
if (asset?.width && asset?.height) {
|
|
||||||
const nextCanvasSize = { width: asset.width, height: asset.height };
|
if (asset?.width && asset?.height) {
|
||||||
const shouldSetOriginalCanvasSize =
|
const nextCanvasSize = { width: asset.width, height: asset.height };
|
||||||
!activeProject?.settings.originalCanvasSize;
|
const shouldSetOriginalCanvasSize =
|
||||||
editor.project.updateSettings({
|
!activeProject?.settings.originalCanvasSize;
|
||||||
settings: {
|
editor.project.updateSettings({
|
||||||
canvasSize: nextCanvasSize,
|
settings: {
|
||||||
...(shouldSetOriginalCanvasSize
|
canvasSize: nextCanvasSize,
|
||||||
? { originalCanvasSize: nextCanvasSize }
|
...(shouldSetOriginalCanvasSize
|
||||||
: {}),
|
? { originalCanvasSize: nextCanvasSize }
|
||||||
},
|
: {}),
|
||||||
pushHistory: false,
|
},
|
||||||
});
|
pushHistory: false,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
if (asset?.type === "video" && asset?.fps) {
|
|
||||||
editor.project.updateSettings({
|
if (asset?.type === "video" && asset?.fps) {
|
||||||
settings: { fps: asset.fps },
|
editor.project.updateSettings({
|
||||||
pushHistory: false,
|
settings: { fps: floatToFrameRate(asset.fps) },
|
||||||
});
|
pushHistory: false,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
editor.timeline.updateTracks(updatedTracks);
|
|
||||||
}
|
editor.timeline.updateTracks(updatedTracks);
|
||||||
|
}
|
||||||
undo(): void {
|
|
||||||
if (this.savedState) {
|
undo(): void {
|
||||||
const editor = EditorCore.getInstance();
|
if (this.savedState) {
|
||||||
editor.timeline.updateTracks(this.savedState);
|
const editor = EditorCore.getInstance();
|
||||||
}
|
editor.timeline.updateTracks(this.savedState);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
getElementId(): string {
|
|
||||||
return this.elementId;
|
getElementId(): string {
|
||||||
}
|
return this.elementId;
|
||||||
|
}
|
||||||
getTrackId(): string | null {
|
|
||||||
return this.targetTrackId;
|
getTrackId(): string | null {
|
||||||
}
|
return this.targetTrackId;
|
||||||
|
}
|
||||||
private buildElement({
|
|
||||||
element,
|
private buildElement({
|
||||||
}: {
|
element,
|
||||||
element: CreateTimelineElement;
|
}: {
|
||||||
}): TimelineElement {
|
element: CreateTimelineElement;
|
||||||
return {
|
}): TimelineElement {
|
||||||
...element,
|
return {
|
||||||
id: this.elementId,
|
...element,
|
||||||
startTime: element.startTime,
|
id: this.elementId,
|
||||||
trimStart: element.trimStart ?? 0,
|
startTime: element.startTime,
|
||||||
trimEnd: element.trimEnd ?? 0,
|
trimStart: element.trimStart ?? 0,
|
||||||
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
trimEnd: element.trimEnd ?? 0,
|
||||||
} as TimelineElement;
|
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
}
|
} as TimelineElement;
|
||||||
|
}
|
||||||
private validateElementBasics({
|
|
||||||
element,
|
private validateElementBasics({
|
||||||
}: {
|
element,
|
||||||
element: CreateTimelineElement;
|
}: {
|
||||||
}): boolean {
|
element: CreateTimelineElement;
|
||||||
if (requiresMediaId({ element }) && !("mediaId" in element)) {
|
}): boolean {
|
||||||
console.error("Element requires mediaId");
|
if (requiresMediaId({ element }) && !("mediaId" in element)) {
|
||||||
return false;
|
console.error("Element requires mediaId");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
if (
|
|
||||||
element.type === "audio" &&
|
if (
|
||||||
element.sourceType === "library" &&
|
element.type === "audio" &&
|
||||||
!element.sourceUrl
|
element.sourceType === "library" &&
|
||||||
) {
|
!element.sourceUrl
|
||||||
console.error("Library audio element must have sourceUrl");
|
) {
|
||||||
return false;
|
console.error("Library audio element must have sourceUrl");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
if (element.type === "sticker" && !element.stickerId) {
|
|
||||||
console.error("Sticker element must have stickerId");
|
if (element.type === "sticker" && !element.stickerId) {
|
||||||
return false;
|
console.error("Sticker element must have stickerId");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
if (element.type === "graphic") {
|
|
||||||
registerDefaultGraphics();
|
if (element.type === "graphic") {
|
||||||
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
|
registerDefaultGraphics();
|
||||||
console.error("Graphic element must have a valid definitionId");
|
if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
|
||||||
return false;
|
console.error("Graphic element must have a valid definitionId");
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (element.type === "text" && !element.content) {
|
|
||||||
console.error("Text element must have content");
|
if (element.type === "text" && !element.content) {
|
||||||
return false;
|
console.error("Text element must have content");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
if (element.type === "effect" && !element.effectType) {
|
|
||||||
console.error("Effect element must have effectType");
|
if (element.type === "effect" && !element.effectType) {
|
||||||
return false;
|
console.error("Effect element must have effectType");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
return true;
|
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
private applyPlacementResult({
|
|
||||||
tracks,
|
private applyPlacementResult({
|
||||||
element,
|
tracks,
|
||||||
}: {
|
element,
|
||||||
tracks: TimelineTrack[];
|
}: {
|
||||||
element: TimelineElement;
|
tracks: TimelineTrack[];
|
||||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
element: TimelineElement;
|
||||||
const placement = this.placement;
|
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
||||||
|
const placement = this.placement;
|
||||||
if (
|
|
||||||
placement.mode === "auto" &&
|
if (
|
||||||
placement.trackType &&
|
placement.mode === "auto" &&
|
||||||
!canElementGoOnTrack({
|
placement.trackType &&
|
||||||
elementType: element.type,
|
!canElementGoOnTrack({
|
||||||
trackType: placement.trackType,
|
elementType: element.type,
|
||||||
})
|
trackType: placement.trackType,
|
||||||
) {
|
})
|
||||||
console.error(
|
) {
|
||||||
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
|
console.error(
|
||||||
);
|
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
|
||||||
return null;
|
);
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
const placementResult = resolveTrackPlacement({
|
|
||||||
tracks,
|
const placementResult = resolveTrackPlacement({
|
||||||
...(placement.mode === "auto" && placement.trackType
|
tracks,
|
||||||
? { trackType: placement.trackType }
|
...(placement.mode === "auto" && placement.trackType
|
||||||
: { elementType: element.type }),
|
? { trackType: placement.trackType }
|
||||||
timeSpans: [
|
: { elementType: element.type }),
|
||||||
{
|
timeSpans: [
|
||||||
startTime: element.startTime,
|
{
|
||||||
duration: element.duration,
|
startTime: element.startTime,
|
||||||
},
|
duration: element.duration,
|
||||||
],
|
},
|
||||||
strategy:
|
],
|
||||||
placement.mode === "explicit"
|
strategy:
|
||||||
? { type: "explicit", trackId: placement.trackId }
|
placement.mode === "explicit"
|
||||||
: { type: "firstAvailable" },
|
? { type: "explicit", trackId: placement.trackId }
|
||||||
});
|
: { type: "firstAvailable" },
|
||||||
if (!placementResult) {
|
});
|
||||||
if (placement.mode === "explicit") {
|
if (!placementResult) {
|
||||||
const targetTrack = tracks.find((track) => track.id === placement.trackId);
|
if (placement.mode === "explicit") {
|
||||||
if (!targetTrack) {
|
const targetTrack = tracks.find((track) => track.id === placement.trackId);
|
||||||
console.error("Track not found:", placement.trackId);
|
if (!targetTrack) {
|
||||||
return null;
|
console.error("Track not found:", placement.trackId);
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
const validation = validateElementTrackCompatibility({
|
|
||||||
element,
|
const validation = validateElementTrackCompatibility({
|
||||||
track: targetTrack,
|
element,
|
||||||
});
|
track: targetTrack,
|
||||||
console.error(validation.errorMessage);
|
});
|
||||||
}
|
console.error(validation.errorMessage);
|
||||||
|
}
|
||||||
return null;
|
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
const elementToPlace =
|
|
||||||
placementResult.kind === "existingTrack"
|
const elementToPlace =
|
||||||
? {
|
placementResult.kind === "existingTrack"
|
||||||
...element,
|
? {
|
||||||
startTime:
|
...element,
|
||||||
placementResult.adjustedStartTime ?? element.startTime,
|
startTime:
|
||||||
}
|
placementResult.adjustedStartTime ?? element.startTime,
|
||||||
: element;
|
}
|
||||||
|
: element;
|
||||||
return applyPlacement({
|
|
||||||
tracks,
|
return applyPlacement({
|
||||||
placementResult,
|
tracks,
|
||||||
elements: [elementToPlace],
|
placementResult,
|
||||||
newTrackInsertIndexOverride:
|
elements: [elementToPlace],
|
||||||
placement.mode === "auto" && typeof placement.insertIndex === "number"
|
newTrackInsertIndexOverride:
|
||||||
? placement.insertIndex
|
placement.mode === "auto" && typeof placement.insertIndex === "number"
|
||||||
: undefined,
|
? placement.insertIndex
|
||||||
});
|
: undefined,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
|
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
|
||||||
|
|
||||||
export const EXPORT_QUALITY_VALUES = [
|
export const EXPORT_QUALITY_VALUES = [
|
||||||
|
|
@ -15,7 +16,7 @@ export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
quality: ExportQuality;
|
quality: ExportQuality;
|
||||||
fps?: number;
|
fps?: FrameRate;
|
||||||
includeAudio?: boolean;
|
includeAudio?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,16 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
|
||||||
test("raises the project fps to match a higher-fps import", () => {
|
test("raises the project fps to match a higher-fps import", () => {
|
||||||
expect(
|
expect(
|
||||||
getRaisedProjectFpsForImportedMedia({
|
getRaisedProjectFpsForImportedMedia({
|
||||||
currentFps: 30,
|
currentFps: { numerator: 30, denominator: 1 },
|
||||||
importedAssets: [{ type: "video", fps: 60 }],
|
importedAssets: [{ type: "video", fps: 60 }],
|
||||||
}),
|
}),
|
||||||
).toBe(60);
|
).toEqual({ numerator: 60, denominator: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not lower the project fps for lower-fps imports", () => {
|
test("does not lower the project fps for lower-fps imports", () => {
|
||||||
expect(
|
expect(
|
||||||
getRaisedProjectFpsForImportedMedia({
|
getRaisedProjectFpsForImportedMedia({
|
||||||
currentFps: 60,
|
currentFps: { numerator: 60, denominator: 1 },
|
||||||
importedAssets: [{ type: "video", fps: 10 }],
|
importedAssets: [{ type: "video", fps: 10 }],
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
@ -54,7 +54,7 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
|
||||||
test("ignores non-video imports", () => {
|
test("ignores non-video imports", () => {
|
||||||
expect(
|
expect(
|
||||||
getRaisedProjectFpsForImportedMedia({
|
getRaisedProjectFpsForImportedMedia({
|
||||||
currentFps: 30,
|
currentFps: { numerator: 30, denominator: 1 },
|
||||||
importedAssets: [
|
importedAssets: [
|
||||||
{ type: "image", fps: 60 },
|
{ type: "image", fps: 60 },
|
||||||
{ type: "audio", fps: 120 },
|
{ type: "audio", fps: 120 },
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
|
||||||
export const FPS_PRESETS = [
|
export const FPS_PRESETS = [
|
||||||
{ value: "24", label: "24 fps" },
|
{ value: "24", label: "24 fps" },
|
||||||
{ value: "25", label: "25 fps" },
|
{ value: "25", label: "25 fps" },
|
||||||
|
|
@ -6,4 +8,4 @@ export const FPS_PRESETS = [
|
||||||
{ value: "120", label: "120 fps" },
|
{ value: "120", label: "120 fps" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_FPS = 30;
|
export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,61 @@
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
|
|
||||||
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
|
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({
|
export function getHighestImportedVideoFps({
|
||||||
mediaAssets,
|
mediaAssets,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -24,16 +78,18 @@ export function getRaisedProjectFpsForImportedMedia({
|
||||||
currentFps,
|
currentFps,
|
||||||
importedAssets,
|
importedAssets,
|
||||||
}: {
|
}: {
|
||||||
currentFps: number;
|
currentFps: FrameRate;
|
||||||
importedAssets: MediaAssetFpsInput[];
|
importedAssets: MediaAssetFpsInput[];
|
||||||
}): number | null {
|
}): FrameRate | null {
|
||||||
const highestImportedVideoFps = getHighestImportedVideoFps({
|
const highestImportedVideoFps = getHighestImportedVideoFps({
|
||||||
mediaAssets: importedAssets,
|
mediaAssets: importedAssets,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
|
const currentFpsFloat = frameRateToFloat(currentFps);
|
||||||
|
|
||||||
|
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return highestImportedVideoFps;
|
return floatToFrameRate(highestImportedVideoFps);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||||
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
|
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
|
||||||
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||||
|
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
|
||||||
const MAX_AUDIO_CHANNELS = 2;
|
const MAX_AUDIO_CHANNELS = 2;
|
||||||
const EXPORT_SAMPLE_RATE = 44100;
|
const EXPORT_SAMPLE_RATE = 44100;
|
||||||
const COARSE_SAMPLE_COUNT = 2048;
|
const COARSE_SAMPLE_COUNT = 2048;
|
||||||
|
|
@ -122,10 +124,10 @@ export async function collectAudioElements({
|
||||||
return {
|
return {
|
||||||
timelineElement: element,
|
timelineElement: element,
|
||||||
buffer: audioBuffer,
|
buffer: audioBuffer,
|
||||||
startTime: element.startTime,
|
startTime: element.startTime / TICKS_PER_SECOND,
|
||||||
duration: element.duration,
|
duration: element.duration / TICKS_PER_SECOND,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||||
volume: resolveEffectiveAudioGain({
|
volume: resolveEffectiveAudioGain({
|
||||||
element,
|
element,
|
||||||
trackMuted: isTrackMuted,
|
trackMuted: isTrackMuted,
|
||||||
|
|
@ -152,10 +154,10 @@ export async function collectAudioElements({
|
||||||
return {
|
return {
|
||||||
timelineElement: element,
|
timelineElement: element,
|
||||||
buffer: audioBuffer,
|
buffer: audioBuffer,
|
||||||
startTime: element.startTime,
|
startTime: element.startTime / TICKS_PER_SECOND,
|
||||||
duration: element.duration,
|
duration: element.duration / TICKS_PER_SECOND,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||||
volume: resolveEffectiveAudioGain({
|
volume: resolveEffectiveAudioGain({
|
||||||
element,
|
element,
|
||||||
trackMuted: isTrackMuted,
|
trackMuted: isTrackMuted,
|
||||||
|
|
@ -337,16 +339,16 @@ async function fetchLibraryAudioSource({
|
||||||
type: "audio/mpeg",
|
type: "audio/mpeg",
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timelineElement: element,
|
timelineElement: element,
|
||||||
file,
|
file,
|
||||||
startTime: element.startTime,
|
startTime: element.startTime / TICKS_PER_SECOND,
|
||||||
duration: element.duration,
|
duration: element.duration / TICKS_PER_SECOND,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||||
volume,
|
volume,
|
||||||
retime: element.retime,
|
retime: element.retime,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to fetch library audio:", error);
|
console.warn("Failed to fetch library audio:", error);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -404,10 +406,10 @@ function collectMediaAudioSource({
|
||||||
return {
|
return {
|
||||||
timelineElement: element,
|
timelineElement: element,
|
||||||
file: mediaAsset.file,
|
file: mediaAsset.file,
|
||||||
startTime: element.startTime,
|
startTime: element.startTime / TICKS_PER_SECOND,
|
||||||
duration: element.duration,
|
duration: element.duration / TICKS_PER_SECOND,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||||
volume,
|
volume,
|
||||||
retime: element.retime,
|
retime: element.retime,
|
||||||
};
|
};
|
||||||
|
|
@ -429,10 +431,10 @@ function collectMediaAudioClip({
|
||||||
id: element.id,
|
id: element.id,
|
||||||
sourceKey: mediaAsset.id,
|
sourceKey: mediaAsset.id,
|
||||||
file: mediaAsset.file,
|
file: mediaAsset.file,
|
||||||
startTime: element.startTime,
|
startTime: element.startTime / TICKS_PER_SECOND,
|
||||||
duration: element.duration,
|
duration: element.duration / TICKS_PER_SECOND,
|
||||||
trimStart: element.trimStart,
|
trimStart: element.trimStart / TICKS_PER_SECOND,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: element.trimEnd / TICKS_PER_SECOND,
|
||||||
volume,
|
volume,
|
||||||
muted,
|
muted,
|
||||||
retime: element.retime,
|
retime: element.retime,
|
||||||
|
|
@ -602,7 +604,8 @@ export async function createTimelineAudioBuffer({
|
||||||
if (audioElements.length === 0) return null;
|
if (audioElements.length === 0) return null;
|
||||||
|
|
||||||
const outputChannels = 2;
|
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(
|
const outputBuffer = context.createBuffer(
|
||||||
outputChannels,
|
outputChannels,
|
||||||
outputLength,
|
outputLength,
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,54 @@
|
||||||
import type { TScene } from "@/lib/timeline/types";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
import type { TScene } from "@/lib/timeline/types";
|
||||||
export type TBackground =
|
|
||||||
| {
|
export type TBackground =
|
||||||
type: "color";
|
| {
|
||||||
color: string;
|
type: "color";
|
||||||
}
|
color: string;
|
||||||
| {
|
}
|
||||||
type: "blur";
|
| {
|
||||||
blurIntensity: number;
|
type: "blur";
|
||||||
};
|
blurIntensity: number;
|
||||||
|
};
|
||||||
export interface TCanvasSize {
|
|
||||||
width: number;
|
export interface TCanvasSize {
|
||||||
height: number;
|
width: number;
|
||||||
}
|
height: number;
|
||||||
|
}
|
||||||
export interface TProjectMetadata {
|
|
||||||
id: string;
|
export interface TProjectMetadata {
|
||||||
name: string;
|
id: string;
|
||||||
thumbnail?: string;
|
name: string;
|
||||||
duration: number;
|
thumbnail?: string;
|
||||||
createdAt: Date;
|
duration: number;
|
||||||
updatedAt: Date;
|
createdAt: Date;
|
||||||
}
|
updatedAt: Date;
|
||||||
|
}
|
||||||
export interface TProjectSettings {
|
|
||||||
fps: number;
|
export interface TProjectSettings {
|
||||||
canvasSize: TCanvasSize;
|
fps: FrameRate;
|
||||||
canvasSizeMode?: "preset" | "custom";
|
canvasSize: TCanvasSize;
|
||||||
lastCustomCanvasSize?: TCanvasSize | null;
|
canvasSizeMode?: "preset" | "custom";
|
||||||
originalCanvasSize?: TCanvasSize | null;
|
lastCustomCanvasSize?: TCanvasSize | null;
|
||||||
background: TBackground;
|
originalCanvasSize?: TCanvasSize | null;
|
||||||
}
|
background: TBackground;
|
||||||
|
}
|
||||||
export interface TTimelineViewState {
|
|
||||||
zoomLevel: number;
|
export interface TTimelineViewState {
|
||||||
scrollLeft: number;
|
zoomLevel: number;
|
||||||
playheadTime: number;
|
scrollLeft: number;
|
||||||
}
|
playheadTime: number;
|
||||||
|
}
|
||||||
export interface TProject {
|
|
||||||
metadata: TProjectMetadata;
|
export interface TProject {
|
||||||
scenes: TScene[];
|
metadata: TProjectMetadata;
|
||||||
currentSceneId: string;
|
scenes: TScene[];
|
||||||
settings: TProjectSettings;
|
currentSceneId: string;
|
||||||
version: number;
|
settings: TProjectSettings;
|
||||||
timelineViewState?: TTimelineViewState;
|
version: number;
|
||||||
}
|
timelineViewState?: TTimelineViewState;
|
||||||
|
}
|
||||||
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
|
|
||||||
export type TSortOrder = "asc" | "desc";
|
export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
|
||||||
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
|
export type TSortOrder = "asc" | "desc";
|
||||||
|
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
|
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
|
||||||
import type { RippleAdjustment } from "./apply";
|
import type { RippleAdjustment } from "./apply";
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ function collectTrackIntervals({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) {
|
if (beforeElement.endTime > afterElement.endTime) {
|
||||||
pushInterval({
|
pushInterval({
|
||||||
intervals: vacatedIntervals,
|
intervals: vacatedIntervals,
|
||||||
startTime: afterElement.endTime,
|
startTime: afterElement.endTime,
|
||||||
|
|
@ -141,7 +141,7 @@ function buildAdjustments({
|
||||||
}): RippleAdjustment[] {
|
}): RippleAdjustment[] {
|
||||||
return intervals.flatMap((interval): RippleAdjustment[] => {
|
return intervals.flatMap((interval): RippleAdjustment[] => {
|
||||||
const shiftAmount = interval.endTime - interval.startTime;
|
const shiftAmount = interval.endTime - interval.startTime;
|
||||||
if (shiftAmount <= TIME_EPSILON_SECONDS) {
|
if (shiftAmount <= 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ function normalizeIntervals({
|
||||||
const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
|
const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
|
||||||
for (const interval of sortedIntervals.slice(1)) {
|
for (const interval of sortedIntervals.slice(1)) {
|
||||||
const previousInterval = mergedIntervals[mergedIntervals.length - 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 = Math.max(
|
||||||
previousInterval.endTime,
|
previousInterval.endTime,
|
||||||
interval.endTime,
|
interval.endTime,
|
||||||
|
|
@ -228,12 +228,10 @@ function subtractSingleInterval({
|
||||||
|
|
||||||
for (const overlappingInterval of overlappingIntervals) {
|
for (const overlappingInterval of overlappingIntervals) {
|
||||||
remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
|
remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
|
||||||
if (
|
if (
|
||||||
overlappingInterval.endTime <=
|
overlappingInterval.endTime <= remainingInterval.startTime ||
|
||||||
remainingInterval.startTime + TIME_EPSILON_SECONDS ||
|
overlappingInterval.startTime >= remainingInterval.endTime
|
||||||
overlappingInterval.startTime >=
|
) {
|
||||||
remainingInterval.endTime - TIME_EPSILON_SECONDS
|
|
||||||
) {
|
|
||||||
return [remainingInterval];
|
return [remainingInterval];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,7 +262,7 @@ function pushInterval({
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
}: { intervals: Interval[]; startTime: number; endTime: number }): void {
|
}: { intervals: Interval[]; startTime: number; endTime: number }): void {
|
||||||
if (endTime - startTime <= TIME_EPSILON_SECONDS) {
|
if (endTime <= startTime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,102 @@
|
||||||
import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
|
import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
|
||||||
import { resolveNumberAtTime } from "@/lib/animation/resolve";
|
import { resolveNumberAtTime } from "@/lib/animation/resolve";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
|
||||||
import type { TimelineElement } from "./types";
|
import type { TimelineElement } from "./types";
|
||||||
const DEFAULT_STEP_SECONDS = 1 / 60;
|
const DEFAULT_STEP_SECONDS = 1 / 60;
|
||||||
|
|
||||||
export type AudioCapableElement = Extract<
|
export type AudioCapableElement = Extract<
|
||||||
TimelineElement,
|
TimelineElement,
|
||||||
{ type: "audio" | "video" }
|
{ type: "audio" | "video" }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function clampDb(value: number): number {
|
export function clampDb(value: number): number {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
|
return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dBToLinear(db: number): number {
|
export function dBToLinear(db: number): number {
|
||||||
return 10 ** (clampDb(db) / 20);
|
return 10 ** (clampDb(db) / 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasAnimatedVolume({
|
export function hasAnimatedVolume({
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
element: AudioCapableElement;
|
element: AudioCapableElement;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return hasKeyframesForPath({
|
return hasKeyframesForPath({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath: "volume",
|
propertyPath: "volume",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveEffectiveAudioGain({
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
element,
|
|
||||||
trackMuted = false,
|
export function resolveEffectiveAudioGain({
|
||||||
localTime,
|
element,
|
||||||
}: {
|
trackMuted = false,
|
||||||
element: AudioCapableElement;
|
localTime,
|
||||||
trackMuted?: boolean;
|
}: {
|
||||||
localTime: number;
|
element: AudioCapableElement;
|
||||||
}): number {
|
trackMuted?: boolean;
|
||||||
if (trackMuted || element.muted === true) {
|
localTime: number;
|
||||||
return 0;
|
}): number {
|
||||||
}
|
if (trackMuted || element.muted === true) {
|
||||||
|
return 0;
|
||||||
const resolvedDb = resolveNumberAtTime({
|
}
|
||||||
baseValue: element.volume ?? 0,
|
|
||||||
animations: element.animations,
|
const resolvedDb = resolveNumberAtTime({
|
||||||
propertyPath: "volume",
|
baseValue: element.volume ?? 0,
|
||||||
localTime,
|
animations: element.animations,
|
||||||
});
|
propertyPath: "volume",
|
||||||
|
localTime: Math.round(localTime * TICKS_PER_SECOND),
|
||||||
return dBToLinear(resolvedDb);
|
});
|
||||||
}
|
|
||||||
|
return dBToLinear(resolvedDb);
|
||||||
export function buildAudioGainAutomation({
|
}
|
||||||
element,
|
|
||||||
trackMuted = false,
|
export function buildAudioGainAutomation({
|
||||||
fromLocalTime,
|
element,
|
||||||
toLocalTime,
|
trackMuted = false,
|
||||||
stepSeconds = DEFAULT_STEP_SECONDS,
|
fromLocalTime,
|
||||||
}: {
|
toLocalTime,
|
||||||
element: AudioCapableElement;
|
stepSeconds = DEFAULT_STEP_SECONDS,
|
||||||
trackMuted?: boolean;
|
}: {
|
||||||
fromLocalTime: number;
|
element: AudioCapableElement;
|
||||||
toLocalTime: number;
|
trackMuted?: boolean;
|
||||||
stepSeconds?: number;
|
fromLocalTime: number;
|
||||||
}): Array<{ localTime: number; gain: number }> {
|
toLocalTime: number;
|
||||||
const startTime = Math.max(0, fromLocalTime);
|
stepSeconds?: number;
|
||||||
const endTime = Math.max(startTime, toLocalTime);
|
}): Array<{ localTime: number; gain: number }> {
|
||||||
const safeStep =
|
const startTime = Math.max(0, fromLocalTime);
|
||||||
Number.isFinite(stepSeconds) && stepSeconds > 0
|
const endTime = Math.max(startTime, toLocalTime);
|
||||||
? stepSeconds
|
const safeStep =
|
||||||
: DEFAULT_STEP_SECONDS;
|
Number.isFinite(stepSeconds) && stepSeconds > 0
|
||||||
const points: Array<{ localTime: number; gain: number }> = [];
|
? stepSeconds
|
||||||
|
: DEFAULT_STEP_SECONDS;
|
||||||
for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
|
const points: Array<{ localTime: number; gain: number }> = [];
|
||||||
points.push({
|
|
||||||
localTime,
|
for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
|
||||||
gain: resolveEffectiveAudioGain({
|
points.push({
|
||||||
element,
|
localTime,
|
||||||
trackMuted,
|
gain: resolveEffectiveAudioGain({
|
||||||
localTime,
|
element,
|
||||||
}),
|
trackMuted,
|
||||||
});
|
localTime,
|
||||||
}
|
}),
|
||||||
|
});
|
||||||
points.push({
|
}
|
||||||
localTime: endTime,
|
|
||||||
gain: resolveEffectiveAudioGain({
|
points.push({
|
||||||
element,
|
localTime: endTime,
|
||||||
trackMuted,
|
gain: resolveEffectiveAudioGain({
|
||||||
localTime: endTime,
|
element,
|
||||||
}),
|
trackMuted,
|
||||||
});
|
localTime: endTime,
|
||||||
|
}),
|
||||||
return points;
|
});
|
||||||
}
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import type { Bookmark } from "@/lib/timeline";
|
import type { Bookmark } from "@/lib/timeline";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
|
|
||||||
export const BOOKMARK_TIME_EPSILON = 0.001;
|
|
||||||
|
|
||||||
function bookmarkTimeEqual({
|
function bookmarkTimeEqual({
|
||||||
bookmarkTime,
|
bookmarkTime,
|
||||||
frameTime,
|
frameTime,
|
||||||
|
|
@ -10,7 +9,7 @@ function bookmarkTimeEqual({
|
||||||
bookmarkTime: number;
|
bookmarkTime: number;
|
||||||
frameTime: number;
|
frameTime: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
|
return bookmarkTime === frameTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findBookmarkIndex({
|
export function findBookmarkIndex({
|
||||||
|
|
@ -112,9 +111,9 @@ export function getFrameTime({
|
||||||
fps,
|
fps,
|
||||||
}: {
|
}: {
|
||||||
time: number;
|
time: number;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
}): number {
|
}): number {
|
||||||
return roundToFrame({ time, fps });
|
return roundToFrame({ time, rate: fps }) ?? time;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBookmarkAtTime({
|
export function getBookmarkAtTime({
|
||||||
|
|
@ -141,9 +140,6 @@ export function getBookmarksActiveAtTime({
|
||||||
bookmark.duration != null && bookmark.duration > 0
|
bookmark.duration != null && bookmark.duration > 0
|
||||||
? start + bookmark.duration
|
? start + bookmark.duration
|
||||||
: start;
|
: start;
|
||||||
return (
|
return time >= start && time <= end;
|
||||||
time >= start - BOOKMARK_TIME_EPSILON &&
|
|
||||||
time <= end + BOOKMARK_TIME_EPSILON
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,77 @@
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||||
import type { TTimelineViewState } from "@/lib/project/types";
|
import type { TTimelineViewState } from "@/lib/project/types";
|
||||||
import type { BlendMode, Transform } from "@/lib/rendering";
|
import type { BlendMode, Transform } from "@/lib/rendering";
|
||||||
import type { TextElement } from "./types";
|
import type { TextElement } from "./types";
|
||||||
|
|
||||||
const defaultTransform: Transform = {
|
const defaultTransform: Transform = {
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
scaleY: 1,
|
scaleY: 1,
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOpacity = 1;
|
const defaultOpacity = 1;
|
||||||
const defaultBlendMode: BlendMode = "normal";
|
const defaultBlendMode: BlendMode = "normal";
|
||||||
const defaultVolume = 0;
|
const defaultVolume = 0;
|
||||||
|
|
||||||
const defaultTextLetterSpacing = 0;
|
const defaultTextLetterSpacing = 0;
|
||||||
const defaultTextLineHeight = 1.2;
|
const defaultTextLineHeight = 1.2;
|
||||||
|
|
||||||
const defaultTextBackground = {
|
const defaultTextBackground = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
cornerRadius: 0,
|
cornerRadius: 0,
|
||||||
paddingX: 30,
|
paddingX: 30,
|
||||||
paddingY: 42,
|
paddingY: 42,
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTextElement: Omit<TextElement, "id"> = {
|
const defaultTextElement: Omit<TextElement, "id"> = {
|
||||||
type: "text",
|
type: "text",
|
||||||
name: "Text",
|
name: "Text",
|
||||||
content: "Default text",
|
content: "Default text",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: "Arial",
|
fontFamily: "Arial",
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
background: { ...defaultTextBackground },
|
background: { ...defaultTextBackground },
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontWeight: "normal",
|
fontWeight: "normal",
|
||||||
fontStyle: "normal",
|
fontStyle: "normal",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
letterSpacing: defaultTextLetterSpacing,
|
letterSpacing: defaultTextLetterSpacing,
|
||||||
lineHeight: defaultTextLineHeight,
|
lineHeight: defaultTextLineHeight,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
transform: {
|
transform: {
|
||||||
...defaultTransform,
|
...defaultTransform,
|
||||||
position: { ...defaultTransform.position },
|
position: { ...defaultTransform.position },
|
||||||
},
|
},
|
||||||
opacity: defaultOpacity,
|
opacity: defaultOpacity,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTimelineViewState: TTimelineViewState = {
|
const defaultTimelineViewState: TTimelineViewState = {
|
||||||
zoomLevel: 1,
|
zoomLevel: 1,
|
||||||
scrollLeft: 0,
|
scrollLeft: 0,
|
||||||
playheadTime: 0,
|
playheadTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
element: {
|
element: {
|
||||||
transform: defaultTransform,
|
transform: defaultTransform,
|
||||||
opacity: defaultOpacity,
|
opacity: defaultOpacity,
|
||||||
blendMode: defaultBlendMode,
|
blendMode: defaultBlendMode,
|
||||||
volume: defaultVolume,
|
volume: defaultVolume,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
letterSpacing: defaultTextLetterSpacing,
|
letterSpacing: defaultTextLetterSpacing,
|
||||||
lineHeight: defaultTextLineHeight,
|
lineHeight: defaultTextLineHeight,
|
||||||
background: defaultTextBackground,
|
background: defaultTextBackground,
|
||||||
element: defaultTextElement,
|
element: defaultTextElement,
|
||||||
},
|
},
|
||||||
timeline: {
|
timeline: {
|
||||||
viewState: defaultTimelineViewState,
|
viewState: defaultTimelineViewState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
export function getMouseTimeFromClientX({
|
|
||||||
clientX,
|
export function getMouseTimeFromClientX({
|
||||||
containerRect,
|
clientX,
|
||||||
zoomLevel,
|
containerRect,
|
||||||
scrollLeft,
|
zoomLevel,
|
||||||
}: {
|
scrollLeft,
|
||||||
clientX: number;
|
}: {
|
||||||
containerRect: DOMRect;
|
clientX: number;
|
||||||
zoomLevel: number;
|
containerRect: DOMRect;
|
||||||
scrollLeft: number;
|
zoomLevel: number;
|
||||||
}): number {
|
scrollLeft: number;
|
||||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
}): number {
|
||||||
return Math.max(
|
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||||
0,
|
const seconds = Math.max(
|
||||||
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
0,
|
||||||
);
|
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
}
|
);
|
||||||
|
return Math.round(seconds * TICKS_PER_SECOND);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,418 +1,418 @@
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
|
||||||
import {
|
import {
|
||||||
MASKABLE_ELEMENT_TYPES,
|
MASKABLE_ELEMENT_TYPES,
|
||||||
RETIMABLE_ELEMENT_TYPES,
|
RETIMABLE_ELEMENT_TYPES,
|
||||||
VISUAL_ELEMENT_TYPES,
|
VISUAL_ELEMENT_TYPES,
|
||||||
type CreateEffectElement,
|
type CreateEffectElement,
|
||||||
type CreateGraphicElement,
|
type CreateGraphicElement,
|
||||||
type CreateTimelineElement,
|
type CreateTimelineElement,
|
||||||
type CreateVideoElement,
|
type CreateVideoElement,
|
||||||
type CreateImageElement,
|
type CreateImageElement,
|
||||||
type CreateStickerElement,
|
type CreateStickerElement,
|
||||||
type CreateUploadAudioElement,
|
type CreateUploadAudioElement,
|
||||||
type CreateLibraryAudioElement,
|
type CreateLibraryAudioElement,
|
||||||
type TextBackground,
|
type TextBackground,
|
||||||
type TextElement,
|
type TextElement,
|
||||||
type TimelineElement,
|
type TimelineElement,
|
||||||
type TimelineTrack,
|
type TimelineTrack,
|
||||||
type AudioElement,
|
type AudioElement,
|
||||||
type VideoElement,
|
type VideoElement,
|
||||||
type ImageElement,
|
type ImageElement,
|
||||||
type MaskableElement,
|
type MaskableElement,
|
||||||
type RetimableElement,
|
type RetimableElement,
|
||||||
type VisualElement,
|
type VisualElement,
|
||||||
type UploadAudioElement,
|
type UploadAudioElement,
|
||||||
} from "@/lib/timeline";
|
} from "@/lib/timeline";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import type { MediaType } from "@/lib/media/types";
|
import type { MediaType } from "@/lib/media/types";
|
||||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||||
import { buildDefaultGraphicInstance } from "@/lib/graphics";
|
import { buildDefaultGraphicInstance } from "@/lib/graphics";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import { capitalizeFirstLetter } from "@/utils/string";
|
import { capitalizeFirstLetter } from "@/utils/string";
|
||||||
|
|
||||||
export function canElementHaveAudio(
|
export function canElementHaveAudio(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is AudioElement | VideoElement {
|
): element is AudioElement | VideoElement {
|
||||||
return element.type === "audio" || element.type === "video";
|
return element.type === "audio" || element.type === "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVisualElement(
|
export function isVisualElement(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is VisualElement {
|
): element is VisualElement {
|
||||||
return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMaskableElement(
|
export function isMaskableElement(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is MaskableElement {
|
): element is MaskableElement {
|
||||||
return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRetimableElement(
|
export function isRetimableElement(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is RetimableElement {
|
): element is RetimableElement {
|
||||||
return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canElementBeHidden(
|
export function canElementBeHidden(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is VisualElement {
|
): element is VisualElement {
|
||||||
return isVisualElement(element);
|
return isVisualElement(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasElementEffects({
|
export function hasElementEffects({
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
|
return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasMediaId(
|
export function hasMediaId(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
): element is UploadAudioElement | VideoElement | ImageElement {
|
): element is UploadAudioElement | VideoElement | ImageElement {
|
||||||
return "mediaId" in element;
|
return "mediaId" in element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requiresMediaId({
|
export function requiresMediaId({
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
element: CreateTimelineElement;
|
element: CreateTimelineElement;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
element.type === "video" ||
|
element.type === "video" ||
|
||||||
element.type === "image" ||
|
element.type === "image" ||
|
||||||
(element.type === "audio" && element.sourceType === "upload")
|
(element.type === "audio" && element.sourceType === "upload")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextBackground(
|
function buildTextBackground(
|
||||||
raw: Partial<TextBackground> | undefined,
|
raw: Partial<TextBackground> | undefined,
|
||||||
): TextBackground {
|
): TextBackground {
|
||||||
const color = raw?.color ?? DEFAULTS.text.element.background.color;
|
const color = raw?.color ?? DEFAULTS.text.element.background.color;
|
||||||
const enabled = raw?.enabled ?? color !== "transparent";
|
const enabled = raw?.enabled ?? color !== "transparent";
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
color,
|
color,
|
||||||
cornerRadius: raw?.cornerRadius,
|
cornerRadius: raw?.cornerRadius,
|
||||||
paddingX: raw?.paddingX,
|
paddingX: raw?.paddingX,
|
||||||
paddingY: raw?.paddingY,
|
paddingY: raw?.paddingY,
|
||||||
offsetX: raw?.offsetX,
|
offsetX: raw?.offsetX,
|
||||||
offsetY: raw?.offsetY,
|
offsetY: raw?.offsetY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTextElement({
|
export function buildTextElement({
|
||||||
raw,
|
raw,
|
||||||
startTime,
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
raw: Partial<Omit<TextElement, "type" | "id">>;
|
raw: Partial<Omit<TextElement, "type" | "id">>;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}): CreateTimelineElement {
|
}): CreateTimelineElement {
|
||||||
const t = raw as Partial<TextElement>;
|
const t = raw as Partial<TextElement>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
name: t.name ?? DEFAULTS.text.element.name,
|
name: t.name ?? DEFAULTS.text.element.name,
|
||||||
content: t.content ?? DEFAULTS.text.element.content,
|
content: t.content ?? DEFAULTS.text.element.content,
|
||||||
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
||||||
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
||||||
color: t.color ?? DEFAULTS.text.element.color,
|
color: t.color ?? DEFAULTS.text.element.color,
|
||||||
background: buildTextBackground(t.background),
|
background: buildTextBackground(t.background),
|
||||||
textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
|
textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
|
||||||
fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
|
fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
|
||||||
fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
|
fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
|
||||||
textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
|
textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
|
||||||
letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
|
letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
|
||||||
lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
|
lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
|
||||||
transform: t.transform ?? DEFAULTS.text.element.transform,
|
transform: t.transform ?? DEFAULTS.text.element.transform,
|
||||||
opacity: t.opacity ?? DEFAULTS.text.element.opacity,
|
opacity: t.opacity ?? DEFAULTS.text.element.opacity,
|
||||||
blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
|
blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildEffectElement({
|
export function buildEffectElement({
|
||||||
effectType,
|
effectType,
|
||||||
startTime,
|
startTime,
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
effectType: string;
|
effectType: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
}): CreateEffectElement {
|
}): CreateEffectElement {
|
||||||
const instance = buildDefaultEffectInstance({ effectType });
|
const instance = buildDefaultEffectInstance({ effectType });
|
||||||
return {
|
return {
|
||||||
type: "effect",
|
type: "effect",
|
||||||
name: capitalizeFirstLetter({ string: instance.type }),
|
name: capitalizeFirstLetter({ string: instance.type }),
|
||||||
effectType,
|
effectType,
|
||||||
params: instance.params,
|
params: instance.params,
|
||||||
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStickerElement({
|
export function buildStickerElement({
|
||||||
stickerId,
|
stickerId,
|
||||||
name,
|
name,
|
||||||
startTime,
|
startTime,
|
||||||
intrinsicWidth,
|
intrinsicWidth,
|
||||||
intrinsicHeight,
|
intrinsicHeight,
|
||||||
}: {
|
}: {
|
||||||
stickerId: string;
|
stickerId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
intrinsicWidth?: number;
|
intrinsicWidth?: number;
|
||||||
intrinsicHeight?: number;
|
intrinsicHeight?: number;
|
||||||
}): CreateStickerElement {
|
}): CreateStickerElement {
|
||||||
const stickerNameFromId =
|
const stickerNameFromId =
|
||||||
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
|
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
|
||||||
return {
|
return {
|
||||||
type: "sticker",
|
type: "sticker",
|
||||||
name: name ?? stickerNameFromId,
|
name: name ?? stickerNameFromId,
|
||||||
stickerId,
|
stickerId,
|
||||||
intrinsicWidth,
|
intrinsicWidth,
|
||||||
intrinsicHeight,
|
intrinsicHeight,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
},
|
},
|
||||||
opacity: DEFAULTS.element.opacity,
|
opacity: DEFAULTS.element.opacity,
|
||||||
blendMode: DEFAULTS.element.blendMode,
|
blendMode: DEFAULTS.element.blendMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGraphicElement({
|
export function buildGraphicElement({
|
||||||
definitionId,
|
definitionId,
|
||||||
name,
|
name,
|
||||||
startTime,
|
startTime,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
params?: Partial<ParamValues>;
|
params?: Partial<ParamValues>;
|
||||||
}): CreateGraphicElement {
|
}): CreateGraphicElement {
|
||||||
const instance = buildDefaultGraphicInstance({ definitionId });
|
const instance = buildDefaultGraphicInstance({ definitionId });
|
||||||
return {
|
return {
|
||||||
type: "graphic",
|
type: "graphic",
|
||||||
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
|
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
|
||||||
definitionId: instance.definitionId,
|
definitionId: instance.definitionId,
|
||||||
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
},
|
},
|
||||||
opacity: DEFAULTS.element.opacity,
|
opacity: DEFAULTS.element.opacity,
|
||||||
blendMode: DEFAULTS.element.blendMode,
|
blendMode: DEFAULTS.element.blendMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVideoElement({
|
function buildVideoElement({
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}): CreateVideoElement {
|
}): CreateVideoElement {
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
muted: false,
|
muted: false,
|
||||||
isSourceAudioEnabled: true,
|
isSourceAudioEnabled: true,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
},
|
},
|
||||||
opacity: DEFAULTS.element.opacity,
|
opacity: DEFAULTS.element.opacity,
|
||||||
blendMode: DEFAULTS.element.blendMode,
|
blendMode: DEFAULTS.element.blendMode,
|
||||||
volume: DEFAULTS.element.volume,
|
volume: DEFAULTS.element.volume,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImageElement({
|
function buildImageElement({
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
}): CreateImageElement {
|
}): CreateImageElement {
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
},
|
},
|
||||||
opacity: DEFAULTS.element.opacity,
|
opacity: DEFAULTS.element.opacity,
|
||||||
blendMode: DEFAULTS.element.blendMode,
|
blendMode: DEFAULTS.element.blendMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUploadAudioElement({
|
function buildUploadAudioElement({
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer,
|
buffer,
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateUploadAudioElement {
|
}): CreateUploadAudioElement {
|
||||||
const element: CreateUploadAudioElement = {
|
const element: CreateUploadAudioElement = {
|
||||||
type: "audio",
|
type: "audio",
|
||||||
sourceType: "upload",
|
sourceType: "upload",
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
volume: DEFAULTS.element.volume,
|
volume: DEFAULTS.element.volume,
|
||||||
muted: false,
|
muted: false,
|
||||||
};
|
};
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
element.buffer = buffer;
|
element.buffer = buffer;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildElementFromMedia({
|
export function buildElementFromMedia({
|
||||||
mediaId,
|
mediaId,
|
||||||
mediaType,
|
mediaType,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer,
|
buffer,
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateTimelineElement {
|
}): CreateTimelineElement {
|
||||||
switch (mediaType) {
|
switch (mediaType) {
|
||||||
case "audio":
|
case "audio":
|
||||||
return buildUploadAudioElement({
|
return buildUploadAudioElement({
|
||||||
mediaId,
|
mediaId,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer,
|
buffer,
|
||||||
});
|
});
|
||||||
case "video":
|
case "video":
|
||||||
return buildVideoElement({ mediaId, name, duration, startTime });
|
return buildVideoElement({ mediaId, name, duration, startTime });
|
||||||
case "image":
|
case "image":
|
||||||
return buildImageElement({ mediaId, name, duration, startTime });
|
return buildImageElement({ mediaId, name, duration, startTime });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLibraryAudioElement({
|
export function buildLibraryAudioElement({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
buffer,
|
buffer,
|
||||||
}: {
|
}: {
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateLibraryAudioElement {
|
}): CreateLibraryAudioElement {
|
||||||
const element: CreateLibraryAudioElement = {
|
const element: CreateLibraryAudioElement = {
|
||||||
type: "audio",
|
type: "audio",
|
||||||
sourceType: "library",
|
sourceType: "library",
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
volume: DEFAULTS.element.volume,
|
volume: DEFAULTS.element.volume,
|
||||||
muted: false,
|
muted: false,
|
||||||
};
|
};
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
element.buffer = buffer;
|
element.buffer = buffer;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getElementsAtTime({
|
export function getElementsAtTime({
|
||||||
tracks,
|
tracks,
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
time: number;
|
time: number;
|
||||||
}): { trackId: string; elementId: string }[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const result: { trackId: string; elementId: string }[] = [];
|
const result: { trackId: string; elementId: string }[] = [];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
const elementStart = element.startTime;
|
const elementStart = element.startTime;
|
||||||
const elementEnd = element.startTime + element.duration;
|
const elementEnd = element.startTime + element.duration;
|
||||||
|
|
||||||
if (time > elementStart && time < elementEnd) {
|
if (time > elementStart && time < elementEnd) {
|
||||||
result.push({ trackId: track.id, elementId: element.id });
|
result.push({ trackId: track.id, elementId: element.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getElementFontFamilies({
|
export function getElementFontFamilies({
|
||||||
tracks,
|
tracks,
|
||||||
}: {
|
}: {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const families = new Set<string>();
|
const families = new Set<string>();
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.type === "text" && element.fontFamily) {
|
if (element.type === "text" && element.fontFamily) {
|
||||||
families.add(element.fontFamily);
|
families.add(element.fontFamily);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...families];
|
return [...families];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,80 @@
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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;
|
|
||||||
|
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
|
||||||
function getDevicePixelRatio({
|
|
||||||
devicePixelRatio,
|
function getDevicePixelRatio({
|
||||||
}: {
|
devicePixelRatio,
|
||||||
devicePixelRatio?: number;
|
}: {
|
||||||
}): number {
|
devicePixelRatio?: number;
|
||||||
if (
|
}): number {
|
||||||
typeof devicePixelRatio === "number" &&
|
if (
|
||||||
Number.isFinite(devicePixelRatio) &&
|
typeof devicePixelRatio === "number" &&
|
||||||
devicePixelRatio > 0
|
Number.isFinite(devicePixelRatio) &&
|
||||||
) {
|
devicePixelRatio > 0
|
||||||
return devicePixelRatio;
|
) {
|
||||||
}
|
return devicePixelRatio;
|
||||||
|
}
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return 1;
|
if (typeof window === "undefined") {
|
||||||
}
|
return 1;
|
||||||
|
}
|
||||||
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
|
|
||||||
return window.devicePixelRatio;
|
if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
|
||||||
}
|
return window.devicePixelRatio;
|
||||||
|
}
|
||||||
return 1;
|
|
||||||
}
|
return 1;
|
||||||
|
}
|
||||||
export function getTimelinePixelsPerSecond({
|
|
||||||
zoomLevel,
|
export function getTimelinePixelsPerSecond({
|
||||||
}: {
|
zoomLevel,
|
||||||
zoomLevel: number;
|
}: {
|
||||||
}): number {
|
zoomLevel: number;
|
||||||
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
}): number {
|
||||||
}
|
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||||
|
}
|
||||||
export function timelineTimeToPixels({
|
|
||||||
time,
|
export function timelineTimeToPixels({
|
||||||
zoomLevel,
|
time,
|
||||||
}: {
|
zoomLevel,
|
||||||
time: number;
|
}: {
|
||||||
zoomLevel: number;
|
time: number;
|
||||||
}): number {
|
zoomLevel: number;
|
||||||
return time * getTimelinePixelsPerSecond({ zoomLevel });
|
}): number {
|
||||||
}
|
return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel });
|
||||||
|
}
|
||||||
export function snapPixelToDeviceGrid({
|
|
||||||
pixel,
|
export function snapPixelToDeviceGrid({
|
||||||
devicePixelRatio,
|
pixel,
|
||||||
}: {
|
devicePixelRatio,
|
||||||
pixel: number;
|
}: {
|
||||||
devicePixelRatio?: number;
|
pixel: number;
|
||||||
}): number {
|
devicePixelRatio?: number;
|
||||||
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
|
}): number {
|
||||||
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
|
const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
|
||||||
}
|
return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
|
||||||
|
}
|
||||||
export function timelineTimeToSnappedPixels({
|
|
||||||
time,
|
export function timelineTimeToSnappedPixels({
|
||||||
zoomLevel,
|
time,
|
||||||
devicePixelRatio,
|
zoomLevel,
|
||||||
}: {
|
devicePixelRatio,
|
||||||
time: number;
|
}: {
|
||||||
zoomLevel: number;
|
time: number;
|
||||||
devicePixelRatio?: number;
|
zoomLevel: number;
|
||||||
}): number {
|
devicePixelRatio?: number;
|
||||||
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
|
}): number {
|
||||||
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
|
const rawPixel = timelineTimeToPixels({ time, zoomLevel });
|
||||||
}
|
return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
|
||||||
|
}
|
||||||
export function getCenteredLineLeft({
|
|
||||||
centerPixel,
|
export function getCenteredLineLeft({
|
||||||
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
centerPixel,
|
||||||
}: {
|
lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||||
centerPixel: number;
|
}: {
|
||||||
lineWidthPx?: number;
|
centerPixel: number;
|
||||||
}): number {
|
lineWidthPx?: number;
|
||||||
return centerPixel - lineWidthPx / 2;
|
}): number {
|
||||||
}
|
return centerPixel - lineWidthPx / 2;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
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
|
* frame intervals for labels - starts at 2 so there's always at least
|
||||||
|
|
@ -54,15 +56,16 @@ export function getRulerConfig({
|
||||||
fps,
|
fps,
|
||||||
}: {
|
}: {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
}): RulerConfig {
|
}): RulerConfig {
|
||||||
|
const fpsFloat = frameRateToFloat(fps);
|
||||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||||
const pixelsPerFrame = pixelsPerSecond / fps;
|
const pixelsPerFrame = pixelsPerSecond / fpsFloat;
|
||||||
|
|
||||||
const labelIntervalSeconds = findOptimalInterval({
|
const labelIntervalSeconds = findOptimalInterval({
|
||||||
pixelsPerFrame,
|
pixelsPerFrame,
|
||||||
pixelsPerSecond,
|
pixelsPerSecond,
|
||||||
fps,
|
fps: fpsFloat,
|
||||||
minSpacingPx: MIN_LABEL_SPACING_PX,
|
minSpacingPx: MIN_LABEL_SPACING_PX,
|
||||||
frameIntervals: LABEL_FRAME_INTERVALS,
|
frameIntervals: LABEL_FRAME_INTERVALS,
|
||||||
});
|
});
|
||||||
|
|
@ -70,7 +73,7 @@ export function getRulerConfig({
|
||||||
const rawTickIntervalSeconds = findOptimalInterval({
|
const rawTickIntervalSeconds = findOptimalInterval({
|
||||||
pixelsPerFrame,
|
pixelsPerFrame,
|
||||||
pixelsPerSecond,
|
pixelsPerSecond,
|
||||||
fps,
|
fps: fpsFloat,
|
||||||
minSpacingPx: MIN_TICK_SPACING_PX,
|
minSpacingPx: MIN_TICK_SPACING_PX,
|
||||||
frameIntervals: TICK_FRAME_INTERVALS,
|
frameIntervals: TICK_FRAME_INTERVALS,
|
||||||
});
|
});
|
||||||
|
|
@ -81,7 +84,7 @@ export function getRulerConfig({
|
||||||
labelIntervalSeconds,
|
labelIntervalSeconds,
|
||||||
pixelsPerFrame,
|
pixelsPerFrame,
|
||||||
pixelsPerSecond,
|
pixelsPerSecond,
|
||||||
fps,
|
fps: fpsFloat,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { labelIntervalSeconds, tickIntervalSeconds };
|
return { labelIntervalSeconds, tickIntervalSeconds };
|
||||||
|
|
@ -197,13 +200,13 @@ export function formatRulerLabel({
|
||||||
fps,
|
fps,
|
||||||
}: {
|
}: {
|
||||||
timeInSeconds: number;
|
timeInSeconds: number;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
}): string {
|
}): string {
|
||||||
if (isSecondBoundary({ timeInSeconds })) {
|
if (isSecondBoundary({ timeInSeconds })) {
|
||||||
return formatTimestamp({ timeInSeconds });
|
return formatTimestamp({ timeInSeconds });
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps });
|
const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
|
||||||
return `${frameWithinSecond}f`;
|
return `${frameWithinSecond}f`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,172 +1,172 @@
|
||||||
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
|
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
import { getElementKeyframes } from "@/lib/animation";
|
||||||
import { getElementKeyframes } from "@/lib/animation";
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
|
||||||
export interface SnapPoint {
|
export interface SnapPoint {
|
||||||
time: number;
|
time: number;
|
||||||
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
|
type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
|
||||||
elementId?: string;
|
elementId?: string;
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapResult {
|
export interface SnapResult {
|
||||||
snappedTime: number;
|
snappedTime: number;
|
||||||
snapPoint: SnapPoint | null;
|
snapPoint: SnapPoint | null;
|
||||||
snapDistance: number;
|
snapDistance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SNAP_THRESHOLD_PX = 10;
|
const DEFAULT_SNAP_THRESHOLD_PX = 10;
|
||||||
|
|
||||||
export function findSnapPoints({
|
export function findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
excludeBookmarkTime,
|
excludeBookmarkTime,
|
||||||
enableElementSnapping = true,
|
enableElementSnapping = true,
|
||||||
enablePlayheadSnapping = true,
|
enablePlayheadSnapping = true,
|
||||||
enableBookmarkSnapping = true,
|
enableBookmarkSnapping = true,
|
||||||
enableKeyframeSnapping = true,
|
enableKeyframeSnapping = true,
|
||||||
}: {
|
}: {
|
||||||
tracks: Array<TimelineTrack>;
|
tracks: Array<TimelineTrack>;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
bookmarks?: Array<Bookmark>;
|
bookmarks?: Array<Bookmark>;
|
||||||
excludeBookmarkTime?: number;
|
excludeBookmarkTime?: number;
|
||||||
enableElementSnapping?: boolean;
|
enableElementSnapping?: boolean;
|
||||||
enablePlayheadSnapping?: boolean;
|
enablePlayheadSnapping?: boolean;
|
||||||
enableBookmarkSnapping?: boolean;
|
enableBookmarkSnapping?: boolean;
|
||||||
enableKeyframeSnapping?: boolean;
|
enableKeyframeSnapping?: boolean;
|
||||||
}): SnapPoint[] {
|
}): SnapPoint[] {
|
||||||
const snapPoints: SnapPoint[] = [];
|
const snapPoints: SnapPoint[] = [];
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.id === excludeElementId) continue;
|
if (element.id === excludeElementId) continue;
|
||||||
|
|
||||||
if (enableElementSnapping) {
|
if (enableElementSnapping) {
|
||||||
snapPoints.push(
|
snapPoints.push(
|
||||||
{
|
{
|
||||||
time: element.startTime,
|
time: element.startTime,
|
||||||
type: "element-start",
|
type: "element-start",
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: element.startTime + element.duration,
|
time: element.startTime + element.duration,
|
||||||
type: "element-end",
|
type: "element-end",
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableKeyframeSnapping) {
|
if (enableKeyframeSnapping) {
|
||||||
for (const keyframe of getElementKeyframes({
|
for (const keyframe of getElementKeyframes({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
})) {
|
})) {
|
||||||
snapPoints.push({
|
snapPoints.push({
|
||||||
time: element.startTime + keyframe.time,
|
time: element.startTime + keyframe.time,
|
||||||
type: "keyframe",
|
type: "keyframe",
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enablePlayheadSnapping) {
|
if (enablePlayheadSnapping) {
|
||||||
snapPoints.push({ time: playheadTime, type: "playhead" });
|
snapPoints.push({ time: playheadTime, type: "playhead" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableBookmarkSnapping) {
|
if (enableBookmarkSnapping) {
|
||||||
for (const bookmark of bookmarks) {
|
for (const bookmark of bookmarks) {
|
||||||
if (
|
if (
|
||||||
excludeBookmarkTime != null &&
|
excludeBookmarkTime != null &&
|
||||||
Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
|
bookmark.time === excludeBookmarkTime
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
snapPoints.push({ time: bookmark.time, type: "bookmark" });
|
snapPoints.push({ time: bookmark.time, type: "bookmark" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapPoints;
|
return snapPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapToNearestPoint({
|
export function snapToNearestPoint({
|
||||||
targetTime,
|
targetTime,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
|
snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
|
||||||
}: {
|
}: {
|
||||||
targetTime: number;
|
targetTime: number;
|
||||||
snapPoints: Array<SnapPoint>;
|
snapPoints: Array<SnapPoint>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
snapThreshold?: number;
|
snapThreshold?: number;
|
||||||
}): SnapResult {
|
}): SnapResult {
|
||||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
const thresholdInTicks = (snapThreshold / pixelsPerSecond) * TICKS_PER_SECOND;
|
||||||
|
|
||||||
let closestSnapPoint: SnapPoint | null = null;
|
let closestSnapPoint: SnapPoint | null = null;
|
||||||
let closestDistance = Infinity;
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
for (const snapPoint of snapPoints) {
|
for (const snapPoint of snapPoints) {
|
||||||
const distance = Math.abs(targetTime - snapPoint.time);
|
const distance = Math.abs(targetTime - snapPoint.time);
|
||||||
if (distance < thresholdInSeconds && distance < closestDistance) {
|
if (distance < thresholdInTicks && distance < closestDistance) {
|
||||||
closestDistance = distance;
|
closestDistance = distance;
|
||||||
closestSnapPoint = snapPoint;
|
closestSnapPoint = snapPoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
|
snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
|
||||||
snapPoint: closestSnapPoint,
|
snapPoint: closestSnapPoint,
|
||||||
snapDistance: closestDistance,
|
snapDistance: closestDistance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapElementEdge({
|
export function snapElementEdge({
|
||||||
targetTime,
|
targetTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
tracks,
|
tracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
snapToStart = true,
|
snapToStart = true,
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
}: {
|
}: {
|
||||||
targetTime: number;
|
targetTime: number;
|
||||||
elementDuration: number;
|
elementDuration: number;
|
||||||
tracks: Array<TimelineTrack>;
|
tracks: Array<TimelineTrack>;
|
||||||
playheadTime: number;
|
playheadTime: number;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
snapToStart?: boolean;
|
snapToStart?: boolean;
|
||||||
bookmarks?: Array<Bookmark>;
|
bookmarks?: Array<Bookmark>;
|
||||||
}): SnapResult {
|
}): SnapResult {
|
||||||
const snapPoints = findSnapPoints({
|
const snapPoints = findSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
playheadTime,
|
playheadTime,
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
});
|
});
|
||||||
|
|
||||||
const effectiveTargetTime = snapToStart
|
const effectiveTargetTime = snapToStart
|
||||||
? targetTime
|
? targetTime
|
||||||
: targetTime + elementDuration;
|
: targetTime + elementDuration;
|
||||||
|
|
||||||
const snapResult = snapToNearestPoint({
|
const snapResult = snapToNearestPoint({
|
||||||
targetTime: effectiveTargetTime,
|
targetTime: effectiveTargetTime,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snapToStart && snapResult.snapPoint) {
|
if (!snapToStart && snapResult.snapPoint) {
|
||||||
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
snapResult.snappedTime = snapResult.snappedTime - elementDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapResult;
|
return snapResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||||
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
|
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||||
import { isRetimableElement } from "@/lib/timeline";
|
import { isRetimableElement } from "@/lib/timeline";
|
||||||
|
|
||||||
type ElementUpdateField = keyof TimelineElement;
|
type ElementUpdateField = keyof TimelineElement | string;
|
||||||
|
|
||||||
export interface ElementUpdateContext {
|
export interface ElementUpdateContext {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||||
TIMELINE_ZOOM_MAX,
|
TIMELINE_ZOOM_MAX,
|
||||||
} from "@/lib/timeline/scale";
|
} from "@/lib/timeline/scale";
|
||||||
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
|
||||||
const PADDING_MAX_RATIO = 0.75;
|
const PADDING_MAX_RATIO = 0.75;
|
||||||
const PADDING_MIN_RATIO = 0.15;
|
const PADDING_MIN_RATIO = 0.15;
|
||||||
|
|
@ -14,12 +15,12 @@ export function getTimelineZoomMin({
|
||||||
duration: number;
|
duration: number;
|
||||||
containerWidth: number | null | undefined;
|
containerWidth: number | null | undefined;
|
||||||
}): number {
|
}): number {
|
||||||
const safeDuration = Math.max(duration, 1);
|
const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1);
|
||||||
const safeContainerWidth = containerWidth ?? 1000;
|
const safeContainerWidth = containerWidth ?? 1000;
|
||||||
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
|
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
|
||||||
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
|
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
|
||||||
const zoomToFit =
|
const zoomToFit =
|
||||||
availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND);
|
availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
|
||||||
|
|
||||||
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
|
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
|
||||||
|
|
||||||
|
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./constants";
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
import { frameRateToFloat } from "@/lib/fps/utils";
|
||||||
import type { BaseNode } from "./nodes/base-node";
|
import type { BaseNode } from "./nodes/base-node";
|
||||||
|
|
||||||
export type CanvasRendererParams = {
|
export type CanvasRendererParams = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CanvasRenderer {
|
export class CanvasRenderer {
|
||||||
|
|
@ -11,7 +13,7 @@ export class CanvasRenderer {
|
||||||
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fps: number;
|
fps: FrameRate;
|
||||||
|
|
||||||
constructor({ width, height, fps }: CanvasRendererParams) {
|
constructor({ width, height, fps }: CanvasRendererParams) {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
|
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
|
||||||
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
import type { RetimeConfig } from "@/lib/timeline";
|
import type { RetimeConfig } from "@/lib/timeline";
|
||||||
|
|
@ -39,9 +39,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||||
|
|
||||||
private isInRange({ time }: { time: number }): boolean {
|
private isInRange({ time }: { time: number }): boolean {
|
||||||
const localTime = time - this.params.timeOffset;
|
const localTime = time - this.params.timeOffset;
|
||||||
return (
|
return localTime >= 0 && localTime < this.params.duration;
|
||||||
localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSourceLocalTime({ time }: { time: number }): number {
|
private getSourceLocalTime({ time }: { time: number }): number {
|
||||||
|
|
@ -61,10 +59,11 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||||
time: number;
|
time: number;
|
||||||
}): Promise<BackdropSource | null> {
|
}): Promise<BackdropSource | null> {
|
||||||
if (this.params.mediaType === "video") {
|
if (this.params.mediaType === "video") {
|
||||||
|
const sourceTimeTicks = this.getSourceLocalTime({ time });
|
||||||
const frame = await videoCache.getFrameAt({
|
const frame = await videoCache.getFrameAt({
|
||||||
mediaId: this.params.mediaId,
|
mediaId: this.params.mediaId,
|
||||||
file: this.params.file,
|
file: this.params.file,
|
||||||
time: this.getSourceLocalTime({ time }),
|
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,39 @@
|
||||||
import type { CanvasRenderer } from "../canvas-renderer";
|
import type { CanvasRenderer } from "../canvas-renderer";
|
||||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { videoCache } from "@/services/video-cache/service";
|
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||||
|
import { videoCache } from "@/services/video-cache/service";
|
||||||
export interface VideoNodeParams extends VisualNodeParams {
|
|
||||||
url: string;
|
export interface VideoNodeParams extends VisualNodeParams {
|
||||||
file: File;
|
url: string;
|
||||||
mediaId: string;
|
file: File;
|
||||||
}
|
mediaId: string;
|
||||||
|
}
|
||||||
export class VideoNode extends VisualNode<VideoNodeParams> {
|
|
||||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||||
await super.render({ renderer, time });
|
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||||
|
await super.render({ renderer, time });
|
||||||
if (!this.isInRange({ time })) {
|
|
||||||
return;
|
if (!this.isInRange({ time })) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const videoTime = this.getSourceLocalTime({ time });
|
|
||||||
|
const videoTimeTicks = this.getSourceLocalTime({ time });
|
||||||
const frame = await videoCache.getFrameAt({
|
const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks });
|
||||||
mediaId: this.params.mediaId,
|
|
||||||
file: this.params.file,
|
const frame = await videoCache.getFrameAt({
|
||||||
time: videoTime,
|
mediaId: this.params.mediaId,
|
||||||
});
|
file: this.params.file,
|
||||||
|
time: videoTimeSeconds,
|
||||||
if (frame) {
|
});
|
||||||
this.renderVisual({
|
|
||||||
renderer,
|
if (frame) {
|
||||||
source: frame.canvas,
|
this.renderVisual({
|
||||||
sourceWidth: frame.canvas.width,
|
renderer,
|
||||||
sourceHeight: frame.canvas.height,
|
source: frame.canvas,
|
||||||
timelineTime: time,
|
sourceWidth: frame.canvas.width,
|
||||||
});
|
sourceHeight: frame.canvas.height,
|
||||||
}
|
timelineTime: time,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
resolveTransformAtTime,
|
resolveTransformAtTime,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
||||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||||
import { masksRegistry } from "@/lib/masks";
|
import { masksRegistry } from "@/lib/masks";
|
||||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||||
|
|
@ -58,7 +57,7 @@ export abstract class VisualNode<
|
||||||
protected isInRange({ time }: { time: number }): boolean {
|
protected isInRange({ time }: { time: number }): boolean {
|
||||||
const localTime = time - this.params.timeOffset;
|
const localTime = time - this.params.timeOffset;
|
||||||
return (
|
return (
|
||||||
localTime >= -TIME_EPSILON_SECONDS &&
|
localTime >= 0 &&
|
||||||
localTime < this.params.duration
|
localTime < this.params.duration
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,163 +1,170 @@
|
||||||
import EventEmitter from "eventemitter3";
|
import EventEmitter from "eventemitter3";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Output,
|
Output,
|
||||||
Mp4OutputFormat,
|
Mp4OutputFormat,
|
||||||
WebMOutputFormat,
|
WebMOutputFormat,
|
||||||
BufferTarget,
|
BufferTarget,
|
||||||
CanvasSource,
|
CanvasSource,
|
||||||
AudioBufferSource,
|
AudioBufferSource,
|
||||||
QUALITY_LOW,
|
QUALITY_LOW,
|
||||||
QUALITY_MEDIUM,
|
QUALITY_MEDIUM,
|
||||||
QUALITY_HIGH,
|
QUALITY_HIGH,
|
||||||
QUALITY_VERY_HIGH,
|
QUALITY_VERY_HIGH,
|
||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
import type { RootNode } from "./nodes/root-node";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import type { ExportFormat, ExportQuality } from "@/lib/export";
|
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||||
import { CanvasRenderer } from "./canvas-renderer";
|
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||||
|
import { frameRateToFloat } from "@/lib/fps/utils";
|
||||||
type ExportParams = {
|
import type { RootNode } from "./nodes/root-node";
|
||||||
width: number;
|
import type { ExportFormat, ExportQuality } from "@/lib/export";
|
||||||
height: number;
|
import { CanvasRenderer } from "./canvas-renderer";
|
||||||
fps: number;
|
|
||||||
format: ExportFormat;
|
type ExportParams = {
|
||||||
quality: ExportQuality;
|
width: number;
|
||||||
shouldIncludeAudio?: boolean;
|
height: number;
|
||||||
audioBuffer?: AudioBuffer;
|
fps: FrameRate;
|
||||||
};
|
format: ExportFormat;
|
||||||
|
quality: ExportQuality;
|
||||||
const qualityMap = {
|
shouldIncludeAudio?: boolean;
|
||||||
low: QUALITY_LOW,
|
audioBuffer?: AudioBuffer;
|
||||||
medium: QUALITY_MEDIUM,
|
};
|
||||||
high: QUALITY_HIGH,
|
|
||||||
very_high: QUALITY_VERY_HIGH,
|
const qualityMap = {
|
||||||
};
|
low: QUALITY_LOW,
|
||||||
|
medium: QUALITY_MEDIUM,
|
||||||
export type SceneExporterEvents = {
|
high: QUALITY_HIGH,
|
||||||
progress: [progress: number];
|
very_high: QUALITY_VERY_HIGH,
|
||||||
complete: [buffer: ArrayBuffer];
|
};
|
||||||
error: [error: Error];
|
|
||||||
cancelled: [];
|
export type SceneExporterEvents = {
|
||||||
};
|
progress: [progress: number];
|
||||||
|
complete: [buffer: ArrayBuffer];
|
||||||
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
|
error: [error: Error];
|
||||||
private renderer: CanvasRenderer;
|
cancelled: [];
|
||||||
private format: ExportFormat;
|
};
|
||||||
private quality: ExportQuality;
|
|
||||||
private shouldIncludeAudio: boolean;
|
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
|
||||||
private audioBuffer?: AudioBuffer;
|
private renderer: CanvasRenderer;
|
||||||
|
private format: ExportFormat;
|
||||||
private isCancelled = false;
|
private quality: ExportQuality;
|
||||||
|
private shouldIncludeAudio: boolean;
|
||||||
constructor({
|
private audioBuffer?: AudioBuffer;
|
||||||
width,
|
|
||||||
height,
|
private isCancelled = false;
|
||||||
fps,
|
|
||||||
format,
|
constructor({
|
||||||
quality,
|
width,
|
||||||
shouldIncludeAudio,
|
height,
|
||||||
audioBuffer,
|
fps,
|
||||||
}: ExportParams) {
|
format,
|
||||||
super();
|
quality,
|
||||||
this.renderer = new CanvasRenderer({
|
shouldIncludeAudio,
|
||||||
width,
|
audioBuffer,
|
||||||
height,
|
}: ExportParams) {
|
||||||
fps,
|
super();
|
||||||
});
|
this.renderer = new CanvasRenderer({
|
||||||
|
width,
|
||||||
this.format = format;
|
height,
|
||||||
this.quality = quality;
|
fps,
|
||||||
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
|
});
|
||||||
this.audioBuffer = audioBuffer;
|
|
||||||
}
|
this.format = format;
|
||||||
|
this.quality = quality;
|
||||||
cancel(): void {
|
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
|
||||||
this.isCancelled = true;
|
this.audioBuffer = audioBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async export({
|
cancel(): void {
|
||||||
rootNode,
|
this.isCancelled = true;
|
||||||
}: {
|
}
|
||||||
rootNode: RootNode;
|
|
||||||
}): Promise<ArrayBuffer | null> {
|
async export({
|
||||||
const { fps } = this.renderer;
|
rootNode,
|
||||||
const frameCount = Math.ceil(rootNode.duration * fps);
|
}: {
|
||||||
|
rootNode: RootNode;
|
||||||
const outputFormat =
|
}): Promise<ArrayBuffer | null> {
|
||||||
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
const fps = this.renderer.fps;
|
||||||
|
const fpsFloat = frameRateToFloat(fps);
|
||||||
const output = new Output({
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||||
format: outputFormat,
|
const frameCount = Math.floor(rootNode.duration / ticksPerFrame);
|
||||||
target: new BufferTarget(),
|
|
||||||
});
|
const outputFormat =
|
||||||
|
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||||||
const videoSource = new CanvasSource(this.renderer.canvas, {
|
|
||||||
codec: this.format === "webm" ? "vp9" : "avc",
|
const output = new Output({
|
||||||
bitrate: qualityMap[this.quality],
|
format: outputFormat,
|
||||||
});
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
output.addVideoTrack(videoSource, { frameRate: fps });
|
|
||||||
|
const videoSource = new CanvasSource(this.renderer.canvas, {
|
||||||
let audioSource: AudioBufferSource | null = null;
|
codec: this.format === "webm" ? "vp9" : "avc",
|
||||||
if (this.shouldIncludeAudio && this.audioBuffer) {
|
bitrate: qualityMap[this.quality],
|
||||||
let audioCodec: "aac" | "opus" =
|
});
|
||||||
this.format === "webm" ? "opus" : "aac";
|
|
||||||
|
output.addVideoTrack(videoSource, { frameRate: fpsFloat });
|
||||||
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
|
|
||||||
const { supported } = await AudioEncoder.isConfigSupported({
|
let audioSource: AudioBufferSource | null = null;
|
||||||
codec: "mp4a.40.2",
|
if (this.shouldIncludeAudio && this.audioBuffer) {
|
||||||
sampleRate: this.audioBuffer.sampleRate,
|
let audioCodec: "aac" | "opus" =
|
||||||
numberOfChannels: this.audioBuffer.numberOfChannels,
|
this.format === "webm" ? "opus" : "aac";
|
||||||
bitrate: 192000,
|
|
||||||
});
|
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
|
||||||
if (!supported) audioCodec = "opus";
|
const { supported } = await AudioEncoder.isConfigSupported({
|
||||||
}
|
codec: "mp4a.40.2",
|
||||||
|
sampleRate: this.audioBuffer.sampleRate,
|
||||||
audioSource = new AudioBufferSource({
|
numberOfChannels: this.audioBuffer.numberOfChannels,
|
||||||
codec: audioCodec,
|
bitrate: 192000,
|
||||||
bitrate: qualityMap[this.quality],
|
});
|
||||||
});
|
if (!supported) audioCodec = "opus";
|
||||||
output.addAudioTrack(audioSource);
|
}
|
||||||
}
|
|
||||||
|
audioSource = new AudioBufferSource({
|
||||||
await output.start();
|
codec: audioCodec,
|
||||||
|
bitrate: qualityMap[this.quality],
|
||||||
if (audioSource && this.audioBuffer) {
|
});
|
||||||
await audioSource.add(this.audioBuffer);
|
output.addAudioTrack(audioSource);
|
||||||
audioSource.close();
|
}
|
||||||
}
|
|
||||||
|
await output.start();
|
||||||
for (let i = 0; i < frameCount; i++) {
|
|
||||||
if (this.isCancelled) {
|
if (audioSource && this.audioBuffer) {
|
||||||
await output.cancel();
|
await audioSource.add(this.audioBuffer);
|
||||||
this.emit("cancelled");
|
audioSource.close();
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
const time = i / fps;
|
if (this.isCancelled) {
|
||||||
await this.renderer.render({ node: rootNode, time });
|
await output.cancel();
|
||||||
await videoSource.add(time, 1 / fps);
|
this.emit("cancelled");
|
||||||
|
return null;
|
||||||
this.emit("progress", i / frameCount);
|
}
|
||||||
}
|
|
||||||
|
const timeTicks = i * ticksPerFrame;
|
||||||
if (this.isCancelled) {
|
const timeSeconds = mediaTimeToSeconds({ time: timeTicks });
|
||||||
await output.cancel();
|
await this.renderer.render({ node: rootNode, time: timeTicks });
|
||||||
this.emit("cancelled");
|
await videoSource.add(timeSeconds, 1 / fpsFloat);
|
||||||
return null;
|
|
||||||
}
|
this.emit("progress", i / frameCount);
|
||||||
|
}
|
||||||
videoSource.close();
|
|
||||||
await output.finalize();
|
if (this.isCancelled) {
|
||||||
this.emit("progress", 1);
|
await output.cancel();
|
||||||
|
this.emit("cancelled");
|
||||||
const buffer = output.target.buffer;
|
return null;
|
||||||
if (!buffer) {
|
}
|
||||||
this.emit("error", new Error("Failed to export video"));
|
|
||||||
return null;
|
videoSource.close();
|
||||||
}
|
await output.finalize();
|
||||||
|
this.emit("progress", 1);
|
||||||
this.emit("complete", buffer);
|
|
||||||
return buffer;
|
const buffer = output.target.buffer;
|
||||||
}
|
if (!buffer) {
|
||||||
}
|
this.emit("error", new Error("Failed to export video"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("complete", buffer);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
DEFAULT_BACKGROUND_COLOR,
|
DEFAULT_BACKGROUND_COLOR,
|
||||||
} from "@/lib/background/constants";
|
} from "@/lib/background/constants";
|
||||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/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 type { MediaAssetData } from "@/services/storage/types";
|
||||||
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
|
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,10 +21,11 @@ import { V18toV19Migration } from "./v18-to-v19";
|
||||||
import { V19toV20Migration } from "./v19-to-v20";
|
import { V19toV20Migration } from "./v19-to-v20";
|
||||||
import { V20toV21Migration } from "./v20-to-v21";
|
import { V20toV21Migration } from "./v20-to-v21";
|
||||||
import { V21toV22Migration } from "./v21-to-v22";
|
import { V21toV22Migration } from "./v21-to-v22";
|
||||||
|
import { V22toV23Migration } from "./v22-to-v23";
|
||||||
export { runStorageMigrations } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
export type { MigrationProgress } from "./runner";
|
export type { MigrationProgress } from "./runner";
|
||||||
|
|
||||||
export const CURRENT_PROJECT_VERSION = 22;
|
export const CURRENT_PROJECT_VERSION = 23;
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
new V0toV1Migration(),
|
new V0toV1Migration(),
|
||||||
|
|
@ -49,4 +50,5 @@ export const migrations = [
|
||||||
new V19toV20Migration(),
|
new V19toV20Migration(),
|
||||||
new V20toV21Migration(),
|
new V20toV21Migration(),
|
||||||
new V21toV22Migration(),
|
new V21toV22Migration(),
|
||||||
|
new V22toV23Migration(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
DEFAULT_BACKGROUND_COLOR,
|
DEFAULT_BACKGROUND_COLOR,
|
||||||
} from "@/lib/background/constants";
|
} from "@/lib/background/constants";
|
||||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/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 { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
|
||||||
import type { MediaAssetData } from "@/services/storage/types";
|
import type { MediaAssetData } from "@/services/storage/types";
|
||||||
import type { MigrationResult, ProjectRecord } from "./types";
|
import type { MigrationResult, ProjectRecord } from "./types";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
4
bun.lock
4
bun.lock
|
|
@ -54,7 +54,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.1.3",
|
"opencut-wasm": "^0.2.3",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"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=="],
|
"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=="],
|
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,36 @@
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{ItemFn, parse_macro_input};
|
use syn::{FnArg, Item, ItemConst, ItemFn, parse_macro_input};
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
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());
|
let js_name = snake_to_camel(&function.sig.ident.to_string());
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
|
|
@ -14,6 +40,26 @@ pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
.into()
|
.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 {
|
fn snake_to_camel(name: &str) -> String {
|
||||||
let mut camel = String::with_capacity(name.len());
|
let mut camel = String::with_capacity(name.len());
|
||||||
let mut should_uppercase_next = false;
|
let mut should_uppercase_next = false;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod pipeline;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use pipeline::{ApplyEffectsOptions, EffectPipeline, EffectsError};
|
||||||
|
pub use types::{EffectPass, UniformValue};
|
||||||
|
|
@ -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]])
|
||||||
|
}
|
||||||
|
|
@ -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>),
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,7 @@ edition = "2024"
|
||||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
wgpu = "29.0.1"
|
wgpu = "29.0.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
wasm = []
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuError};
|
||||||
GPU_TEXTURE_FORMAT, GpuError,
|
|
||||||
effect_pipeline::{ApplyEffectsOptions, apply_effects},
|
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
|
||||||
mask_feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline},
|
|
||||||
sdf_pipeline::SdfPipeline,
|
|
||||||
shader_registry::ShaderRegistry,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
||||||
[-1.0, -1.0],
|
[-1.0, -1.0],
|
||||||
|
|
@ -25,9 +21,8 @@ pub struct GpuContext {
|
||||||
fullscreen_quad: wgpu::Buffer,
|
fullscreen_quad: wgpu::Buffer,
|
||||||
linear_sampler: wgpu::Sampler,
|
linear_sampler: wgpu::Sampler,
|
||||||
nearest_sampler: wgpu::Sampler,
|
nearest_sampler: wgpu::Sampler,
|
||||||
shader_registry: ShaderRegistry,
|
texture_sampler_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
sdf_pipeline: SdfPipeline,
|
blit_pipeline: wgpu::RenderPipeline,
|
||||||
mask_feather_pipeline: MaskFeatherPipeline,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuContext {
|
impl GpuContext {
|
||||||
|
|
@ -77,9 +72,74 @@ impl GpuContext {
|
||||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let shader_registry = ShaderRegistry::new(&device);
|
let texture_sampler_bind_group_layout =
|
||||||
let sdf_pipeline = SdfPipeline::new(&device);
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
let mask_feather_pipeline = MaskFeatherPipeline::new(&device);
|
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 {
|
Ok(Self {
|
||||||
instance,
|
instance,
|
||||||
|
|
@ -89,26 +149,11 @@ impl GpuContext {
|
||||||
fullscreen_quad,
|
fullscreen_quad,
|
||||||
linear_sampler,
|
linear_sampler,
|
||||||
nearest_sampler,
|
nearest_sampler,
|
||||||
shader_registry,
|
texture_sampler_bind_group_layout,
|
||||||
sdf_pipeline,
|
blit_pipeline,
|
||||||
mask_feather_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(
|
pub fn create_render_texture(
|
||||||
&self,
|
&self,
|
||||||
width: u32,
|
width: u32,
|
||||||
|
|
@ -162,12 +207,8 @@ impl GpuContext {
|
||||||
&self.nearest_sampler
|
&self.nearest_sampler
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shader_registry(&self) -> &ShaderRegistry {
|
pub fn texture_sampler_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
|
||||||
&self.shader_registry
|
&self.texture_sampler_bind_group_layout
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sdf_pipeline(&self) -> &SdfPipeline {
|
|
||||||
&self.sdf_pipeline
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_texture_to_surface(
|
pub fn render_texture_to_surface(
|
||||||
|
|
@ -202,7 +243,7 @@ impl GpuContext {
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("gpu-blit-bind-group"),
|
label: Some("gpu-blit-bind-group"),
|
||||||
layout: self.shader_registry.effect_texture_bind_group_layout(),
|
layout: &self.texture_sampler_bind_group_layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
|
|
@ -237,7 +278,7 @@ impl GpuContext {
|
||||||
timestamp_writes: None,
|
timestamp_writes: None,
|
||||||
multiview_mask: 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_vertex_buffer(0, self.fullscreen_quad.slice(..));
|
||||||
render_pass.set_bind_group(0, &bind_group, &[]);
|
render_pass.set_bind_group(0, &bind_group, &[]);
|
||||||
render_pass.draw(0..6, 0..1);
|
render_pass.draw(0..6, 0..1);
|
||||||
|
|
@ -247,4 +288,50 @@ impl GpuContext {
|
||||||
surface_texture.present();
|
surface_texture.present();
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]])
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
mod context;
|
mod context;
|
||||||
mod effect_pipeline;
|
|
||||||
mod mask_feather;
|
|
||||||
mod sdf_pipeline;
|
|
||||||
mod shader_registry;
|
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use wgpu;
|
|
||||||
pub use context::GpuContext;
|
pub use context::GpuContext;
|
||||||
pub use effect_pipeline::{ApplyEffectsOptions, EffectPass, UniformValue};
|
pub use wgpu;
|
||||||
pub use mask_feather::ApplyMaskFeatherOptions;
|
|
||||||
|
|
||||||
pub const GPU_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
|
pub const GPU_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
|
||||||
|
pub const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl");
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum GpuError {
|
pub enum GpuError {
|
||||||
|
|
@ -23,18 +18,4 @@ pub enum GpuError {
|
||||||
CreateSurface(#[from] wgpu::CreateSurfaceError),
|
CreateSurface(#[from] wgpu::CreateSurfaceError),
|
||||||
#[error("The output surface does not support the required texture format")]
|
#[error("The output surface does not support the required texture format")]
|
||||||
UnsupportedSurfaceFormat,
|
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 },
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
|
||||||
use wgpu::util::DeviceExt;
|
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");
|
const JFA_DISTANCE_SHADER_SOURCE: &str = include_str!("shaders/jfa_distance.wgsl");
|
||||||
|
|
||||||
pub struct ApplyMaskFeatherOptions<'a> {
|
pub struct ApplyMaskFeatherOptions<'a> {
|
||||||
|
|
@ -14,6 +14,7 @@ pub struct ApplyMaskFeatherOptions<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct MaskFeatherPipeline {
|
pub struct MaskFeatherPipeline {
|
||||||
|
sdf_pipeline: SdfPipeline,
|
||||||
inside_texture_bind_group_layout: wgpu::BindGroupLayout,
|
inside_texture_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
outside_texture_bind_group_layout: wgpu::BindGroupLayout,
|
outside_texture_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
uniform_bind_group_layout: wgpu::BindGroupLayout,
|
uniform_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
|
@ -29,7 +30,8 @@ struct DistanceUniformBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MaskFeatherPipeline {
|
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 =
|
let inside_texture_bind_group_layout =
|
||||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("gpu-mask-distance-inside-layout"),
|
label: Some("gpu-mask-distance-inside-layout"),
|
||||||
|
|
@ -140,6 +142,7 @@ impl MaskFeatherPipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
sdf_pipeline: SdfPipeline::new(context),
|
||||||
inside_texture_bind_group_layout,
|
inside_texture_bind_group_layout,
|
||||||
outside_texture_bind_group_layout,
|
outside_texture_bind_group_layout,
|
||||||
uniform_bind_group_layout,
|
uniform_bind_group_layout,
|
||||||
|
|
@ -157,11 +160,10 @@ impl MaskFeatherPipeline {
|
||||||
feather,
|
feather,
|
||||||
}: ApplyMaskFeatherOptions<'_>,
|
}: ApplyMaskFeatherOptions<'_>,
|
||||||
) -> wgpu::Texture {
|
) -> wgpu::Texture {
|
||||||
let sdf = context
|
let sdf = self
|
||||||
.sdf_pipeline()
|
.sdf_pipeline
|
||||||
.compute_signed_distance_field(context, mask, width, height);
|
.compute_signed_distance_field(context, mask, width, height);
|
||||||
let output_texture =
|
let output_texture = context.create_render_texture(width, height, "masks-feather-output");
|
||||||
context.create_render_texture(width, height, "gpu-mask-feather-output");
|
|
||||||
let inside_view = sdf
|
let inside_view = sdf
|
||||||
.inside_texture
|
.inside_texture
|
||||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
|
@ -201,17 +203,18 @@ impl MaskFeatherPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer = context
|
let uniform_buffer =
|
||||||
.device()
|
context
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.device()
|
||||||
label: Some("gpu-mask-distance-uniform-buffer"),
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
|
label: Some("gpu-mask-distance-uniform-buffer"),
|
||||||
resolution: [width as f32, height as f32],
|
contents: bytemuck::bytes_of(&DistanceUniformBuffer {
|
||||||
feather_half: feather / 2.0,
|
resolution: [width as f32, height as f32],
|
||||||
_padding: 0.0,
|
feather_half: feather / 2.0,
|
||||||
}),
|
_padding: 0.0,
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
}),
|
||||||
});
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
@ -222,11 +225,12 @@ impl MaskFeatherPipeline {
|
||||||
resource: uniform_buffer.as_entire_binding(),
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
let mut encoder = context
|
let mut encoder =
|
||||||
.device()
|
context
|
||||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
.device()
|
||||||
label: Some("gpu-mask-distance-command-encoder"),
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
});
|
label: Some("gpu-mask-distance-command-encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
mod feather;
|
||||||
|
mod sdf;
|
||||||
|
|
||||||
|
pub use feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline};
|
||||||
|
pub use sdf::{SdfPipeline, SignedDistanceFieldTextures};
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext};
|
||||||
use wgpu::util::DeviceExt;
|
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_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl");
|
||||||
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
|
const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl");
|
||||||
|
|
||||||
|
|
@ -36,7 +34,8 @@ struct JfaStepUniformBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SdfPipeline {
|
impl SdfPipeline {
|
||||||
pub fn new(device: &wgpu::Device) -> Self {
|
pub fn new(context: &GpuContext) -> Self {
|
||||||
|
let device = context.device();
|
||||||
let texture_bind_group_layout =
|
let texture_bind_group_layout =
|
||||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("gpu-sdf-texture-bind-group-layout"),
|
label: Some("gpu-sdf-texture-bind-group-layout"),
|
||||||
|
|
@ -213,13 +212,14 @@ impl SdfPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
let uniform_buffer = context
|
let uniform_buffer =
|
||||||
.device()
|
context
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.device()
|
||||||
label: Some("gpu-sdf-uniform-buffer"),
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
contents: uniform_buffer_bytes,
|
label: Some("gpu-sdf-uniform-buffer"),
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
contents: uniform_buffer_bytes,
|
||||||
});
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
let uniform_bind_group = context
|
let uniform_bind_group = context
|
||||||
.device()
|
.device()
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
|
@ -230,11 +230,12 @@ impl SdfPipeline {
|
||||||
resource: uniform_buffer.as_entire_binding(),
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
let mut encoder = context
|
let mut encoder =
|
||||||
.device()
|
context
|
||||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
.device()
|
||||||
label: Some("gpu-sdf-command-encoder"),
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
});
|
label: Some("gpu-sdf-command-encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
|
@ -5,13 +5,14 @@ edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
path = "src/time.rs"
|
path = "src/time.rs"
|
||||||
crate-type = ["rlib", "cdylib"]
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bridge = { version = "0.1.0", path = "../bridge" }
|
bridge = { version = "0.1.0", path = "../bridge" }
|
||||||
|
num-traits = "0.2.19"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tsify-next = { version = "0.5", optional = true }
|
tsify-next = { version = "0.5", optional = true }
|
||||||
wasm-bindgen = { version = "0.2.115", optional = true }
|
wasm-bindgen = { version = "0.2.115", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
|
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,422 +1,19 @@
|
||||||
use bridge::export;
|
mod frame_rate;
|
||||||
use serde::{Deserialize, Serialize};
|
mod media_time;
|
||||||
|
mod timecode;
|
||||||
|
|
||||||
const SECONDS_PER_HOUR: f64 = 3600.0;
|
pub use frame_rate::FrameRate;
|
||||||
const SECONDS_PER_MINUTE: f64 = 60.0;
|
pub use media_time::{
|
||||||
const CENTISECONDS_PER_SECOND: f64 = 100.0;
|
FloorToFrameOptions, IsFrameAlignedOptions, LastFrameTimeOptions, MediaTime,
|
||||||
|
MediaTimeAddOptions, MediaTimeClampOptions, MediaTimeFromFrameOptions,
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
|
MediaTimeFromSecondsOptions, MediaTimeMaxOptions, MediaTimeMinOptions, MediaTimeSubOptions,
|
||||||
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
|
MediaTimeToFrameOptions, MediaTimeToSecondsOptions, RoundToFrameOptions,
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
SnappedSeekTimeOptions, TICKS_PER_SECOND, floor_to_frame, is_frame_aligned, last_frame_time,
|
||||||
pub enum TimeCodeFormat {
|
media_time_add, media_time_clamp, media_time_from_frame, media_time_from_seconds,
|
||||||
#[serde(rename = "MM:SS")]
|
media_time_max, media_time_min, media_time_sub, media_time_to_frame, media_time_to_seconds,
|
||||||
MmSs,
|
round_to_frame, snapped_seek_time,
|
||||||
#[serde(rename = "HH:MM:SS")]
|
};
|
||||||
HhMmSs,
|
pub use timecode::{
|
||||||
#[serde(rename = "HH:MM:SS:CS")]
|
FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions, TimeCodeFormat,
|
||||||
HhMmSsCs,
|
format_timecode, guess_timecode_format, parse_timecode,
|
||||||
#[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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "opencut-wasm"
|
name = "opencut-wasm"
|
||||||
version = "0.1.3"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Shared video editor logic compiled to WebAssembly"
|
description = "Shared video editor logic compiled to WebAssembly"
|
||||||
repository = "https://github.com/opencut/opencut"
|
repository = "https://github.com/opencut/opencut"
|
||||||
|
|
@ -11,11 +11,19 @@ path = "src/wasm.rs"
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
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 = { version = "1.0.228", features = ["derive"] }
|
||||||
serde-wasm-bindgen = "0.6.5"
|
serde-wasm-bindgen = "0.6.5"
|
||||||
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
|
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
|
||||||
wasm-bindgen = "0.2.116"
|
wasm-bindgen = "0.2.116"
|
||||||
wasm-bindgen-futures = "0.4.66"
|
wasm-bindgen-futures = "0.4.66"
|
||||||
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
|
web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["wasm"]
|
||||||
|
wasm = []
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ npm install opencut-wasm
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```ts
|
```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).
|
All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts).
|
||||||
|
|
|
||||||
|
|
@ -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")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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}")))
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue