Compare commits

...
76 changed files with 3999 additions and 2703 deletions
+20
View File
@@ -41,6 +41,26 @@ jobs:
- name: Checkout repository
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
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with:
+2
View File
@@ -22,3 +22,5 @@ bun.lockb
.open-next
target/
.cargo-tools/
Generated
+91 -18
View File
@@ -642,6 +642,14 @@ dependencies = [
"piper",
]
[[package]]
name = "bridge"
version = "0.1.0"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1977,6 +1985,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glow"
version = "0.16.0"
@@ -2780,10 +2801,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3540,6 +3563,13 @@ dependencies = [
"gpui",
]
[[package]]
name = "opencut-wasm"
version = "0.1.2"
dependencies = [
"time",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4588,6 +4618,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -5253,6 +5294,16 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"serde",
"tsify-next",
"wasm-bindgen",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -5527,6 +5578,32 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tsify-next"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
dependencies = [
"gloo-utils",
"serde",
"serde-wasm-bindgen",
"serde_json",
"tsify-next-macros",
"wasm-bindgen",
]
[[package]]
name = "tsify-next-macros"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "ttf-parser"
version = "0.20.0"
@@ -5850,9 +5927,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@@ -5863,23 +5940,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5887,9 +5960,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5900,9 +5973,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
@@ -6053,9 +6126,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [
"js-sys",
"wasm-bindgen",
+3 -2
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"apps/desktop",
"rust/crates/time"
"rust/crates/bridge"
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm",
]
+1
View File
@@ -22,6 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format });
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [
+149 -150
View File
@@ -1,150 +1,149 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
import type { TTimeCode } from "@/lib/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TTimeCode;
fps: number;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
}
export function EditableTimecode({
time,
duration,
format = "HH:MM:SS:FF",
fps,
onTimeChange,
className,
disabled = false,
}: EditableTimecodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState("");
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
const startEditing = () => {
if (disabled) return;
setIsEditing(true);
setInputValue(formattedTime);
setHasError(false);
enterPressedRef.current = false;
};
const cancelEditing = () => {
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime === null) {
setHasError(true);
return;
}
const clampedTime = Math.max(
0,
duration ? Math.min(duration, parsedTime) : parsedTime,
);
onTimeChange?.({ time: clampedTime });
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
enterPressedRef.current = true;
applyEdit();
} else if (event.key === "Escape") {
event.preventDefault();
cancelEditing();
}
};
const handleInputChange = ({
target,
}: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(target.value);
setHasError(false);
};
const handleBlur = () => {
if (!enterPressedRef.current && isEditing) {
applyEdit();
}
};
const handleDisplayKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
startEditing();
}
};
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={cn(
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded",
"text-primary tabular-nums",
hasError && "text-destructive focus:border-destructive",
className,
)}
style={{ width: `${formattedTime.length + 1}ch` }}
placeholder={formattedTime}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
onKeyDown={handleDisplayKeyDown}
disabled={disabled}
className={cn(
"text-primary cursor-pointer font-mono text-xs tabular-nums",
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
disabled && "cursor-default hover:bg-transparent",
className,
)}
title={disabled ? undefined : "Click to edit time"}
>
{formattedTime}
</button>
);
}
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TimeCodeFormat;
fps: number;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
}
export function EditableTimecode({
time,
duration,
format = "HH:MM:SS:FF",
fps,
onTimeChange,
className,
disabled = false,
}: EditableTimecodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState("");
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const startEditing = () => {
if (disabled) return;
setIsEditing(true);
setInputValue(formattedTime);
setHasError(false);
enterPressedRef.current = false;
};
const cancelEditing = () => {
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime == null) {
setHasError(true);
return;
}
const clampedTime = Math.max(
0,
duration ? Math.min(duration, parsedTime) : parsedTime,
);
onTimeChange?.({ time: clampedTime });
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
enterPressedRef.current = true;
applyEdit();
} else if (event.key === "Escape") {
event.preventDefault();
cancelEditing();
}
};
const handleInputChange = ({
target,
}: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(target.value);
setHasError(false);
};
const handleBlur = () => {
if (!enterPressedRef.current && isEditing) {
applyEdit();
}
};
const handleDisplayKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
startEditing();
}
};
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={cn(
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded",
"text-primary tabular-nums",
hasError && "text-destructive focus:border-destructive",
className,
)}
style={{ width: `${formattedTime.length + 1}ch` }}
placeholder={formattedTime}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
onKeyDown={handleDisplayKeyDown}
disabled={disabled}
className={cn(
"text-primary cursor-pointer font-mono text-xs tabular-nums",
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
disabled && "cursor-default hover:bg-transparent",
className,
)}
title={disabled ? undefined : "Click to edit time"}
>
{formattedTime}
</button>
);
}
@@ -1,83 +1,80 @@
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "@/components/ui/button";
function InfoRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
export function ProjectInfoDialog({
isOpen,
onOpenChange,
project,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<DialogBody className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
value={formatDate({ date: project.createdAt })}
/>
<InfoRow
label="Modified"
value={formatDate({ date: project.updatedAt })}
/>
<InfoRow
label="Project ID"
value={
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{project.id.slice(0, 8)}
</code>
}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "opencut-wasm";
import { Button } from "@/components/ui/button";
function InfoRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
export function ProjectInfoDialog({
isOpen,
onOpenChange,
project,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationFormatted =
project.duration > 0
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<DialogBody className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
value={formatDate({ date: project.createdAt })}
/>
<InfoRow
label="Modified"
value={formatDate({ date: project.updatedAt })}
/>
<InfoRow
label="Project ID"
value={
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{project.id.slice(0, 8)}
</code>
}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,151 +1,147 @@
"use client";
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
}: {
onToggleFullscreen: () => void;
}) {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
<PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5">
<ZoomSelect />
<Separator orientation="vertical" className="h-4" />
<GridPopover>
<Button
variant={activeGuideDefinition ? "secondary" : "text"}
size="icon"
>
{activeGuideDefinition ? (
activeGuideDefinition.renderTriggerIcon()
) : (
<HugeiconsIcon icon={GridTableIcon} />
)}
</Button>
</GridPopover>
<Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() =>
editor.playback.getCurrentTime(),
);
useEffect(() => {
const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
window.addEventListener("playback-update", handler);
window.addEventListener("playback-seek", handler);
return () => {
window.removeEventListener("playback-update", handler);
window.removeEventListener("playback-seek", handler);
};
}, []);
return (
<div className="flex items-center">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
</span>
</div>
);
}
function ZoomSelect() {
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
usePreviewViewport();
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
const onValueChange = (value: string) => {
if (value === "fit") {
fitToScreen();
} else {
setViewportPercent({ percent: Number(value) });
}
};
return (
<Select
value={isAtFit ? "fit" : String(zoomPercent)}
onValueChange={onValueChange}
>
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
<SelectContent>
<SelectItem value="fit">Fit</SelectItem>
<SelectSeparator />
{PREVIEW_ZOOM_PRESETS.map((preset) => (
<SelectItem key={preset} value={String(preset)}>
{preset}%
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PlayPauseButton() {
const isPlaying = useEditor((e) => e.playback.getIsPlaying());
return (
<Button
variant="text"
size="icon"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
);
}
"use client";
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
onToggleFullscreen,
}: {
onToggleFullscreen: () => void;
}) {
const activeGuide = usePreviewStore((state) => state.activeGuide);
const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
<PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5">
<ZoomSelect />
<Separator orientation="vertical" className="h-4" />
<GridPopover>
<Button
variant={activeGuideDefinition ? "secondary" : "text"}
size="icon"
>
{activeGuideDefinition ? (
activeGuideDefinition.renderTriggerIcon()
) : (
<HugeiconsIcon icon={GridTableIcon} />
)}
</Button>
</GridPopover>
<Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() =>
editor.playback.getCurrentTime(),
);
useEffect(() => {
const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
window.addEventListener("playback-update", handler);
window.addEventListener("playback-seek", handler);
return () => {
window.removeEventListener("playback-update", handler);
window.removeEventListener("playback-seek", handler);
};
}, []);
return (
<div className="flex items-center">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={fps}
onTimeChange={({ time }) => editor.playback.seek({ time })}
className="text-center"
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
</span>
</div>
);
}
function ZoomSelect() {
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
usePreviewViewport();
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
const onValueChange = (value: string) => {
if (value === "fit") {
fitToScreen();
} else {
setViewportPercent({ percent: Number(value) });
}
};
return (
<Select
value={isAtFit ? "fit" : String(zoomPercent)}
onValueChange={onValueChange}
>
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
<SelectContent>
<SelectItem value="fit">Fit</SelectItem>
<SelectSeparator />
{PREVIEW_ZOOM_PRESETS.map((preset) => (
<SelectItem key={preset} value={String(preset)}>
{preset}%
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PlayPauseButton() {
const isPlaying = useEditor((e) => e.playback.getIsPlaying());
return (
<Button
variant="text"
size="icon"
onClick={() => invokeAction("toggle-play")}
>
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
</Button>
);
}
@@ -1,5 +1,7 @@
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
clamp,
@@ -10,6 +12,7 @@ import {
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useEditor } from "@/hooks/use-editor";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { KeyframeToggle } from "../components/keyframe-toggle";
@@ -34,6 +37,7 @@ export function AudioTab({
element: AudioElement | VideoElement;
trackId: string;
}) {
const editor = useEditor();
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
@@ -80,46 +84,68 @@ export function AudioTab({
rightValue: DEFAULTS.element.volume,
})
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
const isSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
<>
{isSeparated && (
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
<p className="text-sm">Audio has been separated.</p>
<Button
className="mt-3"
size="sm"
variant="secondary"
onClick={() =>
editor.timeline.toggleSourceAudioSeparation({
trackId,
elementId: element.id,
})
}
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
Recover audio
</Button>
</div>
)}
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
</>
);
}
@@ -10,7 +10,7 @@ import {
TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { getSnappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -52,7 +52,7 @@ import {
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@@ -39,6 +39,11 @@ import type {
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import {
getActionDefinition,
type TAction,
@@ -61,7 +66,9 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
Link02Icon,
MagicWand05Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
@@ -280,6 +287,19 @@ export function TimelineElement({
};
const isMuted = canElementHaveAudio(element) && element.muted === true;
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
canToggleSourceAudio({
element,
mediaAsset,
});
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
: "Extract audio";
const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<ContextMenu>
@@ -334,6 +354,21 @@ export function TimelineElement({
isMuted={isMuted}
/>
)}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
}}
>
{sourceAudioLabel}
</ContextMenuItem>
)}
{canElementBeHidden(element) && (
<VisibilityMenuItem
element={element}
@@ -1,4 +1,5 @@
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
TooltipProvider,
Tooltip,
@@ -6,7 +7,6 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { SplitSquareHorizontal } from "lucide-react";
import {
SplitButton,
SplitButtonLeft,
@@ -18,6 +18,12 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import { hasMediaId } from "@/lib/timeline";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,8 +39,10 @@ import {
Copy01Icon,
AlignLeftIcon,
AlignRightIcon,
Link02Icon,
Layers01Icon,
Chart03Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
@@ -78,9 +86,47 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
const editor = useEditor();
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
const { selectedElements } = useElementSelection();
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
const selectedElement =
selectedElements.length === 1
? (editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0] ?? null)
: null;
const selectedMediaAsset = (() => {
if (!selectedElement) {
return null;
}
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
canToggleSourceAudio({
element: selectedElement.element,
mediaAsset: selectedMediaAsset,
});
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
element: selectedElement.element,
})
: "Extract audio";
const isSelectedSourceAudioSeparated =
selectedElement?.element.type === "video" &&
isSourceAudioSeparated({
element: selectedElement.element,
});
const handleAction = ({
action,
@@ -117,10 +163,16 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<SplitSquareHorizontal />}
tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
icon={
<HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
handleAction({ action: "toggle-source-audio", event })
}
/>
<ToolbarButton
@@ -275,6 +327,7 @@ function ToolbarButton({
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
+4 -4
View File
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
] as const;
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
{ label: "Light", value: 10 },
{ label: "Medium", value: 50 },
{ label: "Heavy", value: 100 },
{ label: "Light", value: 50 },
{ label: "Medium", value: 250 },
{ label: "Heavy", value: 500 },
] as const;
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
export const DEFAULT_BLUR_INTENSITY = 10;
export const DEFAULT_BLUR_INTENSITY = 50;
export const DEFAULT_COLOR = "#000000";
+63 -53
View File
@@ -1,53 +1,63 @@
import { PlaybackManager } from "./managers/playback-manager";
import { TimelineManager } from "./managers/timeline-manager";
import { ScenesManager } from "./managers/scenes-manager";
import { ProjectManager } from "./managers/project-manager";
import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks";
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly timeline: TimelineManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
public readonly save: SaveManager;
public readonly audio: AudioManager;
public readonly selection: SelectionManager;
private constructor() {
registerDefaultEffects();
registerDefaultMasks();
this.command = new CommandManager();
this.timeline = new TimelineManager(this);
this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
this.save = new SaveManager(this);
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.save.start();
}
static getInstance(): EditorCore {
if (!EditorCore.instance) {
EditorCore.instance = new EditorCore();
}
return EditorCore.instance;
}
static reset(): void {
EditorCore.instance = null;
}
}
import { PlaybackManager } from "./managers/playback-manager";
import { TimelineManager } from "./managers/timeline-manager";
import { ScenesManager } from "./managers/scenes-manager";
import { ProjectManager } from "./managers/project-manager";
import { MediaManager } from "./managers/media-manager";
import { RendererManager } from "./managers/renderer-manager";
import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks";
import { isMainTrack } from "@/lib/timeline/placement";
export class EditorCore {
private static instance: EditorCore | null = null;
public readonly timeline: TimelineManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
public readonly renderer: RendererManager;
public readonly save: SaveManager;
public readonly audio: AudioManager;
public readonly selection: SelectionManager;
private constructor() {
registerDefaultEffects();
registerDefaultMasks();
this.command = new CommandManager(this);
this.timeline = new TimelineManager(this);
this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
this.save = new SaveManager(this);
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.command.registerReactor(() => {
const tracks = this.timeline.getTracks();
const prunedTracks = tracks.filter(
(track) => track.elements.length > 0 || isMainTrack(track),
);
if (prunedTracks.length !== tracks.length) {
this.timeline.updateTracks(prunedTracks);
}
});
this.save.start();
}
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";
export class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
execute({ command }: { command: Command }): Command {
command.execute();
this.history.push(command);
this.redoStack = [];
return command;
}
push({ command }: { command: Command }): void {
this.history.push(command);
this.redoStack = [];
}
undo(): void {
if (this.history.length === 0) return;
const command = this.history.pop();
command?.undo();
if (command) {
this.redoStack.push(command);
}
}
redo(): void {
if (this.redoStack.length === 0) return;
const command = this.redoStack.pop();
command?.redo();
if (command) {
this.history.push(command);
}
}
canUndo(): boolean {
return this.history.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.history = [];
this.redoStack = [];
}
}
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
import type { ElementRef } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
previousSelection: ElementRef[];
selectionOverride?: ElementRef[];
}
export class CommandManager {
private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
private reactors: Array<() => void> = [];
constructor(private editor: EditorCore) {}
execute({ command }: { command: Command }): Command {
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
command,
previousSelection,
selectionOverride,
});
this.redoStack = [];
return command;
}
push({ command }: { command: Command }): void {
this.history.push({
command,
previousSelection: this.getSelectionSnapshot(),
});
this.redoStack = [];
}
registerReactor(reactor: () => void): void {
this.reactors.push(reactor);
}
undo(): void {
if (this.history.length === 0) return;
const entry = this.history.pop();
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 { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimeCode({
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/track-placement";
import { ensureMainTrack } from "@/lib/timeline/placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
+17 -2
View File
@@ -13,7 +13,7 @@ import type {
AnimationValue,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time";
import { getLastFrameTime } from "opencut-wasm";
import {
AddTrackCommand,
RemoveTrackCommand,
@@ -45,6 +45,7 @@ import {
ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@@ -265,7 +266,7 @@ export class TimelineManager {
time: number;
clipboardItems: ClipboardItem[];
}): { trackId: string; elementId: string }[] {
const command = new PasteCommand(time, clipboardItems);
const command = new PasteCommand({ time, clipboardItems });
this.editor.command.execute({ command });
return command.getPastedElements();
}
@@ -281,6 +282,20 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
toggleSourceAudioSeparation({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
const command = new ToggleSourceAudioSeparationCommand({
trackId,
elementId,
});
this.editor.command.execute({ command });
}
updateElements({
updates,
pushHistory = true,
@@ -6,9 +6,13 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
import {
getElementsAtTime,
hasMediaId,
} from "@/lib/timeline";
import { cancelInteraction } from "@/lib/cancel-interaction";
import { invokeAction } from "@/lib/actions";
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
import {
activateScope,
clearActiveScope,
@@ -261,7 +265,48 @@ export function useEditorActions() {
elements: selectedElements,
rippleEnabled: rippleEditingEnabled,
});
editor.selection.clearSelection();
},
undefined,
);
useActionHandler(
"toggle-source-audio",
() => {
if (selectedElements.length !== 1) {
return;
}
const selectedElement = editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0];
if (!selectedElement) {
return;
}
const mediaAsset = (() => {
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return (
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
null
);
})();
if (
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
return;
}
editor.timeline.toggleSourceAudioSeparation({
trackId: selectedElement.track.id,
elementId: selectedElement.element.id,
});
},
undefined,
);
@@ -14,7 +14,7 @@ import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -302,10 +302,7 @@ export function useElementInteraction({
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
@@ -7,7 +7,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
DRAG_THRESHOLD_PX,
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({
rawTime: displayedStartTime + indicatorTime,
duration,
fps,
});
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
+275 -281
View File
@@ -1,281 +1,275 @@
import {
useState,
useCallback,
useEffect,
useRef,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState {
isDragging: boolean;
bookmarkTime: number | null;
currentTime: number;
}
interface PendingBookmarkDrag {
bookmarkTime: number;
startMouseX: number;
startMouseY: number;
}
interface UseBookmarkDragProps {
zoomLevel: number;
scrollRef: RefObject<HTMLElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useBookmarkDrag({
zoomLevel,
scrollRef,
snappingEnabled,
onSnapPointChange,
}: UseBookmarkDragProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
const lastMouseXRef = useRef(0);
const startDrag = useCallback(
({
bookmarkTime,
initialCurrentTime,
}: {
bookmarkTime: number;
initialCurrentTime: number;
}) => {
setDragState({
isDragging: true,
bookmarkTime,
currentTime: initialCurrentTime,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
}, []);
const getSnapResult = useCallback(
({
rawTime,
excludeBookmarkTime,
}: {
rawTime: number;
excludeBookmarkTime: number;
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null };
}
const snapPoints = findSnapPoints({
tracks,
playheadTime,
bookmarks,
excludeBookmarkTime,
});
const result = snapToNearestPoint({
targetTime: rawTime,
snapPoints,
zoomLevel,
});
return {
snappedTime: result.snappedTime,
snapPoint: result.snapPoint,
};
},
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = (event: MouseEvent) => {
lastMouseXRef.current = event.clientX;
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
if (isPendingDrag && pendingDragRef.current) {
const { startMouseX, startMouseY, bookmarkTime } =
pendingDragRef.current;
const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
return;
}
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({
time: Math.max(0, Math.min(mouseTime, duration)),
fps: activeProject.settings.fps,
});
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
});
startDrag({
bookmarkTime,
initialCurrentTime: initialTime,
});
pendingDragRef.current = null;
setIsPendingDrag(false);
return;
}
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({
time: clampedTime,
fps: activeProject.settings.fps,
});
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snapResult.snappedTime,
}));
onSnapPointChange?.(snapResult.snapPoint);
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.bookmarkTime,
zoomLevel,
duration,
editor.project,
scrollRef,
isPendingDrag,
startDrag,
getSnapResult,
onSnapPointChange,
]);
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = () => {
if (dragState.bookmarkTime === null) {
endDrag();
onSnapPointChange?.(null);
return;
}
const clampedTime = Math.max(
0,
Math.min(dragState.currentTime, duration),
);
editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime,
toTime: clampedTime,
});
endDrag();
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.bookmarkTime,
dragState.currentTime,
duration,
endDrag,
onSnapPointChange,
editor.scenes,
]);
useEffect(() => {
if (!isPendingDrag) return;
const handleMouseUp = () => {
pendingDragRef.current = null;
setIsPendingDrag(false);
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag, onSnapPointChange]);
const handleBookmarkMouseDown = useCallback(
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
pendingDragRef.current = {
bookmarkTime: bookmark.time,
startMouseX: event.clientX,
startMouseY: event.clientY,
};
setIsPendingDrag(true);
},
[],
);
return {
dragState,
handleBookmarkMouseDown,
lastMouseXRef,
};
}
import {
useState,
useCallback,
useEffect,
useRef,
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState {
isDragging: boolean;
bookmarkTime: number | null;
currentTime: number;
}
interface PendingBookmarkDrag {
bookmarkTime: number;
startMouseX: number;
startMouseY: number;
}
interface UseBookmarkDragProps {
zoomLevel: number;
scrollRef: RefObject<HTMLElement | null>;
snappingEnabled: boolean;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useBookmarkDrag({
zoomLevel,
scrollRef,
snappingEnabled,
onSnapPointChange,
}: UseBookmarkDragProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
const lastMouseXRef = useRef(0);
const startDrag = useCallback(
({
bookmarkTime,
initialCurrentTime,
}: {
bookmarkTime: number;
initialCurrentTime: number;
}) => {
setDragState({
isDragging: true,
bookmarkTime,
currentTime: initialCurrentTime,
});
},
[],
);
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
bookmarkTime: null,
currentTime: 0,
});
}, []);
const getSnapResult = useCallback(
({
rawTime,
excludeBookmarkTime,
}: {
rawTime: number;
excludeBookmarkTime: number;
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null };
}
const snapPoints = findSnapPoints({
tracks,
playheadTime,
bookmarks,
excludeBookmarkTime,
});
const result = snapToNearestPoint({
targetTime: rawTime,
snapPoints,
zoomLevel,
});
return {
snappedTime: result.snappedTime,
snapPoint: result.snapPoint,
};
},
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
const handleMouseMove = (event: MouseEvent) => {
lastMouseXRef.current = event.clientX;
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
if (isPendingDrag && pendingDragRef.current) {
const { startMouseX, startMouseY, bookmarkTime } =
pendingDragRef.current;
const deltaX = Math.abs(event.clientX - startMouseX);
const deltaY = Math.abs(event.clientY - startMouseY);
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
return;
}
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
});
startDrag({
bookmarkTime,
initialCurrentTime: initialTime,
});
pendingDragRef.current = null;
setIsPendingDrag(false);
return;
}
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
const activeProject = editor.project.getActive();
if (!activeProject) return;
const scrollLeft = scrollContainer.scrollLeft;
const mouseTime = getMouseTimeFromClientX({
clientX: event.clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
});
setDragState((previousDragState) => ({
...previousDragState,
currentTime: snapResult.snappedTime,
}));
onSnapPointChange?.(snapResult.snapPoint);
};
document.addEventListener("mousemove", handleMouseMove);
return () => document.removeEventListener("mousemove", handleMouseMove);
}, [
dragState.isDragging,
dragState.bookmarkTime,
zoomLevel,
duration,
editor.project,
scrollRef,
isPendingDrag,
startDrag,
getSnapResult,
onSnapPointChange,
]);
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseUp = () => {
if (dragState.bookmarkTime === null) {
endDrag();
onSnapPointChange?.(null);
return;
}
const clampedTime = Math.max(
0,
Math.min(dragState.currentTime, duration),
);
editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime,
toTime: clampedTime,
});
endDrag();
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [
dragState.isDragging,
dragState.bookmarkTime,
dragState.currentTime,
duration,
endDrag,
onSnapPointChange,
editor.scenes,
]);
useEffect(() => {
if (!isPendingDrag) return;
const handleMouseUp = () => {
pendingDragRef.current = null;
setIsPendingDrag(false);
onSnapPointChange?.(null);
};
document.addEventListener("mouseup", handleMouseUp);
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isPendingDrag, onSnapPointChange]);
const handleBookmarkMouseDown = useCallback(
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
pendingDragRef.current = {
bookmarkTime: bookmark.time,
startMouseX: event.clientX,
startMouseY: event.clientY,
};
setIsPendingDrag(true);
},
[],
);
return {
dragState,
handleBookmarkMouseDown,
lastMouseXRef,
};
}
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 { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
+193 -197
View File
@@ -1,197 +1,193 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>;
trackLabelsRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
duration: number;
isSelecting: boolean;
clearSelectedElements: () => void;
seek: (time: number) => void;
}
function resetMouseTracking({
mouseTrackingRef,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
}) {
mouseTrackingRef.current = {
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
};
}
function setMouseTracking({
mouseTrackingRef,
event,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
event: React.MouseEvent;
}) {
mouseTrackingRef.current = {
isMouseDown: true,
downX: event.clientX,
downY: event.clientY,
downTime: event.timeStamp,
};
}
export function useTimelineSeek({
playheadRef,
trackLabelsRef,
rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration,
isSelecting,
clearSelectedElements,
seek,
}: UseTimelineSeekProps) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const mouseTrackingRef = useRef({
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
});
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const shouldProcessTimelineClick = useCallback(
({ event }: { event: React.MouseEvent }) => {
const target = event.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime;
const isPlayhead = !!playheadRef.current?.contains(target);
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
if (!isMouseDown) return false;
if (shouldBlockForDrag) return false;
if (isSelecting) return false;
if (isPlayhead) return false;
if (isTrackLabels) {
clearSelectedElements();
return false;
}
return true;
},
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
);
const handleTimelineSeek = useCallback(
({
event,
source,
}: {
event: React.MouseEvent;
source: "ruler" | "tracks";
}) => {
const scrollContainer =
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
if (!scrollContainer) return;
const rect = scrollContainer.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({
rawTime,
duration,
fps: projectFps,
});
seek(time);
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollContainer.scrollLeft,
playheadTime: time,
},
});
},
[
duration,
zoomLevel,
rulerScrollRef,
tracksScrollRef,
seek,
editor,
activeProject?.settings.fps,
],
);
const handleTracksClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "tracks" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
const handleRulerClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "ruler" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
return {
handleTracksMouseDown,
handleTracksClick,
handleRulerMouseDown,
handleRulerClick,
};
}
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>;
trackLabelsRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
duration: number;
isSelecting: boolean;
clearSelectedElements: () => void;
seek: (time: number) => void;
}
function resetMouseTracking({
mouseTrackingRef,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
}) {
mouseTrackingRef.current = {
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
};
}
function setMouseTracking({
mouseTrackingRef,
event,
}: {
mouseTrackingRef: MutableRefObject<{
isMouseDown: boolean;
downX: number;
downY: number;
downTime: number;
}>;
event: React.MouseEvent;
}) {
mouseTrackingRef.current = {
isMouseDown: true,
downX: event.clientX,
downY: event.clientY,
downTime: event.timeStamp,
};
}
export function useTimelineSeek({
playheadRef,
trackLabelsRef,
rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration,
isSelecting,
clearSelectedElements,
seek,
}: UseTimelineSeekProps) {
const editor = useEditor();
const activeProject = editor.project.getActive();
const mouseTrackingRef = useRef({
isMouseDown: false,
downX: 0,
downY: 0,
downTime: 0,
});
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
if (event.button !== 0) return;
setMouseTracking({ mouseTrackingRef, event });
}, []);
const shouldProcessTimelineClick = useCallback(
({ event }: { event: React.MouseEvent }) => {
const target = event.target as HTMLElement;
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
const deltaX = Math.abs(event.clientX - downX);
const deltaY = Math.abs(event.clientY - downY);
const deltaTime = event.timeStamp - downTime;
const isPlayhead = !!playheadRef.current?.contains(target);
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
if (!isMouseDown) return false;
if (shouldBlockForDrag) return false;
if (isSelecting) return false;
if (isPlayhead) return false;
if (isTrackLabels) {
clearSelectedElements();
return false;
}
return true;
},
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
);
const handleTimelineSeek = useCallback(
({
event,
source,
}: {
event: React.MouseEvent;
source: "ruler" | "tracks";
}) => {
const scrollContainer =
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
if (!scrollContainer) return;
const rect = scrollContainer.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
seek(time);
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: scrollContainer.scrollLeft,
playheadTime: time,
},
});
},
[
duration,
zoomLevel,
rulerScrollRef,
tracksScrollRef,
seek,
editor,
activeProject?.settings.fps,
],
);
const handleTracksClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "tracks" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
const handleRulerClick = useCallback(
(event: React.MouseEvent) => {
const shouldProcess = shouldProcessTimelineClick({ event });
resetMouseTracking({ mouseTrackingRef });
if (shouldProcess) {
clearSelectedElements();
handleTimelineSeek({ event, source: "ruler" });
}
},
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
);
return {
handleTracksMouseDown,
handleTracksClick,
handleRulerMouseDown,
handleRulerClick,
};
}
+4
View File
@@ -101,6 +101,10 @@ export const ACTIONS = {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
+9 -3
View File
@@ -1,11 +1,17 @@
import type { ElementRef } from "@/lib/timeline/types";
export interface CommandResult {
select?: ElementRef[];
}
export abstract class Command {
abstract execute(): void;
abstract execute(): CommandResult | undefined;
undo(): void {
throw new Error("Undo not implemented for this command");
}
redo(): void {
this.execute();
redo(): CommandResult | undefined {
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 {
constructor(private commands: Command[]) {
super();
}
execute(): void {
execute(): CommandResult | undefined {
let latestSelectionResult: CommandResult | undefined;
for (const command of this.commands) {
command.execute();
const result = command.execute();
if (result?.select !== undefined) {
latestSelectionResult = result;
}
}
return latestSelectionResult;
}
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) {
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 type { CommandResult } from "./base-command";
export { BatchCommand } from "./batch-command";
export * from "./timeline";
@@ -1,182 +1,184 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/track-placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
constructor(
private time: number,
private clipboardItems: ClipboardItem[],
) {
super();
}
execute(): void {
if (this.clipboardItems.length === 0) return;
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
const updatedTracks = [...this.savedState];
const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems,
});
for (const [trackId, items] of itemsByTrackId) {
const elementsToAdd = buildPastedElements({
items,
minStart,
time: this.time,
});
if (elementsToAdd.length === 0) {
continue;
}
const trackType = items[0].trackType;
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
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) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of elementsForPlacement) {
this.pastedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) {
editor.selection.setSelectedElements({ elements: this.pastedElements });
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
}
}
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
}
function groupClipboardItemsByTrackId({
clipboardItems,
}: {
clipboardItems: ClipboardItem[];
}): Map<string, ClipboardItem[]> {
const groupedItems = new Map<string, ClipboardItem[]>();
for (const item of clipboardItems) {
const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]);
}
return groupedItems;
}
function buildPastedElements({
items,
minStart,
time,
}: {
items: ClipboardItem[];
minStart: number;
time: number;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
elementsToAdd.push({
...item.element,
id: newElementId,
startTime,
animations: cloneAnimations({
animations: item.element.animations,
shouldRegenerateKeyframeIds: true,
}),
} as TimelineElement);
}
return elementsToAdd;
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number;
private readonly clipboardItems: ClipboardItem[];
constructor({
time,
clipboardItems,
}: {
time: number;
clipboardItems: ClipboardItem[];
}) {
super();
this.time = time;
this.clipboardItems = clipboardItems;
}
execute(): CommandResult | undefined {
if (this.clipboardItems.length === 0) return;
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.pastedElements = [];
const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime),
);
let updatedTracks = [...this.savedState];
const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems,
});
for (const [trackId, items] of itemsByTrackId) {
const elementsToAdd = buildPastedElements({
items,
minStart,
time: this.time,
});
if (elementsToAdd.length === 0) {
continue;
}
const trackType = items[0].trackType;
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
const adjustedEarliestStartTime = enforceMainTrackStart({
tracks: updatedTracks,
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
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) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of elementsForPlacement) {
this.pastedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.pastedElements.length > 0) {
return { select: this.pastedElements };
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getPastedElements(): { trackId: string; elementId: string }[] {
return this.pastedElements;
}
}
function groupClipboardItemsByTrackId({
clipboardItems,
}: {
clipboardItems: ClipboardItem[];
}): Map<string, ClipboardItem[]> {
const groupedItems = new Map<string, ClipboardItem[]>();
for (const item of clipboardItems) {
const existingItems = groupedItems.get(item.trackId) ?? [];
groupedItems.set(item.trackId, [...existingItems, item]);
}
return groupedItems;
}
function buildPastedElements({
items,
minStart,
time,
}: {
items: ClipboardItem[];
minStart: number;
time: number;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
const relativeOffset = item.element.startTime - minStart;
const startTime = Math.max(0, time + relativeOffset);
const newElementId = generateUUID();
elementsToAdd.push({
...item.element,
id: newElementId,
startTime,
animations: cloneAnimations({
animations: item.element.animations,
shouldRegenerateKeyframeIds: true,
}),
} as TimelineElement);
}
return elementsToAdd;
}
@@ -1,80 +1,81 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
private readonly rippleEnabled: boolean;
constructor({
elements,
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
this.rippleEnabled = rippleEnabled;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState
.map((track) => {
const elementsToDeleteOnTrack = this.elements.filter(
(target) => target.trackId === track.id,
);
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
if (!hasElementsToDelete) {
return track;
}
const deletedElementInfos = elementsToDeleteOnTrack
.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 }));
let elements = track.elements.filter(
(element) =>
!this.elements.some(
(target) =>
target.trackId === track.id && target.elementId === element.id,
),
);
if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(
(a, b) => b.startTime - a.startTime,
);
for (const { startTime, duration } of sortedByStartDesc) {
elements = rippleShiftElements({
elements,
afterTime: startTime,
shiftAmount: duration,
});
}
}
return { ...track, elements } as typeof track;
})
.filter((track) => track.elements.length > 0 || isMainTrack(track));
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
private readonly rippleEnabled: boolean;
constructor({
elements,
rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
this.rippleEnabled = rippleEnabled;
}
execute(): CommandResult {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
const elementsToDeleteOnTrack = this.elements.filter(
(target) => target.trackId === track.id,
);
const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
if (!hasElementsToDelete) {
return track;
}
const deletedElementInfos = elementsToDeleteOnTrack
.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 }));
let elements = track.elements.filter(
(element) =>
!this.elements.some(
(target) =>
target.trackId === track.id && target.elementId === element.id,
),
);
if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(
(a, b) => b.startTime - a.startTime,
);
for (const { startTime, duration } of sortedByStartDesc) {
elements = rippleShiftElements({
elements,
afterTime: startTime,
shiftAmount: duration,
});
}
}
return { ...track, elements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
return {
select: [],
};
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -1,132 +1,127 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
}
export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null;
private previousSelection: { trackId: string; elementId: string }[] = [];
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
super();
this.elements = elements;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.duplicatedElements = [];
const updatedTracks = [...this.savedState];
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
continue;
}
const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
);
const newTrackElements: TimelineElement[] = [];
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
newTrackElements.push(
buildDuplicateElement({
element,
id: newId,
startTime: element.startTime,
}),
);
}
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
editor.selection.setSelectedElements({
elements: this.duplicatedElements,
});
}
}
undo(): void {
if (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;
}
}
function buildDuplicateElement({
element,
id,
startTime,
}: {
element: TimelineElement;
id: string;
startTime: number;
}): TimelineElement {
return {
...element,
id,
name: `${element.name} (copy)`,
startTime,
animations: cloneAnimations({
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}
import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
}
export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null;
private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) {
super();
this.elements = elements;
}
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.duplicatedElements = [];
let updatedTracks = [...this.savedState];
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
(elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
continue;
}
const elementIdsToDuplicate = new Set(
elementsToDuplicate.map((element) => element.elementId),
);
const newTrackElements: TimelineElement[] = [];
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
newTrackElements.push(
buildDuplicateElement({
element,
id: newId,
startTime: element.startTime,
}),
);
}
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
return {
select: this.duplicatedElements,
};
}
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getDuplicatedElements(): { trackId: string; elementId: string }[] {
return this.duplicatedElements;
}
}
function buildDuplicateElement({
element,
id,
startTime,
}: {
element: TimelineElement;
id: string;
startTime: number;
}): TimelineElement {
return {
...element,
id,
name: `${element.name} (copy)`,
startTime,
animations: cloneAnimations({
animations: element.animations,
shouldRegenerateKeyframeIds: true,
}),
};
}
@@ -8,6 +8,7 @@ export { SplitElementsCommand } from "./split-elements";
export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
@@ -16,7 +16,7 @@ import {
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/lib/track-placement";
} from "@/lib/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -7,10 +7,9 @@ import type {
} from "@/lib/timeline";
import {
buildEmptyTrack,
isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/track-placement";
} from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
@@ -100,7 +99,7 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
const updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
@@ -134,21 +133,6 @@ export class MoveElementCommand extends Command {
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);
}
@@ -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 { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
@@ -9,7 +9,6 @@ import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
@@ -37,10 +36,9 @@ export class SplitElementsCommand extends Command {
return this.rightSideElements;
}
execute(): void {
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
this.previousSelection = editor.selection.getSelectedElements();
this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => {
@@ -173,9 +171,9 @@ export class SplitElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) {
editor.selection.setSelectedElements({
elements: this.rightSideElements,
});
return {
select: this.rightSideElements,
};
}
}
@@ -183,9 +181,6 @@ export class SplitElementsCommand extends Command {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
editor.selection.setSelectedElements({
elements: this.previousSelection,
});
}
}
}
@@ -0,0 +1,143 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import {
buildSeparatedAudioElement,
canExtractSourceAudio,
canRecoverSourceAudio,
} from "@/lib/timeline/audio-separation";
import {
applyPlacement,
resolveTrackPlacement,
} from "@/lib/timeline/placement";
import { updateElementInTracks } from "@/lib/timeline/track-element-update";
import type { TimelineTrack, VideoElement } from "@/lib/timeline/types";
import { generateUUID } from "@/utils/id";
export class ToggleSourceAudioSeparationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private readonly params: {
trackId: string;
elementId: string;
},
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const trackIndex = this.savedState.findIndex(
(track) => track.id === this.params.trackId,
);
if (trackIndex < 0) {
return;
}
const sourceTrack = this.savedState[trackIndex];
const sourceElement = sourceTrack.elements.find(
(element) => element.id === this.params.elementId,
);
if (!sourceElement) {
return;
}
if (canRecoverSourceAudio({ element: sourceElement })) {
editor.timeline.updateTracks(
updateSourceAudioEnabled({
tracks: this.savedState,
trackId: this.params.trackId,
elementId: this.params.elementId,
isSourceAudioEnabled: true,
}),
);
return;
}
const mediaAsset = editor
.media
.getAssets()
.find((asset) =>
sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false,
);
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
return;
}
if (sourceElement.duration <= 0) {
return;
}
const separatedAudioElement = {
...buildSeparatedAudioElement({
sourceElement,
}),
id: generateUUID(),
};
const placementResult = resolveTrackPlacement({
tracks: this.savedState,
trackType: "audio",
timeSpans: [
{
startTime: separatedAudioElement.startTime,
duration: separatedAudioElement.duration,
},
],
strategy: { type: "aboveSource", sourceTrackIndex: trackIndex },
});
if (!placementResult) {
return;
}
const appliedPlacement = applyPlacement({
tracks: this.savedState,
placementResult,
elements: [separatedAudioElement],
});
if (!appliedPlacement) {
return;
}
editor.timeline.updateTracks(
updateSourceAudioEnabled({
tracks: appliedPlacement.updatedTracks,
trackId: this.params.trackId,
elementId: this.params.elementId,
isSourceAudioEnabled: false,
}),
);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
function updateSourceAudioEnabled({
tracks,
trackId,
elementId,
isSourceAudioEnabled,
}: {
tracks: TimelineTrack[];
trackId: string;
elementId: string;
isSourceAudioEnabled: boolean;
}): TimelineTrack[] {
return updateElementInTracks({
tracks,
trackId,
elementId,
elementPredicate: (element): element is VideoElement => element.type === "video",
update: (element) => ({
...element,
isSourceAudioEnabled,
}),
});
}
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/track-placement";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/track-placement";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/track-placement";
} from "@/lib/timeline/placement";
export class AddTrackCommand extends Command {
private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/track-placement";
import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+9 -12
View File
@@ -6,11 +6,6 @@ const MAX_STEP = 4;
const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
const MAX_ITERATIONS = 8;
/**
* Builds multi-pass gaussian blur passes for a given sigma.
* Shared by the blur effect and background blur — they each
* compute their own sigma from their own intensity scale.
*/
export function buildGaussianBlurPasses({
sigmaX,
sigmaY,
@@ -58,8 +53,10 @@ export function buildGaussianBlurPasses({
return passes;
}
function intensityToSigma(intensity: number, resolution: number, reference: number): number {
return (intensity / 5) * (resolution / reference);
export const INTENSITY_TO_SIGMA_DIVISOR = 5;
export function intensityToSigma({ intensity, resolution, reference }: { intensity: number; resolution: number; reference: number }): number {
return (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference);
}
function parseIntensity(effectParams: Record<string, unknown>): number {
@@ -88,7 +85,7 @@ export const blurEffectDefinition: EffectDefinition = {
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, width }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: width, reference: 1920 }), 0.001),
u_step: 1,
u_direction: [1, 0],
}),
@@ -96,7 +93,7 @@ export const blurEffectDefinition: EffectDefinition = {
{
fragmentShader: blurFragmentShader,
uniforms: ({ effectParams, height }) => ({
u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: height, reference: 1080 }), 0.001),
u_step: 1,
u_direction: [0, 1],
}),
@@ -104,9 +101,9 @@ export const blurEffectDefinition: EffectDefinition = {
],
buildPasses: ({ effectParams, width, height }) => {
const intensity = parseIntensity(effectParams);
return buildGaussianBlurPasses({
sigmaX: intensityToSigma(intensity, width, 1920),
sigmaY: intensityToSigma(intensity, height, 1080),
return buildGaussianBlurPasses({
sigmaX: intensityToSigma({ intensity, resolution: width, reference: 1920 }),
sigmaY: intensityToSigma({ intensity, resolution: height, reference: 1080 }),
});
},
},
+20 -10
View File
@@ -12,7 +12,10 @@ import {
hasAnimatedVolume,
resolveEffectiveAudioGain,
} from "@/lib/timeline/audio-state";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import {
doesElementHaveEnabledAudio,
} from "@/lib/timeline/audio-separation";
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
@@ -100,6 +103,11 @@ export async function collectAudioElements({
if (!canElementHaveAudio(element)) continue;
if (element.duration <= 0) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
if (element.type === "audio") {
@@ -132,7 +140,6 @@ export async function collectAudioElements({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
pendingElements.push(
@@ -451,6 +458,10 @@ export async function collectAudioMixSources({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
if (element.muted === true) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const volume = resolveEffectiveAudioGain({
element,
localTime: 0,
@@ -473,10 +484,7 @@ export async function collectAudioMixSources({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (mediaSupportsAudio({ media: mediaAsset })) {
if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset, volume }),
);
@@ -512,6 +520,11 @@ export async function collectAudioClips({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isElementMuted =
"muted" in element ? (element.muted ?? false) : false;
const muted = isTrackMuted || isElementMuted;
@@ -543,10 +556,7 @@ export async function collectAudioClips({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (mediaSupportsAudio({ media: mediaAsset })) {
if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
clips.push(
collectMediaAudioClip({
element,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
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 { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
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 {
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);
}
@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
buildSeparatedAudioElement,
doesElementHaveEnabledAudio,
isSourceAudioEnabled,
isSourceAudioSeparated,
} from "..";
describe("audio separation", () => {
test("treats missing source audio state as enabled", () => {
const element = buildVideoElement({});
expect(isSourceAudioEnabled({ element })).toBe(true);
expect(isSourceAudioSeparated({ element })).toBe(false);
});
test("builds a detached audio element from the source clip audio state", () => {
const element = buildVideoElement({
duration: 8,
startTime: 3,
trimStart: 1.5,
trimEnd: 0.5,
sourceDuration: 12,
volume: -6,
muted: true,
retime: { rate: 1.25, maintainPitch: true },
animations: {
channels: {
volume: {
valueKind: "number",
keyframes: [
{
id: "volume-keyframe",
time: 2,
value: -12,
interpolation: "linear",
},
],
},
opacity: {
valueKind: "number",
keyframes: [
{
id: "opacity-keyframe",
time: 1,
value: 0.5,
interpolation: "linear",
},
],
},
},
},
});
const separatedAudioElement = buildSeparatedAudioElement({
sourceElement: element,
});
expect(separatedAudioElement).toMatchObject({
type: "audio",
sourceType: "upload",
mediaId: element.mediaId,
name: element.name,
duration: 8,
startTime: 3,
trimStart: 1.5,
trimEnd: 0.5,
sourceDuration: 12,
volume: -6,
muted: true,
retime: { rate: 1.25, maintainPitch: true },
});
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
"volume",
]);
expect(
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
).not.toBe("volume-keyframe");
});
test("skips source audio collection when the source clip is separated", () => {
const mediaAsset = {
id: "media-1",
type: "video",
name: "Clip",
size: 1,
lastModified: 1,
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
url: "blob:clip",
hasAudio: true,
};
const videoElement = buildVideoElement({
isSourceAudioEnabled: false,
});
const audioElement = {
id: "audio-1",
type: "audio",
sourceType: "upload",
mediaId: "audio-media-1",
name: "Detached audio",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
} as AudioElement;
expect(
doesElementHaveEnabledAudio({
element: videoElement,
mediaAsset,
}),
).toBe(false);
expect(
doesElementHaveEnabledAudio({
element: audioElement,
}),
).toBe(true);
});
});
function buildVideoElement(
overrides: Partial<VideoElement>,
): VideoElement {
return {
id: "video-1",
type: "video",
mediaId: "media-1",
name: "Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
muted: false,
isSourceAudioEnabled: true,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
...overrides,
};
}
@@ -0,0 +1,133 @@
import { cloneAnimations, getChannel } from "@/lib/animation";
import type { ElementAnimations } from "@/lib/animation/types";
import type { MediaAsset } from "@/lib/media/types";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type {
CreateUploadAudioElement,
TimelineElement,
AudioElement,
VideoElement,
} from "../types";
export function isSourceAudioEnabled({
element,
}: {
element: VideoElement;
}): boolean {
return element.isSourceAudioEnabled !== false;
}
export function isSourceAudioSeparated({
element,
}: {
element: VideoElement;
}): boolean {
return !isSourceAudioEnabled({ element });
}
export function canExtractSourceAudio({
element,
mediaAsset,
}: {
element: TimelineElement;
mediaAsset: MediaAsset | null | undefined;
}): element is VideoElement {
return (
element.type === "video" &&
isSourceAudioEnabled({ element }) &&
!!mediaAsset &&
mediaAsset.hasAudio !== false
);
}
export function canRecoverSourceAudio({
element,
}: {
element: TimelineElement;
}): element is VideoElement {
return element.type === "video" && isSourceAudioSeparated({ element });
}
export function canToggleSourceAudio({
element,
mediaAsset,
}: {
element: TimelineElement;
mediaAsset: MediaAsset | null | undefined;
}): element is VideoElement {
return (
canRecoverSourceAudio({ element }) ||
canExtractSourceAudio({ element, mediaAsset })
);
}
export function doesElementHaveEnabledAudio({
element,
mediaAsset,
}: {
element: AudioElement | VideoElement;
mediaAsset?: MediaAsset | null;
}): boolean {
if (element.type === "audio") {
return true;
}
return !!mediaAsset && mediaAsset.hasAudio !== false && isSourceAudioEnabled({ element });
}
export function buildSeparatedAudioElement({
sourceElement,
}: {
sourceElement: VideoElement;
}): CreateUploadAudioElement {
return {
type: "audio",
sourceType: "upload",
mediaId: sourceElement.mediaId,
name: sourceElement.name,
duration: sourceElement.duration,
startTime: sourceElement.startTime,
trimStart: sourceElement.trimStart,
trimEnd: sourceElement.trimEnd,
sourceDuration: sourceElement.sourceDuration,
volume: sourceElement.volume ?? DEFAULTS.element.volume,
muted: sourceElement.muted ?? false,
retime: sourceElement.retime
? {
rate: sourceElement.retime.rate,
maintainPitch: sourceElement.retime.maintainPitch,
}
: undefined,
animations: cloneVolumeAnimations({
animations: sourceElement.animations,
}),
};
}
export function getSourceAudioActionLabel({
element,
}: {
element: VideoElement;
}): "Extract audio" | "Recover audio" {
return isSourceAudioSeparated({ element }) ? "Recover audio" : "Extract audio";
}
function cloneVolumeAnimations({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations | undefined {
const volumeChannel = getChannel({ animations, propertyPath: "volume" });
if (!volumeChannel) {
return undefined;
}
return cloneAnimations({
animations: {
channels: {
volume: volumeChannel,
},
},
shouldRegenerateKeyframeIds: true,
});
}
+149 -149
View File
@@ -1,149 +1,149 @@
import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "@/lib/time";
export const BOOKMARK_TIME_EPSILON = 0.001;
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
}: {
bookmarkTime: number;
frameTime: number;
}): boolean {
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
}
export function findBookmarkIndex({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): number {
return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function isBookmarkAtTime({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): boolean {
return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function toggleBookmarkInArray({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
if (bookmarkIndex !== -1) {
return bookmarks.filter((_, index) => index !== bookmarkIndex);
}
const newBookmarks = [...bookmarks, { time: frameTime }];
return newBookmarks.slice().sort((a, b) => a.time - b.time);
}
export function removeBookmarkFromArray({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark[] {
return bookmarks.filter(
(bookmark) =>
!bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function updateBookmarkInArray({
bookmarks,
frameTime,
updates,
}: {
bookmarks: Bookmark[];
frameTime: number;
updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime });
if (index === -1) {
return bookmarks;
}
const updated = { ...bookmarks[index], ...updates };
const result = [...bookmarks];
result[index] = updated;
return result;
}
export function moveBookmarkInArray({
bookmarks,
fromTime,
toTime,
}: {
bookmarks: Bookmark[];
fromTime: number;
toTime: number;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) {
return bookmarks;
}
const updated = { ...bookmarks[index], time: toTime };
const result = [...bookmarks];
result[index] = updated;
return result.slice().sort((a, b) => a.time - b.time);
}
export function getFrameTime({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return roundToFrame({ time, fps });
}
export function getBookmarkAtTime({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index];
}
export function getBookmarksActiveAtTime({
bookmarks,
time,
}: {
bookmarks: Bookmark[];
time: number;
}): Bookmark[] {
return bookmarks.filter((bookmark) => {
const start = bookmark.time;
const end =
bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration
: start;
return (
time >= start - BOOKMARK_TIME_EPSILON &&
time <= end + BOOKMARK_TIME_EPSILON
);
});
}
import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "opencut-wasm";
export const BOOKMARK_TIME_EPSILON = 0.001;
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
}: {
bookmarkTime: number;
frameTime: number;
}): boolean {
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
}
export function findBookmarkIndex({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): number {
return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function isBookmarkAtTime({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): boolean {
return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function toggleBookmarkInArray({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
if (bookmarkIndex !== -1) {
return bookmarks.filter((_, index) => index !== bookmarkIndex);
}
const newBookmarks = [...bookmarks, { time: frameTime }];
return newBookmarks.slice().sort((a, b) => a.time - b.time);
}
export function removeBookmarkFromArray({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark[] {
return bookmarks.filter(
(bookmark) =>
!bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
export function updateBookmarkInArray({
bookmarks,
frameTime,
updates,
}: {
bookmarks: Bookmark[];
frameTime: number;
updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime });
if (index === -1) {
return bookmarks;
}
const updated = { ...bookmarks[index], ...updates };
const result = [...bookmarks];
result[index] = updated;
return result;
}
export function moveBookmarkInArray({
bookmarks,
fromTime,
toTime,
}: {
bookmarks: Bookmark[];
fromTime: number;
toTime: number;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) {
return bookmarks;
}
const updated = { ...bookmarks[index], time: toTime };
const result = [...bookmarks];
result[index] = updated;
return result.slice().sort((a, b) => a.time - b.time);
}
export function getFrameTime({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return roundToFrame({ time, fps });
}
export function getBookmarkAtTime({
bookmarks,
frameTime,
}: {
bookmarks: Bookmark[];
frameTime: number;
}): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index];
}
export function getBookmarksActiveAtTime({
bookmarks,
time,
}: {
bookmarks: Bookmark[];
time: number;
}): Bookmark[] {
return bookmarks.filter((bookmark) => {
const start = bookmark.time;
const end =
bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration
: start;
return (
time >= start - BOOKMARK_TIME_EPSILON &&
time <= end + BOOKMARK_TIME_EPSILON
);
});
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function findElementAtPosition({
mouseX,
@@ -242,6 +242,7 @@ function buildVideoElement({
trimEnd: 0,
sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true,
hidden: false,
transform: {
...DEFAULTS.element.transform,
+1
View File
@@ -5,6 +5,7 @@ export * from "./drag";
export * from "./track-utils";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./ripple-utils";
@@ -1,7 +1,53 @@
import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement";
import type {
AudioElement,
AudioTrack,
GraphicElement,
GraphicTrack,
TextElement,
TextTrack,
TimelineTrack,
VideoElement,
VideoTrack,
} from "@/lib/timeline";
import type { Transform } from "@/lib/rendering";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function buildTransform(): Transform {
return {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
}
type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
function buildElement(params: {
id: string;
type: "audio";
startTime: number;
duration: number;
}): AudioElement;
function buildElement(params: {
id: string;
type: "graphic";
startTime: number;
duration: number;
}): GraphicElement;
function buildElement(params: {
id: string;
type: "text";
startTime: number;
duration: number;
}): TextElement;
function buildElement(params: {
id: string;
type: "video";
startTime: number;
duration: number;
}): VideoElement;
function buildElement({
id,
type,
@@ -9,61 +55,171 @@ function buildElement({
duration,
}: {
id: string;
type: ElementType;
type: TestElement["type"];
startTime: number;
duration: number;
}): TimelineElement {
return {
id,
type,
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
} as TimelineElement;
}): TestElement {
switch (type) {
case "audio":
return {
id,
type: "audio",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
volume: 1,
sourceType: "upload",
mediaId: `media-${id}`,
} satisfies AudioElement;
case "graphic":
return {
id,
type: "graphic",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
definitionId: `graphic-${id}`,
params: {},
transform: buildTransform(),
opacity: 1,
} satisfies GraphicElement;
case "text":
return {
id,
type: "text",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
content: id,
fontSize: 32,
fontFamily: "sans-serif",
color: "#ffffff",
background: {
enabled: false,
color: "#000000",
},
textAlign: "left",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
transform: buildTransform(),
opacity: 1,
} satisfies TextElement;
case "video":
return {
id,
type: "video",
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
mediaId: `media-${id}`,
transform: buildTransform(),
opacity: 1,
} satisfies VideoElement;
}
throw new Error(`Unsupported test element type: ${type}`);
}
function buildTrack({
id,
type,
elements = [],
isMain = false,
}: {
type BuildTrackParams =
| {
id: string;
type: "audio";
elements?: AudioTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "text";
elements?: TextTrack["elements"];
isMain?: boolean;
}
| {
id: string;
type: "video";
elements?: VideoTrack["elements"];
isMain?: boolean;
};
function buildTrack(params: {
id: string;
type: TrackType;
elements?: TimelineElement[];
type: "audio";
elements?: AudioTrack["elements"];
isMain?: boolean;
}): TimelineTrack {
if (type === "video") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
isMain,
muted: false,
hidden: false,
};
}): AudioTrack;
function buildTrack(params: {
id: string;
type: "graphic";
elements?: GraphicTrack["elements"];
isMain?: boolean;
}): GraphicTrack;
function buildTrack(params: {
id: string;
type: "text";
elements?: TextTrack["elements"];
isMain?: boolean;
}): TextTrack;
function buildTrack(params: {
id: string;
type: "video";
elements?: VideoTrack["elements"];
isMain?: boolean;
}): VideoTrack;
function buildTrack(params: BuildTrackParams): TimelineTrack {
const { id, type, isMain = false } = params;
switch (type) {
case "audio":
return {
id,
type: "audio",
name: id,
elements: params.elements ?? [],
muted: false,
};
case "graphic":
return {
id,
type: "graphic",
name: id,
elements: params.elements ?? [],
hidden: false,
};
case "text":
return {
id,
type: "text",
name: id,
elements: params.elements ?? [],
hidden: false,
};
case "video":
return {
id,
type: "video",
name: id,
elements: params.elements ?? [],
isMain,
muted: false,
hidden: false,
};
}
if (type === "audio") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
throw new Error(`Unsupported test track type: ${type}`);
}
function buildTimeSpan({
@@ -124,7 +280,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-1",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({ id: "text-2", type: "text" }),
];
@@ -256,7 +414,9 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-middle",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({ id: "text-source", type: "text" }),
];
@@ -281,12 +441,16 @@ describe("resolveTrackPlacement", () => {
buildTrack({
id: "text-top",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
],
}),
buildTrack({
id: "text-source",
type: "text",
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
elements: [
buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
],
}),
];
+28 -9
View File
@@ -44,9 +44,7 @@ function buildExistingTrackResult({
trackId: track.id,
trackIndex,
trackType: track.type,
...(adjustedStartTime !== requestedStartTime
? { adjustedStartTime }
: {}),
...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
};
}
@@ -114,6 +112,24 @@ function resolveAlwaysNewTrack({
});
}
function getInsertDirection({
hoverDirection,
verticalDragDirection,
}: {
hoverDirection: "above" | "below";
verticalDragDirection?: "up" | "down" | null;
}): "above" | "below" {
if (verticalDragDirection === "up") {
return "above";
}
if (verticalDragDirection === "down") {
return "below";
}
return hoverDirection;
}
export function resolveTrackPlacement({
tracks,
...placement
@@ -127,7 +143,9 @@ export function resolveTrackPlacement({
const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") {
const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId);
const trackIndex = tracks.findIndex(
(track) => track.id === strategy.trackId,
);
if (trackIndex < 0) {
return null;
}
@@ -182,15 +200,16 @@ export function resolveTrackPlacement({
});
}
const insertDirection =
!isPreferredTrackCompatible && strategy.verticalDragDirection
? strategy.verticalDragDirection
: strategy.hoverDirection;
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex: strategy.trackIndex,
direction: insertDirection,
direction: getInsertDirection({
hoverDirection: strategy.hoverDirection,
verticalDragDirection: !isPreferredTrackCompatible
? strategy.verticalDragDirection
: null,
}),
});
return buildNewTrackResult({
trackType,
+1
View File
@@ -113,6 +113,7 @@ export interface VideoElement extends BaseTimelineElement {
mediaId: string;
volume?: number;
muted?: boolean;
isSourceAudioEnabled?: boolean;
hidden?: boolean;
retime?: RetimeConfig;
transform: Transform;
@@ -1,5 +1,5 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { buildGaussianBlurPasses } from "@/lib/effects/definitions/blur";
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
import { getSourceTimeAtClipTime } from "@/lib/retime";
import { videoCache } from "@/services/video-cache/service";
import type { RetimeConfig } from "@/lib/timeline";
@@ -128,8 +128,8 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
);
const passes = buildGaussianBlurPasses({
sigmaX: this.params.blurIntensity * (renderer.width / 1920),
sigmaY: this.params.blurIntensity * (renderer.height / 1080),
sigmaX: intensityToSigma({ intensity: this.params.blurIntensity, resolution: renderer.width, reference: 1920 }),
sigmaY: intensityToSigma({ intensity: this.params.blurIntensity, resolution: renderer.height, reference: 1080 }),
});
const effectResult = webglEffectRenderer.applyEffect({
source: offscreen as CanvasImageSource,
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
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;
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
describe("V19 to V20 Migration", () => {
test("backfills source audio enabled state on video elements", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-source-audio",
version: 19,
metadata: {
id: "project-v19-source-audio",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [
{
id: "scene-main",
name: "Main",
isMain: true,
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
tracks: [
{
id: "track-video",
type: "video",
name: "Video",
isMain: true,
muted: false,
hidden: false,
elements: [
{
id: "video-1",
type: "video",
name: "Clip",
mediaId: "media-1",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
position: { x: 0, y: 0 },
scale: { x: 1, y: 1 },
rotation: 0,
},
opacity: 1,
},
],
},
{
id: "track-audio",
type: "audio",
name: "Audio",
muted: false,
elements: [
{
id: "audio-1",
type: "audio",
sourceType: "upload",
mediaId: "media-audio-1",
name: "Audio",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
},
],
},
],
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(20);
expect(
((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0].elements,
).toEqual([
expect.objectContaining({
id: "video-1",
isSourceAudioEnabled: true,
}),
]);
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[1]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBeUndefined();
});
test("preserves existing explicit source audio state", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-existing-state",
version: 19,
scenes: [
{
tracks: [
{
elements: [
{
type: "video",
isSourceAudioEnabled: false,
},
],
},
],
},
],
},
});
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBe(false);
});
test("skips projects already on v20", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v20",
version: 20,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v20");
});
});
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { transformProjectV20ToV21 } from "../transformers/v20-to-v21";
describe("V20 to V21 Migration", () => {
test("multiplies blur background intensity by 5", () => {
const result = transformProjectV20ToV21({
project: {
id: "project-v20-blur",
version: 20,
metadata: {
id: "project-v20-blur",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "blur", blurIntensity: 100 },
},
currentSceneId: "scene-main",
scenes: [],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(21);
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.blurIntensity).toBe(500);
});
test("uses default blur intensity when blur type but intensity missing", () => {
const result = transformProjectV20ToV21({
project: {
id: "project-v20-blur-no-intensity",
version: 20,
settings: {
background: { type: "blur" },
},
scenes: [],
},
});
expect(result.skipped).toBe(false);
const settings = result.project.settings as Record<string, unknown>;
const background = settings.background as Record<string, unknown>;
expect(background.blurIntensity).toBe(DEFAULT_BLUR_INTENSITY);
});
test("leaves color background unchanged", () => {
const result = transformProjectV20ToV21({
project: {
id: "project-v20-color",
version: 20,
settings: {
background: { type: "color", color: "#000000" },
},
scenes: [],
},
});
expect(result.skipped).toBe(false);
const settings = result.project.settings as Record<string, unknown>;
expect(settings.background).toEqual({ type: "color", color: "#000000" });
});
test("skips projects already on v21", () => {
const result = transformProjectV20ToV21({
project: {
id: "project-v21",
version: 21,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v21");
});
test("skips projects not on v20", () => {
const result = transformProjectV20ToV21({
project: {
id: "project-v19",
version: 19,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("not v20");
});
});
@@ -18,10 +18,12 @@ import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 19;
export const CURRENT_PROJECT_VERSION = 21;
export const migrations = [
new V0toV1Migration(),
@@ -43,4 +45,6 @@ export const migrations = [
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
new V19toV20Migration(),
new V20toV21Migration(),
];
@@ -0,0 +1,87 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV19ToV20({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 20) {
return { project, skipped: true, reason: "already v20" };
}
return {
project: {
...migrateVideoSourceAudioState({ project }),
version: 20,
},
skipped: false,
};
}
function migrateVideoSourceAudioState({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
if (!Array.isArray(project.scenes)) {
return project;
}
return {
...project,
scenes: project.scenes.map((scene) => migrateScene({ scene })),
};
}
function migrateScene({
scene,
}: {
scene: unknown;
}): unknown {
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
return scene;
}
return {
...scene,
tracks: scene.tracks.map((track) => migrateTrack({ track })),
};
}
function migrateTrack({
track,
}: {
track: unknown;
}): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({
element,
}: {
element: unknown;
}): unknown {
if (!isRecord(element) || element.type !== "video") {
return element;
}
return {
...element,
isSourceAudioEnabled:
typeof element.isSourceAudioEnabled === "boolean"
? element.isSourceAudioEnabled
: true,
};
}
@@ -0,0 +1,66 @@
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { INTENSITY_TO_SIGMA_DIVISOR } from "@/lib/effects/definitions/blur";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV20ToV21({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
const version = project.version;
if (typeof version !== "number") {
return { project, skipped: true, reason: "invalid version" };
}
if (version >= 21) {
return { project, skipped: true, reason: "already v21" };
}
if (version !== 20) {
return { project, skipped: true, reason: "not v20" };
}
return {
project: {
...migrateBackgroundBlurScale({ project }),
version: 21,
},
skipped: false,
};
}
function migrateBackgroundBlurScale({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
if (!isRecord(project.settings)) {
return project;
}
const settings = { ...project.settings };
const background = settings.background;
if (!isRecord(background) || background.type !== "blur") {
return { ...project, settings };
}
const raw = background.blurIntensity;
const blurIntensity =
typeof raw === "number" && Number.isFinite(raw)
? raw * INTENSITY_TO_SIGMA_DIVISOR
: DEFAULT_BLUR_INTENSITY;
return {
...project,
settings: {
...settings,
background: {
...background,
blurIntensity,
},
},
};
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV19ToV20 } from "./transformers/v19-to-v20";
export class V19toV20Migration extends StorageMigration {
from = 19;
to = 20;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV19ToV20({ project });
}
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV20ToV21 } from "./transformers/v20-to-v21";
export class V20toV21Migration extends StorageMigration {
from = 20;
to = 21;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV20ToV21({ project });
}
}
+7
View File
@@ -54,6 +54,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "workspace:*",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -102,6 +103,10 @@
"wrangler": "^4.77.0",
},
},
"rust/wasm/pkg": {
"name": "opencut-wasm",
"version": "0.1.0",
},
},
"trustedDependencies": [
"@tailwindcss/oxide",
@@ -1453,6 +1458,8 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
"opencut-wasm": ["opencut-wasm@workspace:rust/wasm/pkg"],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+5 -1
View File
@@ -3,9 +3,13 @@
"packageManager": "bun@1.2.18",
"workspaces": [
"apps/*",
"packages/*"
"packages/*",
"rust/wasm/pkg"
],
"scripts": {
"build:wasm": "wasm-pack build rust/wasm --target bundler --out-dir pkg",
"publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public",
"dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'",
"dev:web": "turbo run dev --filter=@opencut/web",
"build:web": "turbo run build --filter=@opencut/web",
"dev:tools": "turbo run dev --filter=@opencut/tools",
+3 -1
View File
@@ -9,7 +9,9 @@ crate-type = ["rlib", "cdylib"]
[dependencies]
bridge = { version = "0.1.0", path = "../bridge" }
serde = { version = "1", features = ["derive"] }
tsify-next = { version = "0.5", optional = true }
wasm-bindgen = { version = "0.2.115", optional = true }
[features]
wasm = ["dep:wasm-bindgen"]
wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"]
+223 -75
View File
@@ -1,52 +1,129 @@
use bridge::export;
use serde::{Deserialize, Serialize};
const DEFAULT_TIME_CODE_FORMAT: &str = "HH:MM:SS:CS";
const SECONDS_PER_HOUR: f64 = 3600.0;
const SECONDS_PER_MINUTE: f64 = 60.0;
const CENTISECONDS_PER_SECOND: f64 = 100.0;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TimeCodeFormat {
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
pub enum TimeCodeFormat {
#[serde(rename = "MM:SS")]
MmSs,
#[serde(rename = "HH:MM:SS")]
HhMmSs,
#[serde(rename = "HH:MM:SS:CS")]
HhMmSsCs,
#[serde(rename = "HH:MM:SS:FF")]
HhMmSsFf,
}
impl TimeCodeFormat {
fn parse(format: Option<&str>) -> Option<Self> {
match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) {
"MM:SS" => Some(Self::MmSs),
"HH:MM:SS" => Some(Self::HhMmSs),
"HH:MM:SS:CS" => Some(Self::HhMmSsCs),
"HH:MM:SS:FF" => Some(Self::HhMmSsFf),
_ => None,
}
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoundToFrameOptions {
pub time: f64,
pub fps: f64,
}
fn as_str(self) -> &'static str {
match self {
Self::MmSs => "MM:SS",
Self::HhMmSs => "HH:MM:SS",
Self::HhMmSsCs => "HH:MM:SS:CS",
Self::HhMmSsFf => "HH:MM:SS:FF",
}
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatTimeCodeOptions {
pub time_in_seconds: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParseTimeCodeOptions {
pub time_code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<TimeCodeFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuessTimeCodeFormatOptions {
pub time_code: String,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrameToTimeOptions {
pub frame: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnapTimeToFrameOptions {
pub time: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSnappedSeekTimeOptions {
pub raw_time: f64,
pub duration: f64,
pub fps: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetLastFrameTimeOptions {
pub duration: f64,
pub fps: f64,
}
#[export]
pub fn round_to_frame(time: f64, fps: f64) -> f64 {
pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 {
(time * fps).round() / fps
}
#[export]
pub fn format_time_code(
time_in_seconds: f64,
format: Option<String>,
fps: Option<f64>,
FormatTimeCodeOptions {
time_in_seconds,
format,
fps,
}: FormatTimeCodeOptions,
) -> Option<String> {
let format = TimeCodeFormat::parse(format.as_deref())?;
let hours = (time_in_seconds / 3600.0).floor() as u64;
let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64;
let seconds = (time_in_seconds % 60.0).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * 100.0).floor() as u64;
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64;
let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64;
let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64;
match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
@@ -69,12 +146,18 @@ pub fn format_time_code(
}
#[export]
pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>) -> Option<f64> {
pub fn parse_time_code(
ParseTimeCodeOptions {
time_code,
format,
fps,
}: ParseTimeCodeOptions,
) -> Option<f64> {
if time_code.trim().is_empty() {
return None;
}
let format = TimeCodeFormat::parse(format.as_deref())?;
let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs);
let parts = time_code
.trim()
.split(':')
@@ -86,35 +169,42 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [minutes, seconds] = parts.as_slice() else {
return None;
};
if *seconds >= 60 {
if *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some((*minutes as f64 * 60.0) + *seconds as f64)
Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64)
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 {
if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 {
return None;
}
Some((*hours as f64 * 3600.0) + (*minutes as f64 * 60.0) + *seconds as f64)
Some(
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64,
)
}
TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 || *centiseconds >= 100 {
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *centiseconds >= CENTISECONDS_PER_SECOND as u32
{
return None;
}
Some(
(*hours as f64 * 3600.0)
+ (*minutes as f64 * 60.0)
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*centiseconds as f64 / 100.0),
+ (*centiseconds as f64 / CENTISECONDS_PER_SECOND),
)
}
TimeCodeFormat::HhMmSsFf => {
@@ -126,13 +216,16 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 || *frames as f64 >= fps {
if *minutes >= SECONDS_PER_MINUTE as u32
|| *seconds >= SECONDS_PER_MINUTE as u32
|| *frames as f64 >= fps
{
return None;
}
Some(
(*hours as f64 * 3600.0)
+ (*minutes as f64 * 60.0)
(*hours as f64 * SECONDS_PER_HOUR)
+ (*minutes as f64 * SECONDS_PER_MINUTE)
+ *seconds as f64
+ (*frames as f64 / fps),
)
@@ -141,7 +234,9 @@ pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>
}
#[export]
pub fn guess_time_code_format(time_code: &str) -> Option<String> {
pub fn guess_time_code_format(
GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions,
) -> Option<TimeCodeFormat> {
if time_code.trim().is_empty() {
return None;
}
@@ -153,41 +248,52 @@ pub fn guess_time_code_format(time_code: &str) -> Option<String> {
})?;
match part_count {
2 => Some(TimeCodeFormat::MmSs.as_str().to_string()),
3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()),
4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()),
2 => Some(TimeCodeFormat::MmSs),
3 => Some(TimeCodeFormat::HhMmSs),
4 => Some(TimeCodeFormat::HhMmSsFf),
_ => None,
}
}
#[export]
pub fn time_to_frame(time: f64, fps: f64) -> f64 {
pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 {
(time * fps).round()
}
#[export]
pub fn frame_to_time(frame: f64, fps: f64) -> f64 {
pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 {
frame / fps
}
#[export]
pub fn snap_time_to_frame(time: f64, fps: f64) -> f64 {
pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 {
if fps <= 0.0 {
return time;
}
frame_to_time(time_to_frame(time, fps), fps)
frame_to_time(FrameToTimeOptions {
frame: time_to_frame(TimeToFrameOptions { time, fps }),
fps,
})
}
#[export]
pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 {
let snapped_time = snap_time_to_frame(raw_time, fps);
let last_frame = get_last_frame_time(duration, fps);
pub fn get_snapped_seek_time(
GetSnappedSeekTimeOptions {
raw_time,
duration,
fps,
}: GetSnappedSeekTimeOptions,
) -> f64 {
let snapped_time = snap_time_to_frame(SnapTimeToFrameOptions { time: raw_time, fps });
let last_frame = get_last_frame_time(GetLastFrameTimeOptions { duration, fps });
snapped_time.clamp(0.0, last_frame)
}
#[export]
pub fn get_last_frame_time(duration: f64, fps: f64) -> f64 {
pub fn get_last_frame_time(
GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions,
) -> f64 {
if duration <= 0.0 {
return 0.0;
}
@@ -206,18 +312,26 @@ mod tests {
#[test]
fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(1.24, 10.0), 1.2);
assert_eq!(round_to_frame(1.26, 10.0), 1.3);
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2);
assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
}
#[test]
fn formats_default_time_codes() {
assert_eq!(
format_time_code(3723.45, None, None),
Some("01:02:03:44".to_string()),
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 3723.45,
format: None,
fps: None,
}),
Some("01:02:03:44".to_string()),
);
assert_eq!(
format_time_code(65.0, Some("MM:SS".to_string()), None),
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 65.0,
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some("01:05".to_string()),
);
}
@@ -225,11 +339,19 @@ mod tests {
#[test]
fn formats_frame_based_time_codes() {
assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), Some(30.0)),
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some("00:00:01:15".to_string()),
);
assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), None),
format_time_code(FormatTimeCodeOptions {
time_in_seconds: 1.5,
format: Some(TimeCodeFormat::HhMmSsFf),
fps: None,
}),
None,
);
}
@@ -237,38 +359,64 @@ mod tests {
#[test]
fn parses_time_codes() {
assert_eq!(
parse_time_code("01:05", Some("MM:SS".to_string()), None),
parse_time_code(ParseTimeCodeOptions {
time_code: "01:05".to_string(),
format: Some(TimeCodeFormat::MmSs),
fps: None,
}),
Some(65.0),
);
assert_eq!(
parse_time_code("00:00:01:15", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:15".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
Some(1.5),
);
assert_eq!(
parse_time_code("00:00:01:30", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
parse_time_code(ParseTimeCodeOptions {
time_code: "00:00:01:30".to_string(),
format: Some(TimeCodeFormat::HhMmSsFf),
fps: Some(30.0),
}),
None,
);
}
#[test]
fn guesses_time_code_formats() {
assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
assert_eq!(
guess_time_code_format("00:00:01"),
Some("HH:MM:SS".to_string()),
guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }),
Some(TimeCodeFormat::MmSs),
);
assert_eq!(
guess_time_code_format("00:00:01:15"),
Some("HH:MM:SS:FF".to_string()),
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01".to_string(),
}),
Some(TimeCodeFormat::HhMmSs),
);
assert_eq!(
guess_time_code_format(GuessTimeCodeFormatOptions {
time_code: "00:00:01:15".to_string(),
}),
Some(TimeCodeFormat::HhMmSsFf),
);
}
#[test]
fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(1.26, 10.0), 13.0);
assert_eq!(frame_to_time(13.0, 10.0), 1.3);
assert_eq!(snap_time_to_frame(1.26, 10.0), 1.3);
assert_eq!(get_last_frame_time(10.0, 5.0), 9.8);
assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8);
assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0);
assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3);
assert_eq!(snap_time_to_frame(SnapTimeToFrameOptions { time: 1.26, fps: 10.0 }), 1.3);
assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8);
assert_eq!(
get_snapped_seek_time(GetSnappedSeekTimeOptions {
raw_time: 10.0,
duration: 10.0,
fps: 5.0,
}),
9.8,
);
}
}
+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..."
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",
"tasks": {
"build:wasm": {
"outputs": ["rust/wasm/pkg/**"]
},
"build": {
"dependsOn": ["^build"],
"dependsOn": ["//#build:wasm", "^build"],
"outputs": [".next/**", "!.next/cache/**"],
"env": [
"NODE_ENV",