Compare commits

...
53 changed files with 2648 additions and 2570 deletions
+20
View File
@@ -41,6 +41,26 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Rust wasm target
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack
uses: jetli/wasm-pack-action@v0.4.0
with:
version: latest
- name: Cache Rust build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
- name: Build WASM
run: wasm-pack build rust/wasm --target bundler --out-dir pkg
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with: with:
+2
View File
@@ -22,3 +22,5 @@ bun.lockb
.open-next .open-next
target/ target/
.cargo-tools/
Generated
+91 -18
View File
@@ -642,6 +642,14 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "bridge"
version = "0.1.0"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -1977,6 +1985,19 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.16.0" version = "0.16.0"
@@ -2780,10 +2801,12 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [ dependencies = [
"cfg-if",
"futures-util",
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -3540,6 +3563,13 @@ dependencies = [
"gpui", "gpui",
] ]
[[package]]
name = "opencut-wasm"
version = "0.1.2"
dependencies = [
"time",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" version = "0.2.1"
@@ -4588,6 +4618,17 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -5253,6 +5294,16 @@ dependencies = [
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"serde",
"tsify-next",
"wasm-bindgen",
]
[[package]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"
@@ -5527,6 +5578,32 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tsify-next"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
dependencies = [
"gloo-utils",
"serde",
"serde-wasm-bindgen",
"serde_json",
"tsify-next-macros",
"wasm-bindgen",
]
[[package]]
name = "tsify-next-macros"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.20.0" version = "0.20.0"
@@ -5850,9 +5927,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -5863,23 +5940,19 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.64" version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [ dependencies = [
"cfg-if",
"futures-util",
"js-sys", "js-sys",
"once_cell",
"wasm-bindgen", "wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -5887,9 +5960,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -5900,9 +5973,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -6053,9 +6126,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
+3 -2
View File
@@ -2,6 +2,7 @@
resolver = "2" resolver = "2"
members = [ members = [
"apps/desktop", "apps/desktop",
"rust/crates/time" "rust/crates/time",
"rust/crates/bridge" "rust/crates/bridge",
"rust/wasm",
] ]
+1
View File
@@ -22,6 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
TProjectSortKey, TProjectSortKey,
TProjectSortOption, TProjectSortOption,
} from "@/lib/project/types"; } from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { formatDate } from "@/utils/date"; import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
} }
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS"; const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format }); return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
}; };
const VIEW_MODE_OPTIONS = [ const VIEW_MODE_OPTIONS = [
+149 -150
View File
@@ -1,150 +1,149 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time"; import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import type { TTimeCode } from "@/lib/time"; import { cn } from "@/utils/ui";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
interface EditableTimecodeProps { time: number;
time: number; duration: number;
duration: number; format?: TimeCodeFormat;
format?: TTimeCode; fps: number;
fps: number; onTimeChange?: ({ time }: { time: number }) => void;
onTimeChange?: ({ time }: { time: number }) => void; className?: string;
className?: string; disabled?: boolean;
disabled?: boolean; }
}
export function EditableTimecode({
export function EditableTimecode({ time,
time, duration,
duration, format = "HH:MM:SS:FF",
format = "HH:MM:SS:FF", fps,
fps, onTimeChange,
onTimeChange, className,
className, disabled = false,
disabled = false, }: EditableTimecodeProps) {
}: EditableTimecodeProps) { const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [inputValue, setInputValue] = useState("");
const [inputValue, setInputValue] = useState(""); const [hasError, setHasError] = useState(false);
const [hasError, setHasError] = useState(false); const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const enterPressedRef = useRef(false);
const enterPressedRef = useRef(false); const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
const startEditing = () => {
const startEditing = () => { if (disabled) return;
if (disabled) return; setIsEditing(true);
setIsEditing(true); setInputValue(formattedTime);
setInputValue(formattedTime); setHasError(false);
setHasError(false); enterPressedRef.current = false;
enterPressedRef.current = false; };
};
const cancelEditing = () => {
const cancelEditing = () => { setIsEditing(false);
setIsEditing(false); setInputValue("");
setInputValue(""); setHasError(false);
setHasError(false); enterPressedRef.current = false;
enterPressedRef.current = false; };
};
const applyEdit = () => {
const applyEdit = () => { const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime == null) {
if (parsedTime === null) { setHasError(true);
setHasError(true); return;
return; }
}
const clampedTime = Math.max(
const clampedTime = Math.max( 0,
0, duration ? Math.min(duration, parsedTime) : parsedTime,
duration ? Math.min(duration, parsedTime) : parsedTime, );
);
onTimeChange?.({ time: clampedTime });
onTimeChange?.({ time: clampedTime }); setIsEditing(false);
setIsEditing(false); setInputValue("");
setInputValue(""); setHasError(false);
setHasError(false); enterPressedRef.current = false;
enterPressedRef.current = false; };
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") {
if (event.key === "Enter") { event.preventDefault();
event.preventDefault(); enterPressedRef.current = true;
enterPressedRef.current = true; applyEdit();
applyEdit(); } else if (event.key === "Escape") {
} else if (event.key === "Escape") { event.preventDefault();
event.preventDefault(); cancelEditing();
cancelEditing(); }
} };
};
const handleInputChange = ({
const handleInputChange = ({ target,
target, }: React.ChangeEvent<HTMLInputElement>) => {
}: React.ChangeEvent<HTMLInputElement>) => { setInputValue(target.value);
setInputValue(target.value); setHasError(false);
setHasError(false); };
};
const handleBlur = () => {
const handleBlur = () => { if (!enterPressedRef.current && isEditing) {
if (!enterPressedRef.current && isEditing) { applyEdit();
applyEdit(); }
} };
};
const handleDisplayKeyDown = (
const handleDisplayKeyDown = ( event: React.KeyboardEvent<HTMLButtonElement>,
event: React.KeyboardEvent<HTMLButtonElement>, ) => {
) => { if (disabled) return;
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
if (event.key === "Enter" || event.key === " ") { event.preventDefault();
event.preventDefault(); startEditing();
startEditing(); }
} };
};
useEffect(() => {
useEffect(() => { if (isEditing && inputRef.current) {
if (isEditing && inputRef.current) { inputRef.current.focus();
inputRef.current.focus(); inputRef.current.select();
inputRef.current.select(); }
} }, [isEditing]);
}, [isEditing]);
if (isEditing) {
if (isEditing) { return (
return ( <input
<input ref={inputRef}
ref={inputRef} type="text"
type="text" value={inputValue}
value={inputValue} onChange={handleInputChange}
onChange={handleInputChange} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} onBlur={handleBlur}
onBlur={handleBlur} className={cn(
className={cn( "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none", "focus:bg-background focus:border-primary focus:rounded",
"focus:bg-background focus:border-primary focus:rounded", "text-primary tabular-nums",
"text-primary tabular-nums", hasError && "text-destructive focus:border-destructive",
hasError && "text-destructive focus:border-destructive", className,
className, )}
)} style={{ width: `${formattedTime.length + 1}ch` }}
style={{ width: `${formattedTime.length + 1}ch` }} placeholder={formattedTime}
placeholder={formattedTime} />
/> );
); }
}
return (
return ( <button
<button type="button"
type="button" onClick={startEditing}
onClick={startEditing} onKeyDown={handleDisplayKeyDown}
onKeyDown={handleDisplayKeyDown} disabled={disabled}
disabled={disabled} className={cn(
className={cn( "text-primary cursor-pointer font-mono text-xs tabular-nums",
"text-primary cursor-pointer font-mono text-xs tabular-nums", "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
"hover:bg-muted/50 -mx-1 px-1 hover:rounded", disabled && "cursor-default hover:bg-transparent",
disabled && "cursor-default hover:bg-transparent", className,
className, )}
)} title={disabled ? undefined : "Click to edit time"}
title={disabled ? undefined : "Click to edit time"} >
> {formattedTime}
{formattedTime} </button>
</button> );
); }
}
@@ -1,83 +1,80 @@
import { import {
Dialog, Dialog,
DialogBody, DialogBody,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types"; import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date"; import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
function InfoRow({ function InfoRow({
label, label,
value, value,
}: { }: {
label: string; label: string;
value: string | React.ReactNode; value: string | React.ReactNode;
}) { }) {
return ( return (
<div className="flex justify-between items-center py-0 last:pb-0"> <div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span> <span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span> <span className="text-sm font-medium">{value}</span>
</div> </div>
); );
} }
export function ProjectInfoDialog({ export function ProjectInfoDialog({
isOpen, isOpen,
onOpenChange, onOpenChange,
project, project,
}: { }: {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
project: TProjectMetadata; project: TProjectMetadata;
}) { }) {
const durationFormatted = const durationFormatted =
project.duration > 0 project.duration > 0
? formatTimeCode({ ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
timeInSeconds: project.duration, : "0:00";
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
}) return (
: "0:00"; <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
return ( <DialogHeader>
<Dialog open={isOpen} onOpenChange={onOpenChange}> <DialogTitle className="truncate max-w-[350px]">
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}> {project.name}
<DialogHeader> </DialogTitle>
<DialogTitle className="truncate max-w-[350px]"> </DialogHeader>
{project.name}
</DialogTitle> <DialogBody className="flex flex-col">
</DialogHeader> <InfoRow label="Duration" value={durationFormatted} />
<InfoRow
<DialogBody className="flex flex-col"> label="Created"
<InfoRow label="Duration" value={durationFormatted} /> value={formatDate({ date: project.createdAt })}
<InfoRow />
label="Created" <InfoRow
value={formatDate({ date: project.createdAt })} label="Modified"
/> value={formatDate({ date: project.updatedAt })}
<InfoRow />
label="Modified" <InfoRow
value={formatDate({ date: project.updatedAt })} label="Project ID"
/> value={
<InfoRow <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
label="Project ID" {project.id.slice(0, 8)}
value={ </code>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded"> }
{project.id.slice(0, 8)} />
</code> </DialogBody>
} <DialogFooter>
/> <Button variant="outline" onClick={() => onOpenChange(false)}>
</DialogBody> Close
<DialogFooter> </Button>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button onClick={() => onOpenChange(false)}>Done</Button>
Close </DialogFooter>
</Button> </DialogContent>
<Button onClick={() => onOpenChange(false)}>Done</Button> </Dialog>
</DialogFooter> );
</DialogContent> }
</Dialog>
);
}
@@ -1,151 +1,147 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions"; import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode"; import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
FullScreenIcon, FullScreenIcon,
GridTableIcon, GridTableIcon,
PauseIcon, PauseIcon,
PlayIcon, PlayIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/lib/guides"; import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
Select, Select,
SelectTrigger, SelectTrigger,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
import { usePreviewViewport } from "./preview-viewport"; import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover"; import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store"; import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({ export function PreviewToolbar({
onToggleFullscreen, onToggleFullscreen,
}: { }: {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
}) { }) {
const activeGuide = usePreviewStore((state) => state.activeGuide); const activeGuide = usePreviewStore((state) => state.activeGuide);
const activeGuideDefinition = getGuideById(activeGuide); const activeGuideDefinition = getGuideById(activeGuide);
return ( return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay /> <TimecodeDisplay />
<PlayPauseButton /> <PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5"> <div className="justify-self-end flex items-center gap-2.5">
<ZoomSelect /> <ZoomSelect />
<Separator orientation="vertical" className="h-4" /> <Separator orientation="vertical" className="h-4" />
<GridPopover> <GridPopover>
<Button <Button
variant={activeGuideDefinition ? "secondary" : "text"} variant={activeGuideDefinition ? "secondary" : "text"}
size="icon" size="icon"
> >
{activeGuideDefinition ? ( {activeGuideDefinition ? (
activeGuideDefinition.renderTriggerIcon() activeGuideDefinition.renderTriggerIcon()
) : ( ) : (
<HugeiconsIcon icon={GridTableIcon} /> <HugeiconsIcon icon={GridTableIcon} />
)} )}
</Button> </Button>
</GridPopover> </GridPopover>
<Button variant="text" onClick={onToggleFullscreen}> <Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} /> <HugeiconsIcon icon={FullScreenIcon} />
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
function TimecodeDisplay() { function TimecodeDisplay() {
const editor = useEditor(); const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps); const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() => const [currentTime, setCurrentTime] = useState(() =>
editor.playback.getCurrentTime(), editor.playback.getCurrentTime(),
); );
useEffect(() => { useEffect(() => {
const handler = (e: Event) => const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
window.addEventListener("playback-update", handler); window.addEventListener("playback-update", handler);
window.addEventListener("playback-seek", handler); window.addEventListener("playback-seek", handler);
return () => { return () => {
window.removeEventListener("playback-update", handler); window.removeEventListener("playback-update", handler);
window.removeEventListener("playback-seek", handler); window.removeEventListener("playback-seek", handler);
}; };
}, []); }, []);
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<EditableTimecode <EditableTimecode
time={currentTime} time={currentTime}
duration={totalDuration} duration={totalDuration}
format="HH:MM:SS:FF" format="HH:MM:SS:FF"
fps={fps} fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })} onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center" className="text-center"
/> />
<span className="text-muted-foreground px-2 font-mono text-xs">/</span> <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs"> <span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({ {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
timeInSeconds: totalDuration, </span>
format: "HH:MM:SS:FF", </div>
fps, );
})} }
</span>
</div> function ZoomSelect() {
); const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
} usePreviewViewport();
function ZoomSelect() { const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
usePreviewViewport(); const onValueChange = (value: string) => {
if (value === "fit") {
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; fitToScreen();
} else {
const onValueChange = (value: string) => { setViewportPercent({ percent: Number(value) });
if (value === "fit") { }
fitToScreen(); };
} else {
setViewportPercent({ percent: Number(value) }); return (
} <Select
}; value={isAtFit ? "fit" : String(zoomPercent)}
onValueChange={onValueChange}
return ( >
<Select <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
value={isAtFit ? "fit" : String(zoomPercent)} <SelectContent>
onValueChange={onValueChange} <SelectItem value="fit">Fit</SelectItem>
> <SelectSeparator />
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> {PREVIEW_ZOOM_PRESETS.map((preset) => (
<SelectContent> <SelectItem key={preset} value={String(preset)}>
<SelectItem value="fit">Fit</SelectItem> {preset}%
<SelectSeparator /> </SelectItem>
{PREVIEW_ZOOM_PRESETS.map((preset) => ( ))}
<SelectItem key={preset} value={String(preset)}> </SelectContent>
{preset}% </Select>
</SelectItem> );
))} }
</SelectContent>
</Select> function PlayPauseButton() {
); const isPlaying = useEditor((e) => e.playback.getIsPlaying());
}
return (
function PlayPauseButton() { <Button
const isPlaying = useEditor((e) => e.playback.getIsPlaying()); variant="text"
size="icon"
return ( onClick={() => invokeAction("toggle-play")}
<Button >
variant="text" <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
size="icon" </Button>
onClick={() => invokeAction("toggle-play")} );
> }
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
);
}
@@ -10,7 +10,7 @@ import {
TIMELINE_BOOKMARK_ROW_HEIGHT, TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants"; import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-wasm";
import { import {
ArrowTurnBackwardIcon, ArrowTurnBackwardIcon,
Delete02Icon, Delete02Icon,
@@ -52,7 +52,7 @@ import {
getTimelineZoomMin, getTimelineZoomMin,
getTimelinePaddingPx, getTimelinePaddingPx,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
+63 -53
View File
@@ -1,53 +1,63 @@
import { PlaybackManager } from "./managers/playback-manager"; import { PlaybackManager } from "./managers/playback-manager";
import { TimelineManager } from "./managers/timeline-manager"; import { TimelineManager } from "./managers/timeline-manager";
import { ScenesManager } from "./managers/scenes-manager"; import { ScenesManager } from "./managers/scenes-manager";
import { ProjectManager } from "./managers/project-manager"; import { ProjectManager } from "./managers/project-manager";
import { MediaManager } from "./managers/media-manager"; import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager"; import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands"; import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager"; import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager"; import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager"; import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects"; import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks"; import { registerDefaultMasks } from "@/lib/masks";
import { isMainTrack } from "@/lib/timeline/placement";
export class EditorCore {
private static instance: EditorCore | null = null; export class EditorCore {
public readonly timeline: TimelineManager; private static instance: EditorCore | null = null;
public readonly command: CommandManager; public readonly timeline: TimelineManager;
public readonly playback: PlaybackManager; public readonly command: CommandManager;
public readonly scenes: ScenesManager; public readonly playback: PlaybackManager;
public readonly project: ProjectManager; public readonly scenes: ScenesManager;
public readonly media: MediaManager; public readonly project: ProjectManager;
public readonly renderer: RendererManager; public readonly media: MediaManager;
public readonly save: SaveManager; public readonly renderer: RendererManager;
public readonly audio: AudioManager; public readonly save: SaveManager;
public readonly selection: SelectionManager; public readonly audio: AudioManager;
public readonly selection: SelectionManager;
private constructor() {
registerDefaultEffects(); private constructor() {
registerDefaultMasks(); registerDefaultEffects();
this.command = new CommandManager(); registerDefaultMasks();
this.timeline = new TimelineManager(this); this.command = new CommandManager(this);
this.playback = new PlaybackManager(this); this.timeline = new TimelineManager(this);
this.scenes = new ScenesManager(this); this.playback = new PlaybackManager(this);
this.project = new ProjectManager(this); this.scenes = new ScenesManager(this);
this.media = new MediaManager(this); this.project = new ProjectManager(this);
this.renderer = new RendererManager(this); this.media = new MediaManager(this);
this.save = new SaveManager(this); this.renderer = new RendererManager(this);
this.audio = new AudioManager(this); this.save = new SaveManager(this);
this.selection = new SelectionManager(this); this.audio = new AudioManager(this);
this.save.start(); this.selection = new SelectionManager(this);
} this.command.registerReactor(() => {
const tracks = this.timeline.getTracks();
static getInstance(): EditorCore { const prunedTracks = tracks.filter(
if (!EditorCore.instance) { (track) => track.elements.length > 0 || isMainTrack(track),
EditorCore.instance = new EditorCore(); );
} if (prunedTracks.length !== tracks.length) {
return EditorCore.instance; this.timeline.updateTracks(prunedTracks);
} }
});
static reset(): void { this.save.start();
EditorCore.instance = null; }
}
} static getInstance(): EditorCore {
if (!EditorCore.instance) {
EditorCore.instance = new EditorCore();
}
return EditorCore.instance;
}
static reset(): void {
EditorCore.instance = null;
}
}
+116 -49
View File
@@ -1,49 +1,116 @@
import type { Command } from "@/lib/commands"; import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
export class CommandManager { import type { ElementRef } from "@/lib/timeline/types";
private history: Command[] = [];
private redoStack: Command[] = []; interface CommandHistoryEntry {
command: Command;
execute({ command }: { command: Command }): Command { previousSelection: ElementRef[];
command.execute(); selectionOverride?: ElementRef[];
this.history.push(command); }
this.redoStack = [];
return command; export class CommandManager {
} private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
push({ command }: { command: Command }): void { private reactors: Array<() => void> = [];
this.history.push(command);
this.redoStack = []; constructor(private editor: EditorCore) {}
}
execute({ command }: { command: Command }): Command {
undo(): void { const previousSelection = this.getSelectionSnapshot();
if (this.history.length === 0) return; const result = command.execute();
const command = this.history.pop(); const selectionOverride = this.applySelectionOverride(result);
command?.undo(); this.runReactors();
if (command) { this.history.push({
this.redoStack.push(command); command,
} previousSelection,
} selectionOverride,
});
redo(): void { this.redoStack = [];
if (this.redoStack.length === 0) return; return command;
const command = this.redoStack.pop(); }
command?.redo();
if (command) { push({ command }: { command: Command }): void {
this.history.push(command); this.history.push({
} command,
} previousSelection: this.getSelectionSnapshot(),
});
canUndo(): boolean { this.redoStack = [];
return this.history.length > 0; }
}
registerReactor(reactor: () => void): void {
canRedo(): boolean { this.reactors.push(reactor);
return this.redoStack.length > 0; }
}
undo(): void {
clear(): void { if (this.history.length === 0) return;
this.history = []; const entry = this.history.pop();
this.redoStack = []; entry?.command.undo();
} if (entry) {
} // Only restore selection for commands that explicitly changed it.
// Commands without selection intent leave selection untouched,
// preserving any UI-driven selection changes (clicks, box select)
// that happened between commands. Commands that remove elements
// must declare { select: [] } to clear stale refs.
if (entry.selectionOverride !== undefined) {
this.editor.selection.setSelectedElements({
elements: [...entry.previousSelection],
});
}
this.redoStack.push(entry);
}
}
redo(): void {
if (this.redoStack.length === 0) return;
const entry = this.redoStack.pop();
if (!entry) {
return;
}
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
command: entry.command,
previousSelection,
selectionOverride,
});
}
canUndo(): boolean {
return this.history.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.history = [];
this.redoStack = [];
}
private getSelectionSnapshot(): ElementRef[] {
return [...this.editor.selection.getSelectedElements()];
}
private applySelectionOverride(
result: CommandResult | undefined,
): ElementRef[] | undefined {
if (result?.select === undefined) {
return undefined;
}
const selectionOverride = [...result.select];
this.editor.selection.setSelectedElements({ elements: selectionOverride });
return selectionOverride;
}
private runReactors(): void {
for (const reactor of this.reactors) {
reactor();
}
}
}
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter"; import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder"; import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio"; import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser"; import { downloadBlob } from "@/utils/browser";
type SnapshotResult = type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" }; return { success: false, error: "Failed to create image" };
} }
const timecode = formatTimeCode({ const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const safeName = const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() || activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot"; "snapshot";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime, getFrameTime,
isBookmarkAtTime, isBookmarkAtTime,
} from "@/lib/timeline/bookmarks"; } from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/track-placement"; import { ensureMainTrack } from "@/lib/timeline/placement";
import { import {
CreateSceneCommand, CreateSceneCommand,
DeleteSceneCommand, DeleteSceneCommand,
@@ -13,7 +13,7 @@ import type {
AnimationValue, AnimationValue,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time"; import { getLastFrameTime } from "opencut-wasm";
import { import {
AddTrackCommand, AddTrackCommand,
RemoveTrackCommand, RemoveTrackCommand,
@@ -265,7 +265,7 @@ export class TimelineManager {
time: number; time: number;
clipboardItems: ClipboardItem[]; clipboardItems: ClipboardItem[];
}): { trackId: string; elementId: string }[] { }): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems); const command = new PasteCommand({ time, clipboardItems });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.getPastedElements(); return command.getPastedElements();
} }
@@ -261,7 +261,6 @@ export function useEditorActions() {
elements: selectedElements, elements: selectedElements,
rippleEnabled: rippleEditingEnabled, rippleEnabled: rippleEditingEnabled,
}); });
editor.selection.clearSelection();
}, },
undefined, undefined,
); );
@@ -14,7 +14,7 @@ import {
DRAG_THRESHOLD_PX, DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS, TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
@@ -302,10 +302,7 @@ export function useElementInteraction({
0, 0,
mouseTime - pendingDragRef.current.clickOffsetTime, mouseTime - pendingDragRef.current.clickOffsetTime,
); );
const snappedTime = snapTimeToFrame({ const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
time: adjustedTime,
fps: activeProject.settings.fps,
});
startDrag({ startDrag({
...pendingDragRef.current, ...pendingDragRef.current,
initialCurrentTime: snappedTime, initialCurrentTime: snappedTime,
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) { if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({ const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const visibleSourceSpan = Math.max( const visibleSourceSpan = Math.max(
0, 0,
sourceDuration - newTrimStart - resizing.initialTrimEnd, sourceDuration - newTrimStart - resizing.initialTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan), const trimDelta = resizing.initialDuration - newDuration;
fps: projectFps, const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
});
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
setCurrentTrimStart(newTrimStart); setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
) )
: Math.min(extensionAmount, maxExtension), : Math.min(extensionAmount, maxExtension),
); );
const newStartTime = snapTimeToFrame({ const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
time: resizing.initialStartTime - actualExtension, const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
setCurrentTrimStart(0); setCurrentTrimStart(0);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0, 0,
sourceDuration - newTrimStart - resizing.initialTrimEnd, sourceDuration - newTrimStart - resizing.initialTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan), const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
fps: projectFps,
});
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
setCurrentTrimStart(newTrimStart); setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime); setCurrentStartTime(newStartTime);
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd); const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration = const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd; resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
setCurrentTrimEnd(0); setCurrentTrimEnd(0);
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan( const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart), Math.max(0, sourceDuration - resizing.initialTrimStart),
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
setCurrentTrimEnd(0); setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd, maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd), Math.max(minTrimEndForNeighbor, newTrimEnd),
); );
const finalTrimEnd = snapTimeToFrame({ const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
time: clampedTrimEnd,
fps: projectFps,
});
const visibleSourceSpan = Math.max( const visibleSourceSpan = Math.max(
0, 0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd, sourceDuration - resizing.initialTrimStart - finalTrimEnd,
); );
const newDuration = snapTimeToFrame({ const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
setCurrentTrimEnd(finalTrimEnd); setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration); setCurrentDuration(newDuration);
@@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection"; import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { import {
DRAG_THRESHOLD_PX, DRAG_THRESHOLD_PX,
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
if (wasDrag) return; if (wasDrag) return;
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({ const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
rawTime: displayedStartTime + indicatorTime,
duration,
fps,
});
editor.playback.seek({ time: seekTime }); editor.playback.seek({ time: seekTime });
if (event.shiftKey) { if (event.shiftKey) {
+275 -281
View File
@@ -1,281 +1,275 @@
import { import {
useState, useState,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
type RefObject, type RefObject,
} from "react"; } from "react";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants"; import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time"; import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { import {
findSnapPoints, findSnapPoints,
snapToNearestPoint, snapToNearestPoint,
type SnapPoint, type SnapPoint,
} from "@/lib/timeline/snap-utils"; } from "@/lib/timeline/snap-utils";
import type { Bookmark } from "@/lib/timeline"; import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState { export interface BookmarkDragState {
isDragging: boolean; isDragging: boolean;
bookmarkTime: number | null; bookmarkTime: number | null;
currentTime: number; currentTime: number;
} }
interface PendingBookmarkDrag { interface PendingBookmarkDrag {
bookmarkTime: number; bookmarkTime: number;
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
} }
interface UseBookmarkDragProps { interface UseBookmarkDragProps {
zoomLevel: number; zoomLevel: number;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
snappingEnabled: boolean; snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
} }
export function useBookmarkDrag({ export function useBookmarkDrag({
zoomLevel, zoomLevel,
scrollRef, scrollRef,
snappingEnabled, snappingEnabled,
onSnapPointChange, onSnapPointChange,
}: UseBookmarkDragProps) { }: UseBookmarkDragProps) {
const editor = useEditor(); const editor = useEditor();
const isShiftHeldRef = useShiftKey(); const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks(); const tracks = editor.timeline.getTracks();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? []; const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime(); const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const [dragState, setDragState] = useState<BookmarkDragState>({ const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false, isDragging: false,
bookmarkTime: null, bookmarkTime: null,
currentTime: 0, currentTime: 0,
}); });
const [isPendingDrag, setIsPendingDrag] = useState(false); const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
const lastMouseXRef = useRef(0); const lastMouseXRef = useRef(0);
const startDrag = useCallback( const startDrag = useCallback(
({ ({
bookmarkTime, bookmarkTime,
initialCurrentTime, initialCurrentTime,
}: { }: {
bookmarkTime: number; bookmarkTime: number;
initialCurrentTime: number; initialCurrentTime: number;
}) => { }) => {
setDragState({ setDragState({
isDragging: true, isDragging: true,
bookmarkTime, bookmarkTime,
currentTime: initialCurrentTime, currentTime: initialCurrentTime,
}); });
}, },
[], [],
); );
const endDrag = useCallback(() => { const endDrag = useCallback(() => {
setDragState({ setDragState({
isDragging: false, isDragging: false,
bookmarkTime: null, bookmarkTime: null,
currentTime: 0, currentTime: 0,
}); });
}, []); }, []);
const getSnapResult = useCallback( const getSnapResult = useCallback(
({ ({
rawTime, rawTime,
excludeBookmarkTime, excludeBookmarkTime,
}: { }: {
rawTime: number; rawTime: number;
excludeBookmarkTime: number; excludeBookmarkTime: number;
}): { snappedTime: number; snapPoint: SnapPoint | null } => { }): { snappedTime: number; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) { if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null }; return { snappedTime: rawTime, snapPoint: null };
} }
const snapPoints = findSnapPoints({ const snapPoints = findSnapPoints({
tracks, tracks,
playheadTime, playheadTime,
bookmarks, bookmarks,
excludeBookmarkTime, excludeBookmarkTime,
}); });
const result = snapToNearestPoint({ const result = snapToNearestPoint({
targetTime: rawTime, targetTime: rawTime,
snapPoints, snapPoints,
zoomLevel, zoomLevel,
}); });
return { return {
snappedTime: result.snappedTime, snappedTime: result.snappedTime,
snapPoint: result.snapPoint, snapPoint: result.snapPoint,
}; };
}, },
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
); );
useEffect(() => { useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return; if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
lastMouseXRef.current = event.clientX; lastMouseXRef.current = event.clientX;
const scrollContainer = scrollRef.current; const scrollContainer = scrollRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
if (isPendingDrag && pendingDragRef.current) { if (isPendingDrag && pendingDragRef.current) {
const { startMouseX, startMouseY, bookmarkTime } = const { startMouseX, startMouseY, bookmarkTime } =
pendingDragRef.current; pendingDragRef.current;
const deltaX = Math.abs(event.clientX - startMouseX); const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY); const deltaY = Math.abs(event.clientY - startMouseY);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) { if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
return; return;
} }
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeProject) return; if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft; const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({ const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX, clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(), containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const frameSnappedTime = snapTimeToFrame({ const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
time: Math.max(0, Math.min(mouseTime, duration)), const { snappedTime: initialTime } = getSnapResult({
fps: activeProject.settings.fps, rawTime: frameSnappedTime,
}); excludeBookmarkTime: bookmarkTime,
const { snappedTime: initialTime } = getSnapResult({ });
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime, startDrag({
}); bookmarkTime,
initialCurrentTime: initialTime,
startDrag({ });
bookmarkTime, pendingDragRef.current = null;
initialCurrentTime: initialTime, setIsPendingDrag(false);
}); return;
pendingDragRef.current = null; }
setIsPendingDrag(false);
return; if (!dragState.isDragging || dragState.bookmarkTime === null) return;
}
const activeProject = editor.project.getActive();
if (!dragState.isDragging || dragState.bookmarkTime === null) return; if (!activeProject) return;
const activeProject = editor.project.getActive(); const scrollLeft = scrollContainer.scrollLeft;
if (!activeProject) return; const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
const scrollLeft = scrollContainer.scrollLeft; containerRect: scrollContainer.getBoundingClientRect(),
const mouseTime = getMouseTimeFromClientX({ zoomLevel,
clientX: event.clientX, scrollLeft,
containerRect: scrollContainer.getBoundingClientRect(), });
zoomLevel, const clampedTime = Math.max(0, Math.min(mouseTime, duration));
scrollLeft, const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
}); const snapResult = getSnapResult({
const clampedTime = Math.max(0, Math.min(mouseTime, duration)); rawTime: frameSnappedTime,
const frameSnappedTime = snapTimeToFrame({ excludeBookmarkTime: dragState.bookmarkTime,
time: clampedTime, });
fps: activeProject.settings.fps,
}); setDragState((previousDragState) => ({
const snapResult = getSnapResult({ ...previousDragState,
rawTime: frameSnappedTime, currentTime: snapResult.snappedTime,
excludeBookmarkTime: dragState.bookmarkTime, }));
}); onSnapPointChange?.(snapResult.snapPoint);
};
setDragState((previousDragState) => ({
...previousDragState, document.addEventListener("mousemove", handleMouseMove);
currentTime: snapResult.snappedTime, return () => document.removeEventListener("mousemove", handleMouseMove);
})); }, [
onSnapPointChange?.(snapResult.snapPoint); dragState.isDragging,
}; dragState.bookmarkTime,
zoomLevel,
document.addEventListener("mousemove", handleMouseMove); duration,
return () => document.removeEventListener("mousemove", handleMouseMove); editor.project,
}, [ scrollRef,
dragState.isDragging, isPendingDrag,
dragState.bookmarkTime, startDrag,
zoomLevel, getSnapResult,
duration, onSnapPointChange,
editor.project, ]);
scrollRef,
isPendingDrag, useEffect(() => {
startDrag, if (!dragState.isDragging) return;
getSnapResult,
onSnapPointChange, const handleMouseUp = () => {
]); if (dragState.bookmarkTime === null) {
endDrag();
useEffect(() => { onSnapPointChange?.(null);
if (!dragState.isDragging) return; return;
}
const handleMouseUp = () => {
if (dragState.bookmarkTime === null) { const clampedTime = Math.max(
endDrag(); 0,
onSnapPointChange?.(null); Math.min(dragState.currentTime, duration),
return; );
}
editor.scenes.moveBookmark({
const clampedTime = Math.max( fromTime: dragState.bookmarkTime,
0, toTime: clampedTime,
Math.min(dragState.currentTime, duration), });
);
endDrag();
editor.scenes.moveBookmark({ onSnapPointChange?.(null);
fromTime: dragState.bookmarkTime, };
toTime: clampedTime,
}); document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
endDrag(); }, [
onSnapPointChange?.(null); dragState.isDragging,
}; dragState.bookmarkTime,
dragState.currentTime,
document.addEventListener("mouseup", handleMouseUp); duration,
return () => document.removeEventListener("mouseup", handleMouseUp); endDrag,
}, [ onSnapPointChange,
dragState.isDragging, editor.scenes,
dragState.bookmarkTime, ]);
dragState.currentTime,
duration, useEffect(() => {
endDrag, if (!isPendingDrag) return;
onSnapPointChange,
editor.scenes, const handleMouseUp = () => {
]); pendingDragRef.current = null;
setIsPendingDrag(false);
useEffect(() => { onSnapPointChange?.(null);
if (!isPendingDrag) return; };
const handleMouseUp = () => { document.addEventListener("mouseup", handleMouseUp);
pendingDragRef.current = null; return () => document.removeEventListener("mouseup", handleMouseUp);
setIsPendingDrag(false); }, [isPendingDrag, onSnapPointChange]);
onSnapPointChange?.(null);
}; const handleBookmarkMouseDown = useCallback(
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
document.addEventListener("mouseup", handleMouseUp); if (event.button !== 0) return;
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag, onSnapPointChange]); event.preventDefault();
event.stopPropagation();
const handleBookmarkMouseDown = useCallback(
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { pendingDragRef.current = {
if (event.button !== 0) return; bookmarkTime: bookmark.time,
startMouseX: event.clientX,
event.preventDefault(); startMouseY: event.clientY,
event.stopPropagation(); };
setIsPendingDrag(true);
pendingDragRef.current = { },
bookmarkTime: bookmark.time, [],
startMouseX: event.clientX, );
startMouseY: event.clientY,
}; return {
setIsPendingDrag(true); dragState,
}, handleBookmarkMouseDown,
[], lastMouseXRef,
); };
}
return {
dragState,
handleBookmarkMouseDown,
lastMouseXRef,
};
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-wasm";
import { useEffect, useCallback, useRef } from "react"; import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor"; import { useEditor } from "../use-editor";
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
); );
const framesPerSecond = activeProject.settings.fps; const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({ const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
rawTime,
duration,
fps: framesPerSecond,
});
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => { const time = (() => {
+193 -197
View File
@@ -1,197 +1,193 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react"; import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time"; import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor"; import { useEditor } from "../use-editor";
interface UseTimelineSeekProps { interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>; playheadRef: RefObject<HTMLDivElement | null>;
trackLabelsRef: RefObject<HTMLDivElement | null>; trackLabelsRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number; zoomLevel: number;
duration: number; duration: number;
isSelecting: boolean; isSelecting: boolean;
clearSelectedElements: () => void; clearSelectedElements: () => void;
seek: (time: number) => void; seek: (time: number) => void;
} }
function resetMouseTracking({ function resetMouseTracking({
mouseTrackingRef, mouseTrackingRef,
}: { }: {
mouseTrackingRef: MutableRefObject<{ mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean; isMouseDown: boolean;
downX: number; downX: number;
downY: number; downY: number;
downTime: number; downTime: number;
}>; }>;
}) { }) {
mouseTrackingRef.current = { mouseTrackingRef.current = {
isMouseDown: false, isMouseDown: false,
downX: 0, downX: 0,
downY: 0, downY: 0,
downTime: 0, downTime: 0,
}; };
} }
function setMouseTracking({ function setMouseTracking({
mouseTrackingRef, mouseTrackingRef,
event, event,
}: { }: {
mouseTrackingRef: MutableRefObject<{ mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean; isMouseDown: boolean;
downX: number; downX: number;
downY: number; downY: number;
downTime: number; downTime: number;
}>; }>;
event: React.MouseEvent; event: React.MouseEvent;
}) { }) {
mouseTrackingRef.current = { mouseTrackingRef.current = {
isMouseDown: true, isMouseDown: true,
downX: event.clientX, downX: event.clientX,
downY: event.clientY, downY: event.clientY,
downTime: event.timeStamp, downTime: event.timeStamp,
}; };
} }
export function useTimelineSeek({ export function useTimelineSeek({
playheadRef, playheadRef,
trackLabelsRef, trackLabelsRef,
rulerScrollRef, rulerScrollRef,
tracksScrollRef, tracksScrollRef,
zoomLevel, zoomLevel,
duration, duration,
isSelecting, isSelecting,
clearSelectedElements, clearSelectedElements,
seek, seek,
}: UseTimelineSeekProps) { }: UseTimelineSeekProps) {
const editor = useEditor(); const editor = useEditor();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const mouseTrackingRef = useRef({ const mouseTrackingRef = useRef({
isMouseDown: false, isMouseDown: false,
downX: 0, downX: 0,
downY: 0, downY: 0,
downTime: 0, downTime: 0,
}); });
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return; if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event }); setMouseTracking({ mouseTrackingRef, event });
}, []); }, []);
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return; if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event }); setMouseTracking({ mouseTrackingRef, event });
}, []); }, []);
const shouldProcessTimelineClick = useCallback( const shouldProcessTimelineClick = useCallback(
({ event }: { event: React.MouseEvent }) => { ({ event }: { event: React.MouseEvent }) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
const deltaX = Math.abs(event.clientX - downX); const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY); const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime; const deltaTime = event.timeStamp - downTime;
const isPlayhead = !!playheadRef.current?.contains(target); const isPlayhead = !!playheadRef.current?.contains(target);
const isTrackLabels = !!trackLabelsRef.current?.contains(target); const isTrackLabels = !!trackLabelsRef.current?.contains(target);
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
if (!isMouseDown) return false; if (!isMouseDown) return false;
if (shouldBlockForDrag) return false; if (shouldBlockForDrag) return false;
if (isSelecting) return false; if (isSelecting) return false;
if (isPlayhead) return false; if (isPlayhead) return false;
if (isTrackLabels) { if (isTrackLabels) {
clearSelectedElements(); clearSelectedElements();
return false; return false;
} }
return true; return true;
}, },
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
); );
const handleTimelineSeek = useCallback( const handleTimelineSeek = useCallback(
({ ({
event, event,
source, source,
}: { }: {
event: React.MouseEvent; event: React.MouseEvent;
source: "ruler" | "tracks"; source: "ruler" | "tracks";
}) => { }) => {
const scrollContainer = const scrollContainer =
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
const rect = scrollContainer.getBoundingClientRect(); const rect = scrollContainer.getBoundingClientRect();
const mouseX = event.clientX - rect.left; const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft; const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max( const rawTime = Math.max(
0, 0,
Math.min( Math.min(
duration, duration,
(mouseX + scrollLeft) / (mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
), ),
); );
const projectFps = activeProject?.settings.fps || 30; const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
rawTime, seek(time);
duration, editor.project.setTimelineViewState({
fps: projectFps, viewState: {
}); zoomLevel,
seek(time); scrollLeft: scrollContainer.scrollLeft,
editor.project.setTimelineViewState({ playheadTime: time,
viewState: { },
zoomLevel, });
scrollLeft: scrollContainer.scrollLeft, },
playheadTime: time, [
}, duration,
}); zoomLevel,
}, rulerScrollRef,
[ tracksScrollRef,
duration, seek,
zoomLevel, editor,
rulerScrollRef, activeProject?.settings.fps,
tracksScrollRef, ],
seek, );
editor,
activeProject?.settings.fps, const handleTracksClick = useCallback(
], (event: React.MouseEvent) => {
); const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
const handleTracksClick = useCallback(
(event: React.MouseEvent) => { if (shouldProcess) {
const shouldProcess = shouldProcessTimelineClick({ event }); clearSelectedElements();
resetMouseTracking({ mouseTrackingRef }); handleTimelineSeek({ event, source: "tracks" });
}
if (shouldProcess) { },
clearSelectedElements(); [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
handleTimelineSeek({ event, source: "tracks" }); );
}
}, const handleRulerClick = useCallback(
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], (event: React.MouseEvent) => {
); const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
const handleRulerClick = useCallback(
(event: React.MouseEvent) => { if (shouldProcess) {
const shouldProcess = shouldProcessTimelineClick({ event }); clearSelectedElements();
resetMouseTracking({ mouseTrackingRef }); handleTimelineSeek({ event, source: "ruler" });
}
if (shouldProcess) { },
clearSelectedElements(); [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
handleTimelineSeek({ event, source: "ruler" }); );
}
}, return {
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], handleTracksMouseDown,
); handleTracksClick,
handleRulerMouseDown,
return { handleRulerClick,
handleTracksMouseDown, };
handleTracksClick, }
handleRulerMouseDown,
handleRulerClick,
};
}
+9 -3
View File
@@ -1,11 +1,17 @@
import type { ElementRef } from "@/lib/timeline/types";
export interface CommandResult {
select?: ElementRef[];
}
export abstract class Command { export abstract class Command {
abstract execute(): void; abstract execute(): CommandResult | undefined;
undo(): void { undo(): void {
throw new Error("Undo not implemented for this command"); throw new Error("Undo not implemented for this command");
} }
redo(): void { redo(): CommandResult | undefined {
this.execute(); return this.execute();
} }
} }
+19 -5
View File
@@ -1,14 +1,21 @@
import { Command } from "./base-command"; import { Command, type CommandResult } from "./base-command";
export class BatchCommand extends Command { export class BatchCommand extends Command {
constructor(private commands: Command[]) { constructor(private commands: Command[]) {
super(); super();
} }
execute(): void { execute(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) { for (const command of this.commands) {
command.execute(); const result = command.execute();
if (result?.select !== undefined) {
latestSelectionResult = result;
}
} }
return latestSelectionResult;
} }
undo(): void { undo(): void {
@@ -17,9 +24,16 @@ export class BatchCommand extends Command {
} }
} }
redo(): void { redo(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) { for (const command of this.commands) {
command.execute(); const result = command.redo();
if (result?.select !== undefined) {
latestSelectionResult = result;
}
} }
return latestSelectionResult;
} }
} }
+1
View File
@@ -1,4 +1,5 @@
export { Command } from "./base-command"; export { Command } from "./base-command";
export type { CommandResult } from "./base-command";
export { BatchCommand } from "./batch-command"; export { BatchCommand } from "./batch-command";
export * from "./timeline"; export * from "./timeline";
@@ -1,182 +1,184 @@
import { Command } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { import type {
TimelineTrack, TimelineTrack,
TimelineElement, TimelineElement,
ClipboardItem, ClipboardItem,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { import {
applyPlacement, applyPlacement,
resolveTrackPlacement, resolveTrackPlacement,
isMainTrack, isMainTrack,
enforceMainTrackStart, enforceMainTrackStart,
} from "@/lib/track-placement"; } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = []; private readonly time: number;
private readonly clipboardItems: ClipboardItem[];
constructor(
private time: number, constructor({
private clipboardItems: ClipboardItem[], time,
) { clipboardItems,
super(); }: {
} time: number;
clipboardItems: ClipboardItem[];
execute(): void { }) {
if (this.clipboardItems.length === 0) return; super();
this.time = time;
const editor = EditorCore.getInstance(); this.clipboardItems = clipboardItems;
this.savedState = editor.timeline.getTracks(); }
this.previousSelection = editor.selection.getSelectedElements();
this.pastedElements = []; execute(): CommandResult | undefined {
if (this.clipboardItems.length === 0) return;
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime), const editor = EditorCore.getInstance();
); this.savedState = editor.timeline.getTracks();
this.pastedElements = [];
const updatedTracks = [...this.savedState];
const itemsByTrackId = groupClipboardItemsByTrackId({ const minStart = Math.min(
clipboardItems: this.clipboardItems, ...this.clipboardItems.map((item) => item.element.startTime),
}); );
for (const [trackId, items] of itemsByTrackId) { let updatedTracks = [...this.savedState];
const elementsToAdd = buildPastedElements({ const itemsByTrackId = groupClipboardItemsByTrackId({
items, clipboardItems: this.clipboardItems,
minStart, });
time: this.time,
}); for (const [trackId, items] of itemsByTrackId) {
const elementsToAdd = buildPastedElements({
if (elementsToAdd.length === 0) { items,
continue; minStart,
} time: this.time,
});
const trackType = items[0].trackType;
const sourceTrackIndex = updatedTracks.findIndex( if (elementsToAdd.length === 0) {
(track) => track.id === trackId, continue;
); }
const placementResult = resolveTrackPlacement({
tracks: updatedTracks, const trackType = items[0].trackType;
trackType, const sourceTrackIndex = updatedTracks.findIndex(
timeSpans: elementsToAdd.map((element) => ({ (track) => track.id === trackId,
startTime: element.startTime, );
duration: element.duration, const placementResult = resolveTrackPlacement({
})), tracks: updatedTracks,
strategy: { type: "aboveSource", sourceTrackIndex }, trackType,
}); timeSpans: elementsToAdd.map((element) => ({
if (!placementResult) { startTime: element.startTime,
continue; duration: element.duration,
} })),
strategy: { type: "aboveSource", sourceTrackIndex },
let elementsForPlacement = elementsToAdd; });
if (placementResult.kind === "existingTrack") { if (!placementResult) {
const targetTrack = updatedTracks[placementResult.trackIndex]; continue;
if (isMainTrack(targetTrack)) { }
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest, let elementsForPlacement = elementsToAdd;
); if (placementResult.kind === "existingTrack") {
const adjustedEarliestStartTime = enforceMainTrackStart({ const targetTrack = updatedTracks[placementResult.trackIndex];
tracks: updatedTracks, if (isMainTrack(targetTrack)) {
targetTrackId: targetTrack.id, const earliestElement = elementsToAdd.reduce((earliest, element) =>
requestedStartTime: earliestElement.startTime, element.startTime < earliest.startTime ? element : earliest,
}); );
const delta = adjustedEarliestStartTime - earliestElement.startTime; const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
if (delta !== 0) { targetTrackId: targetTrack.id,
elementsForPlacement = elementsToAdd.map((element) => ({ requestedStartTime: earliestElement.startTime,
...element, });
startTime: Math.max(0, element.startTime + delta), const delta = adjustedEarliestStartTime - earliestElement.startTime;
}));
} if (delta !== 0) {
} elementsForPlacement = elementsToAdd.map((element) => ({
} ...element,
startTime: Math.max(0, element.startTime + delta),
const applied = applyPlacement({ }));
tracks: updatedTracks, }
placementResult, }
elements: elementsForPlacement, }
});
if (!applied) { const applied = applyPlacement({
continue; tracks: updatedTracks,
} placementResult,
elements: elementsForPlacement,
updatedTracks = applied.updatedTracks; });
if (!applied) {
for (const element of elementsForPlacement) { continue;
this.pastedElements.push({ }
trackId: applied.targetTrackId,
elementId: element.id, updatedTracks = applied.updatedTracks;
});
} for (const element of elementsForPlacement) {
} this.pastedElements.push({
trackId: applied.targetTrackId,
editor.timeline.updateTracks(updatedTracks); elementId: element.id,
});
if (this.pastedElements.length > 0) { }
editor.selection.setSelectedElements({ elements: this.pastedElements }); }
}
} editor.timeline.updateTracks(updatedTracks);
undo(): void { if (this.pastedElements.length > 0) {
if (this.savedState) { return { select: this.pastedElements };
const editor = EditorCore.getInstance(); }
editor.timeline.updateTracks(this.savedState); }
editor.selection.setSelectedElements({
elements: this.previousSelection, undo(): void {
}); if (this.savedState) {
} const editor = EditorCore.getInstance();
} editor.timeline.updateTracks(this.savedState);
}
getPastedElements(): { trackId: string; elementId: string }[] { }
return this.pastedElements;
} getPastedElements(): { trackId: string; elementId: string }[] {
} return this.pastedElements;
}
function groupClipboardItemsByTrackId({ }
clipboardItems,
}: { function groupClipboardItemsByTrackId({
clipboardItems: ClipboardItem[]; clipboardItems,
}): Map<string, ClipboardItem[]> { }: {
const groupedItems = new Map<string, ClipboardItem[]>(); clipboardItems: ClipboardItem[];
}): Map<string, ClipboardItem[]> {
for (const item of clipboardItems) { const groupedItems = new Map<string, ClipboardItem[]>();
const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]); for (const item of clipboardItems) {
} const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]);
return groupedItems; }
}
return groupedItems;
function buildPastedElements({ }
items,
minStart, function buildPastedElements({
time, items,
}: { minStart,
items: ClipboardItem[]; time,
minStart: number; }: {
time: number; items: ClipboardItem[];
}): TimelineElement[] { minStart: number;
const elementsToAdd: TimelineElement[] = []; time: number;
}): TimelineElement[] {
for (const item of items) { const elementsToAdd: TimelineElement[] = [];
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset); for (const item of items) {
const newElementId = generateUUID(); const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
elementsToAdd.push({ const newElementId = generateUUID();
...item.element,
id: newElementId, elementsToAdd.push({
startTime, ...item.element,
animations: cloneAnimations({ id: newElementId,
animations: item.element.animations, startTime,
shouldRegenerateKeyframeIds: true, animations: cloneAnimations({
}), animations: item.element.animations,
} as TimelineElement); shouldRegenerateKeyframeIds: true,
} }),
} as TimelineElement);
return elementsToAdd; }
}
return elementsToAdd;
}
@@ -1,80 +1,81 @@
import { Command } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline"; import { rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
export class DeleteElementsCommand extends Command {
export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null;
private savedState: TimelineTrack[] | null = null; private readonly elements: { trackId: string; elementId: string }[];
private readonly elements: { trackId: string; elementId: string }[]; private readonly rippleEnabled: boolean;
private readonly rippleEnabled: boolean;
constructor({
constructor({ elements,
elements, rippleEnabled = false,
rippleEnabled = false, }: {
}: { elements: { trackId: string; elementId: string }[];
elements: { trackId: string; elementId: string }[]; rippleEnabled?: boolean;
rippleEnabled?: boolean; }) {
}) { super();
super(); this.elements = elements;
this.elements = elements; this.rippleEnabled = rippleEnabled;
this.rippleEnabled = rippleEnabled; }
}
execute(): CommandResult {
execute(): void { const editor = EditorCore.getInstance();
const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const updatedTracks = this.savedState const elementsToDeleteOnTrack = this.elements.filter(
.map((track) => { (target) => target.trackId === track.id,
const elementsToDeleteOnTrack = this.elements.filter( );
(target) => target.trackId === track.id, const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
);
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; if (!hasElementsToDelete) {
return track;
if (!hasElementsToDelete) { }
return track;
} const deletedElementInfos = elementsToDeleteOnTrack
.map((target) =>
const deletedElementInfos = elementsToDeleteOnTrack track.elements.find((element) => element.id === target.elementId),
.map((target) => )
track.elements.find((element) => element.id === target.elementId), .filter((element): element is NonNullable<typeof element> => element !== undefined)
) .map((element) => ({ startTime: element.startTime, duration: element.duration }));
.filter((element): element is NonNullable<typeof element> => element !== undefined)
.map((element) => ({ startTime: element.startTime, duration: element.duration })); let elements = track.elements.filter(
(element) =>
let elements = track.elements.filter( !this.elements.some(
(element) => (target) =>
!this.elements.some( target.trackId === track.id && target.elementId === element.id,
(target) => ),
target.trackId === track.id && target.elementId === element.id, );
),
); if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(
if (this.rippleEnabled && deletedElementInfos.length > 0) { (a, b) => b.startTime - a.startTime,
const sortedByStartDesc = [...deletedElementInfos].sort( );
(a, b) => b.startTime - a.startTime, for (const { startTime, duration } of sortedByStartDesc) {
); elements = rippleShiftElements({
for (const { startTime, duration } of sortedByStartDesc) { elements,
elements = rippleShiftElements({ afterTime: startTime,
elements, shiftAmount: duration,
afterTime: startTime, });
shiftAmount: duration, }
}); }
}
} return { ...track, elements } as typeof track;
});
return { ...track, elements } as typeof track;
}) editor.timeline.updateTracks(updatedTracks);
.filter((track) => track.elements.length > 0 || isMainTrack(track));
return {
editor.timeline.updateTracks(updatedTracks); select: [],
} };
}
undo(): void {
if (this.savedState) { undo(): void {
const editor = EditorCore.getInstance(); if (this.savedState) {
editor.timeline.updateTracks(this.savedState); const editor = EditorCore.getInstance();
} editor.timeline.updateTracks(this.savedState);
} }
} }
}
@@ -1,132 +1,127 @@
import { Command } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement"; import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams { interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
} }
export class DuplicateElementsCommand extends Command { export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = []; private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private previousSelection: { trackId: string; elementId: string }[] = []; private elements: DuplicateElementsParams["elements"];
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
constructor({ elements }: DuplicateElementsParams) { super();
super(); this.elements = elements;
this.elements = elements; }
}
execute(): CommandResult | undefined {
execute(): void { const editor = EditorCore.getInstance();
const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks();
this.savedState = editor.timeline.getTracks(); this.duplicatedElements = [];
this.previousSelection = editor.selection.getSelectedElements();
this.duplicatedElements = []; let updatedTracks = [...this.savedState];
const updatedTracks = [...this.savedState]; for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
for (const track of this.savedState) { (elementEntry) => elementEntry.trackId === track.id,
const elementsToDuplicate = this.elements.filter( );
(elementEntry) => elementEntry.trackId === track.id,
); if (elementsToDuplicate.length === 0) {
continue;
if (elementsToDuplicate.length === 0) { }
continue;
} const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
const elementIdsToDuplicate = new Set( );
elementsToDuplicate.map((element) => element.elementId), const newTrackElements: TimelineElement[] = [];
);
const newTrackElements: TimelineElement[] = []; for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
for (const element of track.elements) { continue;
if (!elementIdsToDuplicate.has(element.id)) { }
continue;
} const newId = generateUUID();
newTrackElements.push(
const newId = generateUUID(); buildDuplicateElement({
newTrackElements.push( element,
buildDuplicateElement({ id: newId,
element, startTime: element.startTime,
id: newId, }),
startTime: element.startTime, );
}), }
);
} const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
const placementResult = resolveTrackPlacement({ trackType: track.type,
tracks: updatedTracks, timeSpans: [],
trackType: track.type, strategy: { type: "alwaysNew", position: "highest" },
timeSpans: [], });
strategy: { type: "alwaysNew", position: "highest" }, if (!placementResult || placementResult.kind !== "newTrack") {
}); continue;
if (!placementResult || placementResult.kind !== "newTrack") { }
continue;
} const applied = applyPlacement({
tracks: updatedTracks,
const applied = applyPlacement({ placementResult,
tracks: updatedTracks, elements: newTrackElements,
placementResult, });
elements: newTrackElements, if (!applied) {
}); continue;
if (!applied) { }
continue;
} updatedTracks = applied.updatedTracks;
updatedTracks = applied.updatedTracks; for (const element of newTrackElements) {
this.duplicatedElements.push({
for (const element of newTrackElements) { trackId: applied.targetTrackId,
this.duplicatedElements.push({ elementId: element.id,
trackId: applied.targetTrackId, });
elementId: element.id, }
}); }
}
} editor.timeline.updateTracks(updatedTracks);
editor.timeline.updateTracks(updatedTracks); if (this.duplicatedElements.length > 0) {
return {
if (this.duplicatedElements.length > 0) { select: this.duplicatedElements,
editor.selection.setSelectedElements({ };
elements: this.duplicatedElements, }
}); }
}
} undo(): void {
if (this.savedState) {
undo(): void { const editor = EditorCore.getInstance();
if (this.savedState) { editor.timeline.updateTracks(this.savedState);
const editor = EditorCore.getInstance(); }
editor.timeline.updateTracks(this.savedState); }
editor.selection.setSelectedElements({
elements: this.previousSelection, getDuplicatedElements(): { trackId: string; elementId: string }[] {
}); return this.duplicatedElements;
} }
} }
getDuplicatedElements(): { trackId: string; elementId: string }[] { function buildDuplicateElement({
return this.duplicatedElements; element,
} id,
} startTime,
}: {
function buildDuplicateElement({ element: TimelineElement;
element, id: string;
id, startTime: number;
startTime, }): TimelineElement {
}: { return {
element: TimelineElement; ...element,
id: string; id,
startTime: number; name: `${element.name} (copy)`,
}): TimelineElement { startTime,
return { animations: cloneAnimations({
...element, animations: element.animations,
id, shouldRegenerateKeyframeIds: true,
name: `${element.name} (copy)`, }),
startTime, };
animations: cloneAnimations({ }
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}
@@ -16,7 +16,7 @@ import {
canElementGoOnTrack, canElementGoOnTrack,
resolveTrackPlacement, resolveTrackPlacement,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/lib/track-placement"; } from "@/lib/timeline/placement";
type InsertElementPlacement = type InsertElementPlacement =
| { mode: "explicit"; trackId: string } | { mode: "explicit"; trackId: string }
@@ -7,10 +7,9 @@ import type {
} from "@/lib/timeline"; } from "@/lib/timeline";
import { import {
buildEmptyTrack, buildEmptyTrack,
isMainTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart, enforceMainTrackStart,
} from "@/lib/track-placement"; } from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command { export class MoveElementCommand extends Command {
@@ -100,7 +99,7 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId; const isSameTrack = this.sourceTrackId === this.targetTrackId;
let updatedTracks = tracksToUpdate.map((track): TimelineTrack => { const updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) { if (isSameTrack && track.id === this.sourceTrackId) {
return { return {
...track, ...track,
@@ -134,21 +133,6 @@ export class MoveElementCommand extends Command {
return track; return track;
}); });
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
(track) => track.id === this.sourceTrackId,
);
if (
sourceTrackAfterMove &&
sourceTrackAfterMove.elements.length === 0 &&
!isMainTrack(sourceTrackAfterMove)
) {
updatedTracks = updatedTracks.filter(
(track) => track.id !== this.sourceTrackId,
);
}
}
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
} }
@@ -1,4 +1,4 @@
import { Command } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
@@ -9,7 +9,6 @@ import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number; private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right"; private readonly retainSide: "both" | "left" | "right";
@@ -37,10 +36,9 @@ export class SplitElementsCommand extends Command {
return this.rightSideElements; return this.rightSideElements;
} }
execute(): void { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.rightSideElements = []; this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => { const updatedTracks = this.savedState.map((track) => {
@@ -173,9 +171,9 @@ export class SplitElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) { if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({ return {
elements: this.rightSideElements, select: this.rightSideElements,
}); };
} }
} }
@@ -183,9 +181,6 @@ export class SplitElementsCommand extends Command {
if (this.savedState) { if (this.savedState) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState); editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
} }
} }
} }
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/track-placement"; import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementStartTimeCommand extends Command { export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation"; import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/track-placement"; import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementTrimCommand extends Command { export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import { import {
buildEmptyTrack, buildEmptyTrack,
getDefaultInsertIndexForTrack, getDefaultInsertIndexForTrack,
} from "@/lib/track-placement"; } from "@/lib/timeline/placement";
export class AddTrackCommand extends Command { export class AddTrackCommand extends Command {
private trackId: string; private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/track-placement"; import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command { export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline/placement";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element"; import { measureTextElement } from "@/lib/text/measure-element";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/track-placement"; import { ensureMainTrack } from "@/lib/timeline/placement";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null; return scenes.find((scene) => scene.isMain) || null;
-225
View File
@@ -1,225 +0,0 @@
export type TTimeCode =
| "MM:SS"
| "HH:MM:SS"
| "HH:MM:SS:CS"
| "HH:MM:SS:FF";
export function roundToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps) / fps;
}
export function formatTimeCode({
timeInSeconds,
format = "HH:MM:SS:CS",
fps,
}: {
timeInSeconds: number;
format?: TTimeCode;
fps?: number;
}): string {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = fps ? Math.floor((timeInSeconds % 1) * fps) : 0;
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
if (!fps) throw new Error("FPS is required for HH:MM:SS:FF format");
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
}
export function parseTimeCode({
timeCode,
format = "HH:MM:SS:CS",
fps,
}: {
timeCode: string;
format?: TTimeCode;
fps: number;
}): number | null {
if (!timeCode || typeof timeCode !== "string") return null;
const cleanTimeCode = timeCode.trim();
try {
switch (format) {
case "MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 2) return null;
const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
return minutes * 60 + seconds;
}
case "HH:MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 3) return null;
const [hours, minutes, seconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
minutes >= 60 ||
seconds >= 60
)
return null;
return hours * 3600 + minutes * 60 + seconds;
}
case "HH:MM:SS:CS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(centiseconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
centiseconds < 0 ||
minutes >= 60 ||
seconds >= 60 ||
centiseconds >= 100
)
return null;
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
}
case "HH:MM:SS:FF": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, frames] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(frames)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
frames < 0 ||
minutes >= 60 ||
seconds >= 60 ||
frames >= fps
)
return null;
return hours * 3600 + minutes * 60 + seconds + frames / fps;
}
}
} catch {
return null;
}
}
export function guessTimeCodeFormat({
timeCode,
}: {
timeCode: string;
}): TTimeCode | null {
if (!timeCode || typeof timeCode !== "string") return null;
const numbers = timeCode.split(":");
if (!numbers.every((n) => !Number.isNaN(Number(n)))) return null;
if (numbers.length === 2) return "MM:SS";
if (numbers.length === 3) return "HH:MM:SS";
// todo: how to tell frames apart from cs?
if (numbers.length === 4) return "HH:MM:SS:FF";
return null;
}
export function timeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps);
}
export function frameToTime({
frame,
fps,
}: {
frame: number;
fps: number;
}): number {
return frame / fps;
}
export function snapTimeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
if (fps <= 0) return time;
const frame = timeToFrame({ time, fps });
return frameToTime({ frame, fps });
}
export function getSnappedSeekTime({
rawTime,
duration,
fps,
}: {
rawTime: number;
duration: number;
fps: number;
}): number {
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
const lastFrame = getLastFrameTime({ duration, fps });
return Math.max(0, Math.min(lastFrame, snappedTime));
}
export function getLastFrameTime({
duration,
fps,
}: {
duration: number;
fps: number;
}): number {
if (duration <= 0) return 0;
if (fps <= 0) return duration;
const frameOffset = 1 / fps;
return Math.max(0, duration - frameOffset);
}
+149 -149
View File
@@ -1,149 +1,149 @@
import type { Bookmark } from "@/lib/timeline"; import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "@/lib/time"; import { roundToFrame } from "opencut-wasm";
export const BOOKMARK_TIME_EPSILON = 0.001; export const BOOKMARK_TIME_EPSILON = 0.001;
function bookmarkTimeEqual({ function bookmarkTimeEqual({
bookmarkTime, bookmarkTime,
frameTime, frameTime,
}: { }: {
bookmarkTime: number; bookmarkTime: number;
frameTime: number; frameTime: number;
}): boolean { }): boolean {
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
} }
export function findBookmarkIndex({ export function findBookmarkIndex({
bookmarks, bookmarks,
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
}): number { }): number {
return bookmarks.findIndex((bookmark) => return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
); );
} }
export function isBookmarkAtTime({ export function isBookmarkAtTime({
bookmarks, bookmarks,
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
}): boolean { }): boolean {
return bookmarks.some((bookmark) => return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
); );
} }
export function toggleBookmarkInArray({ export function toggleBookmarkInArray({
bookmarks, bookmarks,
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
}): Bookmark[] { }): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
if (bookmarkIndex !== -1) { if (bookmarkIndex !== -1) {
return bookmarks.filter((_, index) => index !== bookmarkIndex); return bookmarks.filter((_, index) => index !== bookmarkIndex);
} }
const newBookmarks = [...bookmarks, { time: frameTime }]; const newBookmarks = [...bookmarks, { time: frameTime }];
return newBookmarks.slice().sort((a, b) => a.time - b.time); return newBookmarks.slice().sort((a, b) => a.time - b.time);
} }
export function removeBookmarkFromArray({ export function removeBookmarkFromArray({
bookmarks, bookmarks,
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
}): Bookmark[] { }): Bookmark[] {
return bookmarks.filter( return bookmarks.filter(
(bookmark) => (bookmark) =>
!bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
); );
} }
export function updateBookmarkInArray({ export function updateBookmarkInArray({
bookmarks, bookmarks,
frameTime, frameTime,
updates, updates,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
updates: Partial<Omit<Bookmark, "time">>; updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] { }): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime }); const index = findBookmarkIndex({ bookmarks, frameTime });
if (index === -1) { if (index === -1) {
return bookmarks; return bookmarks;
} }
const updated = { ...bookmarks[index], ...updates }; const updated = { ...bookmarks[index], ...updates };
const result = [...bookmarks]; const result = [...bookmarks];
result[index] = updated; result[index] = updated;
return result; return result;
} }
export function moveBookmarkInArray({ export function moveBookmarkInArray({
bookmarks, bookmarks,
fromTime, fromTime,
toTime, toTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
fromTime: number; fromTime: number;
toTime: number; toTime: number;
}): Bookmark[] { }): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) { if (index === -1) {
return bookmarks; return bookmarks;
} }
const updated = { ...bookmarks[index], time: toTime }; const updated = { ...bookmarks[index], time: toTime };
const result = [...bookmarks]; const result = [...bookmarks];
result[index] = updated; result[index] = updated;
return result.slice().sort((a, b) => a.time - b.time); return result.slice().sort((a, b) => a.time - b.time);
} }
export function getFrameTime({ export function getFrameTime({
time, time,
fps, fps,
}: { }: {
time: number; time: number;
fps: number; fps: number;
}): number { }): number {
return roundToFrame({ time, fps }); return roundToFrame({ time, fps });
} }
export function getBookmarkAtTime({ export function getBookmarkAtTime({
bookmarks, bookmarks,
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: number;
}): Bookmark | null { }): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime }); const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index]; return index === -1 ? null : bookmarks[index];
} }
export function getBookmarksActiveAtTime({ export function getBookmarksActiveAtTime({
bookmarks, bookmarks,
time, time,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
time: number; time: number;
}): Bookmark[] { }): Bookmark[] {
return bookmarks.filter((bookmark) => { return bookmarks.filter((bookmark) => {
const start = bookmark.time; const start = bookmark.time;
const end = const end =
bookmark.duration != null && bookmark.duration > 0 bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration ? start + bookmark.duration
: start; : start;
return ( return (
time >= start - BOOKMARK_TIME_EPSILON && time >= start - BOOKMARK_TIME_EPSILON &&
time <= end + BOOKMARK_TIME_EPSILON time <= end + BOOKMARK_TIME_EPSILON
); );
}); });
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
} from "@/lib/timeline"; } from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement"; import { resolveTrackPlacement } from "@/lib/timeline/placement";
function findElementAtPosition({ function findElementAtPosition({
mouseX, mouseX,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline"; import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement"; import { resolveTrackPlacement } from "@/lib/timeline/placement";
function buildElement({ function buildElement({
id, id,
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node"; import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types"; import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048; const PREVIEW_MAX_IMAGE_SIZE = 2048;
+7
View File
@@ -54,6 +54,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "16.1.3", "next": "16.1.3",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"opencut-wasm": "workspace:*",
"pg": "^8.16.2", "pg": "^8.16.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -102,6 +103,10 @@
"wrangler": "^4.77.0", "wrangler": "^4.77.0",
}, },
}, },
"rust/wasm/pkg": {
"name": "opencut-wasm",
"version": "0.1.0",
},
}, },
"trustedDependencies": [ "trustedDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
@@ -1453,6 +1458,8 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
"opencut-wasm": ["opencut-wasm@workspace:rust/wasm/pkg"],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+5 -1
View File
@@ -3,9 +3,13 @@
"packageManager": "bun@1.2.18", "packageManager": "bun@1.2.18",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*",
"rust/wasm/pkg"
], ],
"scripts": { "scripts": {
"build:wasm": "wasm-pack build rust/wasm --target bundler --out-dir pkg",
"publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public",
"dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'",
"dev:web": "turbo run dev --filter=@opencut/web", "dev:web": "turbo run dev --filter=@opencut/web",
"build:web": "turbo run build --filter=@opencut/web", "build:web": "turbo run build --filter=@opencut/web",
"dev:tools": "turbo run dev --filter=@opencut/tools", "dev:tools": "turbo run dev --filter=@opencut/tools",
+3 -1
View File
@@ -9,7 +9,9 @@ crate-type = ["rlib", "cdylib"]
[dependencies] [dependencies]
bridge = { version = "0.1.0", path = "../bridge" } bridge = { version = "0.1.0", path = "../bridge" }
serde = { version = "1", features = ["derive"] }
tsify-next = { version = "0.5", optional = true }
wasm-bindgen = { version = "0.2.115", optional = true } wasm-bindgen = { version = "0.2.115", optional = true }
[features] [features]
wasm = ["dep:wasm-bindgen"] wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
+223 -75
View File
@@ -1,52 +1,129 @@
use bridge::export; use bridge::export;
use serde::{Deserialize, Serialize};
const DEFAULT_TIME_CODE_FORMAT: &str = "HH:MM:SS:CS"; const SECONDS_PER_HOUR: f64 = 3600.0;
const SECONDS_PER_MINUTE: f64 = 60.0;
const CENTISECONDS_PER_SECOND: f64 = 100.0;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
enum TimeCodeFormat { #[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
pub enum TimeCodeFormat {
#[serde(rename = "MM:SS")]
MmSs, MmSs,
#[serde(rename = "HH:MM:SS")]
HhMmSs, HhMmSs,
#[serde(rename = "HH:MM:SS:CS")]
HhMmSsCs, HhMmSsCs,
#[serde(rename = "HH:MM:SS:FF")]
HhMmSsFf, HhMmSsFf,
} }
impl TimeCodeFormat { #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
fn parse(format: Option<&str>) -> Option<Self> { #[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) { #[derive(Deserialize)]
"MM:SS" => Some(Self::MmSs), #[serde(rename_all = "camelCase")]
"HH:MM:SS" => Some(Self::HhMmSs), pub struct RoundToFrameOptions {
"HH:MM:SS:CS" => Some(Self::HhMmSsCs), pub time: f64,
"HH:MM:SS:FF" => Some(Self::HhMmSsFf), pub fps: f64,
_ => None, }
}
}
fn as_str(self) -> &'static str { #[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
match self { #[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
Self::MmSs => "MM:SS", #[derive(Deserialize)]
Self::HhMmSs => "HH:MM:SS", #[serde(rename_all = "camelCase")]
Self::HhMmSsCs => "HH:MM:SS:CS", pub struct FormatTimeCodeOptions {
Self::HhMmSsFf => "HH:MM:SS:FF", pub time_in_seconds: f64,
} #[serde(default, skip_serializing_if = "Option::is_none")]
} pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParseTimeCodeOptions {
pub time_code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuessTimeCodeFormatOptions {
pub time_code: String,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameToTimeOptions {
pub frame: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapTimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSnappedSeekTimeOptions {
pub raw_time: f64,
pub duration: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLastFrameTimeOptions {
pub duration: f64,
pub fps: f64,
} }
#[export] #[export]
pub fn round_to_frame(time: f64, fps: f64) -> f64 { pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps (time * fps).round() / fps
} }
#[export] #[export]
pub fn format_time_code( pub fn format_time_code(
time_in_seconds: f64, FormatTimeCodeOptions {
format: Option<String>, time_in_seconds,
fps: Option<f64>, format,
fps,
}: FormatTimeCodeOptions,
) -> Option<String> { ) -> Option<String> {
let format = TimeCodeFormat::parse(format.as_deref())?; let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let hours = (time_in_seconds / 3600.0).floor() as u64; let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64;
let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64; let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64;
let seconds = (time_in_seconds % 60.0).floor() as u64; let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * 100.0).floor() as u64; let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64;
match format { match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")), TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
@@ -69,12 +146,18 @@ pub fn format_time_code(
} }
#[export] #[export]
pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>) -> Option<f64> { pub fn parse_time_code(
ParseTimeCodeOptions {
time_code,
format,
fps,
}: ParseTimeCodeOptions,
) -> Option<f64> {
if time_code.trim().is_empty() { if time_code.trim().is_empty() {
return None; return None;
} }
let format = TimeCodeFormat::parse(format.as_deref())?; let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code let parts = time_code
.trim() .trim()
.split(':') .split(':')
@@ -86,35 +169,42 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [minutes, seconds] = parts.as_slice() else { let [minutes, seconds] = parts.as_slice() else {
return None; return None;
}; };
if *seconds >= 60 { if *seconds >= SECONDS_PER_MINUTE as u32 {
return None; return None;
} }
Some((*minutes as f64 * 60.0) + *seconds as f64) Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
} }
TimeCodeFormat::HhMmSs => { TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else { let [hours, minutes, seconds] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 { if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
return None; return None;
} }
Some((*hours as f64 * 3600.0) + (*minutes as f64 * 60.0) + *seconds as f64) Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64,
)
} }
TimeCodeFormat::HhMmSsCs => { TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else { let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 || *centiseconds >= 100 { if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *centiseconds >= CENTISECONDS_PER_SECOND as u32
{
return None; return None;
} }
Some( Some(
(*hours as f64 * 3600.0) (*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * 60.0) + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64 + *seconds as f64
+ (*centiseconds as f64 / 100.0), + (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
) )
} }
TimeCodeFormat::HhMmSsFf => { TimeCodeFormat::HhMmSsFf => {
@@ -126,13 +216,16 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [hours, minutes, seconds, frames] = parts.as_slice() else { let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None; return None;
}; };
if *minutes >= 60 || *seconds >= 60 || *frames as f64 >= fps { if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *frames as f64 >= fps
{
return None; return None;
} }
Some( Some(
(*hours as f64 * 3600.0) (*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * 60.0) + (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64 + *seconds as f64
+ (*frames as f64 / fps), + (*frames as f64 / fps),
) )
@@ -141,7 +234,9 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
} }
#[export] #[export]
pub fn guess_time_code_format(time_code: &str) -> Option<String> { pub fn guess_time_code_format(
GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() { if time_code.trim().is_empty() {
return None; return None;
} }
@@ -153,41 +248,52 @@ pub fn guess_time_code_format(time_code: &str) -> Option<String> {
})?; })?;
match part_count { match part_count {
2 => Some(TimeCodeFormat::MmSs.as_str().to_string()), 2 => Some(TimeCodeFormat::MmSs),
3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()), 3 => Some(TimeCodeFormat::HhMmSs),
4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()), 4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None, _ => None,
} }
} }
#[export] #[export]
pub fn time_to_frame(time: f64, fps: f64) -> f64 { pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round() (time * fps).round()
} }
#[export] #[export]
pub fn frame_to_time(frame: f64, fps: f64) -> f64 { pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps frame / fps
} }
#[export] #[export]
pub fn snap_time_to_frame(time: f64, fps: f64) -> f64 { pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 {
if fps <= 0.0 { if fps <= 0.0 {
return time; return time;
} }
frame_to_time(time_to_frame(time, fps), fps) frame_to_time(FrameToTimeOptions {
frame: time_to_frame(TimeToFrameOptions { time, fps }),
fps,
})
} }
#[export] #[export]
pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 { pub fn get_snapped_seek_time(
let snapped_time = snap_time_to_frame(raw_time, fps); GetSnappedSeekTimeOptions {
let last_frame = get_last_frame_time(duration, fps); raw_time,
duration,
fps,
}: GetSnappedSeekTimeOptions,
) -> f64 {
let snapped_time = snap_time_to_frame(SnapTimeToFrameOptions { time: raw_time, fps });
let last_frame = get_last_frame_time(GetLastFrameTimeOptions { duration, fps });
snapped_time.clamp(0.0, last_frame) snapped_time.clamp(0.0, last_frame)
} }
#[export] #[export]
pub fn get_last_frame_time(duration: f64, fps: f64) -> f64 { pub fn get_last_frame_time(
GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
) -> f64 {
if duration <= 0.0 { if duration <= 0.0 {
return 0.0; return 0.0;
} }
@@ -206,18 +312,26 @@ mod tests {
#[test] #[test]
fn rounds_to_the_nearest_frame() { fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(1.24, 10.0), 1.2); assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2);
assert_eq!(round_to_frame(1.26, 10.0), 1.3); assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
} }
#[test] #[test]
fn formats_default_time_codes() { fn formats_default_time_codes() {
assert_eq!( assert_eq!(
format_time_code(3723.45, None, None), format_time_code(FormatTimeCodeOptions {
Some("01:02:03:44".to_string()), time_in_seconds: 3723.45,
format: None,
fps: None,
}),
Some("01:02:03:44".to_string()),
); );
assert_eq!( assert_eq!(
format_time_code(65.0, Some("MM:SS".to_string()), None), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 65.0,
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some("01:05".to_string()), Some("01:05".to_string()),
); );
} }
@@ -225,11 +339,19 @@ mod tests {
#[test] #[test]
fn formats_frame_based_time_codes() { fn formats_frame_based_time_codes() {
assert_eq!( assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), Some(30.0)), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some("00:00:01:15".to_string()), Some("00:00:01:15".to_string()),
); );
assert_eq!( assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), None), format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: None,
}),
None, None,
); );
} }
@@ -237,38 +359,64 @@ mod tests {
#[test] #[test]
fn parses_time_codes() { fn parses_time_codes() {
assert_eq!( assert_eq!(
parse_time_code("01:05", Some("MM:SS".to_string()), None), parse_time_code(ParseTimeCodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some(65.0), Some(65.0),
); );
assert_eq!( assert_eq!(
parse_time_code("00:00:01:15", Some("HH:MM:SS:FF".to_string()), Some(30.0)), parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some(1.5), Some(1.5),
); );
assert_eq!( assert_eq!(
parse_time_code("00:00:01:30", Some("HH:MM:SS:FF".to_string()), Some(30.0)), parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
None, None,
); );
} }
#[test] #[test]
fn guesses_time_code_formats() { fn guesses_time_code_formats() {
assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
assert_eq!( assert_eq!(
guess_time_code_format("00:00:01"), guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }),
Some("HH:MM:SS".to_string()), Some(TimeCodeFormat::MmSs),
); );
assert_eq!( assert_eq!(
guess_time_code_format("00:00:01:15"), guess_time_code_format(GuessTimeCodeFormatOptions {
Some("HH:MM:SS:FF".to_string()), time_code: "00:00:01".to_string(),
}),
Some(TimeCodeFormat::HhMmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01:15".to_string(),
}),
Some(TimeCodeFormat::HhMmSsFf),
); );
} }
#[test] #[test]
fn snaps_and_clamps_seek_time() { fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(1.26, 10.0), 13.0); assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0);
assert_eq!(frame_to_time(13.0, 10.0), 1.3); assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3);
assert_eq!(snap_time_to_frame(1.26, 10.0), 1.3); assert_eq!(snap_time_to_frame(SnapTimeToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
assert_eq!(get_last_frame_time(10.0, 5.0), 9.8); assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8);
assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8); assert_eq!(
get_snapped_seek_time(GetSnappedSeekTimeOptions {
raw_time: 10.0,
duration: 10.0,
fps: 5.0,
}),
9.8,
);
} }
} }
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "opencut-wasm"
version = "0.1.2"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"
license = "MIT"
[lib]
path = "src/wasm.rs"
crate-type = ["cdylib", "rlib"]
[dependencies]
time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] }
+21
View File
@@ -0,0 +1,21 @@
# opencut-wasm
Shared video editor logic compiled to WebAssembly. Used by the [OpenCut](https://github.com/opencut/opencut) web app.
## Install
```bash
npm install opencut-wasm
```
## Usage
```ts
import { formatTimeCode } from "opencut-wasm";
```
All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts).
## Source
Functions are implemented in Rust under [`rust/crates/`](../crates/). This package is the compiled WebAssembly output — do not edit it directly.
+1
View File
@@ -0,0 +1 @@
pub use time::*;
+5 -1
View File
@@ -14,4 +14,8 @@ fi
echo "Installing rustup..." echo "Installing rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "Rust installed. Reload your shell: source \"\$HOME/.cargo/env\"" source "$HOME/.cargo/env"
echo "Installing wasm-pack..."
cargo install wasm-pack
echo "Done. Rust toolchain ready."
+4 -1
View File
@@ -1,8 +1,11 @@
{ {
"$schema": "https://turborepo.com/schema.json", "$schema": "https://turborepo.com/schema.json",
"tasks": { "tasks": {
"build:wasm": {
"outputs": ["rust/wasm/pkg/**"]
},
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["//#build:wasm", "^build"],
"outputs": [".next/**", "!.next/cache/**"], "outputs": [".next/**", "!.next/cache/**"],
"env": [ "env": [
"NODE_ENV", "NODE_ENV",