From dcf54c1250431f5a4a2848f7c07781ef5eebacaf Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 31 Mar 2026 04:03:52 +0200 Subject: [PATCH] feat: migrate time utilities from typescript to rust WASM --- .gitignore | 2 + Cargo.lock | 109 +- Cargo.toml | 5 +- apps/web/package.json | 1 + apps/web/src/app/projects/page.tsx | 4 +- apps/web/src/components/editable-timecode.tsx | 299 ++-- .../editor/dialogs/project-info-dialog.tsx | 163 ++- .../editor/panels/preview/toolbar.tsx | 298 ++-- .../editor/panels/timeline/bookmarks.tsx | 2 +- .../web/src/core/managers/renderer-manager.ts | 7 +- .../web/src/core/managers/timeline-manager.ts | 2 +- .../element/use-element-interaction.ts | 7 +- .../timeline/element/use-element-resize.ts | 67 +- .../timeline/element/use-keyframe-drag.ts | 8 +- .../src/hooks/timeline/use-bookmark-drag.ts | 556 ++++---- .../hooks/timeline/use-timeline-drag-drop.ts | 1218 ++++++++--------- .../hooks/timeline/use-timeline-playhead.ts | 8 +- .../src/hooks/timeline/use-timeline-seek.ts | 390 +++--- apps/web/src/lib/time.ts | 225 --- apps/web/src/lib/timeline/bookmarks.ts | 298 ++-- bun.lock | 9 +- package.json | 6 +- rust/crates/time/Cargo.toml | 4 +- rust/crates/time/src/time.rs | 298 +++- rust/wasm/Cargo.toml | 14 + rust/wasm/README.md | 21 + rust/wasm/src/wasm.rs | 1 + script/setup-rust | 6 +- turbo.json | 5 +- 29 files changed, 2008 insertions(+), 2025 deletions(-) delete mode 100644 apps/web/src/lib/time.ts create mode 100644 rust/wasm/Cargo.toml create mode 100644 rust/wasm/README.md create mode 100644 rust/wasm/src/wasm.rs 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 @@ -642,6 +642,14 @@ dependencies = [ "piper", ] +[[package]] +name = "bridge" +version = "0.1.0" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1977,6 +1985,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.16.0" @@ -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", ] @@ -3540,6 +3563,13 @@ dependencies = [ "gpui", ] +[[package]] +name = "opencut-wasm" +version = "0.1.2" +dependencies = [ + "time", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4588,6 +4618,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5253,6 +5294,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.1.0" +dependencies = [ + "bridge", + "serde", + "tsify-next", + "wasm-bindgen", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5527,6 +5578,32 @@ version = "0.2.5" 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" @@ -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(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) => { - if (event.key === "Enter") { - event.preventDefault(); - enterPressedRef.current = true; - applyEdit(); - } else if (event.key === "Escape") { - event.preventDefault(); - cancelEditing(); - } - }; - - const handleInputChange = ({ - target, - }: React.ChangeEvent) => { - setInputValue(target.value); - setHasError(false); - }; - - const handleBlur = () => { - if (!enterPressedRef.current && isEditing) { - applyEdit(); - } - }; - - const handleDisplayKeyDown = ( - event: React.KeyboardEvent, - ) => { - 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 ( - - ); - } - - return ( - - ); -} +"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(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) => { + if (event.key === "Enter") { + event.preventDefault(); + enterPressedRef.current = true; + applyEdit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEditing(); + } + }; + + const handleInputChange = ({ + target, + }: React.ChangeEvent) => { + setInputValue(target.value); + setHasError(false); + }; + + const handleBlur = () => { + if (!enterPressedRef.current && isEditing) { + applyEdit(); + } + }; + + const handleDisplayKeyDown = ( + event: React.KeyboardEvent, + ) => { + 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 ( + + ); + } + + return ( + + ); +} 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 ( -
- {label} - {value} -
- ); -} - -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 ( - - event.preventDefault()}> - - - {project.name} - - - - - - - - - {project.id.slice(0, 8)} - - } - /> - - - - - - - - ); -} +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 ( +
+ {label} + {value} +
+ ); +} + +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 ( + + event.preventDefault()}> + + + {project.name} + + + + + + + + + {project.id.slice(0, 8)} + + } + /> + + + + + + + + ); +} 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 ( -
- - -
- - - - - - -
-
- ); -} - -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 ( -
- editor.playback.seek({ time })} - className="text-center" - /> - / - - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - -
- ); -} - -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 ( - - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - - ); -} +"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 ( +
+ + +
+ + + + + + +
+
+ ); +} + +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 ( +
+ editor.playback.seek({ time })} + className="text-center" + /> + / + + {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })} + +
+ ); +} + +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 ( + + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + + ); +} 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; - 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({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(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; + 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({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef(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; - headerRef?: RefObject; - tracksScrollRef?: RefObject; - zoomLevel: number; -} - -export function useTimelineDragDrop({ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, -}: UseTimelineDragDropProps) { - const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(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; + headerRef?: RefObject; + tracksScrollRef?: RefObject; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + tracksScrollRef, + zoomLevel, +}: UseTimelineDragDropProps) { + const editor = useEditor(); + const [isDragOver, setIsDragOver] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + const [dragElementType, setElementType] = useState(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; - trackLabelsRef: RefObject; - rulerScrollRef: RefObject; - tracksScrollRef: RefObject; - 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; + trackLabelsRef: RefObject; + rulerScrollRef: RefObject; + tracksScrollRef: RefObject; + 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>; -}): 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>; +}): 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 { - 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fps: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fps: Option, +} + +#[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, - fps: Option, + FormatTimeCodeOptions { + time_in_seconds, + format, + fps, + }: FormatTimeCodeOptions, ) -> Option { - 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, fps: Option) -> Option { +pub fn parse_time_code( + ParseTimeCodeOptions { + time_code, + format, + fps, + }: ParseTimeCodeOptions, +) -> Option { 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, fps: Option 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, fps: Option 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, fps: Option } #[export] -pub fn guess_time_code_format(time_code: &str) -> Option { +pub fn guess_time_code_format( + GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions, +) -> Option { if time_code.trim().is_empty() { return None; } @@ -153,41 +248,52 @@ pub fn guess_time_code_format(time_code: &str) -> Option { })?; 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("00:00:01:15"), - Some("HH:MM:SS:FF".to_string()), + guess_time_code_format(GuessTimeCodeFormatOptions { + time_code: "00:00:01".to_string(), + }), + Some(TimeCodeFormat::HhMmSs), + ); + assert_eq!( + guess_time_code_format(GuessTimeCodeFormatOptions { + time_code: "00:00:01:15".to_string(), + }), + Some(TimeCodeFormat::HhMmSsFf), ); } #[test] fn snaps_and_clamps_seek_time() { - assert_eq!(time_to_frame(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",