mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301e89df89 | ||
|
|
b048b739bd | ||
|
|
dc82826951 | ||
|
|
608dc1a62f | ||
|
|
f1d5932c54 | ||
|
|
cc150c7cfb | ||
|
|
f8fb238b22 | ||
|
|
0488a6be45 | ||
|
|
0f5dca4f12 | ||
|
|
154b78c7d1 | ||
|
|
d23be079cc | ||
|
|
dcf54c1250 |
@@ -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:
|
||||
|
||||
@@ -22,3 +22,5 @@ bun.lockb
|
||||
.open-next
|
||||
|
||||
target/
|
||||
|
||||
.cargo-tools/
|
||||
Generated
+91
-18
@@ -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
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time"
|
||||
"rust/crates/bridge"
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
pub use time::*;
|
||||
+5
-1
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user