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