feat: migrate time utilities from typescript to rust WASM

This commit is contained in:
Maze Winther
2026-03-31 04:03:52 +02:00
parent 5cc6abc3a4
commit dcf54c1250
29 changed files with 2008 additions and 2025 deletions
+2
View File
@@ -22,3 +22,5 @@ bun.lockb
.open-next .open-next
target/ target/
.cargo-tools/
Generated
+91 -18
View File
@@ -642,6 +642,14 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "bridge"
version = "0.1.0"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -1977,6 +1985,19 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.16.0" version = "0.16.0"
@@ -2780,10 +2801,12 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [ dependencies = [
"cfg-if",
"futures-util",
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -3540,6 +3563,13 @@ dependencies = [
"gpui", "gpui",
] ]
[[package]]
name = "opencut-wasm"
version = "0.1.2"
dependencies = [
"time",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" version = "0.2.1"
@@ -4588,6 +4618,17 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -5253,6 +5294,16 @@ dependencies = [
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"serde",
"tsify-next",
"wasm-bindgen",
]
[[package]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"
@@ -5527,6 +5578,32 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tsify-next"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
dependencies = [
"gloo-utils",
"serde",
"serde-wasm-bindgen",
"serde_json",
"tsify-next-macros",
"wasm-bindgen",
]
[[package]]
name = "tsify-next-macros"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.20.0" version = "0.20.0"
@@ -5850,9 +5927,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5863,23 +5940,19 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.64" version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [ dependencies = [
"cfg-if",
"futures-util",
"js-sys", "js-sys",
"once_cell",
"wasm-bindgen", "wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5887,9 +5960,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -5900,9 +5973,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -6053,9 +6126,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
+3 -2
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"apps/desktop", "apps/desktop",
"rust/crates/time" "rust/crates/time",
"rust/crates/bridge" "rust/crates/bridge",
"rust/wasm",
] ]
+1
View File
@@ -22,6 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
TProjectSortKey, TProjectSortKey,
TProjectSortOption, TProjectSortOption,
} from "@/lib/project/types"; } from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } 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 {
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
} }
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS"; const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format }); return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
}; };
const VIEW_MODE_OPTIONS = [ const VIEW_MODE_OPTIONS = [
@@ -1,14 +1,13 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time"; import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import type { TTimeCode } from "@/lib/time";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
interface EditableTimecodeProps { interface EditableTimecodeProps {
time: number; time: number;
duration: number; duration: number;
format?: TTimeCode; format?: TimeCodeFormat;
fps: number; fps: number;
onTimeChange?: ({ time }: { time: number }) => void; onTimeChange?: ({ time }: { time: number }) => void;
className?: string; className?: string;
@@ -29,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({ timeInSeconds: time, format, fps }) ?? "";
const startEditing = () => { const startEditing = () => {
if (disabled) return; if (disabled) return;
@@ -49,7 +48,7 @@ export function EditableTimecode({
const applyEdit = () => { const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps }); const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime === null) { if (parsedTime == null) {
setHasError(true); setHasError(true);
return; return;
} }
@@ -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 "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
function InfoRow({ function InfoRow({
@@ -37,11 +37,8 @@ export function ProjectInfoDialog({
}) { }) {
const durationFormatted = const durationFormatted =
project.duration > 0 project.duration > 0
? formatTimeCode({ ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
timeInSeconds: project.duration, : "0:00";
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -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 "@/lib/time"; 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,11 +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({ {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span> </span>
</div> </div>
); );
@@ -10,7 +10,7 @@ import {
TIMELINE_BOOKMARK_ROW_HEIGHT, TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-wasm";
import { import {
ArrowTurnBackwardIcon, ArrowTurnBackwardIcon,
Delete02Icon, Delete02Icon,
@@ -5,7 +5,7 @@ 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 "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser"; import { downloadBlob } from "@/utils/browser";
type SnapshotResult = type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" }; return { success: false, error: "Failed to create image" };
} }
const timecode = formatTimeCode({ const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const safeName = const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() || activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot"; "snapshot";
@@ -13,7 +13,7 @@ import type {
AnimationValue, AnimationValue,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time"; import { getLastFrameTime } from "opencut-wasm";
import { import {
AddTrackCommand, AddTrackCommand,
RemoveTrackCommand, RemoveTrackCommand,
@@ -14,7 +14,7 @@ import {
DRAG_THRESHOLD_PX, DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS, TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
@@ -302,10 +302,7 @@ export function useElementInteraction({
0, 0,
mouseTime - pendingDragRef.current.clickOffsetTime, mouseTime - pendingDragRef.current.clickOffsetTime,
); );
const snappedTime = snapTimeToFrame({ const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
time: adjustedTime,
fps: activeProject.settings.fps,
});
startDrag({ startDrag({
...pendingDragRef.current, ...pendingDragRef.current,
initialCurrentTime: snappedTime, initialCurrentTime: snappedTime,
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } 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";
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) { if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({ const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const visibleSourceSpan = Math.max( const visibleSourceSpan = Math.max(
0, 0,
sourceDuration - newTrimStart - resizing.initialTrimEnd, sourceDuration - newTrimStart - resizing.initialTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan), const trimDelta = resizing.initialDuration - newDuration;
fps: projectFps, const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
});
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart); setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
) )
: Math.min(extensionAmount, maxExtension), : Math.min(extensionAmount, maxExtension),
); );
const newStartTime = snapTimeToFrame({ const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
time: resizing.initialStartTime - actualExtension, const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0); setCurrentTrimStart(0);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0, 0,
sourceDuration - newTrimStart - resizing.initialTrimEnd, sourceDuration - newTrimStart - resizing.initialTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan), const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
fps: projectFps,
});
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
setCurrentTrimStart(newTrimStart); setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -395,13 +369,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({ const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
setCurrentTrimEnd(0); setCurrentTrimEnd(0);
@@ -411,10 +379,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({ const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
setCurrentTrimEnd(0); setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd, maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd), Math.max(minTrimEndForNeighbor, newTrimEnd),
); );
const finalTrimEnd = snapTimeToFrame({ const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
time: clampedTrimEnd,
fps: projectFps,
});
const visibleSourceSpan = Math.max( const visibleSourceSpan = Math.max(
0, 0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd, sourceDuration - resizing.initialTrimStart - finalTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
setCurrentTrimEnd(finalTrimEnd); setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
@@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection"; import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { import {
DRAG_THRESHOLD_PX, DRAG_THRESHOLD_PX,
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
if (wasDrag) return; if (wasDrag) return;
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({ const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
rawTime: displayedStartTime + indicatorTime,
duration,
fps,
});
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 { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { import {
findSnapPoints, findSnapPoints,
@@ -145,10 +145,7 @@ export function useBookmarkDrag({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const frameSnappedTime = snapTimeToFrame({ const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
time: Math.max(0, Math.min(mouseTime, duration)),
fps: activeProject.settings.fps,
});
const { snappedTime: initialTime } = getSnapResult({ const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime, rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime, excludeBookmarkTime: bookmarkTime,
@@ -176,10 +173,7 @@ 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({ const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
time: clampedTime,
fps: activeProject.settings.fps,
});
const snapResult = getSnapResult({ const snapResult = getSnapResult({
rawTime: frameSnappedTime, rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime, excludeBookmarkTime: dragState.bookmarkTime,
@@ -4,7 +4,7 @@ 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 { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import { import {
buildTextElement, buildTextElement,
buildGraphicElement, buildGraphicElement,
@@ -1,4 +1,4 @@
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-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";
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
); );
const framesPerSecond = activeProject.settings.fps; const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({ const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
rawTime,
duration,
fps: framesPerSecond,
});
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => { const time = (() => {
@@ -1,7 +1,7 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react"; import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor"; import { useEditor } from "../use-editor";
interface UseTimelineSeekProps { interface UseTimelineSeekProps {
@@ -137,11 +137,7 @@ export function useTimelineSeek({
); );
const projectFps = activeProject?.settings.fps || 30; const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
rawTime,
duration,
fps: projectFps,
});
seek(time); seek(time);
editor.project.setTimelineViewState({ editor.project.setTimelineViewState({
viewState: { viewState: {
-225
View File
@@ -1,225 +0,0 @@
export type TTimeCode =
| "MM:SS"
| "HH:MM:SS"
| "HH:MM:SS:CS"
| "HH:MM:SS:FF";
export function roundToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps) / fps;
}
export function formatTimeCode({
timeInSeconds,
format = "HH:MM:SS:CS",
fps,
}: {
timeInSeconds: number;
format?: TTimeCode;
fps?: number;
}): string {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = fps ? Math.floor((timeInSeconds % 1) * fps) : 0;
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
if (!fps) throw new Error("FPS is required for HH:MM:SS:FF format");
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
}
export function parseTimeCode({
timeCode,
format = "HH:MM:SS:CS",
fps,
}: {
timeCode: string;
format?: TTimeCode;
fps: number;
}): number | null {
if (!timeCode || typeof timeCode !== "string") return null;
const cleanTimeCode = timeCode.trim();
try {
switch (format) {
case "MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 2) return null;
const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
return minutes * 60 + seconds;
}
case "HH:MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 3) return null;
const [hours, minutes, seconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
minutes >= 60 ||
seconds >= 60
)
return null;
return hours * 3600 + minutes * 60 + seconds;
}
case "HH:MM:SS:CS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(centiseconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
centiseconds < 0 ||
minutes >= 60 ||
seconds >= 60 ||
centiseconds >= 100
)
return null;
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
}
case "HH:MM:SS:FF": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, frames] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(frames)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
frames < 0 ||
minutes >= 60 ||
seconds >= 60 ||
frames >= fps
)
return null;
return hours * 3600 + minutes * 60 + seconds + frames / fps;
}
}
} catch {
return null;
}
}
export function guessTimeCodeFormat({
timeCode,
}: {
timeCode: string;
}): TTimeCode | null {
if (!timeCode || typeof timeCode !== "string") return null;
const numbers = timeCode.split(":");
if (!numbers.every((n) => !Number.isNaN(Number(n)))) return null;
if (numbers.length === 2) return "MM:SS";
if (numbers.length === 3) return "HH:MM:SS";
// todo: how to tell frames apart from cs?
if (numbers.length === 4) return "HH:MM:SS:FF";
return null;
}
export function timeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps);
}
export function frameToTime({
frame,
fps,
}: {
frame: number;
fps: number;
}): number {
return frame / fps;
}
export function snapTimeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
if (fps <= 0) return time;
const frame = timeToFrame({ time, fps });
return frameToTime({ frame, fps });
}
export function getSnappedSeekTime({
rawTime,
duration,
fps,
}: {
rawTime: number;
duration: number;
fps: number;
}): number {
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
const lastFrame = getLastFrameTime({ duration, fps });
return Math.max(0, Math.min(lastFrame, snappedTime));
}
export function getLastFrameTime({
duration,
fps,
}: {
duration: number;
fps: number;
}): number {
if (duration <= 0) return 0;
if (fps <= 0) return duration;
const frameOffset = 1 / fps;
return Math.max(0, duration - frameOffset);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Bookmark } from "@/lib/timeline"; import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "@/lib/time"; import { roundToFrame } from "opencut-wasm";
export const BOOKMARK_TIME_EPSILON = 0.001; export const BOOKMARK_TIME_EPSILON = 0.001;
+8 -1
View File
@@ -54,6 +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": "workspace:*",
"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",
@@ -77,7 +78,7 @@
"unified": "^11.0.5", "unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8", "wavesurfer.js": "^7.9.8",
"zod": "^4.3.6", "zod": "4.3.6",
"zustand": "^5.0.2", "zustand": "^5.0.2",
}, },
"devDependencies": { "devDependencies": {
@@ -102,6 +103,10 @@
"wrangler": "^4.77.0", "wrangler": "^4.77.0",
}, },
}, },
"rust/wasm/pkg": {
"name": "opencut-wasm",
"version": "0.1.0",
},
}, },
"trustedDependencies": [ "trustedDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
@@ -1453,6 +1458,8 @@
"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@workspace:rust/wasm/pkg"],
"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=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+5 -1
View File
@@ -3,9 +3,13 @@
"packageManager": "bun@1.2.18", "packageManager": "bun@1.2.18",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*",
"rust/wasm/pkg"
], ],
"scripts": { "scripts": {
"build:wasm": "wasm-pack build rust/wasm --target bundler --out-dir pkg",
"publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public",
"dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'",
"dev:web": "turbo run dev --filter=@opencut/web", "dev:web": "turbo run dev --filter=@opencut/web",
"build:web": "turbo run build --filter=@opencut/web", "build:web": "turbo run build --filter=@opencut/web",
"dev:tools": "turbo run dev --filter=@opencut/tools", "dev:tools": "turbo run dev --filter=@opencut/tools",
+3 -1
View File
@@ -9,7 +9,9 @@ crate-type = ["rlib", "cdylib"]
[dependencies] [dependencies]
bridge = { version = "0.1.0", path = "../bridge" } bridge = { version = "0.1.0", path = "../bridge" }
serde = { version = "1", features = ["derive"] }
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"] wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
+223 -75
View File
@@ -1,52 +1,129 @@
use bridge::export; use bridge::export;
use serde::{Deserialize, Serialize};
const DEFAULT_TIME_CODE_FORMAT: &str = "HH:MM:SS:CS"; const SECONDS_PER_HOUR: f64 = 3600.0;
const SECONDS_PER_MINUTE: f64 = 60.0;
const CENTISECONDS_PER_SECOND: f64 = 100.0;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
enum TimeCodeFormat { #[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, MmSs,
#[serde(rename = "HH:MM:SS")]
HhMmSs, HhMmSs,
#[serde(rename = "HH:MM:SS:CS")]
HhMmSsCs, HhMmSsCs,
#[serde(rename = "HH:MM:SS:FF")]
HhMmSsFf, HhMmSsFf,
} }
impl TimeCodeFormat { #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
fn parse(format: Option<&str>) -> Option<Self> { #[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) { #[derive(Deserialize)]
"MM:SS" => Some(Self::MmSs), #[serde(rename_all = "camelCase")]
"HH:MM:SS" => Some(Self::HhMmSs), pub struct RoundToFrameOptions {
"HH:MM:SS:CS" => Some(Self::HhMmSsCs), pub time: f64,
"HH:MM:SS:FF" => Some(Self::HhMmSsFf), pub fps: f64,
_ => None, }
}
}
fn as_str(self) -> &'static str { #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
match self { #[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
Self::MmSs => "MM:SS", #[derive(Deserialize)]
Self::HhMmSs => "HH:MM:SS", #[serde(rename_all = "camelCase")]
Self::HhMmSsCs => "HH:MM:SS:CS", pub struct FormatTimeCodeOptions {
Self::HhMmSsFf => "HH:MM:SS:FF", 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] #[export]
pub fn round_to_frame(time: f64, fps: f64) -> f64 { pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps (time * fps).round() / fps
} }
#[export] #[export]
pub fn format_time_code( pub fn format_time_code(
time_in_seconds: f64, FormatTimeCodeOptions {
format: Option<String>, time_in_seconds,
fps: Option<f64>, format,
fps,
}: FormatTimeCodeOptions,
) -> Option<String> { ) -> Option<String> {
let format = TimeCodeFormat::parse(format.as_deref())?; let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let hours = (time_in_seconds / 3600.0).floor() as u64; let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64;
let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64; let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64;
let seconds = (time_in_seconds % 60.0).floor() as u64; let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * 100.0).floor() as u64; let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64;
match format { match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")), TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
@@ -69,12 +146,18 @@ pub fn format_time_code(
} }
#[export] #[export]
pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>) -> Option<f64> { pub fn parse_time_code(
ParseTimeCodeOptions {
time_code,
format,
fps,
}: ParseTimeCodeOptions,
) -> Option<f64> {
if time_code.trim().is_empty() { if time_code.trim().is_empty() {
return None; return None;
} }
let format = TimeCodeFormat::parse(format.as_deref())?; let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code let parts = time_code
.trim() .trim()
.split(':') .split(':')
@@ -86,35 +169,42 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [minutes, seconds] = parts.as_slice() else { let [minutes, seconds] = parts.as_slice() else {
return None; return None;
}; };
if *seconds >= 60 { if *seconds >= SECONDS_PER_MINUTE as u32 {
return None; return None;
} }
Some((*minutes as f64 * 60.0) + *seconds as f64) Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
} }
TimeCodeFormat::HhMmSs => { TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else { let [hours, minutes, seconds] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 { if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
return None; return None;
} }
Some((*hours as f64 * 3600.0) + (*minutes as f64 * 60.0) + *seconds as f64) Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64,
)
} }
TimeCodeFormat::HhMmSsCs => { TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else { let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 || *centiseconds >= 100 { if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *centiseconds >= CENTISECONDS_PER_SECOND as u32
{
return None; return None;
} }
Some( Some(
(*hours as f64 * 3600.0) (*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * 60.0) + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64 + *seconds as f64
+ (*centiseconds as f64 / 100.0), + (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
) )
} }
TimeCodeFormat::HhMmSsFf => { TimeCodeFormat::HhMmSsFf => {
@@ -126,13 +216,16 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [hours, minutes, seconds, frames] = parts.as_slice() else { let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 || *frames as f64 >= fps { if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *frames as f64 >= fps
{
return None; return None;
} }
Some( Some(
(*hours as f64 * 3600.0) (*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * 60.0) + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64 + *seconds as f64
+ (*frames as f64 / fps), + (*frames as f64 / fps),
) )
@@ -141,7 +234,9 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
} }
#[export] #[export]
pub fn guess_time_code_format(time_code: &str) -> Option<String> { pub fn guess_time_code_format(
GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() { if time_code.trim().is_empty() {
return None; return None;
} }
@@ -153,41 +248,52 @@ pub fn guess_time_code_format(time_code: &str) -> Option<String> {
})?; })?;
match part_count { match part_count {
2 => Some(TimeCodeFormat::MmSs.as_str().to_string()), 2 => Some(TimeCodeFormat::MmSs),
3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()), 3 => Some(TimeCodeFormat::HhMmSs),
4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()), 4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None, _ => None,
} }
} }
#[export] #[export]
pub fn time_to_frame(time: f64, fps: f64) -> f64 { pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round() (time * fps).round()
} }
#[export] #[export]
pub fn frame_to_time(frame: f64, fps: f64) -> f64 { pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps frame / fps
} }
#[export] #[export]
pub fn snap_time_to_frame(time: f64, fps: f64) -> f64 { pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 {
if fps <= 0.0 { if fps <= 0.0 {
return time; return time;
} }
frame_to_time(time_to_frame(time, fps), fps) frame_to_time(FrameToTimeOptions {
frame: time_to_frame(TimeToFrameOptions { time, fps }),
fps,
})
} }
#[export] #[export]
pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 { pub fn get_snapped_seek_time(
let snapped_time = snap_time_to_frame(raw_time, fps); GetSnappedSeekTimeOptions {
let last_frame = get_last_frame_time(duration, fps); 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) snapped_time.clamp(0.0, last_frame)
} }
#[export] #[export]
pub fn get_last_frame_time(duration: f64, fps: f64) -> f64 { pub fn get_last_frame_time(
GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
) -> f64 {
if duration <= 0.0 { if duration <= 0.0 {
return 0.0; return 0.0;
} }
@@ -206,18 +312,26 @@ mod tests {
#[test] #[test]
fn rounds_to_the_nearest_frame() { fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(1.24, 10.0), 1.2); assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2);
assert_eq!(round_to_frame(1.26, 10.0), 1.3); assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
} }
#[test] #[test]
fn formats_default_time_codes() { fn formats_default_time_codes() {
assert_eq!( assert_eq!(
format_time_code(3723.45, None, None), format_time_code(FormatTimeCodeOptions {
Some("01:02:03:44".to_string()), time_in_seconds: 3723.45,
format: None,
fps: None,
}),
Some("01:02:03:44".to_string()),
); );
assert_eq!( assert_eq!(
format_time_code(65.0, Some("MM:SS".to_string()), None), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 65.0,
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some("01:05".to_string()), Some("01:05".to_string()),
); );
} }
@@ -225,11 +339,19 @@ mod tests {
#[test] #[test]
fn formats_frame_based_time_codes() { fn formats_frame_based_time_codes() {
assert_eq!( assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), Some(30.0)), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some("00:00:01:15".to_string()), Some("00:00:01:15".to_string()),
); );
assert_eq!( assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), None), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: None,
}),
None, None,
); );
} }
@@ -237,38 +359,64 @@ mod tests {
#[test] #[test]
fn parses_time_codes() { fn parses_time_codes() {
assert_eq!( assert_eq!(
parse_time_code("01:05", Some("MM:SS".to_string()), None), parse_time_code(ParseTimeCodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some(65.0), Some(65.0),
); );
assert_eq!( assert_eq!(
parse_time_code("00:00:01:15", Some("HH:MM:SS:FF".to_string()), Some(30.0)), parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some(1.5), Some(1.5),
); );
assert_eq!( assert_eq!(
parse_time_code("00:00:01:30", Some("HH:MM:SS:FF".to_string()), Some(30.0)), parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
None, None,
); );
} }
#[test] #[test]
fn guesses_time_code_formats() { fn guesses_time_code_formats() {
assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
assert_eq!( assert_eq!(
guess_time_code_format("00:00:01"), guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }),
Some("HH:MM:SS".to_string()), Some(TimeCodeFormat::MmSs),
); );
assert_eq!( assert_eq!(
guess_time_code_format("00:00:01:15"), guess_time_code_format(GuessTimeCodeFormatOptions {
Some("HH:MM:SS:FF".to_string()), 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] #[test]
fn snaps_and_clamps_seek_time() { fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(1.26, 10.0), 13.0); assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0);
assert_eq!(frame_to_time(13.0, 10.0), 1.3); assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3);
assert_eq!(snap_time_to_frame(1.26, 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(10.0, 5.0), 9.8); assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8);
assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8); assert_eq!(
get_snapped_seek_time(GetSnappedSeekTimeOptions {
raw_time: 10.0,
duration: 10.0,
fps: 5.0,
}),
9.8,
);
} }
} }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "opencut-wasm"
version = "0.1.2"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"
license = "MIT"
[lib]
path = "src/wasm.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
+21
View File
@@ -0,0 +1,21 @@
# opencut-wasm
Shared video editor logic compiled to WebAssembly. Used by the [OpenCut](https://github.com/opencut/opencut) web app.
## Install
```bash
npm install opencut-wasm
```
## Usage
```ts
import { formatTimeCode } from "opencut-wasm";
```
All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts).
## Source
Functions are implemented in Rust under [`rust/crates/`](../crates/). This package is the compiled WebAssembly output — do not edit it directly.
+1
View File
@@ -0,0 +1 @@
pub use time::*;
+5 -1
View File
@@ -14,4 +14,8 @@ fi
echo "Installing rustup..." echo "Installing rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "Rust installed. Reload your shell: source \"\$HOME/.cargo/env\"" source "$HOME/.cargo/env"
echo "Installing wasm-pack..."
cargo install wasm-pack
echo "Done. Rust toolchain ready."
+4 -1
View File
@@ -1,8 +1,11 @@
{ {
"$schema": "https://turborepo.com/schema.json", "$schema": "https://turborepo.com/schema.json",
"tasks": { "tasks": {
"build:wasm": {
"outputs": ["rust/wasm/pkg/**"]
},
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["//#build:wasm", "^build"],
"outputs": [".next/**", "!.next/cache/**"], "outputs": [".next/**", "!.next/cache/**"],
"env": [ "env": [
"NODE_ENV", "NODE_ENV",