summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock109
-rw-r--r--Cargo.toml5
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/src/app/projects/page.tsx4
-rw-r--r--apps/web/src/components/editable-timecode.tsx299
-rw-r--r--apps/web/src/components/editor/dialogs/project-info-dialog.tsx163
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx298
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx2
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts7
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts2
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts7
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts67
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts556
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts1218
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-seek.ts390
-rw-r--r--apps/web/src/lib/time.ts225
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts298
-rw-r--r--bun.lock9
-rw-r--r--package.json6
-rw-r--r--rust/crates/time/Cargo.toml4
-rw-r--r--rust/crates/time/src/time.rs298
-rw-r--r--rust/wasm/Cargo.toml14
-rw-r--r--rust/wasm/README.md21
-rw-r--r--rust/wasm/src/wasm.rs1
-rw-r--r--script/setup-rust6
-rw-r--r--turbo.json5
29 files changed, 2008 insertions, 2025 deletions
diff --git a/.gitignore b/.gitignore
index b1ee46fb..2909d741 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,5 @@ bun.lockb
.open-next
target/
+
+.cargo-tools/ \ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index a1757e40..091717b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -643,6 +643,14 @@ dependencies = [
]
[[package]]
+name = "bridge"
+version = "0.1.0"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1978,6 +1986,19 @@ dependencies = [
]
[[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]]
name = "glow"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2780,10 +2801,12 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3541,6 +3564,13 @@ dependencies = [
]
[[package]]
+name = "opencut-wasm"
+version = "0.1.2"
+dependencies = [
+ "time",
+]
+
+[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4589,6 +4619,17 @@ dependencies = [
]
[[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]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5254,6 +5295,16 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.1.0"
+dependencies = [
+ "bridge",
+ "serde",
+ "tsify-next",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5528,6 +5579,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ttf-parser"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5850,9 +5927,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@@ -5863,23 +5940,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.64"
+version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
+checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [
- "cfg-if",
- "futures-util",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5887,9 +5960,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5900,9 +5973,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
@@ -6053,9 +6126,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
+checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [
"js-sys",
"wasm-bindgen",
diff --git a/Cargo.toml b/Cargo.toml
index 012a9f27..b037bbed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"apps/desktop",
- "rust/crates/time"
- "rust/crates/bridge"
+ "rust/crates/time",
+ "rust/crates/bridge",
+ "rust/wasm",
]
diff --git a/apps/web/package.json b/apps/web/package.json
index 333f7b4f..0c3a1f39 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,6 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
+ "opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 74d94b3a..8ee9d504 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
-import { formatTimeCode } from "@/lib/time";
+import { formatTimeCode } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
- return formatTimeCode({ timeInSeconds: duration, format });
+ return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index c346ad53..6a323a1c 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -1,150 +1,149 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { formatTimeCode, parseTimeCode } from "@/lib/time";
-import type { TTimeCode } from "@/lib/time";
-import { cn } from "@/utils/ui";
-
-interface EditableTimecodeProps {
- time: number;
- duration: number;
- format?: TTimeCode;
- fps: number;
- onTimeChange?: ({ time }: { time: number }) => void;
- className?: string;
- disabled?: boolean;
-}
-
-export function EditableTimecode({
- time,
- duration,
- format = "HH:MM:SS:FF",
- fps,
- onTimeChange,
- className,
- disabled = false,
-}: EditableTimecodeProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [inputValue, setInputValue] = useState("");
- const [hasError, setHasError] = useState(false);
- const inputRef = useRef<HTMLInputElement>(null);
- const enterPressedRef = useRef(false);
- const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
-
- const startEditing = () => {
- if (disabled) return;
- setIsEditing(true);
- setInputValue(formattedTime);
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const cancelEditing = () => {
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const applyEdit = () => {
- const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
-
- if (parsedTime === null) {
- setHasError(true);
- return;
- }
-
- const clampedTime = Math.max(
- 0,
- duration ? Math.min(duration, parsedTime) : parsedTime,
- );
-
- onTimeChange?.({ time: clampedTime });
- setIsEditing(false);
- setInputValue("");
- setHasError(false);
- enterPressedRef.current = false;
- };
-
- const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
- if (event.key === "Enter") {
- event.preventDefault();
- enterPressedRef.current = true;
- applyEdit();
- } else if (event.key === "Escape") {
- event.preventDefault();
- cancelEditing();
- }
- };
-
- const handleInputChange = ({
- target,
- }: React.ChangeEvent<HTMLInputElement>) => {
- setInputValue(target.value);
- setHasError(false);
- };
-
- const handleBlur = () => {
- if (!enterPressedRef.current && isEditing) {
- applyEdit();
- }
- };
-
- const handleDisplayKeyDown = (
- event: React.KeyboardEvent<HTMLButtonElement>,
- ) => {
- if (disabled) return;
-
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- startEditing();
- }
- };
-
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- if (isEditing) {
- return (
- <input
- ref={inputRef}
- type="text"
- value={inputValue}
- onChange={handleInputChange}
- onKeyDown={handleKeyDown}
- onBlur={handleBlur}
- className={cn(
- "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
- "focus:bg-background focus:border-primary focus:rounded",
- "text-primary tabular-nums",
- hasError && "text-destructive focus:border-destructive",
- className,
- )}
- style={{ width: `${formattedTime.length + 1}ch` }}
- placeholder={formattedTime}
- />
- );
- }
-
- return (
- <button
- type="button"
- onClick={startEditing}
- onKeyDown={handleDisplayKeyDown}
- disabled={disabled}
- className={cn(
- "text-primary cursor-pointer font-mono text-xs tabular-nums",
- "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
- disabled && "cursor-default hover:bg-transparent",
- className,
- )}
- title={disabled ? undefined : "Click to edit time"}
- >
- {formattedTime}
- </button>
- );
-}
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
+import { cn } from "@/utils/ui";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration: number;
+ format?: TimeCodeFormat;
+ fps: number;
+ onTimeChange?: ({ time }: { time: number }) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const enterPressedRef = useRef(false);
+ const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
+
+ if (parsedTime == null) {
+ setHasError(true);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime,
+ );
+
+ onTimeChange?.({ time: clampedTime });
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = ({
+ target,
+ }: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ const handleDisplayKeyDown = (
+ event: React.KeyboardEvent<HTMLButtonElement>,
+ ) => {
+ if (disabled) return;
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ startEditing();
+ }
+ };
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onBlur={handleBlur}
+ className={cn(
+ "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
+ "focus:bg-background focus:border-primary focus:rounded",
+ "text-primary tabular-nums",
+ hasError && "text-destructive focus:border-destructive",
+ className,
+ )}
+ style={{ width: `${formattedTime.length + 1}ch` }}
+ placeholder={formattedTime}
+ />
+ );
+ }
+
+ return (
+ <button
+ type="button"
+ onClick={startEditing}
+ onKeyDown={handleDisplayKeyDown}
+ disabled={disabled}
+ className={cn(
+ "text-primary cursor-pointer font-mono text-xs tabular-nums",
+ "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
+ disabled && "cursor-default hover:bg-transparent",
+ className,
+ )}
+ title={disabled ? undefined : "Click to edit time"}
+ >
+ {formattedTime}
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
index fad711e3..c31c8e96 100644
--- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
@@ -1,83 +1,80 @@
-import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/lib/project/types";
-import { formatDate } from "@/utils/date";
-import { formatTimeCode } from "@/lib/time";
-import { Button } from "@/components/ui/button";
-
-function InfoRow({
- label,
- value,
-}: {
- label: string;
- value: string | React.ReactNode;
-}) {
- return (
- <div className="flex justify-between items-center py-0 last:pb-0">
- <span className="text-muted-foreground text-sm">{label}</span>
- <span className="text-sm font-medium">{value}</span>
- </div>
- );
-}
-
-export function ProjectInfoDialog({
- isOpen,
- onOpenChange,
- project,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- project: TProjectMetadata;
-}) {
- const durationFormatted =
- project.duration > 0
- ? formatTimeCode({
- timeInSeconds: project.duration,
- format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
- })
- : "0:00";
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
- <DialogHeader>
- <DialogTitle className="truncate max-w-[350px]">
- {project.name}
- </DialogTitle>
- </DialogHeader>
-
- <DialogBody className="flex flex-col">
- <InfoRow label="Duration" value={durationFormatted} />
- <InfoRow
- label="Created"
- value={formatDate({ date: project.createdAt })}
- />
- <InfoRow
- label="Modified"
- value={formatDate({ date: project.updatedAt })}
- />
- <InfoRow
- label="Project ID"
- value={
- <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
- {project.id.slice(0, 8)}
- </code>
- }
- />
- </DialogBody>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- Close
- </Button>
- <Button onClick={() => onOpenChange(false)}>Done</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { TProjectMetadata } from "@/lib/project/types";
+import { formatDate } from "@/utils/date";
+import { formatTimeCode } from "opencut-wasm";
+import { Button } from "@/components/ui/button";
+
+function InfoRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | React.ReactNode;
+}) {
+ return (
+ <div className="flex justify-between items-center py-0 last:pb-0">
+ <span className="text-muted-foreground text-sm">{label}</span>
+ <span className="text-sm font-medium">{value}</span>
+ </div>
+ );
+}
+
+export function ProjectInfoDialog({
+ isOpen,
+ onOpenChange,
+ project,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+}) {
+ const durationFormatted =
+ project.duration > 0
+ ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
+ : "0:00";
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
+ <DialogHeader>
+ <DialogTitle className="truncate max-w-[350px]">
+ {project.name}
+ </DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="flex flex-col">
+ <InfoRow label="Duration" value={durationFormatted} />
+ <InfoRow
+ label="Created"
+ value={formatDate({ date: project.createdAt })}
+ />
+ <InfoRow
+ label="Modified"
+ value={formatDate({ date: project.updatedAt })}
+ />
+ <InfoRow
+ label="Project ID"
+ value={
+ <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
+ {project.id.slice(0, 8)}
+ </code>
+ }
+ />
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ <Button onClick={() => onOpenChange(false)}>Done</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 5c183bbb..340058d0 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -1,151 +1,147 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { formatTimeCode } from "@/lib/time";
-import { invokeAction } from "@/lib/actions";
-import { EditableTimecode } from "@/components/editable-timecode";
-import { Button } from "@/components/ui/button";
-import {
- FullScreenIcon,
- GridTableIcon,
- PauseIcon,
- PlayIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { getGuideById } from "@/lib/guides";
-import { Separator } from "@/components/ui/separator";
-import {
- Select,
- SelectTrigger,
- SelectContent,
- SelectItem,
- SelectSeparator,
-} from "@/components/ui/select";
-import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
-import { usePreviewViewport } from "./preview-viewport";
-import { GridPopover } from "./guide-popover";
-import { usePreviewStore } from "@/stores/preview-store";
-
-export function PreviewToolbar({
- onToggleFullscreen,
-}: {
- onToggleFullscreen: () => void;
-}) {
- const activeGuide = usePreviewStore((state) => state.activeGuide);
- const activeGuideDefinition = getGuideById(activeGuide);
-
- return (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <TimecodeDisplay />
- <PlayPauseButton />
- <div className="justify-self-end flex items-center gap-2.5">
- <ZoomSelect />
- <Separator orientation="vertical" className="h-4" />
- <GridPopover>
- <Button
- variant={activeGuideDefinition ? "secondary" : "text"}
- size="icon"
- >
- {activeGuideDefinition ? (
- activeGuideDefinition.renderTriggerIcon()
- ) : (
- <HugeiconsIcon icon={GridTableIcon} />
- )}
- </Button>
- </GridPopover>
- <Button variant="text" onClick={onToggleFullscreen}>
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function TimecodeDisplay() {
- const editor = useEditor();
- const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
- const fps = useEditor((e) => e.project.getActive().settings.fps);
- const [currentTime, setCurrentTime] = useState(() =>
- editor.playback.getCurrentTime(),
- );
-
- useEffect(() => {
- const handler = (e: Event) =>
- setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
- window.addEventListener("playback-update", handler);
- window.addEventListener("playback-seek", handler);
- return () => {
- window.removeEventListener("playback-update", handler);
- window.removeEventListener("playback-seek", handler);
- };
- }, []);
-
- return (
- <div className="flex items-center">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
- );
-}
-
-function ZoomSelect() {
- const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
- usePreviewViewport();
-
- const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
-
- const onValueChange = (value: string) => {
- if (value === "fit") {
- fitToScreen();
- } else {
- setViewportPercent({ percent: Number(value) });
- }
- };
-
- return (
- <Select
- value={isAtFit ? "fit" : String(zoomPercent)}
- onValueChange={onValueChange}
- >
- <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
- <SelectContent>
- <SelectItem value="fit">Fit</SelectItem>
- <SelectSeparator />
- {PREVIEW_ZOOM_PRESETS.map((preset) => (
- <SelectItem key={preset} value={String(preset)}>
- {preset}%
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
-
-function PlayPauseButton() {
- const isPlaying = useEditor((e) => e.playback.getIsPlaying());
-
- return (
- <Button
- variant="text"
- size="icon"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
- );
-}
+"use client";
+
+import { useState, useEffect } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { formatTimeCode } from "opencut-wasm";
+import { invokeAction } from "@/lib/actions";
+import { EditableTimecode } from "@/components/editable-timecode";
+import { Button } from "@/components/ui/button";
+import {
+ FullScreenIcon,
+ GridTableIcon,
+ PauseIcon,
+ PlayIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { getGuideById } from "@/lib/guides";
+import { Separator } from "@/components/ui/separator";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} from "@/components/ui/select";
+import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/stores/preview-store";
+
+export function PreviewToolbar({
+ onToggleFullscreen,
+}: {
+ onToggleFullscreen: () => void;
+}) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const activeGuideDefinition = getGuideById(activeGuide);
+
+ return (
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
+ <TimecodeDisplay />
+ <PlayPauseButton />
+ <div className="justify-self-end flex items-center gap-2.5">
+ <ZoomSelect />
+ <Separator orientation="vertical" className="h-4" />
+ <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover>
+ <Button variant="text" onClick={onToggleFullscreen}>
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index b47f9d46..e2dc09f1 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -10,7 +10,7 @@ import {
TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
-import { getSnappedSeekTime } from "@/lib/time";
+import { getSnappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index a2e9371b..aa34f496 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
-import { formatTimeCode } from "@/lib/time";
+import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
- const timecode = formatTimeCode({
- timeInSeconds: renderTime,
- fps,
- }).replace(/:/g, "-");
+ const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 3dd95ee2..263de41f 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -13,7 +13,7 @@ import type {
AnimationValue,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
-import { getLastFrameTime } from "@/lib/time";
+import { getLastFrameTime } from "opencut-wasm";
import {
AddTrackCommand,
RemoveTrackCommand,
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index fae8c527..3e0a6997 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -14,7 +14,7 @@ import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
-import { snapTimeToFrame } from "@/lib/time";
+import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -302,10 +302,7 @@ export function useElementInteraction({
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
- const snappedTime = snapTimeToFrame({
- time: adjustedTime,
- fps: activeProject.settings.fps,
- });
+ const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index b41f82f9..26bd8ef9 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
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 { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
- const newTrimStart = snapTimeToFrame({
- time: Math.min(
- maxAllowed,
- Math.max(minTrimStartForNeighbor, calculated),
- ),
- fps: projectFps,
- });
+ const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
- const newDuration = snapTimeToFrame({
- time: getDurationForVisibleSourceSpan(visibleSourceSpan),
- fps: projectFps,
- });
- const trimDelta = resizing.initialDuration - newDuration;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
- fps: projectFps,
- });
+ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
+ const trimDelta = resizing.initialDuration - newDuration;
+ const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime - actualExtension,
- fps: projectFps,
- });
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + actualExtension,
- fps: projectFps,
- });
+ const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
+ const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
- const newDuration = snapTimeToFrame({
- time: getDurationForVisibleSourceSpan(visibleSourceSpan),
- fps: projectFps,
- });
- const newStartTime = snapTimeToFrame({
- time:
- resizing.initialStartTime +
- (resizing.initialDuration - newDuration),
- fps: projectFps,
- });
+ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
+ const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({
- time: Math.min(
- baseDuration + extensionNeeded,
- maxAllowedDuration,
- ),
- fps: projectFps,
- });
+ const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
- const newDuration = snapTimeToFrame({
- time: Math.min(unclampedDuration, maxAllowedDuration),
- fps: projectFps,
- });
+ const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
- const finalTrimEnd = snapTimeToFrame({
- time: clampedTrimEnd,
- fps: projectFps,
- });
+ const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
- const newDuration = snapTimeToFrame({
- time: getDurationForVisibleSourceSpan(visibleSourceSpan),
- fps: projectFps,
- });
+ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index 4f961a0a..e0bf8084 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -7,7 +7,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection";
-import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
+import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
DRAG_THRESHOLD_PX,
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
- const seekTime = getSnappedSeekTime({
- rawTime: displayedStartTime + indicatorTime,
- duration,
- fps,
- });
+ const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index e7e70495..4a1185bd 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -1,281 +1,275 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type RefObject,
-} from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { useShiftKey } from "@/hooks/use-shift-key";
-import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
-import { snapTimeToFrame } from "@/lib/time";
-import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/lib/timeline";
-
-export interface BookmarkDragState {
- isDragging: boolean;
- bookmarkTime: number | null;
- currentTime: number;
-}
-
-interface PendingBookmarkDrag {
- bookmarkTime: number;
- startMouseX: number;
- startMouseY: number;
-}
-
-interface UseBookmarkDragProps {
- zoomLevel: number;
- scrollRef: RefObject<HTMLElement | null>;
- snappingEnabled: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}
-
-export function useBookmarkDrag({
- zoomLevel,
- scrollRef,
- snappingEnabled,
- onSnapPointChange,
-}: UseBookmarkDragProps) {
- const editor = useEditor();
- const isShiftHeldRef = useShiftKey();
- const tracks = editor.timeline.getTracks();
- const activeScene = editor.scenes.getActiveScene();
- const bookmarks = activeScene?.bookmarks ?? [];
- const playheadTime = editor.playback.getCurrentTime();
- const duration = editor.timeline.getTotalDuration();
-
- const [dragState, setDragState] = useState<BookmarkDragState>({
- isDragging: false,
- bookmarkTime: null,
- currentTime: 0,
- });
- const [isPendingDrag, setIsPendingDrag] = useState(false);
- const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
- const lastMouseXRef = useRef(0);
-
- const startDrag = useCallback(
- ({
- bookmarkTime,
- initialCurrentTime,
- }: {
- bookmarkTime: number;
- initialCurrentTime: number;
- }) => {
- setDragState({
- isDragging: true,
- bookmarkTime,
- currentTime: initialCurrentTime,
- });
- },
- [],
- );
-
- const endDrag = useCallback(() => {
- setDragState({
- isDragging: false,
- bookmarkTime: null,
- currentTime: 0,
- });
- }, []);
-
- const getSnapResult = useCallback(
- ({
- rawTime,
- excludeBookmarkTime,
- }: {
- rawTime: number;
- excludeBookmarkTime: number;
- }): { snappedTime: number; snapPoint: SnapPoint | null } => {
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- if (!shouldSnap) {
- return { snappedTime: rawTime, snapPoint: null };
- }
-
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- bookmarks,
- excludeBookmarkTime,
- });
- const result = snapToNearestPoint({
- targetTime: rawTime,
- snapPoints,
- zoomLevel,
- });
- return {
- snappedTime: result.snappedTime,
- snapPoint: result.snapPoint,
- };
- },
- [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = (event: MouseEvent) => {
- lastMouseXRef.current = event.clientX;
-
- const scrollContainer = scrollRef.current;
- if (!scrollContainer) return;
-
- if (isPendingDrag && pendingDragRef.current) {
- const { startMouseX, startMouseY, bookmarkTime } =
- pendingDragRef.current;
- const deltaX = Math.abs(event.clientX - startMouseX);
- const deltaY = Math.abs(event.clientY - startMouseY);
-
- if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
- return;
- }
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX: event.clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const frameSnappedTime = snapTimeToFrame({
- time: Math.max(0, Math.min(mouseTime, duration)),
- fps: activeProject.settings.fps,
- });
- const { snappedTime: initialTime } = getSnapResult({
- rawTime: frameSnappedTime,
- excludeBookmarkTime: bookmarkTime,
- });
-
- startDrag({
- bookmarkTime,
- initialCurrentTime: initialTime,
- });
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- return;
- }
-
- if (!dragState.isDragging || dragState.bookmarkTime === null) return;
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX: event.clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const clampedTime = Math.max(0, Math.min(mouseTime, duration));
- const frameSnappedTime = snapTimeToFrame({
- time: clampedTime,
- fps: activeProject.settings.fps,
- });
- const snapResult = getSnapResult({
- rawTime: frameSnappedTime,
- excludeBookmarkTime: dragState.bookmarkTime,
- });
-
- setDragState((previousDragState) => ({
- ...previousDragState,
- currentTime: snapResult.snappedTime,
- }));
- onSnapPointChange?.(snapResult.snapPoint);
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [
- dragState.isDragging,
- dragState.bookmarkTime,
- zoomLevel,
- duration,
- editor.project,
- scrollRef,
- isPendingDrag,
- startDrag,
- getSnapResult,
- onSnapPointChange,
- ]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = () => {
- if (dragState.bookmarkTime === null) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- const clampedTime = Math.max(
- 0,
- Math.min(dragState.currentTime, duration),
- );
-
- editor.scenes.moveBookmark({
- fromTime: dragState.bookmarkTime,
- toTime: clampedTime,
- });
-
- endDrag();
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.bookmarkTime,
- dragState.currentTime,
- duration,
- endDrag,
- onSnapPointChange,
- editor.scenes,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag, onSnapPointChange]);
-
- const handleBookmarkMouseDown = useCallback(
- ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
- if (event.button !== 0) return;
-
- event.preventDefault();
- event.stopPropagation();
-
- pendingDragRef.current = {
- bookmarkTime: bookmark.time,
- startMouseX: event.clientX,
- startMouseY: event.clientY,
- };
- setIsPendingDrag(true);
- },
- [],
- );
-
- return {
- dragState,
- handleBookmarkMouseDown,
- lastMouseXRef,
- };
-}
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "opencut-wasm";
+import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
+import type { Bookmark } from "@/lib/timeline";
+
+export interface BookmarkDragState {
+ isDragging: boolean;
+ bookmarkTime: number | null;
+ currentTime: number;
+}
+
+interface PendingBookmarkDrag {
+ bookmarkTime: number;
+ startMouseX: number;
+ startMouseY: number;
+}
+
+interface UseBookmarkDragProps {
+ zoomLevel: number;
+ scrollRef: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export function useBookmarkDrag({
+ zoomLevel,
+ scrollRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseBookmarkDragProps) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const tracks = editor.timeline.getTracks();
+ const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = activeScene?.bookmarks ?? [];
+ const playheadTime = editor.playback.getCurrentTime();
+ const duration = editor.timeline.getTotalDuration();
+
+ const [dragState, setDragState] = useState<BookmarkDragState>({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
+ const lastMouseXRef = useRef(0);
+
+ const startDrag = useCallback(
+ ({
+ bookmarkTime,
+ initialCurrentTime,
+ }: {
+ bookmarkTime: number;
+ initialCurrentTime: number;
+ }) => {
+ setDragState({
+ isDragging: true,
+ bookmarkTime,
+ currentTime: initialCurrentTime,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ setDragState({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ }, []);
+
+ const getSnapResult = useCallback(
+ ({
+ rawTime,
+ excludeBookmarkTime,
+ }: {
+ rawTime: number;
+ excludeBookmarkTime: number;
+ }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap) {
+ return { snappedTime: rawTime, snapPoint: null };
+ }
+
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ bookmarks,
+ excludeBookmarkTime,
+ });
+ const result = snapToNearestPoint({
+ targetTime: rawTime,
+ snapPoints,
+ zoomLevel,
+ });
+ return {
+ snappedTime: result.snappedTime,
+ snapPoint: result.snapPoint,
+ };
+ },
+ [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ lastMouseXRef.current = event.clientX;
+
+ const scrollContainer = scrollRef.current;
+ if (!scrollContainer) return;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const { startMouseX, startMouseY, bookmarkTime } =
+ pendingDragRef.current;
+ const deltaX = Math.abs(event.clientX - startMouseX);
+ const deltaY = Math.abs(event.clientY - startMouseY);
+
+ if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ return;
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
+ const { snappedTime: initialTime } = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: bookmarkTime,
+ });
+
+ startDrag({
+ bookmarkTime,
+ initialCurrentTime: initialTime,
+ });
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ return;
+ }
+
+ if (!dragState.isDragging || dragState.bookmarkTime === null) return;
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const clampedTime = Math.max(0, Math.min(mouseTime, duration));
+ const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
+ const snapResult = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: dragState.bookmarkTime,
+ });
+
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snapResult.snappedTime,
+ }));
+ onSnapPointChange?.(snapResult.snapPoint);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ zoomLevel,
+ duration,
+ editor.project,
+ scrollRef,
+ isPendingDrag,
+ startDrag,
+ getSnapResult,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ if (dragState.bookmarkTime === null) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ Math.min(dragState.currentTime, duration),
+ );
+
+ editor.scenes.moveBookmark({
+ fromTime: dragState.bookmarkTime,
+ toTime: clampedTime,
+ });
+
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ dragState.currentTime,
+ duration,
+ endDrag,
+ onSnapPointChange,
+ editor.scenes,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag, onSnapPointChange]);
+
+ const handleBookmarkMouseDown = useCallback(
+ ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
+ if (event.button !== 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ pendingDragRef.current = {
+ bookmarkTime: bookmark.time,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ };
+ setIsPendingDrag(true);
+ },
+ [],
+ );
+
+ return {
+ dragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 7cd9f140..9693e8d8 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -1,609 +1,609 @@
-import { useState, useCallback, type RefObject } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import { processMediaAssets } from "@/lib/media/processing";
-import { toast } from "sonner";
-import { showMediaUploadToast } from "@/lib/media/upload-toast";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { snapTimeToFrame } from "@/lib/time";
-import {
- buildTextElement,
- buildGraphicElement,
- buildStickerElement,
- buildElementFromMedia,
- buildEffectElement,
-} from "@/lib/timeline/element-utils";
-import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
-import { computeDropTarget } from "@/lib/timeline/drop-utils";
-import { getDragData, hasDragData } from "@/lib/drag-data";
-import { isMainTrack } from "@/lib/timeline/track-utils";
-import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
-import type {
- MediaDragData,
- GraphicDragData,
- StickerDragData,
- EffectDragData,
-} from "@/lib/timeline/drag";
-
-interface UseTimelineDragDropProps {
- containerRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- tracksScrollRef?: RefObject<HTMLDivElement | null>;
- zoomLevel: number;
-}
-
-export function useTimelineDragDrop({
- containerRef,
- headerRef,
- tracksScrollRef,
- zoomLevel,
-}: UseTimelineDragDropProps) {
- const editor = useEditor();
- const [isDragOver, setIsDragOver] = useState(false);
- const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
- const [dragElementType, setElementType] = useState<ElementType | null>(null);
-
- const getSnappedTime = useCallback(
- ({ time }: { time: number }) => {
- const projectFps = editor.project.getActive().settings.fps;
- return snapTimeToFrame({ time, fps: projectFps });
- },
- [editor],
- );
-
- const getElementType = useCallback(
- ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
- const dragData = getDragData({ dataTransfer });
- if (!dragData) return null;
-
- if (dragData.type === "text") return "text";
- if (dragData.type === "graphic") return "graphic";
- if (dragData.type === "sticker") return "sticker";
- if (dragData.type === "effect") return "effect";
- if (dragData.type === "media") {
- return dragData.mediaType;
- }
- return null;
- },
- [],
- );
-
- const getElementDuration = useCallback(
- ({
- elementType,
- mediaId,
- }: {
- elementType: ElementType;
- mediaId?: string;
- }): number => {
- if (
- elementType === "text" ||
- elementType === "graphic" ||
- elementType === "sticker" ||
- elementType === "effect"
- ) {
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- }
- if (mediaId) {
- const mediaAssets = editor.media.getAssets();
- const media = mediaAssets.find((m) => m.id === mediaId);
- return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- }
- return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- },
- [editor],
- );
-
- const handleDragEnter = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
- const hasFiles = e.dataTransfer.types.includes("Files");
- if (!hasAsset && !hasFiles) return;
- setIsDragOver(true);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
-
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
-
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const hasFiles = e.dataTransfer.types.includes("Files");
- const isExternal =
- hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
-
- const elementType = getElementType({ dataTransfer: e.dataTransfer });
-
- if (!elementType && hasFiles && isExternal) {
- setDropTarget(null);
- setElementType(null);
- return;
- }
-
- if (!elementType) return;
-
- setElementType(elementType);
-
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- const duration = getElementDuration({
- elementType,
- mediaId: dragData?.type === "media" ? dragData.id : undefined,
- });
-
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
-
- const targetElementTypes =
- dragData?.type === "effect"
- ? (dragData as EffectDragData).targetElementTypes
- : dragData?.type === "media"
- ? (dragData as MediaDragData).targetElementTypes
- : undefined;
-
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const target = computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime: currentTime,
- isExternalDrop: isExternal,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- targetElementTypes,
- });
-
- target.xPosition = getSnappedTime({ time: target.xPosition });
-
- setDropTarget(target);
- e.dataTransfer.dropEffect = "copy";
- },
- [
- containerRef,
- headerRef,
- tracksScrollRef,
- zoomLevel,
- getElementType,
- getElementDuration,
- getSnappedTime,
- editor,
- ],
- );
-
- const handleDragLeave = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- const rect = containerRef.current?.getBoundingClientRect();
- if (rect) {
- const { clientX, clientY } = e;
- if (
- clientX < rect.left ||
- clientX > rect.right ||
- clientY < rect.top ||
- clientY > rect.bottom
- ) {
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
- }
- }
- },
- [containerRef],
- );
-
- const executeTextDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: { name?: string; content?: string };
- }) => {
- const element = buildTextElement({
- raw: {
- name: dragData.name ?? "",
- content: dragData.content ?? "",
- },
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeStickerDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: StickerDragData;
- }) => {
- const element = buildStickerElement({
- stickerId: dragData.stickerId,
- name: dragData.name,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeGraphicDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: GraphicDragData;
- }) => {
- const element = buildGraphicElement({
- definitionId: dragData.definitionId,
- name: dragData.name,
- startTime: target.xPosition,
- params: dragData.params,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeMediaDrop = useCallback(
- ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
- if (target.targetElement) {
- toast.info("Replace media source is coming soon!");
- return;
- }
-
- const mediaAssets = editor.media.getAssets();
- const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
- if (!mediaAsset) return;
-
- const trackType: TrackType =
- dragData.mediaType === "audio" ? "audio" : "video";
-
- const duration =
- mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const element = buildElementFromMedia({
- mediaId: mediaAsset.id,
- mediaType: mediaAsset.type,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- });
-
- if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const track = tracks[target.trackIndex];
- if (!track) return;
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: track.id },
- element,
- });
- },
- [editor],
- );
-
- const executeEffectDrop = useCallback(
- ({
- target,
- dragData,
- }: {
- target: DropTarget;
- dragData: EffectDragData;
- }) => {
- if (target.targetElement) {
- editor.timeline.addClipEffect({
- trackId: target.targetElement.trackId,
- elementId: target.targetElement.elementId,
- effectType: dragData.effectType,
- });
- return;
- }
-
- const tracks = editor.timeline.getTracks();
- const effectTrack = tracks.find((t) => t.type === "effect");
- let trackId: string;
-
- if (effectTrack) {
- trackId = effectTrack.id;
- } else if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
- const insertCmd = new InsertElementCommand({
- element: buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- }),
- placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
- });
- editor.command.execute({
- command: new BatchCommand([addTrackCmd, insertCmd]),
- });
- return;
- } else {
- const track = tracks[target.trackIndex];
- if (!track || track.type !== "effect") return;
- trackId = track.id;
- }
-
- const element = buildEffectElement({
- effectType: dragData.effectType,
- startTime: target.xPosition,
- });
-
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element,
- });
- },
- [editor],
- );
-
- const executeFileDrop = useCallback(
- async ({
- files,
- mouseX,
- mouseY,
- }: {
- files: File[];
- mouseX: number;
- mouseY: number;
- }) => {
- const activeProject = editor.project.getActiveOrNull();
- if (!activeProject) return;
-
- await showMediaUploadToast({
- filesCount: files.length,
- promise: async () => {
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
-
- for (const asset of processedAssets) {
- const createdAsset = await editor.media.addMediaAsset({
- projectId,
- asset,
- });
- if (!createdAsset) continue;
-
- const duration =
- createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const onlyTrack = currentTracks[0];
- const reuseMainTrackId =
- createdAsset.type !== "audio" &&
- currentTracks.length === 1 &&
- !!onlyTrack &&
- isMainTrack(onlyTrack) &&
- onlyTrack.elements.length === 0
- ? onlyTrack.id
- : null;
- const dropTarget = reuseMainTrackId
- ? null
- : computeDropTarget({
- elementType: createdAsset.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
-
- const trackType: TrackType =
- createdAsset.type === "audio" ? "audio" : "video";
-
- let trackId: string | undefined;
- if (reuseMainTrackId) {
- trackId = reuseMainTrackId;
- } else {
- if (!dropTarget) continue;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- trackId = addTrackCmd.getTrackId();
- editor.command.execute({ command: addTrackCmd });
- } else {
- trackId = currentTracks[dropTarget.trackIndex]?.id;
- }
- }
-
- if (!trackId) continue;
-
- const element = buildElementFromMedia({
- mediaId: createdAsset.id,
- mediaType: createdAsset.type,
- name: createdAsset.name,
- duration,
- startTime: dropTarget?.xPosition ?? currentTime,
- buffer:
- createdAsset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
-
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- });
- editor.command.execute({ command: insertCmd });
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- },
- [editor, zoomLevel],
- );
-
- const handleDrop = useCallback(
- async (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
- const hasFiles = e.dataTransfer.files?.length > 0;
-
- if (!hasAsset && !hasFiles) return;
-
- const currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
-
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
-
- if (dragData.type === "text") {
- executeTextDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "graphic") {
- executeGraphicDrop({
- target: currentTarget,
- dragData: dragData as GraphicDragData,
- });
- } else if (dragData.type === "sticker") {
- executeStickerDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "effect") {
- executeEffectDrop({
- target: currentTarget,
- dragData: dragData as EffectDragData,
- });
- } else {
- executeMediaDrop({ target: currentTarget, dragData });
- }
- } else if (hasFiles) {
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY =
- e.clientY - referenceRect.top + scrollTop - headerHeight;
- await executeFileDrop({
- files: Array.from(e.dataTransfer.files),
- mouseX,
- mouseY,
- });
- }
- } catch (err) {
- console.error("Failed to process drop:", err);
- }
- },
- [
- dropTarget,
- executeTextDrop,
- executeGraphicDrop,
- executeStickerDrop,
- executeMediaDrop,
- executeEffectDrop,
- executeFileDrop,
- containerRef,
- headerRef,
- tracksScrollRef,
- ],
- );
-
- return {
- isDragOver,
- dropTarget,
- dragElementType,
- dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- },
- };
-}
+import { useState, useCallback, type RefObject } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { toast } from "sonner";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "opencut-wasm";
+import {
+ buildTextElement,
+ buildGraphicElement,
+ buildStickerElement,
+ buildElementFromMedia,
+ buildEffectElement,
+} from "@/lib/timeline/element-utils";
+import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
+import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getDragData, hasDragData } from "@/lib/drag-data";
+import { isMainTrack } from "@/lib/timeline/track-utils";
+import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
+import type {
+ MediaDragData,
+ GraphicDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/lib/timeline/drag";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ zoomLevel,
+}: UseTimelineDragDropProps) {
+ const editor = useEditor();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
+ const [dragElementType, setElementType] = useState<ElementType | null>(null);
+
+ const getSnappedTime = useCallback(
+ ({ time }: { time: number }) => {
+ const projectFps = editor.project.getActive().settings.fps;
+ return snapTimeToFrame({ time, fps: projectFps });
+ },
+ [editor],
+ );
+
+ const getElementType = useCallback(
+ ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
+ const dragData = getDragData({ dataTransfer });
+ if (!dragData) return null;
+
+ if (dragData.type === "text") return "text";
+ if (dragData.type === "graphic") return "graphic";
+ if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "effect") return "effect";
+ if (dragData.type === "media") {
+ return dragData.mediaType;
+ }
+ return null;
+ },
+ [],
+ );
+
+ const getElementDuration = useCallback(
+ ({
+ elementType,
+ mediaId,
+ }: {
+ elementType: ElementType;
+ mediaId?: string;
+ }): number => {
+ if (
+ elementType === "text" ||
+ elementType === "graphic" ||
+ elementType === "sticker" ||
+ elementType === "effect"
+ ) {
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
+ const media = mediaAssets.find((m) => m.id === mediaId);
+ return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ }
+ return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ },
+ [editor],
+ );
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ if (!hasAsset && !hasFiles) return;
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ const isExternal =
+ hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
+
+ const elementType = getElementType({ dataTransfer: e.dataTransfer });
+
+ if (!elementType && hasFiles && isExternal) {
+ setDropTarget(null);
+ setElementType(null);
+ return;
+ }
+
+ if (!elementType) return;
+
+ setElementType(elementType);
+
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ const duration = getElementDuration({
+ elementType,
+ mediaId: dragData?.type === "media" ? dragData.id : undefined,
+ });
+
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
+
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const target = computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: currentTime,
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ targetElementTypes,
+ });
+
+ target.xPosition = getSnappedTime({ time: target.xPosition });
+
+ setDropTarget(target);
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ zoomLevel,
+ getElementType,
+ getElementDuration,
+ getSnappedTime,
+ editor,
+ ],
+ );
+
+ const handleDragLeave = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (rect) {
+ const { clientX, clientY } = e;
+ if (
+ clientX < rect.left ||
+ clientX > rect.right ||
+ clientY < rect.top ||
+ clientY > rect.bottom
+ ) {
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+ }
+ }
+ },
+ [containerRef],
+ );
+
+ const executeTextDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: { name?: string; content?: string };
+ }) => {
+ const element = buildTextElement({
+ raw: {
+ name: dragData.name ?? "",
+ content: dragData.content ?? "",
+ },
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeStickerDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: StickerDragData;
+ }) => {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeMediaDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ if (target.targetElement) {
+ toast.info("Replace media source is coming soon!");
+ return;
+ }
+
+ const mediaAssets = editor.media.getAssets();
+ const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+
+ const duration =
+ mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeEffectDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: EffectDragData;
+ }) => {
+ if (target.targetElement) {
+ editor.timeline.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack) {
+ trackId = effectTrack.id;
+ } else if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element: buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ }),
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track || track.type !== "effect") return;
+ trackId = track.id;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeFileDrop = useCallback(
+ async ({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }) => {
+ const activeProject = editor.project.getActiveOrNull();
+ if (!activeProject) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
+
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration =
+ createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const onlyTrack = currentTracks[0];
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ currentTracks.length === 1 &&
+ !!onlyTrack &&
+ isMainTrack(onlyTrack) &&
+ onlyTrack.elements.length === 0
+ ? onlyTrack.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+
+ let trackId: string | undefined;
+ if (reuseMainTrackId) {
+ trackId = reuseMainTrackId;
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ editor.command.execute({ command: addTrackCmd });
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
+ }
+
+ if (!trackId) continue;
+
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget?.xPosition ?? currentTime,
+ buffer:
+ createdAsset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ editor.command.execute({ command: insertCmd });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ },
+ [editor, zoomLevel],
+ );
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.files?.length > 0;
+
+ if (!hasAsset && !hasFiles) return;
+
+ const currentTarget = dropTarget;
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+
+ try {
+ if (hasAsset) {
+ if (!currentTarget) return;
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ if (!dragData) return;
+
+ if (dragData.type === "text") {
+ executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
+ } else if (dragData.type === "sticker") {
+ executeStickerDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "effect") {
+ executeEffectDrop({
+ target: currentTarget,
+ dragData: dragData as EffectDragData,
+ });
+ } else {
+ executeMediaDrop({ target: currentTarget, dragData });
+ }
+ } else if (hasFiles) {
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
+ await executeFileDrop({
+ files: Array.from(e.dataTransfer.files),
+ mouseX,
+ mouseY,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to process drop:", err);
+ }
+ },
+ [
+ dropTarget,
+ executeTextDrop,
+ executeGraphicDrop,
+ executeStickerDrop,
+ executeMediaDrop,
+ executeEffectDrop,
+ executeFileDrop,
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ ],
+ );
+
+ return {
+ isDragOver,
+ dropTarget,
+ dragElementType,
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index e6204f23..d4cfcc06 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,4 +1,4 @@
-import { getSnappedSeekTime } from "@/lib/time";
+import { getSnappedSeekTime } from "opencut-wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
);
const framesPerSecond = activeProject.settings.fps;
- const frameTime = getSnappedSeekTime({
- rawTime,
- duration,
- fps: framesPerSecond,
- });
+ const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts
index 4abbf023..2672062f 100644
--- a/apps/web/src/hooks/timeline/use-timeline-seek.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts
@@ -1,197 +1,193 @@
-import { useCallback, useRef } from "react";
-import type { MutableRefObject, RefObject } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { getSnappedSeekTime } from "@/lib/time";
-import { useEditor } from "../use-editor";
-
-interface UseTimelineSeekProps {
- playheadRef: RefObject<HTMLDivElement | null>;
- trackLabelsRef: RefObject<HTMLDivElement | null>;
- rulerScrollRef: RefObject<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- zoomLevel: number;
- duration: number;
- isSelecting: boolean;
- clearSelectedElements: () => void;
- seek: (time: number) => void;
-}
-
-function resetMouseTracking({
- mouseTrackingRef,
-}: {
- mouseTrackingRef: MutableRefObject<{
- isMouseDown: boolean;
- downX: number;
- downY: number;
- downTime: number;
- }>;
-}) {
- mouseTrackingRef.current = {
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- };
-}
-
-function setMouseTracking({
- mouseTrackingRef,
- event,
-}: {
- mouseTrackingRef: MutableRefObject<{
- isMouseDown: boolean;
- downX: number;
- downY: number;
- downTime: number;
- }>;
- event: React.MouseEvent;
-}) {
- mouseTrackingRef.current = {
- isMouseDown: true,
- downX: event.clientX,
- downY: event.clientY,
- downTime: event.timeStamp,
- };
-}
-
-export function useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration,
- isSelecting,
- clearSelectedElements,
- seek,
-}: UseTimelineSeekProps) {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
-
- const mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
-
- const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const shouldProcessTimelineClick = useCallback(
- ({ event }: { event: React.MouseEvent }) => {
- const target = event.target as HTMLElement;
- const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
- const deltaX = Math.abs(event.clientX - downX);
- const deltaY = Math.abs(event.clientY - downY);
- const deltaTime = event.timeStamp - downTime;
- const isPlayhead = !!playheadRef.current?.contains(target);
- const isTrackLabels = !!trackLabelsRef.current?.contains(target);
- const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
-
- if (!isMouseDown) return false;
- if (shouldBlockForDrag) return false;
- if (isSelecting) return false;
- if (isPlayhead) return false;
- if (isTrackLabels) {
- clearSelectedElements();
- return false;
- }
-
- return true;
- },
- [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
- );
-
- const handleTimelineSeek = useCallback(
- ({
- event,
- source,
- }: {
- event: React.MouseEvent;
- source: "ruler" | "tracks";
- }) => {
- const scrollContainer =
- source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
-
- if (!scrollContainer) return;
-
- const rect = scrollContainer.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const scrollLeft = scrollContainer.scrollLeft;
-
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
- ),
- );
-
- const projectFps = activeProject?.settings.fps || 30;
- const time = getSnappedSeekTime({
- rawTime,
- duration,
- fps: projectFps,
- });
- seek(time);
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollContainer.scrollLeft,
- playheadTime: time,
- },
- });
- },
- [
- duration,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- seek,
- editor,
- activeProject?.settings.fps,
- ],
- );
-
- const handleTracksClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
-
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "ruler" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
-
- return {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
- };
-}
+import { useCallback, useRef } from "react";
+import type { MutableRefObject, RefObject } from "react";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getSnappedSeekTime } from "opencut-wasm";
+import { useEditor } from "../use-editor";
+
+interface UseTimelineSeekProps {
+ playheadRef: RefObject<HTMLDivElement | null>;
+ trackLabelsRef: RefObject<HTMLDivElement | null>;
+ rulerScrollRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+ duration: number;
+ isSelecting: boolean;
+ clearSelectedElements: () => void;
+ seek: (time: number) => void;
+}
+
+function resetMouseTracking({
+ mouseTrackingRef,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ };
+}
+
+function setMouseTracking({
+ mouseTrackingRef,
+ event,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+ event: React.MouseEvent;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: event.clientX,
+ downY: event.clientY,
+ downTime: event.timeStamp,
+ };
+}
+
+export function useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration,
+ isSelecting,
+ clearSelectedElements,
+ seek,
+}: UseTimelineSeekProps) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
+ const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const shouldProcessTimelineClick = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ const target = event.target as HTMLElement;
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+ const deltaX = Math.abs(event.clientX - downX);
+ const deltaY = Math.abs(event.clientY - downY);
+ const deltaTime = event.timeStamp - downTime;
+ const isPlayhead = !!playheadRef.current?.contains(target);
+ const isTrackLabels = !!trackLabelsRef.current?.contains(target);
+ const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
+
+ if (!isMouseDown) return false;
+ if (shouldBlockForDrag) return false;
+ if (isSelecting) return false;
+ if (isPlayhead) return false;
+ if (isTrackLabels) {
+ clearSelectedElements();
+ return false;
+ }
+
+ return true;
+ },
+ [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
+ );
+
+ const handleTimelineSeek = useCallback(
+ ({
+ event,
+ source,
+ }: {
+ event: React.MouseEvent;
+ source: "ruler" | "tracks";
+ }) => {
+ const scrollContainer =
+ source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
+
+ if (!scrollContainer) return;
+
+ const rect = scrollContainer.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const scrollLeft = scrollContainer.scrollLeft;
+
+ const rawTime = Math.max(
+ 0,
+ Math.min(
+ duration,
+ (mouseX + scrollLeft) /
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+
+ const projectFps = activeProject?.settings.fps || 30;
+ const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
+ seek(time);
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ playheadTime: time,
+ },
+ });
+ },
+ [
+ duration,
+ zoomLevel,
+ rulerScrollRef,
+ tracksScrollRef,
+ seek,
+ editor,
+ activeProject?.settings.fps,
+ ],
+ );
+
+ const handleTracksClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "tracks" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ const handleRulerClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "ruler" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ return {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ };
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
deleted file mode 100644
index aff42bee..00000000
--- a/apps/web/src/lib/time.ts
+++ /dev/null
@@ -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);
-}
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
index 31bf73af..70d1b5ee 100644
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -1,149 +1,149 @@
-import type { Bookmark } from "@/lib/timeline";
-import { roundToFrame } from "@/lib/time";
-
-export const BOOKMARK_TIME_EPSILON = 0.001;
-
-function bookmarkTimeEqual({
- bookmarkTime,
- frameTime,
-}: {
- bookmarkTime: number;
- frameTime: number;
-}): boolean {
- return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
-}
-
-export function findBookmarkIndex({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): number {
- return bookmarks.findIndex((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function isBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): boolean {
- return bookmarks.some((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function toggleBookmarkInArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
-
- if (bookmarkIndex !== -1) {
- return bookmarks.filter((_, index) => index !== bookmarkIndex);
- }
-
- const newBookmarks = [...bookmarks, { time: frameTime }];
- return newBookmarks.slice().sort((a, b) => a.time - b.time);
-}
-
-export function removeBookmarkFromArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- return bookmarks.filter(
- (bookmark) =>
- !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function updateBookmarkInArray({
- bookmarks,
- frameTime,
- updates,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
- updates: Partial<Omit<Bookmark, "time">>;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], ...updates };
- const result = [...bookmarks];
- result[index] = updated;
- return result;
-}
-
-export function moveBookmarkInArray({
- bookmarks,
- fromTime,
- toTime,
-}: {
- bookmarks: Bookmark[];
- fromTime: number;
- toTime: number;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], time: toTime };
- const result = [...bookmarks];
- result[index] = updated;
- return result.slice().sort((a, b) => a.time - b.time);
-}
-
-export function getFrameTime({
- time,
- fps,
-}: {
- time: number;
- fps: number;
-}): number {
- return roundToFrame({ time, fps });
-}
-
-export function getBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark | null {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- return index === -1 ? null : bookmarks[index];
-}
-
-export function getBookmarksActiveAtTime({
- bookmarks,
- time,
-}: {
- bookmarks: Bookmark[];
- time: number;
-}): Bookmark[] {
- return bookmarks.filter((bookmark) => {
- const start = bookmark.time;
- const end =
- bookmark.duration != null && bookmark.duration > 0
- ? start + bookmark.duration
- : start;
- return (
- time >= start - BOOKMARK_TIME_EPSILON &&
- time <= end + BOOKMARK_TIME_EPSILON
- );
- });
-}
+import type { Bookmark } from "@/lib/timeline";
+import { roundToFrame } from "opencut-wasm";
+
+export const BOOKMARK_TIME_EPSILON = 0.001;
+
+function bookmarkTimeEqual({
+ bookmarkTime,
+ frameTime,
+}: {
+ bookmarkTime: number;
+ frameTime: number;
+}): boolean {
+ return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
+}
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, index) => index !== bookmarkIndex);
+ }
+
+ const newBookmarks = [...bookmarks, { time: frameTime }];
+ return newBookmarks.slice().sort((a, b) => a.time - b.time);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ return bookmarks.filter(
+ (bookmark) =>
+ !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], ...updates };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result;
+}
+
+export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+}: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], time: toTime };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result.slice().sort((a, b) => a.time - b.time);
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return roundToFrame({ time, fps });
+}
+
+export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark | null {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ return index === -1 ? null : bookmarks[index];
+}
+
+export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+}): Bookmark[] {
+ return bookmarks.filter((bookmark) => {
+ const start = bookmark.time;
+ const end =
+ bookmark.duration != null && bookmark.duration > 0
+ ? start + bookmark.duration
+ : start;
+ return (
+ time >= start - BOOKMARK_TIME_EPSILON &&
+ time <= end + BOOKMARK_TIME_EPSILON
+ );
+ });
+}
diff --git a/bun.lock b/bun.lock
index 453d507a..d528cc56 100644
--- a/bun.lock
+++ b/bun.lock
@@ -54,6 +54,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
+ "opencut-wasm": "workspace:*",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -77,7 +78,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
- "zod": "^4.3.6",
+ "zod": "4.3.6",
"zustand": "^5.0.2",
},
"devDependencies": {
@@ -102,6 +103,10 @@
"wrangler": "^4.77.0",
},
},
+ "rust/wasm/pkg": {
+ "name": "opencut-wasm",
+ "version": "0.1.0",
+ },
},
"trustedDependencies": [
"@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=="],
+ "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=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
diff --git a/package.json b/package.json
index 59953a3e..0917c212 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,13 @@
"packageManager": "bun@1.2.18",
"workspaces": [
"apps/*",
- "packages/*"
+ "packages/*",
+ "rust/wasm/pkg"
],
"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",
"build:web": "turbo run build --filter=@opencut/web",
"dev:tools": "turbo run dev --filter=@opencut/tools",
diff --git a/rust/crates/time/Cargo.toml b/rust/crates/time/Cargo.toml
index 08a93224..bf66e4e2 100644
--- a/rust/crates/time/Cargo.toml
+++ b/rust/crates/time/Cargo.toml
@@ -9,7 +9,9 @@ crate-type = ["rlib", "cdylib"]
[dependencies]
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 }
[features]
-wasm = ["dep:wasm-bindgen"] \ No newline at end of file
+wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"] \ No newline at end of file
diff --git a/rust/crates/time/src/time.rs b/rust/crates/time/src/time.rs
index 222bb712..84b69327 100644
--- a/rust/crates/time/src/time.rs
+++ b/rust/crates/time/src/time.rs
@@ -1,52 +1,129 @@
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)]
-enum TimeCodeFormat {
+#[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,
}
-impl TimeCodeFormat {
- fn parse(format: Option<&str>) -> Option<Self> {
- match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) {
- "MM:SS" => Some(Self::MmSs),
- "HH:MM:SS" => Some(Self::HhMmSs),
- "HH:MM:SS:CS" => Some(Self::HhMmSsCs),
- "HH:MM:SS:FF" => Some(Self::HhMmSsFf),
- _ => None,
- }
- }
+#[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,
+}
- fn as_str(self) -> &'static str {
- match self {
- Self::MmSs => "MM:SS",
- Self::HhMmSs => "HH:MM:SS",
- Self::HhMmSsCs => "HH:MM:SS:CS",
- Self::HhMmSsFf => "HH:MM:SS:FF",
- }
- }
+#[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(time: f64, fps: f64) -> f64 {
+pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps
}
#[export]
pub fn format_time_code(
- time_in_seconds: f64,
- format: Option<String>,
- fps: Option<f64>,
+ FormatTimeCodeOptions {
+ time_in_seconds,
+ format,
+ fps,
+ }: FormatTimeCodeOptions,
) -> Option<String> {
- let format = TimeCodeFormat::parse(format.as_deref())?;
- let hours = (time_in_seconds / 3600.0).floor() as u64;
- let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64;
- let seconds = (time_in_seconds % 60.0).floor() as u64;
- let centiseconds = ((time_in_seconds % 1.0) * 100.0).floor() as u64;
+ 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}")),
@@ -69,12 +146,18 @@ pub fn format_time_code(
}
#[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() {
return None;
}
- let format = TimeCodeFormat::parse(format.as_deref())?;
+ let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code
.trim()
.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 {
return None;
};
- if *seconds >= 60 {
+ if *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
- Some((*minutes as f64 * 60.0) + *seconds as f64)
+ Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
- if *minutes >= 60 || *seconds >= 60 {
+ if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
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 => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
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;
}
Some(
- (*hours as f64 * 3600.0)
- + (*minutes as f64 * 60.0)
+ (*hours as f64 * SECONDS_PER_HOUR)
+ + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
- + (*centiseconds as f64 / 100.0),
+ + (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
)
}
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 {
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;
}
Some(
- (*hours as f64 * 3600.0)
- + (*minutes as f64 * 60.0)
+ (*hours as f64 * SECONDS_PER_HOUR)
+ + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*frames as f64 / fps),
)
@@ -141,7 +234,9 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
}
#[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() {
return None;
}
@@ -153,41 +248,52 @@ pub fn guess_time_code_format(time_code: &str) -> Option<String> {
})?;
match part_count {
- 2 => Some(TimeCodeFormat::MmSs.as_str().to_string()),
- 3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()),
- 4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()),
+ 2 => Some(TimeCodeFormat::MmSs),
+ 3 => Some(TimeCodeFormat::HhMmSs),
+ 4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None,
}
}
#[export]
-pub fn time_to_frame(time: f64, fps: f64) -> f64 {
+pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round()
}
#[export]
-pub fn frame_to_time(frame: f64, fps: f64) -> f64 {
+pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps
}
#[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 {
return time;
}
- frame_to_time(time_to_frame(time, fps), fps)
+ frame_to_time(FrameToTimeOptions {
+ frame: time_to_frame(TimeToFrameOptions { time, fps }),
+ fps,
+ })
}
#[export]
-pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 {
- let snapped_time = snap_time_to_frame(raw_time, fps);
- let last_frame = get_last_frame_time(duration, fps);
+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(duration: f64, fps: f64) -> f64 {
+pub fn get_last_frame_time(
+ GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
+) -> f64 {
if duration <= 0.0 {
return 0.0;
}
@@ -206,18 +312,26 @@ mod tests {
#[test]
fn rounds_to_the_nearest_frame() {
- assert_eq!(round_to_frame(1.24, 10.0), 1.2);
- assert_eq!(round_to_frame(1.26, 10.0), 1.3);
+ 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(3723.45, None, None),
- Some("01:02:03:44".to_string()),
+ 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(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()),
);
}
@@ -225,11 +339,19 @@ mod tests {
#[test]
fn formats_frame_based_time_codes() {
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()),
);
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,
);
}
@@ -237,38 +359,64 @@ mod tests {
#[test]
fn parses_time_codes() {
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),
);
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),
);
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,
);
}
#[test]
fn guesses_time_code_formats() {
- assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
assert_eq!(
- guess_time_code_format("00:00:01"),
- Some("HH:MM:SS".to_string()),
+ 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("00:00:01:15"),
- Some("HH:MM:SS:FF".to_string()),
+ 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(1.26, 10.0), 13.0);
- assert_eq!(frame_to_time(13.0, 10.0), 1.3);
- assert_eq!(snap_time_to_frame(1.26, 10.0), 1.3);
- assert_eq!(get_last_frame_time(10.0, 5.0), 9.8);
- assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8);
+ 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,
+ );
}
}
diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml
new file mode 100644
index 00000000..031437e5
--- /dev/null
+++ b/rust/wasm/Cargo.toml
@@ -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"] }
diff --git a/rust/wasm/README.md b/rust/wasm/README.md
new file mode 100644
index 00000000..bb6f62e7
--- /dev/null
+++ b/rust/wasm/README.md
@@ -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.
diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs
new file mode 100644
index 00000000..e852df5f
--- /dev/null
+++ b/rust/wasm/src/wasm.rs
@@ -0,0 +1 @@
+pub use time::*;
diff --git a/script/setup-rust b/script/setup-rust
index 90fffdca..489cc500 100644
--- a/script/setup-rust
+++ b/script/setup-rust
@@ -14,4 +14,8 @@ fi
echo "Installing rustup..."
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."
diff --git a/turbo.json b/turbo.json
index c3181bbb..bcea3ffb 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,8 +1,11 @@
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
+ "build:wasm": {
+ "outputs": ["rust/wasm/pkg/**"]
+ },
"build": {
- "dependsOn": ["^build"],
+ "dependsOn": ["//#build:wasm", "^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": [
"NODE_ENV",