Compare commits

..
Author SHA1 Message Date
Maze Winther f1d5932c54 feat: separate audio from elements 2026-03-31 23:23:36 +02:00
Maze Winther d23be079cc Merge branch 'feature/track-placement' 2026-03-31 15:53:23 +02:00
Maze Winther 70a6d58b84 refactor: centralize track placement into its own module 2026-03-31 15:47:30 +02:00
Maze Winther dcf54c1250 feat: migrate time utilities from typescript to rust WASM 2026-03-31 04:03:52 +02:00
Maze Winther 5cc6abc3a4 style: swap ripple icon 2026-03-29 22:31:26 +02:00
Maze Winther 77a14b1023 refactor: standardize icon component props 2026-03-29 22:30:11 +02:00
Maze Winther 1c3b22edec feat: add keyframe graphs button 2026-03-29 21:48:33 +02:00
Maze Winther 41562baeb5 style: remove unnecessary margin from keyframe 2026-03-29 21:48:08 +02:00
Maze Winther df207e0f83 refactor: name magic numbers and rename el in landing handlebars 2026-03-29 21:18:07 +02:00
Maze Winther dc5956900e fix: eliminate first-paint flicker in landing handlebars 2026-03-29 20:45:48 +02:00
Maze Winther b095862cc0 fix: improve DATABASE_URL validation in web environment schema 2026-03-29 18:58:35 +02:00
Maze Winther 0a6ede73ae chore: update deps 2026-03-29 18:57:05 +02:00
Maze Winther 7ce0018c9b chore: update .gitignore 2026-03-29 18:55:56 +02:00
Maze Winther 9528d0053b chore: untrack files 2026-03-29 18:55:10 +02:00
Maze Winther 689853312d fix: remove OpenNext Cloudflare initialization 2026-03-29 18:35:58 +02:00
Maze Winther 5a1d9ef3f4 chore: remove unused middleware file 2026-03-29 18:30:16 +02:00
Rayan SalhabGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Mazecoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
eb66e03f0e fix(security): replace new Function() with safe math expression parser (#753)
* fix(security): replace new Function() with safe math expression parser

Replace the dangerous new Function() constructor in evaluateMathExpression()
with a recursive descent parser that safely evaluates basic arithmetic
expressions.

The Function constructor is a security anti-pattern that could enable
arbitrary code execution if the validation regex is ever bypassed or
relaxed. The new parser:

- Only supports numbers, +, -, *, /, parentheses, and whitespace
- Has no eval-like functionality that could execute arbitrary code
- Maintains backward compatibility with existing expressions
- Handles operator precedence and parentheses correctly

Fixes #725

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-29 18:27:05 +02:00
Maze Winther a2418751e6 chore: remove accidentally committed Zone.Identifier stream 2026-03-29 18:25:43 +02:00
Maze Winther 5777a555f6 feat: add shared Rust crates for cross-platform time utilities
Introduce `bridge` proc-macro crate with `#[export]` attribute that conditionally applies `wasm_bindgen` for WASM builds, and `time` crate porting all timecode/frame utilities from TypeScript to Rust.
2026-03-29 15:58:46 +02:00
Maze Winther 220ec757d4 refactor: move packages sub-folders into web app 2026-03-29 15:58:46 +02:00
Maze Winther 585e28d49b feat: add published status to changelog entries 2026-03-29 15:58:46 +02:00
Maze Winther 1261e663e1 docs: rewrite agents file 2026-03-29 15:58:46 +02:00
Maze Winther c34e01c63e refactor: clean up number formatting and step handling in property tabs 2026-03-29 15:58:46 +02:00
Maze Winther 3e75852ffe chore: remove dead songs tab 2026-03-29 15:58:46 +02:00
Maze Winther e95992b406 chore: add missing open-next config 2026-03-29 15:58:46 +02:00
Maze Winther 6df9d70b9b chore: update dep 2026-03-29 15:58:46 +02:00
Maze Winther f8a0390827 chore: rename proxy to middleware 2026-03-29 15:58:46 +02:00
Maze Winther d862e56e34 feat: init desktop app 2026-03-29 15:58:42 +02:00
Maze Winther d08fbd73e1 fix: add missing file 2026-03-29 15:57:05 +02:00
Maze Winther 4d127a038b chore: normalize line endings 2026-03-29 15:56:48 +02:00
Maze Winther 82817cb50c chore: next-env 2026-03-29 15:49:30 +02:00
Maze Winther a54b3ef076 chore: update botid 2026-03-29 15:49:30 +02:00
Maze Winther 6dd8191f4b chore: init cloudflare 2026-03-29 15:49:30 +02:00
Maze Winther 722dcdd88f fix: resolve typescript/lint errors 2026-03-29 15:49:30 +02:00
Maze Winther 8db3bead13 feat: masks, properties refactor, shaders, storage migrations, and more 2026-03-29 15:48:22 +02:00
Maze Winther 39ea298a9c chore: gitignore 2026-03-29 15:46:35 +02:00
Maze Winther 2c0fd4f86b untrack .cursor/ 2026-03-29 15:46:35 +02:00
Maze Winther 5e2b829f8a changelog: add already-completed item 2026-03-29 15:46:35 +02:00
Maze Winther b09635064a fix: assets having unreasonable widths and heights 2026-03-29 15:41:10 +02:00
Maze Winther 6018efc5ac changelog: new version with already-made changes 2026-03-29 15:41:10 +02:00
AspenandGitHub 7e2a913e13 Update OpenCut logo in README (#741) 2026-03-28 19:07:36 +01:00
kcfancherandGitHub 63496d05a2 Fix transcription sample-rate handling (#740)
Fix for #703.

Adds an optional sampleRate parameter to the decodeAudioToFloat32() function. The caption transcriber passes in a value of 16000 to prepare audio in the format Whisper expects.
2026-03-28 19:06:40 +01:00
118 changed files with 9175 additions and 7057 deletions
+2
View File
@@ -22,3 +22,5 @@ bun.lockb
.open-next
target/
.cargo-tools/
Generated
+91 -18
View File
@@ -642,6 +642,14 @@ dependencies = [
"piper",
]
[[package]]
name = "bridge"
version = "0.1.0"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1977,6 +1985,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glow"
version = "0.16.0"
@@ -2780,10 +2801,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -3540,6 +3563,13 @@ dependencies = [
"gpui",
]
[[package]]
name = "opencut-wasm"
version = "0.1.2"
dependencies = [
"time",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -4588,6 +4618,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -5253,6 +5294,16 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.1.0"
dependencies = [
"bridge",
"serde",
"tsify-next",
"wasm-bindgen",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -5527,6 +5578,32 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tsify-next"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
dependencies = [
"gloo-utils",
"serde",
"serde-wasm-bindgen",
"serde_json",
"tsify-next-macros",
"wasm-bindgen",
]
[[package]]
name = "tsify-next-macros"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "ttf-parser"
version = "0.20.0"
@@ -5850,9 +5927,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@@ -5863,23 +5940,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
version = "0.4.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5887,9 +5960,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5900,9 +5973,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
@@ -6053,9 +6126,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [
"js-sys",
"wasm-bindgen",
+3
View File
@@ -2,4 +2,7 @@
resolver = "2"
members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm",
]
+2 -2
View File
@@ -1,10 +1,10 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</span></h1>
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
+4
View File
@@ -8,3 +8,7 @@
.next/
.font-cache/
next-env.d.ts
*.tsbuildinfo
-5
View File
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/env/package.json packages/env/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/env/ packages/env/
COPY packages/ui/ packages/ui/
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+4 -2
View File
@@ -9,6 +9,7 @@ const changelog = defineCollection({
content: z.string(),
version: z.string(),
date: z.string(),
published: z.boolean().default(true),
title: z.string(),
description: z.string().optional(),
changes: z.array(
@@ -20,10 +21,11 @@ const changelog = defineCollection({
}),
transform: async (doc, { collection }) => {
const allDocs = await collection.documents();
const sorted = [...allDocs].sort((a, b) =>
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
const sorted = [...publishedDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
const isLatest = sorted[0]?.version === doc.version;
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
return { ...doc, isLatest };
},
});
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.1.0"
date: "2026-02-23"
published: true
title: "Editor foundation"
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.2.0"
date: "2026-03-01"
published: true
title: "Motion & effects"
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Brand & polish"
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
changes:
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-3
View File
@@ -1,9 +1,6 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
const nextConfig: NextConfig = {
turbopack: {
+2 -3
View File
@@ -22,8 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"opencut-wasm": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -77,7 +76,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zod": "4.3.6",
"zustand": "^5.0.2"
},
"devDependencies": {
+1 -1
View File
@@ -1,4 +1,4 @@
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
+17 -20
View File
@@ -2,9 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { allChangelogs } from "content-collections";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getSortedReleases } from "../utils";
import { getReleaseByVersion, getSortedReleases } from "../utils";
import {
ReleaseArticle,
ReleaseMeta,
@@ -17,12 +16,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> };
export async function generateStaticParams() {
return allChangelogs.map((r) => ({ version: r.version }));
return getSortedReleases().map((release) => ({ version: release.version }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { version } = await params;
const release = allChangelogs.find((r) => r.version === version);
const release = getReleaseByVersion({ version });
if (!release) return {};
return {
title: `${release.title} (${release.version}) - OpenCut Changelog`,
@@ -33,10 +32,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ReleaseDetailPage({ params }: Props) {
const { version } = await params;
const releases = getSortedReleases();
const index = releases.findIndex((r) => r.version === version);
const index = releases.findIndex((entry) => entry.version === version);
if (index === -1) notFound();
const release = releases[index];
const newer = index > 0 ? releases[index - 1] : null;
const older = index < releases.length - 1 ? releases[index + 1] : null;
@@ -52,20 +49,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
All releases
</Link>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseChanges release={release} />
</ReleaseArticle>
@@ -23,7 +23,7 @@ function buildMarkdown({
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle(type)}`);
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
@@ -87,7 +87,7 @@ export function ReleaseChanges({ release }: { release: Release }) {
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle(type)}:
{getSectionTitle({ type })}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => (
+14 -4
View File
@@ -12,7 +12,7 @@ const knownSectionTitles: Record<string, string> = {
breaking: "Breaking Changes",
};
export function getSectionTitle(type: string): string {
export function getSectionTitle({ type }: { type: string }): string {
return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
);
@@ -36,8 +36,18 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return [...allChangelogs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
+3 -3
View File
@@ -21,7 +21,7 @@ import type {
TProjectSortKey,
TProjectSortOption,
} from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -46,7 +46,7 @@ import {
ArrowDown02Icon,
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { OcVideoIcon } from "@opencut/ui/icons";
import { OcVideoIcon } from "@/components/icons";
import { Label } from "@/components/ui/label";
import {
ContextMenu,
@@ -77,7 +77,7 @@ const formatProjectDuration = ({
}
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
return formatTimeCode({ timeInSeconds: duration, format });
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
};
const VIEW_MODE_OPTIONS = [
@@ -1,14 +1,13 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
import type { TTimeCode } from "@/lib/time";
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TTimeCode;
format?: TimeCodeFormat;
fps: number;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
@@ -29,7 +28,7 @@ export function EditableTimecode({
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
const startEditing = () => {
if (disabled) return;
@@ -49,7 +48,7 @@ export function EditableTimecode({
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime === null) {
if (parsedTime == null) {
setHasError(true);
return;
}
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { Button } from "@/components/ui/button";
function InfoRow({
@@ -37,11 +37,8 @@ export function ProjectInfoDialog({
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@@ -11,11 +11,9 @@ import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import {
BatchCommand,
AddTrackCommand,
InsertElementCommand,
} from "@/lib/commands";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
} from "@/constants/transcription-constants";
import type {
TranscriptionLanguage,
TranscriptionProgress,
@@ -31,6 +29,11 @@ import {
SectionFields,
} from "@/components/section";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -62,7 +65,10 @@ export function Captions() {
});
setProcessingStep("Preparing audio...");
const { samples } = await decodeAudioToFloat32({ audioBlob });
const { samples } = await decodeAudioToFloat32({
audioBlob,
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
});
const result = await transcriptionService.transcribe({
audioData: samples,
@@ -28,7 +28,7 @@ import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@opencut/ui/icons";
import { OcSquarePlusIcon } from "@/components/icons";
import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background";
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { invokeAction } from "@/lib/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
@@ -93,11 +93,7 @@ function TimecodeDisplay() {
/>
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
<span className="text-muted-foreground font-mono text-xs">
{formatTimeCode({
timeInSeconds: totalDuration,
format: "HH:MM:SS:FF",
fps,
})}
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
</span>
</div>
);
@@ -30,7 +30,7 @@ import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@opencut/ui/icons";
import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = {
trackId: string;
@@ -1,5 +1,7 @@
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
clamp,
@@ -10,6 +12,7 @@ import {
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useEditor } from "@/hooks/use-editor";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { KeyframeToggle } from "../components/keyframe-toggle";
@@ -34,6 +37,7 @@ export function AudioTab({
element: AudioElement | VideoElement;
trackId: string;
}) {
const editor = useEditor();
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
@@ -80,46 +84,68 @@ export function AudioTab({
rightValue: DEFAULTS.element.volume,
})
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
const isSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
<>
{isSeparated && (
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
<p className="text-sm">Audio has been separated.</p>
<Button
className="mt-3"
size="sm"
variant="secondary"
onClick={() =>
editor.timeline.toggleSourceAudioSeparation({
trackId,
elementId: element.id,
})
}
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
Recover audio
</Button>
</div>
)}
<Section collapsible sectionKey={`${element.id}:audio`}>
<SectionHeader>
<SectionTitle>Audio</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<SectionField
label="Volume"
beforeLabel={
<KeyframeToggle
isActive={volume.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle volume keyframe"
onToggle={volume.toggleKeyframe}
/>
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
>
<NumberField
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
value={volume.displayValue}
onFocus={volume.onFocus}
onChange={volume.onChange}
onBlur={volume.onBlur}
dragSensitivity="slow"
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
onScrub={volume.scrubTo}
onScrubEnd={volume.commitScrub}
onReset={() =>
volume.commitValue({
value: DEFAULTS.element.volume,
})
}
isDefault={isDefault}
suffix="dB"
/>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
</>
);
}
@@ -1,7 +1,7 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import {
@@ -53,7 +53,7 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
@@ -38,7 +38,7 @@ import {
PlusSignIcon,
TextFontIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
@@ -10,7 +10,7 @@ import {
TIMELINE_BOOKMARK_ROW_HEIGHT,
} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { getSnappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -13,7 +13,7 @@ import {
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
import {
ContextMenu,
ContextMenuContent,
@@ -49,10 +49,10 @@ import {
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
isMainTrack,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@@ -39,6 +39,11 @@ import type {
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import {
getActionDefinition,
type TAction,
@@ -61,7 +66,9 @@ import {
Search01Icon,
Exchange01Icon,
KeyframeIcon,
Link02Icon,
MagicWand05Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
@@ -280,6 +287,19 @@ export function TimelineElement({
};
const isMuted = canElementHaveAudio(element) && element.muted === true;
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
canToggleSourceAudio({
element,
mediaAsset,
});
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
: "Extract audio";
const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
return (
<ContextMenu>
@@ -334,6 +354,21 @@ export function TimelineElement({
isMuted={isMuted}
/>
)}
{canToggleCurrentSourceAudio && (
<ContextMenuItem
icon={
<HugeiconsIcon
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction("toggle-source-audio");
}}
>
{sourceAudioLabel}
</ContextMenuItem>
)}
{canElementBeHidden(element) && (
<VisibilityMenuItem
element={element}
@@ -566,7 +601,7 @@ function KeyframeIndicators({
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 mt-1.5 text-black",
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
@@ -1,4 +1,5 @@
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
TooltipProvider,
Tooltip,
@@ -6,7 +7,6 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { SplitSquareHorizontal } from "lucide-react";
import {
SplitButton,
SplitButtonLeft,
@@ -18,6 +18,12 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
import { ScenesView } from "@/components/editor/scenes-view";
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import {
canToggleSourceAudio,
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation";
import { hasMediaId } from "@/lib/timeline";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -33,9 +39,13 @@ import {
Copy01Icon,
AlignLeftIcon,
AlignRightIcon,
Link02Icon,
Layers01Icon,
Chart03Icon,
Unlink02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
export function TimelineToolbar({
zoomLevel,
@@ -76,9 +86,47 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
const editor = useEditor();
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
const { selectedElements } = useElementSelection();
const isCurrentlyBookmarked = useEditor((e) =>
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
);
const selectedElement =
selectedElements.length === 1
? (editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0] ?? null)
: null;
const selectedMediaAsset = (() => {
if (!selectedElement) {
return null;
}
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
canToggleSourceAudio({
element: selectedElement.element,
mediaAsset: selectedMediaAsset,
});
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
element: selectedElement.element,
})
: "Extract audio";
const isSelectedSourceAudioSeparated =
selectedElement?.element.type === "video" &&
isSourceAudioSeparated({
element: selectedElement.element,
});
const handleAction = ({
action,
@@ -115,10 +163,16 @@ function ToolbarLeftSection() {
/>
<ToolbarButton
icon={<SplitSquareHorizontal />}
tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
icon={
<HugeiconsIcon
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
/>
}
tooltip={sourceAudioLabel}
disabled={!canToggleSelectedSourceAudio}
onClick={({ event }) =>
handleAction({ action: "toggle-source-audio", event })
}
/>
<ToolbarButton
@@ -156,6 +210,14 @@ function ToolbarLeftSection() {
}
/>
</Tooltip>
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Graph"
onClick={() => {}}
/>
</Tooltip>
</TooltipProvider>
</div>
);
@@ -207,7 +269,7 @@ function ToolbarRightSection({
/>
<ToolbarButton
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
icon={<OcRippleIcon size={24} className="scale-110" />}
isActive={rippleEditingEnabled}
tooltip="Ripple editing"
onClick={() => toggleRippleEditing()}
@@ -265,6 +327,7 @@ function ToolbarButton({
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
@@ -1,4 +1,6 @@
export function OcVercelIcon({ className }: { className?: string }) {
import type { IconProps } from "./types";
export function OcVercelIcon({ className }: IconProps) {
return (
<svg
className={className}
@@ -17,10 +19,7 @@ export function OcVercelIcon({ className }: { className?: string }) {
export function OcMarbleIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -43,10 +42,7 @@ export function OcMarbleIcon({
export function OcDataBuddyIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+5
View File
@@ -0,0 +1,5 @@
export interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
@@ -1,10 +1,9 @@
import type { IconProps } from "./types";
export function OcVideoIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -34,10 +33,7 @@ export function OcVideoIcon({
export function OcCheckerboardIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -68,10 +64,7 @@ export function OcCheckerboardIcon({
export function OcSlidersVerticalIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -113,10 +106,7 @@ export function OcSlidersVerticalIcon({
export function OcSocialIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round(size * (38 / 24))}
@@ -162,10 +152,7 @@ export function OcSocialIcon({
export function OcTextWidthIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 17) / 24)}
@@ -190,10 +177,7 @@ export function OcTextWidthIcon({
export function OcTextHeightIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 20) / 23)}
@@ -218,10 +202,7 @@ export function OcTextHeightIcon({
export function OcFontIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -244,11 +225,7 @@ export function OcShapesIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -288,10 +265,7 @@ export function OcShapesIcon({
export function OcMirrorIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -329,11 +303,7 @@ export function OcSquarePlusIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -383,3 +353,52 @@ export function OcSquarePlusIcon({
</svg>
);
}
export function OcRippleIcon({
className = "",
size = 24,
strokeWidth = 1.5,
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className={className}
>
<title>Ripple</title>
<path
d="M5 9C4.39316 9.58984 2 11.1597 2 12C2 12.8403 4.39316 14.4102 5 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 9C19.6068 9.58984 22 11.1597 22 12C22 12.8403 19.6068 14.4102 19 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.42285 11.9795H21.868"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.1454 21.7021L12.1454 2.25692"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+51 -21
View File
@@ -1,9 +1,20 @@
"use client";
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
type HandlebarsProps = PropsWithChildren;
const MIN_HANDLE_SEPARATION_PX = 60;
const MASK_GRADIENT_EDGE_PADDING_PX = 10;
const HANDLEBARS_ROTATE_DEG = 2.76;
const RIGHT_HANDLE_LEFT_OFFSET_PX = -30;
export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
@@ -31,18 +42,18 @@ export function Handlebars({ children }: HandlebarsProps) {
initialPosition: 0,
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () => {
const newWidth = el.offsetWidth;
const newWidth = container.offsetWidth;
setWidth(newWidth);
setRightHandle(newWidth);
};
const observer = new ResizeObserver(updateWidth);
observer.observe(el);
observer.observe(container);
updateWidth();
return () => observer.disconnect();
@@ -66,7 +77,10 @@ export function Handlebars({ children }: HandlebarsProps) {
const deltaX = event.clientX - startX;
if (side === "left") {
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
const maxLeft = Math.max(
0,
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
);
const nextLeftHandle = Math.max(
0,
Math.min(maxLeft, initialPosition + deltaX),
@@ -77,7 +91,7 @@ export function Handlebars({ children }: HandlebarsProps) {
const minRight = Math.min(
widthRef.current,
leftHandlePositionRef.current + 60,
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX,
);
const nextRightHandle = Math.max(
minRight,
@@ -106,13 +120,30 @@ export function Handlebars({ children }: HandlebarsProps) {
};
}, []);
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
const rightGradientPercent =
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
const hasMeasuredWidth = width > 0;
const leftGradientPercent = hasMeasuredWidth
? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 0;
const rightGradientPercent = hasMeasuredWidth
? (rightHandle / (width + MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 100;
const textMask = hasMeasuredWidth
? `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`
: undefined;
return (
<div className="flex justify-center gap-4 leading-16">
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
<div
ref={containerRef}
className="relative mt-0.5"
style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }}
>
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
<div
ref={leftHandleRef}
@@ -135,9 +166,13 @@ export function Handlebars({ children }: HandlebarsProps) {
<div
ref={rightHandleRef}
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{
translate: `${rightHandle}px 0`,
left: hasMeasuredWidth
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
: undefined,
right: hasMeasuredWidth ? undefined : "0px",
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
}}
onPointerDown={(event) => {
event.preventDefault();
@@ -156,13 +191,8 @@ export function Handlebars({ children }: HandlebarsProps) {
<span
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
style={{
mask: `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`,
mask: textMask,
WebkitMask: textMask,
}}
>
{children}
+1 -1
View File
@@ -1,4 +1,4 @@
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
export const SITE_URL = "https://opencut.app";
+1 -11
View File
@@ -1,4 +1,4 @@
import type { ElementType, TrackType } from "@/lib/timeline";
import type { TrackType } from "@/lib/timeline";
export const TRACK_CONFIG: Record<
TrackType,
@@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record<
effect: { background: "bg-[#5d93ba]" },
} as const;
export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export const TRACK_GAP = 6;
export const TRACK_LABELS_WIDTH_PX = 112;
@@ -51,6 +51,8 @@ export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
"whisper-small";
export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
export const DEFAULT_STRIDE_SECONDS = 5;
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode } from "@/lib/time";
import { formatTimeCode } from "opencut-wasm";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
@@ -107,10 +107,7 @@ export class RendererManager {
return { success: false, error: "Failed to create image" };
}
const timecode = formatTimeCode({
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { ensureMainTrack } from "@/lib/timeline/placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
+16 -1
View File
@@ -13,7 +13,7 @@ import type {
AnimationValue,
} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "@/lib/time";
import { getLastFrameTime } from "opencut-wasm";
import {
AddTrackCommand,
RemoveTrackCommand,
@@ -45,6 +45,7 @@ import {
ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@@ -281,6 +282,20 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
toggleSourceAudioSeparation({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
const command = new ToggleSourceAudioSeparationCommand({
trackId,
elementId,
});
this.editor.command.execute({ command });
}
updateElements({
updates,
pushHistory = true,
@@ -6,9 +6,13 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
import {
getElementsAtTime,
hasMediaId,
} from "@/lib/timeline";
import { cancelInteraction } from "@/lib/cancel-interaction";
import { invokeAction } from "@/lib/actions";
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
import {
activateScope,
clearActiveScope,
@@ -266,6 +270,48 @@ export function useEditorActions() {
undefined,
);
useActionHandler(
"toggle-source-audio",
() => {
if (selectedElements.length !== 1) {
return;
}
const selectedElement = editor.timeline.getElementsWithTracks({
elements: selectedElements,
})[0];
if (!selectedElement) {
return;
}
const mediaAsset = (() => {
const { element } = selectedElement;
if (!hasMediaId(element)) {
return null;
}
return (
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
null
);
})();
if (
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
return;
}
editor.timeline.toggleSourceAudioSeparation({
trackId: selectedElement.track.id,
elementId: selectedElement.element.id,
});
},
undefined,
);
useActionHandler(
"select-all",
() => {
@@ -14,7 +14,7 @@ import {
DRAG_THRESHOLD_PX,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -302,10 +302,7 @@ export function useElementInteraction({
0,
mouseTime - pendingDragRef.current.clickOffsetTime,
);
const snappedTime = snapTimeToFrame({
time: adjustedTime,
fps: activeProject.settings.fps,
});
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
startDrag({
...pendingDragRef.current,
initialCurrentTime: snappedTime,
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
time: Math.min(
maxAllowed,
Math.max(minTrimStartForNeighbor, calculated),
),
fps: projectFps,
});
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const trimDelta = resizing.initialDuration - newDuration;
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
)
: Math.min(extensionAmount, maxExtension),
);
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
});
const newDuration = snapTimeToFrame({
time: resizing.initialDuration + actualExtension,
fps: projectFps,
});
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
setCurrentTrimStart(0);
setCurrentStartTime(newStartTime);
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
0,
sourceDuration - newTrimStart - resizing.initialTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newStartTime = snapTimeToFrame({
time:
resizing.initialStartTime +
(resizing.initialDuration - newDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
const extensionNeeded = Math.abs(newTrimEnd);
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
time: Math.min(
baseDuration + extensionNeeded,
maxAllowedDuration,
),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
const unclampedDuration = getDurationForVisibleSourceSpan(
Math.max(0, sourceDuration - resizing.initialTrimStart),
);
const newDuration = snapTimeToFrame({
time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
setCurrentDuration(newDuration);
setCurrentTrimEnd(0);
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
);
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
});
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
const visibleSourceSpan = Math.max(
0,
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
);
const newDuration = snapTimeToFrame({
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
@@ -7,7 +7,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import {
DRAG_THRESHOLD_PX,
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
if (wasDrag) return;
const duration = editor.timeline.getTotalDuration();
const seekTime = getSnappedSeekTime({
rawTime: displayedStartTime + indicatorTime,
duration,
fps,
});
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
editor.playback.seek({ time: seekTime });
if (event.shiftKey) {
@@ -8,7 +8,7 @@ import {
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import {
findSnapPoints,
@@ -145,10 +145,7 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
const frameSnappedTime = snapTimeToFrame({
time: Math.max(0, Math.min(mouseTime, duration)),
fps: activeProject.settings.fps,
});
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -176,10 +173,7 @@ export function useBookmarkDrag({
scrollLeft,
});
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
const frameSnappedTime = snapTimeToFrame({
time: clampedTime,
fps: activeProject.settings.fps,
});
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
@@ -4,7 +4,7 @@ import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { snapTimeToFrame } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/track-utils";
import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
@@ -444,7 +444,8 @@ export function useTimelineDragDrop({
if (!createdAsset) continue;
const duration =
createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
createdAsset.duration ??
TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
@@ -1,4 +1,4 @@
import { getSnappedSeekTime } from "@/lib/time";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
);
const framesPerSecond = activeProject.settings.fps;
const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => {
@@ -1,7 +1,7 @@
import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getSnappedSeekTime } from "@/lib/time";
import { getSnappedSeekTime } from "opencut-wasm";
import { useEditor } from "../use-editor";
interface UseTimelineSeekProps {
@@ -137,11 +137,7 @@ export function useTimelineSeek({
);
const projectFps = activeProject?.settings.fps || 30;
const time = getSnappedSeekTime({
rawTime,
duration,
fps: projectFps,
});
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
seek(time);
editor.project.setTimelineViewState({
viewState: {
+4
View File
@@ -101,6 +101,10 @@ export const ACTIONS = {
description: "Toggle ripple editing",
category: "editing",
},
"toggle-source-audio": {
description: "Extract or recover source audio",
category: "editing",
},
"select-all": {
description: "Select all elements",
category: "selection",
+1 -1
View File
@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
+1 -1
View File
@@ -2,7 +2,7 @@ import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis";
import { db } from "@/lib/db";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
@@ -6,13 +6,12 @@ import type {
ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
@@ -59,17 +58,22 @@ export class PasteCommand extends Command {
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const resolvedTargetIndex = resolveTargetTrackIndex({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
elements: elementsToAdd,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
let adjustedElements = elementsToAdd;
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
@@ -82,39 +86,28 @@ export class PasteCommand extends Command {
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
adjustedElements = elementsToAdd.map((element) => ({
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
}
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...adjustedElements],
} as TimelineTrack;
for (const element of adjustedElements) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: elementsForPlacement,
});
if (!applied) {
continue;
}
const insertIndex = resolveInsertIndexForNewTrack({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
});
const newTrack = buildTrackWithElements({
trackType,
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
updatedTracks = applied.updatedTracks;
for (const element of elementsForPlacement) {
this.pastedElements.push({
trackId: newTrack.id,
trackId: applied.targetTrackId,
elementId: element.id,
});
}
@@ -187,107 +180,3 @@ function buildPastedElements({
return elementsToAdd;
}
function resolveTargetTrackIndex({
tracks,
sourceTrackIndex,
trackType,
elements,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): number {
if (sourceTrackIndex >= 0) {
const aboveIndex = sourceTrackIndex - 1;
if (aboveIndex < 0) {
return -1;
}
const aboveTrack = tracks[aboveIndex];
if (aboveTrack.type !== trackType) {
return -1;
}
const canPlaceOnAbove = canPlaceElementsOnTrack({
track: aboveTrack,
elements,
});
return canPlaceOnAbove ? aboveIndex : -1;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex < 0) {
return -1;
}
const highestCompatibleTrack = tracks[highestCompatibleIndex];
const canPlaceOnHighest = canPlaceElementsOnTrack({
track: highestCompatibleTrack,
elements,
});
return canPlaceOnHighest ? highestCompatibleIndex : -1;
}
function resolveInsertIndexForNewTrack({
tracks,
sourceTrackIndex,
trackType,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
}): number {
if (sourceTrackIndex >= 0) {
return sourceTrackIndex;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex >= 0) {
return highestCompatibleIndex;
}
return getHighestInsertIndexForTrack({ tracks, trackType });
}
function canPlaceElementsOnTrack({
track,
elements,
}: {
track: TimelineTrack;
elements: TimelineElement[];
}): boolean {
for (const element of elements) {
const endTime = element.startTime + element.duration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime,
});
if (hasOverlap) {
return false;
}
}
return true;
}
function buildTrackWithElements({
trackType,
elements,
}: {
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): TimelineTrack {
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
return {
...newTrackBase,
elements,
} as TimelineTrack;
}
@@ -1,7 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
import { rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline/placement";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
);
const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push(
buildDuplicateElement({
element,
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
);
}
const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
updatedTracks.splice(insertIndex, 0, newTrack);
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
@@ -8,6 +8,7 @@ export { SplitElementsCommand } from "./split-elements";
export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
@@ -5,23 +5,18 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
ElementType,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
wouldElementOverlap,
} from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
canElementGoOnTrack,
getDefaultInsertIndexForTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/lib/timeline/placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.resolveTracksWithElement({
const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
return true;
}
private resolveTracksWithElement({
private applyPlacementResult({
tracks,
element,
}: {
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (placement.mode === "explicit") {
const targetTrack = tracks.find(
(track) => track.id === placement.trackId,
);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return null;
}
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: targetTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const trackType =
placement.trackType ?? this.getTrackTypeForElement({ element });
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${trackType} tracks`,
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const elementEndTime = element.startTime + element.duration;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
const placementResult = resolveTrackPlacement({
tracks,
...(placement.mode === "auto" && placement.trackType
? { trackType: placement.trackType }
: { elementType: element.type }),
timeSpans: [
{
startTime: element.startTime,
duration: element.duration,
},
],
strategy:
placement.mode === "explicit"
? { type: "explicit", trackId: placement.trackId }
: { type: "firstAvailable" },
});
if (!placementResult) {
if (placement.mode === "explicit") {
const targetTrack = tracks.find((track) => track.id === placement.trackId);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
console.error(validation.errorMessage);
}
return !wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime: elementEndTime,
});
});
if (existingTrack) {
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: existingTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: existingTrack.id };
return null;
}
const newTrackId = generateUUID();
const newTrack = buildEmptyTrack({
id: newTrackId,
type: trackType,
});
const newTrackWithElement = {
...newTrack,
elements: [...newTrack.elements, element],
} as TimelineTrack;
const elementToPlace =
placementResult.kind === "existingTrack"
? {
...element,
startTime:
placementResult.adjustedStartTime ?? element.startTime,
}
: element;
const updatedTracks = [...tracks];
const insertIndex =
placement.insertIndex ??
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
return { updatedTracks, targetTrackId: newTrackId };
}
private getAutoInsertIndex({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "text") {
const firstVideoTrackIndex = tracks.findIndex(
(track) => track.type === "video",
);
if (firstVideoTrackIndex >= 0) {
return firstVideoTrackIndex;
}
}
return getDefaultInsertIndexForTrack({
return applyPlacement({
tracks,
trackType,
placementResult,
elements: [elementToPlace],
newTrackInsertIndexOverride:
placement.mode === "auto" && typeof placement.insertIndex === "number"
? placement.insertIndex
: undefined,
});
}
private adjustElementForMainTrack({
tracks,
targetTrackId,
element,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
element: TimelineElement;
}): TimelineElement {
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime: element.startTime,
});
return { ...element, startTime: adjustedStartTime };
}
private getTrackTypeForElement({
element,
}: {
element: { type: ElementType };
}): TrackType {
return ELEMENT_TRACK_MAP[element.type];
}
}
@@ -10,7 +10,7 @@ import {
isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
@@ -0,0 +1,143 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import {
buildSeparatedAudioElement,
canExtractSourceAudio,
canRecoverSourceAudio,
} from "@/lib/timeline/audio-separation";
import {
applyPlacement,
resolveTrackPlacement,
} from "@/lib/timeline/placement";
import { updateElementInTracks } from "@/lib/timeline/track-element-update";
import type { TimelineTrack, VideoElement } from "@/lib/timeline/types";
import { generateUUID } from "@/utils/id";
export class ToggleSourceAudioSeparationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
constructor(
private readonly params: {
trackId: string;
elementId: string;
},
) {
super();
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const trackIndex = this.savedState.findIndex(
(track) => track.id === this.params.trackId,
);
if (trackIndex < 0) {
return;
}
const sourceTrack = this.savedState[trackIndex];
const sourceElement = sourceTrack.elements.find(
(element) => element.id === this.params.elementId,
);
if (!sourceElement) {
return;
}
if (canRecoverSourceAudio({ element: sourceElement })) {
editor.timeline.updateTracks(
updateSourceAudioEnabled({
tracks: this.savedState,
trackId: this.params.trackId,
elementId: this.params.elementId,
isSourceAudioEnabled: true,
}),
);
return;
}
const mediaAsset = editor
.media
.getAssets()
.find((asset) =>
sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false,
);
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
return;
}
if (sourceElement.duration <= 0) {
return;
}
const separatedAudioElement = {
...buildSeparatedAudioElement({
sourceElement,
}),
id: generateUUID(),
};
const placementResult = resolveTrackPlacement({
tracks: this.savedState,
trackType: "audio",
timeSpans: [
{
startTime: separatedAudioElement.startTime,
duration: separatedAudioElement.duration,
},
],
strategy: { type: "aboveSource", sourceTrackIndex: trackIndex },
});
if (!placementResult) {
return;
}
const appliedPlacement = applyPlacement({
tracks: this.savedState,
placementResult,
elements: [separatedAudioElement],
});
if (!appliedPlacement) {
return;
}
editor.timeline.updateTracks(
updateSourceAudioEnabled({
tracks: appliedPlacement.updatedTracks,
trackId: this.params.trackId,
elementId: this.params.elementId,
isSourceAudioEnabled: false,
}),
);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
function updateSourceAudioEnabled({
tracks,
trackId,
elementId,
isSourceAudioEnabled,
}: {
tracks: TimelineTrack[];
trackId: string;
elementId: string;
isSourceAudioEnabled: boolean;
}): TimelineTrack[] {
return updateElementInTracks({
tracks,
trackId,
elementId,
elementPredicate: (element): element is VideoElement => element.type === "video",
update: (element) => ({
...element,
isSourceAudioEnabled,
}),
});
}
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/timeline/placement";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
} from "@/lib/timeline/placement";
export class AddTrackCommand extends Command {
private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+1 -1
View File
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
let _db: ReturnType<typeof drizzle> | null = null;
+5 -4
View File
@@ -11,10 +11,11 @@ const webEnvSchema = z.object({
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
DATABASE_URL: z.string().refine(
(url) =>
url.startsWith("postgres://") || url.startsWith("postgresql://"),
"DATABASE_URL must be a postgres:// or postgresql:// URL",
),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
+23 -11
View File
@@ -12,7 +12,10 @@ import {
hasAnimatedVolume,
resolveEffectiveAudioGain,
} from "@/lib/timeline/audio-state";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import {
doesElementHaveEnabledAudio,
} from "@/lib/timeline/audio-separation";
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
@@ -54,10 +57,12 @@ export interface DecodedAudio {
export async function decodeAudioToFloat32({
audioBlob,
sampleRate,
}: {
audioBlob: Blob;
sampleRate?: number;
}): Promise<DecodedAudio> {
const audioContext = createAudioContext();
const audioContext = createAudioContext({ sampleRate });
const arrayBuffer = await audioBlob.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
@@ -98,6 +103,11 @@ export async function collectAudioElements({
if (!canElementHaveAudio(element)) continue;
if (element.duration <= 0) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
if (element.type === "audio") {
@@ -130,7 +140,6 @@ export async function collectAudioElements({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset || !mediaSupportsAudio({ media: mediaAsset })) continue;
pendingElements.push(
@@ -449,6 +458,10 @@ export async function collectAudioMixSources({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
if (element.muted === true) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const volume = resolveEffectiveAudioGain({
element,
localTime: 0,
@@ -471,10 +484,7 @@ export async function collectAudioMixSources({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (mediaSupportsAudio({ media: mediaAsset })) {
if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
audioMixSources.push(
collectMediaAudioSource({ element, mediaAsset, volume }),
);
@@ -510,6 +520,11 @@ export async function collectAudioClips({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
const isElementMuted =
"muted" in element ? (element.muted ?? false) : false;
const muted = isTrackMuted || isElementMuted;
@@ -541,10 +556,7 @@ export async function collectAudioClips({
}
if (element.type === "video") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset) continue;
if (mediaSupportsAudio({ media: mediaAsset })) {
if (mediaAsset && mediaSupportsAudio({ media: mediaAsset })) {
clips.push(
collectMediaAudioClip({
element,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline/placement";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { ensureMainTrack } from "@/lib/timeline/placement";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
-225
View File
@@ -1,225 +0,0 @@
export type TTimeCode =
| "MM:SS"
| "HH:MM:SS"
| "HH:MM:SS:CS"
| "HH:MM:SS:FF";
export function roundToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps) / fps;
}
export function formatTimeCode({
timeInSeconds,
format = "HH:MM:SS:CS",
fps,
}: {
timeInSeconds: number;
format?: TTimeCode;
fps?: number;
}): string {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = fps ? Math.floor((timeInSeconds % 1) * fps) : 0;
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
if (!fps) throw new Error("FPS is required for HH:MM:SS:FF format");
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
}
export function parseTimeCode({
timeCode,
format = "HH:MM:SS:CS",
fps,
}: {
timeCode: string;
format?: TTimeCode;
fps: number;
}): number | null {
if (!timeCode || typeof timeCode !== "string") return null;
const cleanTimeCode = timeCode.trim();
try {
switch (format) {
case "MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 2) return null;
const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
return minutes * 60 + seconds;
}
case "HH:MM:SS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 3) return null;
const [hours, minutes, seconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
minutes >= 60 ||
seconds >= 60
)
return null;
return hours * 3600 + minutes * 60 + seconds;
}
case "HH:MM:SS:CS": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(centiseconds)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
centiseconds < 0 ||
minutes >= 60 ||
seconds >= 60 ||
centiseconds >= 100
)
return null;
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
}
case "HH:MM:SS:FF": {
const parts = cleanTimeCode.split(":");
if (parts.length !== 4) return null;
const [hours, minutes, seconds, frames] = parts.map((part) =>
parseInt(part, 10),
);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
Number.isNaN(seconds) ||
Number.isNaN(frames)
)
return null;
if (
hours < 0 ||
minutes < 0 ||
seconds < 0 ||
frames < 0 ||
minutes >= 60 ||
seconds >= 60 ||
frames >= fps
)
return null;
return hours * 3600 + minutes * 60 + seconds + frames / fps;
}
}
} catch {
return null;
}
}
export function guessTimeCodeFormat({
timeCode,
}: {
timeCode: string;
}): TTimeCode | null {
if (!timeCode || typeof timeCode !== "string") return null;
const numbers = timeCode.split(":");
if (!numbers.every((n) => !Number.isNaN(Number(n)))) return null;
if (numbers.length === 2) return "MM:SS";
if (numbers.length === 3) return "HH:MM:SS";
// todo: how to tell frames apart from cs?
if (numbers.length === 4) return "HH:MM:SS:FF";
return null;
}
export function timeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
return Math.round(time * fps);
}
export function frameToTime({
frame,
fps,
}: {
frame: number;
fps: number;
}): number {
return frame / fps;
}
export function snapTimeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number {
if (fps <= 0) return time;
const frame = timeToFrame({ time, fps });
return frameToTime({ frame, fps });
}
export function getSnappedSeekTime({
rawTime,
duration,
fps,
}: {
rawTime: number;
duration: number;
fps: number;
}): number {
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
const lastFrame = getLastFrameTime({ duration, fps });
return Math.max(0, Math.min(lastFrame, snappedTime));
}
export function getLastFrameTime({
duration,
fps,
}: {
duration: number;
fps: number;
}): number {
if (duration <= 0) return 0;
if (fps <= 0) return duration;
const frameOffset = 1 / fps;
return Math.max(0, duration - frameOffset);
}
@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import {
buildSeparatedAudioElement,
doesElementHaveEnabledAudio,
isSourceAudioEnabled,
isSourceAudioSeparated,
} from "..";
describe("audio separation", () => {
test("treats missing source audio state as enabled", () => {
const element = buildVideoElement({});
expect(isSourceAudioEnabled({ element })).toBe(true);
expect(isSourceAudioSeparated({ element })).toBe(false);
});
test("builds a detached audio element from the source clip audio state", () => {
const element = buildVideoElement({
duration: 8,
startTime: 3,
trimStart: 1.5,
trimEnd: 0.5,
sourceDuration: 12,
volume: -6,
muted: true,
retime: { rate: 1.25, maintainPitch: true },
animations: {
channels: {
volume: {
valueKind: "number",
keyframes: [
{
id: "volume-keyframe",
time: 2,
value: -12,
interpolation: "linear",
},
],
},
opacity: {
valueKind: "number",
keyframes: [
{
id: "opacity-keyframe",
time: 1,
value: 0.5,
interpolation: "linear",
},
],
},
},
},
});
const separatedAudioElement = buildSeparatedAudioElement({
sourceElement: element,
});
expect(separatedAudioElement).toMatchObject({
type: "audio",
sourceType: "upload",
mediaId: element.mediaId,
name: element.name,
duration: 8,
startTime: 3,
trimStart: 1.5,
trimEnd: 0.5,
sourceDuration: 12,
volume: -6,
muted: true,
retime: { rate: 1.25, maintainPitch: true },
});
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
"volume",
]);
expect(
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
).not.toBe("volume-keyframe");
});
test("skips source audio collection when the source clip is separated", () => {
const mediaAsset = {
id: "media-1",
type: "video",
name: "Clip",
size: 1,
lastModified: 1,
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
url: "blob:clip",
hasAudio: true,
};
const videoElement = buildVideoElement({
isSourceAudioEnabled: false,
});
const audioElement = {
id: "audio-1",
type: "audio",
sourceType: "upload",
mediaId: "audio-media-1",
name: "Detached audio",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
} as AudioElement;
expect(
doesElementHaveEnabledAudio({
element: videoElement,
mediaAsset,
}),
).toBe(false);
expect(
doesElementHaveEnabledAudio({
element: audioElement,
}),
).toBe(true);
});
});
function buildVideoElement(
overrides: Partial<VideoElement>,
): VideoElement {
return {
id: "video-1",
type: "video",
mediaId: "media-1",
name: "Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
muted: false,
isSourceAudioEnabled: true,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
...overrides,
};
}
@@ -0,0 +1,133 @@
import { cloneAnimations, getChannel } from "@/lib/animation";
import type { ElementAnimations } from "@/lib/animation/types";
import type { MediaAsset } from "@/lib/media/types";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type {
CreateUploadAudioElement,
TimelineElement,
AudioElement,
VideoElement,
} from "../types";
export function isSourceAudioEnabled({
element,
}: {
element: VideoElement;
}): boolean {
return element.isSourceAudioEnabled !== false;
}
export function isSourceAudioSeparated({
element,
}: {
element: VideoElement;
}): boolean {
return !isSourceAudioEnabled({ element });
}
export function canExtractSourceAudio({
element,
mediaAsset,
}: {
element: TimelineElement;
mediaAsset: MediaAsset | null | undefined;
}): element is VideoElement {
return (
element.type === "video" &&
isSourceAudioEnabled({ element }) &&
!!mediaAsset &&
mediaAsset.hasAudio !== false
);
}
export function canRecoverSourceAudio({
element,
}: {
element: TimelineElement;
}): element is VideoElement {
return element.type === "video" && isSourceAudioSeparated({ element });
}
export function canToggleSourceAudio({
element,
mediaAsset,
}: {
element: TimelineElement;
mediaAsset: MediaAsset | null | undefined;
}): element is VideoElement {
return (
canRecoverSourceAudio({ element }) ||
canExtractSourceAudio({ element, mediaAsset })
);
}
export function doesElementHaveEnabledAudio({
element,
mediaAsset,
}: {
element: AudioElement | VideoElement;
mediaAsset?: MediaAsset | null;
}): boolean {
if (element.type === "audio") {
return true;
}
return !!mediaAsset && mediaAsset.hasAudio !== false && isSourceAudioEnabled({ element });
}
export function buildSeparatedAudioElement({
sourceElement,
}: {
sourceElement: VideoElement;
}): CreateUploadAudioElement {
return {
type: "audio",
sourceType: "upload",
mediaId: sourceElement.mediaId,
name: sourceElement.name,
duration: sourceElement.duration,
startTime: sourceElement.startTime,
trimStart: sourceElement.trimStart,
trimEnd: sourceElement.trimEnd,
sourceDuration: sourceElement.sourceDuration,
volume: sourceElement.volume ?? DEFAULTS.element.volume,
muted: sourceElement.muted ?? false,
retime: sourceElement.retime
? {
rate: sourceElement.retime.rate,
maintainPitch: sourceElement.retime.maintainPitch,
}
: undefined,
animations: cloneVolumeAnimations({
animations: sourceElement.animations,
}),
};
}
export function getSourceAudioActionLabel({
element,
}: {
element: VideoElement;
}): "Extract audio" | "Recover audio" {
return isSourceAudioSeparated({ element }) ? "Recover audio" : "Extract audio";
}
function cloneVolumeAnimations({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations | undefined {
const volumeChannel = getChannel({ animations, propertyPath: "volume" });
if (!volumeChannel) {
return undefined;
}
return cloneAnimations({
animations: {
channels: {
volume: volumeChannel,
},
},
shouldRegenerateKeyframeIds: true,
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "@/lib/time";
import { roundToFrame } from "opencut-wasm";
export const BOOKMARK_TIME_EPSILON = 0.001;
+64 -121
View File
@@ -1,16 +1,10 @@
import type {
TimelineTrack,
ElementType,
TimelineElement,
} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import {
canElementGoOnTrack,
isMainTrack,
enforceMainTrackStart,
} from "./track-utils";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function findElementAtPosition({
mouseX,
@@ -82,57 +76,22 @@ function getTrackAtY({
return null;
}
function isCompatible({
elementType,
trackType,
const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
elementType: ElementType;
trackType: TimelineTrack["type"];
}): boolean {
return canElementGoOnTrack({ elementType, trackType });
}
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
return tracks.findIndex((track) => isMainTrack(track));
}
function findInsertIndex({
elementType,
tracks,
preferredIndex,
insertAbove,
}: {
elementType: ElementType;
tracks: TimelineTrack[];
preferredIndex: number;
insertAbove: boolean;
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
position: insertAbove ? "above" : "below",
};
}
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
return { index: mainTrackIndex, position: "above" };
}
xPosition: number;
}): DropTarget {
return {
index: overlayInsertIndex,
position: insertAbove ? "above" : "below",
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const EMPTY_TARGET_ELEMENT = null;
export function computeDropTarget({
elementType,
mouseX,
@@ -155,22 +114,28 @@ export function computeDropTarget({
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
const mainTrackIndex = getMainTrackIndex({ tracks });
if (tracks.length === 0) {
if (elementType === "audio") {
return {
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: 0,
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
hoverDirection: "below",
createNewTrackOnly: true,
},
});
const emptyTimelineResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!emptyTimelineResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: 0,
trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
insertPosition: null,
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -181,30 +146,27 @@ export function computeDropTarget({
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
if (elementType === "audio") {
return {
trackIndex: tracks.length,
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
if (isAboveAllTracks) {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true,
},
});
const outOfBoundsResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!outOfBoundsResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: Math.max(0, mainTrackIndex),
trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
insertPosition: "above",
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -237,34 +199,27 @@ export function computeDropTarget({
}
const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
trackType: track.type,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex,
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
verticalDragDirection,
},
});
if (!placementResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
const endTime = xPosition + elementDuration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: xPosition,
endTime,
excludeElementId,
});
if (isTrackCompatible && !hasOverlap) {
const targetTrack = tracks[trackIndex];
// safe: snap to 0 only happens when element becomes the new earliest,
// meaning the space before the current earliest is empty
const adjustedXPosition = enforceMainTrackStart({
tracks,
targetTrackId: targetTrack.id,
requestedStartTime: xPosition,
excludeElementId,
});
if (placementResult.kind === "existingTrack") {
const adjustedXPosition =
placementResult.adjustedStartTime ?? xPosition;
return {
trackIndex,
trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
@@ -272,22 +227,10 @@ export function computeDropTarget({
};
}
let insertAbove = isInUpperHalf;
if (!isTrackCompatible && verticalDragDirection) {
insertAbove = verticalDragDirection === "up";
}
const { index, position } = findInsertIndex({
elementType,
tracks,
preferredIndex: trackIndex,
insertAbove,
});
return {
trackIndex: index,
trackIndex: placementResult.insertIndex,
isNewTrack: true,
insertPosition: position,
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
+1 -18
View File
@@ -86,24 +86,6 @@ export function requiresMediaId({
);
}
export function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) return false;
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
@@ -260,6 +242,7 @@ function buildVideoElement({
trimEnd: 0,
sourceDuration: duration,
muted: false,
isSourceAudioEnabled: true,
hidden: false,
transform: {
...DEFAULTS.element.transform,
+1
View File
@@ -5,6 +5,7 @@ export * from "./drag";
export * from "./track-utils";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./ripple-utils";
@@ -0,0 +1,462 @@
import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/timeline/placement";
function buildElement({
id,
type,
startTime,
duration,
}: {
id: string;
type: ElementType;
startTime: number;
duration: number;
}): TimelineElement {
return {
id,
type,
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
} as TimelineElement;
}
function buildTrack({
id,
type,
elements = [],
isMain = false,
}: {
id: string;
type: TrackType;
elements?: TimelineElement[];
isMain?: boolean;
}): TimelineTrack {
if (type === "video") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
isMain,
muted: false,
hidden: false,
};
}
if (type === "audio") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
}
function buildTimeSpan({
startTime,
duration,
excludeElementId,
}: {
startTime: number;
duration: number;
excludeElementId?: string;
}) {
return { startTime, duration, excludeElementId };
}
describe("resolveTrackPlacement", () => {
test("explicit returns the requested compatible track", () => {
const tracks = [buildTrack({ id: "text-1", type: "text" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
strategy: { type: "explicit", trackId: "text-1" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-1",
trackIndex: 0,
trackType: "text",
});
});
test("explicit rejects missing and incompatible tracks", () => {
const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "missing" },
}),
).toBeNull();
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "video-1" },
}),
).toBeNull();
});
test("firstAvailable picks the first compatible track without overlap", () => {
const tracks = [
buildTrack({
id: "text-1",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-2", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-2",
trackIndex: 1,
trackType: "text",
});
});
test("firstAvailable creates a new track when all compatible tracks are full", () => {
const tracks = [
buildTrack({
id: "graphic-1",
type: "graphic",
elements: [
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
],
}),
buildTrack({
id: "video-main",
type: "video",
isMain: true,
}),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 1,
insertPosition: null,
});
});
test("preferIndex uses the preferred track when it fits", () => {
const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
},
}),
).toEqual({
kind: "existingTrack",
trackId: "audio-1",
trackIndex: 0,
trackType: "audio",
});
});
test("preferIndex creates a new overlay track above the main track", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 1,
hoverDirection: "below",
},
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 0,
insertPosition: "above",
});
});
test("preferIndex keeps audio tracks below the main track", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
test("aboveSource tries the track above source, then any compatible track", () => {
const tracks = [
buildTrack({ id: "text-top", type: "text" }),
buildTrack({
id: "text-middle",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-source", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 2 },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-top",
trackIndex: 0,
trackType: "text",
});
});
test("aboveSource creates a new track near the source when none fit", () => {
const tracks = [
buildTrack({
id: "text-top",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({
id: "text-source",
type: "text",
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 1 },
}),
).toEqual({
kind: "newTrack",
trackType: "text",
insertIndex: 1,
insertPosition: null,
});
});
test("alwaysNew honors highest and default insertion rules", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: null,
});
});
test("batch time spans reject tracks when any span overlaps", () => {
const tracks = [
buildTrack({
id: "audio-1",
type: "audio",
elements: [
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [
buildTimeSpan({ startTime: 2.5, duration: 1 }),
buildTimeSpan({ startTime: 5.5, duration: 1 }),
],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("handles empty timelines, single tracks, and track-type derivation", () => {
expect(
resolveTrackPlacement({
tracks: [],
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "video",
insertIndex: 0,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks: [buildTrack({ id: "audio-1", type: "audio" })],
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
const tracks = [
buildTrack({
id: "video-main",
type: "video",
isMain: true,
elements: [
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
strategy: { type: "explicit", trackId: "video-main" },
}),
).toEqual({
kind: "existingTrack",
trackId: "video-main",
trackIndex: 0,
trackType: "video",
adjustedStartTime: 0,
});
});
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
verticalDragDirection: "down",
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
});
@@ -0,0 +1,45 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { buildEmptyTrack } from "./track-factory";
import type { PlacementResult } from "./types";
export function applyPlacement({
tracks,
placementResult,
elements,
newTrackInsertIndexOverride,
}: {
tracks: TimelineTrack[];
placementResult: PlacementResult;
elements: TimelineElement[];
newTrackInsertIndexOverride?: number;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
if (placementResult.kind === "existingTrack") {
const targetTrack = tracks[placementResult.trackIndex];
if (!targetTrack) {
return null;
}
const updatedTracks = tracks.map((track, trackIndex) =>
trackIndex === placementResult.trackIndex
? {
...track,
elements: [...track.elements, ...elements],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const newTrackId = generateUUID();
const newTrack = {
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
elements,
} as TimelineTrack;
const insertIndex =
newTrackInsertIndexOverride ?? placementResult.insertIndex;
const updatedTracks = [...tracks];
updatedTracks.splice(insertIndex, 0, newTrack);
return { updatedTracks, targetTrackId: newTrackId };
}
@@ -0,0 +1,51 @@
import type { ElementType, TrackType } from "@/lib/timeline";
const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export function getTrackTypeForElementType({
elementType,
}: {
elementType: ElementType;
}): TrackType {
return ELEMENT_TRACK_MAP[elementType];
}
export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): boolean {
return getTrackTypeForElementType({ elementType }) === trackType;
}
export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: ElementType };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
});
if (!isValid) {
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };
}
@@ -0,0 +1,18 @@
export { applyPlacement } from "./apply";
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
export {
enforceMainTrackStart,
ensureMainTrack,
getEarliestMainTrackElement,
getMainTrack,
isMainTrack,
} from "./main-track";
export { resolveTrackPlacement } from "./resolve";
export { buildEmptyTrack } from "./track-factory";
export type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
@@ -0,0 +1,99 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { isMainTrack } from "./main-track";
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex,
direction,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
preferredIndex: number;
direction: "above" | "below";
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
if (tracks.length === 0) {
return {
insertIndex: 0,
insertPosition: trackType === "audio" ? "below" : null,
};
}
const safePreferredIndex = Math.min(
Math.max(preferredIndex, 0),
tracks.length - 1,
);
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
if (safePreferredIndex <= mainTrackIndex) {
return {
insertIndex: mainTrackIndex + 1,
insertPosition: "below",
};
}
return {
insertIndex:
direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
insertPosition: direction,
};
}
const insertIndex =
direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
return {
insertIndex: mainTrackIndex,
insertPosition: "above",
};
}
return {
insertIndex,
insertPosition: direction,
};
}
@@ -0,0 +1,96 @@
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
const MAIN_TRACK_NAME = "Main Track";
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): VideoTrack | null {
return tracks.find((track) => isMainTrack(track)) ?? null;
}
export function ensureMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
if (tracks.some((track) => isMainTrack(track))) {
return tracks;
}
return [
{
id: generateUUID(),
name: MAIN_TRACK_NAME,
type: "video",
elements: [],
muted: false,
isMain: true,
hidden: false,
},
...tracks,
];
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter((element) => {
return !excludeElementId || element.id !== excludeElementId;
});
if (elements.length === 0) {
return null;
}
return elements.reduce((earliestElement, element) => {
return element.startTime < earliestElement.startTime
? element
: earliestElement;
});
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}
@@ -0,0 +1,40 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types";
function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) {
return false;
}
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
export function canPlaceTimeSpansOnTrack({
track,
timeSpans,
}: {
track: TimelineTrack;
timeSpans: PlacementTimeSpan[];
}): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
excludeElementId,
});
});
}
@@ -0,0 +1,255 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import {
getDefaultInsertIndexForTrack,
getHighestInsertIndexForTrack,
resolvePreferredNewTrackPlacement,
} from "./insert-index";
import { getTrackTypeForElementType } from "./compatibility";
import { enforceMainTrackStart } from "./main-track";
import { canPlaceTimeSpansOnTrack } from "./overlap";
import type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
type ResolveTrackPlacementParams = PlacementSubject & {
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
strategy: PlacementStrategy;
};
function buildExistingTrackResult({
track,
trackIndex,
tracks,
timeSpans,
}: {
track: TimelineTrack;
trackIndex: number;
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
const requestedStartTime = firstSpan?.startTime ?? 0;
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId: track.id,
requestedStartTime,
excludeElementId: firstSpan?.excludeElementId,
});
return {
kind: "existingTrack",
trackId: track.id,
trackIndex,
trackType: track.type,
...(adjustedStartTime !== requestedStartTime
? { adjustedStartTime }
: {}),
};
}
function buildNewTrackResult({
trackType,
insertIndex,
insertPosition,
}: {
trackType: TrackType;
insertIndex: number;
insertPosition: "above" | "below" | null;
}): PlacementResult {
return {
kind: "newTrack",
trackType,
insertIndex,
insertPosition,
};
}
function findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
timeSpans: PlacementTimeSpan[];
}): number {
return tracks.findIndex((track) => {
return (
track.type === trackType &&
canPlaceTimeSpansOnTrack({
track,
timeSpans,
})
);
});
}
function resolveAlwaysNewTrack({
tracks,
trackType,
position,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
position: "highest" | "default";
}): PlacementResult {
const insertIndex =
position === "highest"
? getHighestInsertIndexForTrack({
tracks,
trackType,
})
: getDefaultInsertIndexForTrack({
tracks,
trackType,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition: null,
});
}
export function resolveTrackPlacement({
tracks,
...placement
}: ResolveTrackPlacementParams): PlacementResult | null {
const trackType =
"trackType" in placement
? placement.trackType
: getTrackTypeForElementType({
elementType: placement.elementType,
});
const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") {
const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId);
if (trackIndex < 0) {
return null;
}
const track = tracks[trackIndex];
if (track.type !== trackType) {
return null;
}
return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans });
}
if (strategy.type === "firstAvailable") {
const existingTrackIndex = findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
});
if (existingTrackIndex >= 0) {
return buildExistingTrackResult({
track: tracks[existingTrackIndex],
trackIndex: existingTrackIndex,
tracks,
timeSpans,
});
}
return resolveAlwaysNewTrack({
tracks,
trackType,
position: "default",
});
}
if (strategy.type === "preferIndex") {
const preferredTrack = tracks[strategy.trackIndex];
const isPreferredTrackCompatible =
!!preferredTrack && preferredTrack.type === trackType;
const canUseExistingTrack =
!strategy.createNewTrackOnly &&
isPreferredTrackCompatible &&
canPlaceTimeSpansOnTrack({
track: preferredTrack,
timeSpans,
});
if (canUseExistingTrack) {
return buildExistingTrackResult({
track: preferredTrack,
trackIndex: strategy.trackIndex,
tracks,
timeSpans,
});
}
const insertDirection =
!isPreferredTrackCompatible && strategy.verticalDragDirection
? strategy.verticalDragDirection
: strategy.hoverDirection;
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex: strategy.trackIndex,
direction: insertDirection,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition,
});
}
if (strategy.type === "aboveSource") {
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
const aboveTrack = tracks[aboveTrackIndex];
if (
aboveTrack &&
aboveTrack.type === trackType &&
canPlaceTimeSpansOnTrack({
track: aboveTrack,
timeSpans,
})
) {
return buildExistingTrackResult({
track: aboveTrack,
trackIndex: aboveTrackIndex,
tracks,
timeSpans,
});
}
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
});
if (firstAvailableTrackIndex >= 0) {
return buildExistingTrackResult({
track: tracks[firstAvailableTrackIndex],
trackIndex: firstAvailableTrackIndex,
tracks,
timeSpans,
});
}
const insertIndex =
strategy.sourceTrackIndex >= 0
? strategy.sourceTrackIndex
: getHighestInsertIndexForTrack({
tracks,
trackType,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition: null,
});
}
return resolveAlwaysNewTrack({
tracks,
trackType,
position: strategy.position,
});
}
@@ -0,0 +1,61 @@
import { TRACK_CONFIG } from "@/constants/timeline-constants";
import type { TrackType, TimelineTrack } from "@/lib/timeline";
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
return {
id,
name: trackName,
type: "video",
elements: [],
hidden: false,
muted: false,
isMain: false,
};
case "text":
return {
id,
name: trackName,
type: "text",
elements: [],
hidden: false,
};
case "graphic":
return {
id,
name: trackName,
type: "graphic",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
}
@@ -0,0 +1,39 @@
import type { ElementType, TrackType } from "@/lib/timeline";
export interface PlacementTimeSpan {
startTime: number;
duration: number;
excludeElementId?: string;
}
export type PlacementSubject =
| { elementType: ElementType }
| { trackType: TrackType };
export type PlacementStrategy =
| { type: "explicit"; trackId: string }
| { type: "firstAvailable" }
| {
type: "preferIndex";
trackIndex: number;
hoverDirection: "above" | "below";
verticalDragDirection?: "up" | "down" | null;
createNewTrackOnly?: boolean;
}
| { type: "aboveSource"; sourceTrackIndex: number }
| { type: "alwaysNew"; position: "highest" | "default" };
export type PlacementResult =
| {
kind: "existingTrack";
trackId: string;
trackIndex: number;
trackType: TrackType;
adjustedStartTime?: number;
}
| {
kind: "newTrack";
insertIndex: number;
insertPosition: "above" | "below" | null;
trackType: TrackType;
};
-235
View File
@@ -1,21 +1,17 @@
import type {
TrackType,
TimelineTrack,
ElementType,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
TimelineElement,
} from "@/lib/timeline";
import {
ELEMENT_TRACK_MAP,
TRACK_CONFIG,
ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
export function canTracktHaveAudio(
track: TimelineTrack,
@@ -64,234 +60,3 @@ export function getTotalTracksHeight({
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight;
}
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
return {
id,
name: trackName,
type: "video",
elements: [],
hidden: false,
muted: false,
isMain: false,
};
case "text":
return {
id,
name: trackName,
type: "text",
elements: [],
hidden: false,
};
case "graphic":
return {
id,
name: trackName,
type: "graphic",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
}
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex(
(track) => track.type === "audio",
);
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack | null {
return tracks.find((track) => isMainTrack(track)) ?? null;
}
export function ensureMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
const hasMainTrack = tracks.some((track) => isMainTrack(track));
if (!hasMainTrack) {
const mainTrack: TimelineTrack = {
id: generateUUID(),
name: "Main Track",
type: "video",
elements: [],
muted: false,
isMain: true,
hidden: false,
};
return [mainTrack, ...tracks];
}
return tracks;
}
export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): boolean {
return ELEMENT_TRACK_MAP[elementType] === trackType;
}
export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: ElementType };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
});
if (!isValid) {
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter(
(element) => !excludeElementId || element.id !== excludeElementId,
);
if (elements.length === 0) {
return null;
}
return elements.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
// main track must always start at time 0; if this element would
// become the earliest, pin it to the start
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}
+1
View File
@@ -113,6 +113,7 @@ export interface VideoElement extends BaseTimelineElement {
mediaId: string;
volume?: number;
muted?: boolean;
isSourceAudioEnabled?: boolean;
hidden?: boolean;
retime?: RetimeConfig;
transform: Transform;
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
export async function middleware() {
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
describe("V19 to V20 Migration", () => {
test("backfills source audio enabled state on video elements", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-source-audio",
version: 19,
metadata: {
id: "project-v19-source-audio",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [
{
id: "scene-main",
name: "Main",
isMain: true,
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
tracks: [
{
id: "track-video",
type: "video",
name: "Video",
isMain: true,
muted: false,
hidden: false,
elements: [
{
id: "video-1",
type: "video",
name: "Clip",
mediaId: "media-1",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
position: { x: 0, y: 0 },
scale: { x: 1, y: 1 },
rotation: 0,
},
opacity: 1,
},
],
},
{
id: "track-audio",
type: "audio",
name: "Audio",
muted: false,
elements: [
{
id: "audio-1",
type: "audio",
sourceType: "upload",
mediaId: "media-audio-1",
name: "Audio",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
volume: 0,
},
],
},
],
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(20);
expect(
((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0].elements,
).toEqual([
expect.objectContaining({
id: "video-1",
isSourceAudioEnabled: true,
}),
]);
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[1]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBeUndefined();
});
test("preserves existing explicit source audio state", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v19-existing-state",
version: 19,
scenes: [
{
tracks: [
{
elements: [
{
type: "video",
isSourceAudioEnabled: false,
},
],
},
],
},
],
},
});
expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0]
.tracks as Array<Record<string, unknown>>)[0]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBe(false);
});
test("skips projects already on v20", () => {
const result = transformProjectV19ToV20({
project: {
id: "project-v20",
version: 20,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v20");
});
});
@@ -18,10 +18,11 @@ import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 19;
export const CURRENT_PROJECT_VERSION = 20;
export const migrations = [
new V0toV1Migration(),
@@ -43,4 +44,5 @@ export const migrations = [
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
new V19toV20Migration(),
];
@@ -0,0 +1,87 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV19ToV20({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 20) {
return { project, skipped: true, reason: "already v20" };
}
return {
project: {
...migrateVideoSourceAudioState({ project }),
version: 20,
},
skipped: false,
};
}
function migrateVideoSourceAudioState({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
if (!Array.isArray(project.scenes)) {
return project;
}
return {
...project,
scenes: project.scenes.map((scene) => migrateScene({ scene })),
};
}
function migrateScene({
scene,
}: {
scene: unknown;
}): unknown {
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
return scene;
}
return {
...scene,
tracks: scene.tracks.map((track) => migrateTrack({ track })),
};
}
function migrateTrack({
track,
}: {
track: unknown;
}): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({
element,
}: {
element: unknown;
}): unknown {
if (!isRecord(element) || element.type !== "video") {
return element;
}
return {
...element,
isSourceAudioEnabled:
typeof element.isSourceAudioEnabled === "boolean"
? element.isSourceAudioEnabled
: true,
};
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV19ToV20 } from "./transformers/v19-to-v20";
export class V19toV20Migration extends StorageMigration {
from = 19;
to = 20;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV19ToV20({ project });
}
}

Some files were not shown because too many files have changed in this diff Show More