summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/changelog/[version]/page.tsx105
-rw-r--r--apps/web/src/app/changelog/components/copy-markdown-button.tsx68
-rw-r--r--apps/web/src/app/changelog/components/release.tsx106
-rw-r--r--apps/web/src/app/changelog/page.tsx66
-rw-r--r--apps/web/src/app/changelog/utils.ts43
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx21
-rw-r--r--apps/web/src/app/globals.css8
-rw-r--r--apps/web/src/app/layout.tsx7
-rw-r--r--apps/web/src/app/roadmap/page.tsx2
-rw-r--r--apps/web/src/components/editor/export-button.tsx136
-rw-r--r--apps/web/src/components/editor/mobile-gate.tsx74
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx7
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx510
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx95
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx67
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx18
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx8
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx211
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-param-field.tsx152
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx58
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts24
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts95
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts151
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx85
-rw-r--r--apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx165
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx80
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx468
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx826
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx13
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx127
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx8
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx599
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx20
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx6
-rw-r--r--apps/web/src/components/gitHub-contribute-section.tsx2
-rw-r--r--apps/web/src/components/header.tsx4
-rw-r--r--apps/web/src/components/ui/context-menu.tsx2
-rw-r--r--apps/web/src/components/ui/select.tsx2
-rw-r--r--apps/web/src/components/ui/slider.tsx4
-rw-r--r--apps/web/src/constants/animation-constants.ts2
-rw-r--r--apps/web/src/constants/text-constants.ts4
-rw-r--r--apps/web/src/constants/timeline-constants.tsx82
-rw-r--r--apps/web/src/core/index.ts2
-rw-r--r--apps/web/src/core/managers/audio-manager.ts42
-rw-r--r--apps/web/src/core/managers/project-manager.ts43
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts7
-rw-r--r--apps/web/src/core/managers/selection-manager.ts37
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts316
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts49
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts70
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts112
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts302
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts18
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts8
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts208
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts96
-rw-r--r--apps/web/src/lib/actions/definitions.ts11
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/color-channel.ts19
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts117
-rw-r--r--apps/web/src/lib/animation/index.ts41
-rw-r--r--apps/web/src/lib/animation/interpolation.ts370
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts67
-rw-r--r--apps/web/src/lib/animation/keyframes.ts611
-rw-r--r--apps/web/src/lib/animation/number-channel.ts20
-rw-r--r--apps/web/src/lib/animation/property-registry.ts352
-rw-r--r--apps/web/src/lib/animation/resolve.ts148
-rw-r--r--apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts433
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts57
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts74
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/index.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts65
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts73
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts67
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts86
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts68
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts141
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts76
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts84
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts89
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts78
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts165
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts40
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts21
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts92
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts34
-rw-r--r--apps/web/src/lib/effects/definitions/blur.frag.glsl25
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts50
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts13
-rw-r--r--apps/web/src/lib/effects/effect.vert.glsl7
-rw-r--r--apps/web/src/lib/effects/index.ts34
-rw-r--r--apps/web/src/lib/effects/registry.ts31
-rw-r--r--apps/web/src/lib/export.ts20
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts94
-rw-r--r--apps/web/src/lib/text/layout.ts22
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts85
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts71
-rw-r--r--apps/web/src/lib/timeline/index.ts3
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts79
-rw-r--r--apps/web/src/lib/timeline/ripple-utils.ts17
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts27
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts33
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts38
-rw-r--r--apps/web/src/services/renderer/canvas-utils.ts16
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts194
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts75
-rw-r--r--apps/web/src/services/renderer/nodes/composite-effect-node.ts77
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts82
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts3
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts3
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts275
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts5
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts104
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts163
-rw-r--r--apps/web/src/services/renderer/webgl-effect-renderer.ts73
-rw-r--r--apps/web/src/services/renderer/webgl-utils.ts298
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts175
-rw-r--r--apps/web/src/services/storage/migrations/index.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts22
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts96
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts99
-rw-r--r--apps/web/src/services/storage/migrations/v7-to-v8.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v8-to-v9.ts16
-rw-r--r--apps/web/src/stores/assets-panel-store.tsx44
-rw-r--r--apps/web/src/stores/keybindings-store.ts50
-rw-r--r--apps/web/src/stores/keybindings/migrations/index.ts8
-rw-r--r--apps/web/src/stores/keybindings/migrations/v4-to-v5.ts17
-rw-r--r--apps/web/src/stores/properties-store.ts19
-rw-r--r--apps/web/src/types/animation.ts97
-rw-r--r--apps/web/src/types/drag.ts15
-rw-r--r--apps/web/src/types/effects.ts69
-rw-r--r--apps/web/src/types/export.ts8
-rw-r--r--apps/web/src/types/glsl.d.ts4
-rw-r--r--apps/web/src/types/timeline.ts68
-rw-r--r--apps/web/src/utils/math.ts12
148 files changed, 10950 insertions, 1943 deletions
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx
new file mode 100644
index 00000000..af382822
--- /dev/null
+++ b/apps/web/src/app/changelog/[version]/page.tsx
@@ -0,0 +1,105 @@
+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 {
+ ReleaseArticle,
+ ReleaseMeta,
+ ReleaseTitle,
+ ReleaseDescription,
+ ReleaseChanges,
+} from "../components/release";
+import { CopyMarkdownButton } from "../components/copy-markdown-button";
+
+type Props = { params: Promise<{ version: string }> };
+
+export async function generateStaticParams() {
+ return allChangelogs.map((r) => ({ version: r.version }));
+}
+
+export async function generateMetadata({ params }: Props): Promise<Metadata> {
+ const { version } = await params;
+ const release = allChangelogs.find((r) => r.version === version);
+ if (!release) return {};
+ return {
+ title: `${release.title} (${release.version}) - OpenCut Changelog`,
+ description: release.description,
+ };
+}
+
+export default async function ReleaseDetailPage({ params }: Props) {
+ const { version } = await params;
+ const releases = getSortedReleases();
+ const index = releases.findIndex((r) => r.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;
+
+ return (
+ <BasePage>
+ <div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
+ <Link
+ href="/changelog"
+ className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
+ >
+ <ChevronLeftIcon className="size-4" />
+ 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}
+ />
+ </div>
+ <ReleaseTitle as="h1">{release.title}</ReleaseTitle>
+ {release.description && (
+ <ReleaseDescription>{release.description}</ReleaseDescription>
+ )}
+ </div>
+ <ReleaseChanges release={release} />
+ </ReleaseArticle>
+
+ <nav className="flex items-center justify-between border-t pt-8">
+ {older ? (
+ <Link
+ href={`/changelog/${older.version}`}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
+ >
+ <ChevronLeftIcon className="size-4" />
+ <div className="flex flex-col">
+ <span className="text-xs text-muted-foreground/60">Older</span>
+ <span className="font-medium">{older.title}</span>
+ </div>
+ </Link>
+ ) : (
+ <div />
+ )}
+ {newer ? (
+ <Link
+ href={`/changelog/${newer.version}`}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
+ >
+ <div className="flex flex-col">
+ <span className="text-xs text-muted-foreground/60">Newer</span>
+ <span className="font-medium">{newer.title}</span>
+ </div>
+ <ChevronRightIcon className="size-4" />
+ </Link>
+ ) : (
+ <div />
+ )}
+ </nav>
+ </div>
+ </BasePage>
+ );
+}
diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx
new file mode 100644
index 00000000..2366550a
--- /dev/null
+++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useState } from "react";
+import { CheckIcon, ClipboardIcon } from "lucide-react";
+import { getSectionTitle, groupAndOrderChanges } from "../utils";
+import type { Change } from "../utils";
+import { cn } from "@/utils/ui";
+import { Button } from "@/components/ui/button";
+
+function buildMarkdown({
+ description,
+ changes,
+}: {
+ description?: string;
+ changes: Change[];
+}): string {
+ const lines: string[] = [];
+
+ if (description) {
+ lines.push(description, "");
+ }
+
+ const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
+
+ for (const type of orderedTypes) {
+ lines.push(`## ${getSectionTitle(type)}`);
+ for (const change of grouped[type]) {
+ lines.push(`- ${change.text}`);
+ }
+ lines.push("");
+ }
+
+ return lines.join("\n").trimEnd();
+}
+
+export function CopyMarkdownButton({
+ description,
+ changes,
+}: {
+ description?: string;
+ changes: Change[];
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ const markdown = buildMarkdown({ description, changes });
+ await navigator.clipboard.writeText(markdown);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+ <Button
+ size="sm"
+ variant="text"
+ onClick={handleCopy}
+ className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
+ title="Copy as markdown"
+ >
+ {copied ? (
+ <CheckIcon className="size-4" />
+ ) : (
+ <ClipboardIcon className="size-4" />
+ )}
+ {copied ? "Copied!" : "Copy markdown"}
+ </Button>
+ );
+}
diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx
new file mode 100644
index 00000000..eb04c1f0
--- /dev/null
+++ b/apps/web/src/app/changelog/components/release.tsx
@@ -0,0 +1,106 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { cn } from "@/utils/ui";
+import { getSectionTitle, groupAndOrderChanges } from "../utils";
+import type { Release } from "../utils";
+
+export function ReleaseArticle({
+ variant,
+ isLatest,
+ children,
+}: {
+ variant: "list" | "detail";
+ isLatest?: boolean;
+ children: ReactNode;
+}) {
+ if (variant === "list") {
+ return (
+ <article className="relative sm:pl-10">
+ <div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
+ <div
+ className={cn(
+ "size-[11px] rounded-full border-[1.5px]",
+ isLatest
+ ? "border-foreground bg-foreground"
+ : "border-muted-foreground/30 bg-background",
+ )}
+ />
+ </div>
+ <div className="flex flex-col gap-5">{children}</div>
+ </article>
+ );
+ }
+
+ return <article className="flex flex-col gap-8">{children}</article>;
+}
+
+export function ReleaseMeta({ release }: { release: Release }) {
+ return (
+ <span className="text-sm font-medium tracking-widest text-muted-foreground">
+ {release.version} — {release.date}
+ </span>
+ );
+}
+
+const titleSizes: Record<"h1" | "h2", string> = {
+ h1: "text-4xl",
+ h2: "text-2xl",
+};
+
+export function ReleaseTitle({
+ as: As,
+ href,
+ children,
+}: {
+ as: "h1" | "h2";
+ href?: string;
+ children: ReactNode;
+}) {
+ return (
+ <As className={cn("font-bold tracking-tight", titleSizes[As])}>
+ {href ? (
+ <Link href={href} className="hover:underline underline-offset-4">
+ {children}
+ </Link>
+ ) : (
+ children
+ )}
+ </As>
+ );
+}
+
+export function ReleaseDescription({ children }: { children: ReactNode }) {
+ return (
+ <p className="text-base text-foreground leading-relaxed max-w-xl">
+ {children}
+ </p>
+ );
+}
+
+export function ReleaseChanges({ release }: { release: Release }) {
+ const { grouped, orderedTypes } = groupAndOrderChanges({
+ changes: release.changes,
+ });
+
+ return (
+ <div className="flex flex-col gap-4">
+ {orderedTypes.map((type) => (
+ <div key={type} className="flex flex-col gap-1.5">
+ <h3 className="text-base font-semibold text-foreground">
+ {getSectionTitle(type)}:
+ </h3>
+ <ul className="list-disc pl-5 space-y-1.5">
+ {grouped[type].map((change) => (
+ <li
+ key={change.text}
+ className="text-base text-foreground leading-relaxed"
+ >
+ {change.text}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx
new file mode 100644
index 00000000..8cb585b6
--- /dev/null
+++ b/apps/web/src/app/changelog/page.tsx
@@ -0,0 +1,66 @@
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import { Separator } from "@/components/ui/separator";
+import { type Release as ReleaseType, getSortedReleases } from "./utils";
+import {
+ ReleaseArticle,
+ ReleaseMeta,
+ ReleaseTitle,
+ ReleaseDescription,
+ ReleaseChanges,
+} from "./components/release";
+
+export const metadata: Metadata = {
+ title: "Changelog - OpenCut",
+ description: "Every update, improvement, and fix to OpenCut — documented.",
+ openGraph: {
+ title: "Changelog - OpenCut",
+ description: "Every update, improvement, and fix to OpenCut — documented.",
+ type: "website",
+ },
+};
+
+export default function ChangelogPage() {
+ const releases = getSortedReleases();
+
+ return (
+ <BasePage title="Changelog" description="See what's new in OpenCut">
+ <div className="mx-auto w-full max-w-3xl">
+ <div className="relative">
+ <div
+ aria-hidden
+ className="absolute top-2 bottom-0 left-[5px] w-px bg-border hidden sm:block"
+ />
+
+ <div className="flex flex-col">
+ {releases.map((release, releaseIndex) => (
+ <div key={release.version} className="flex flex-col">
+ <ReleaseEntry release={release} />
+ {releaseIndex < releases.length - 1 && (
+ <Separator className="my-10 sm:ml-1.5" />
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </BasePage>
+ );
+}
+
+function ReleaseEntry({ release }: { release: ReleaseType }) {
+ return (
+ <ReleaseArticle variant="list" isLatest={release.isLatest}>
+ <ReleaseMeta release={release} />
+ <div className="flex flex-col gap-4">
+ <ReleaseTitle as="h2" href={`/changelog/${release.version}`}>
+ {release.title}
+ </ReleaseTitle>
+ {release.description && (
+ <ReleaseDescription>{release.description}</ReleaseDescription>
+ )}
+ </div>
+ <ReleaseChanges release={release} />
+ </ReleaseArticle>
+ );
+}
diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts
new file mode 100644
index 00000000..163207c0
--- /dev/null
+++ b/apps/web/src/app/changelog/utils.ts
@@ -0,0 +1,43 @@
+import { allChangelogs } from "content-collections";
+
+export type Change = { type: string; text: string };
+export type Release = (typeof allChangelogs)[number];
+
+const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
+
+const knownSectionTitles: Record<string, string> = {
+ new: "Features",
+ improved: "Improvements",
+ fixed: "Fixes",
+ breaking: "Breaking Changes",
+};
+
+export function getSectionTitle(type: string): string {
+ return (
+ knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
+ );
+}
+
+export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
+ const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
+ if (!acc[change.type]) acc[change.type] = [];
+ acc[change.type].push(change);
+ return acc;
+ }, {});
+
+ const customTypes = Object.keys(grouped).filter(
+ (type) => !knownSectionOrder.includes(type),
+ );
+ const orderedTypes = [
+ ...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
+ ...customTypes,
+ ];
+
+ return { grouped, orderedTypes };
+}
+
+export function getSortedReleases() {
+ return [...allChangelogs].sort((a, b) =>
+ b.version.localeCompare(a.version, undefined, { numeric: true }),
+ );
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 987af7c6..3ec6ddb8 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -16,22 +16,25 @@ import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
+import { MobileGate } from "@/components/editor/mobile-gate";
export default function Editor() {
const params = useParams();
const projectId = params.project_id as string;
return (
- <EditorProvider projectId={projectId}>
- <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
- <EditorHeader />
- <div className="min-h-0 min-w-0 flex-1">
- <EditorLayout />
+ <MobileGate>
+ <EditorProvider projectId={projectId}>
+ <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
+ <EditorHeader />
+ <div className="min-h-0 min-w-0 flex-1">
+ <EditorLayout />
+ </div>
+ <Onboarding />
+ <MigrationDialog />
</div>
- <Onboarding />
- <MigrationDialog />
- </div>
- </EditorProvider>
+ </EditorProvider>
+ </MobileGate>
);
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 2d051c93..6d3e984f 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -60,9 +60,7 @@
--muted-foreground: hsl(0 0% 48%);
--accent: hsl(0, 0%, 93%);
--accent-foreground: hsl(0 0% 5%);
- --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 98%);
- --constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 98%);
--border: hsl(0 0% 89%);
--input: hsl(0 0% 93%);
@@ -83,7 +81,6 @@
--muted: hsl(0 0% 20%);
--accent: hsl(0, 0%, 14%);
--accent-foreground: hsl(0 0% 95%);
- --constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 16%);
--input: hsl(0 0% 5%);
--ring: hsl(0, 0%, 50%);
@@ -103,13 +100,12 @@
--foreground: hsl(0 0% 85%);
--card: hsl(0, 0%, 10%);
--card-foreground: hsl(0 0% 85%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 17%);
+ --secondary: hsl(204, 67%, 9%);
+ --secondary-border: hsl(204, 100%, 14%);
--secondary-foreground: hsl(200, 98%, 63%);
--muted: hsl(0 0% 22%);
--accent: hsl(0, 0%, 15%);
--accent-foreground: hsl(0 0% 93%);
- --constructive: hsl(141, 71%, 52%);
--border: hsl(0 0% 18%);
--input: hsl(0 0% 22%);
--ring: hsl(0, 0%, 52%);
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 2b3d0c30..c2608bd9 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -28,6 +28,13 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<head>
<BotIdClient protect={protectedRoutes} />
+ {process.env.NODE_ENV === "development" && (
+ <Script
+ src="//unpkg.com/react-scan/dist/auto.global.js"
+ crossOrigin="anonymous"
+ strategy="beforeInteractive"
+ />
+ )}
</head>
<body className={`${siteFont.className} font-sans antialiased`}>
<ThemeProvider
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index 77d9a0cc..59c36d78 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -112,7 +112,7 @@ export default function RoadmapPage() {
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
return (
<div className="flex flex-col gap-2">
- <div className="flex items-center gap-2 text-lg font-medium">
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
<span className="leading-normal select-none">{index + 1}</span>
<h3>{item.title}</h3>
<StatusBadge status={item.status} className="ml-1" />
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index bc44e650..ddf8308d 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useRef } from "react";
+import { useState } from "react";
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -14,35 +14,47 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
-import { getExportMimeType, getExportFileExtension } from "@/lib/export";
+import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
- type ExportResult,
} from "@/types/export";
import {
Section,
SectionContent,
SectionHeader,
+ SectionTitle,
} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
+function isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
+
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
+}
+
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
- const handleExport = () => {
- setIsExportPopoverOpen(true);
- };
+ const hasProject = !!editor.project.getActiveOrNull();
- const hasProject = !!editor.project.getActive();
+ const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
+ if (!open) {
+ editor.project.cancelExport();
+ editor.project.clearExportState();
+ }
+ setIsExportPopoverOpen(open);
+ };
return (
- <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
+ <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
<PopoverTrigger asChild>
<button
type="button"
@@ -50,12 +62,12 @@ export function ExportButton() {
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
)}
- onClick={hasProject ? handleExport : undefined}
+ onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
disabled={!hasProject}
onKeyDown={(event) => {
if (hasProject && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
- handleExport();
+ setIsExportPopoverOpen(true);
}
}}
>
@@ -80,71 +92,49 @@ function ExportPopover({
}) {
const editor = useEditor();
const activeProject = editor.project.getActive();
+ const { isExporting, progress, result: exportResult } =
+ editor.project.getExportState();
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality,
);
- const [includeAudio, setIncludeAudio] = useState<boolean>(
- DEFAULT_EXPORT_OPTIONS.includeAudio || true,
+ const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
);
- const [isExporting, setIsExporting] = useState(false);
- const [progress, setProgress] = useState(0);
- const [exportResult, setExportResult] = useState<ExportResult | null>(null);
- const cancelRequestedRef = useRef(false);
const handleExport = async () => {
if (!activeProject) return;
- cancelRequestedRef.current = false;
- setIsExporting(true);
- setProgress(0);
- setExportResult(null);
-
const result = await editor.project.export({
options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio,
- onProgress: ({ progress }) => setProgress(progress),
- onCancel: () => cancelRequestedRef.current,
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
},
});
- setIsExporting(false);
-
if (result.cancelled) {
- setExportResult(null);
- setProgress(0);
+ editor.project.clearExportState();
return;
}
- setExportResult(result);
-
if (result.success && result.buffer) {
- const mimeType = getExportMimeType({ format });
- const extension = getExportFileExtension({ format });
- const blob = new Blob([result.buffer], { type: mimeType });
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement("a");
- a.href = url;
- a.download = `${activeProject.metadata.name}${extension}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ downloadBuffer({
+ buffer: result.buffer,
+ filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
+ mimeType: getExportMimeType({ format }),
+ });
+ editor.project.clearExportState();
onOpenChange(false);
- setExportResult(null);
- setProgress(0);
}
};
const handleCancel = () => {
- cancelRequestedRef.current = true;
+ editor.project.cancelExport();
};
return (
@@ -166,8 +156,10 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <Section collapsible defaultOpen={false} hasBorderTop={false}>
- <SectionHeader title="Format" />
+ <Section collapsible defaultOpen={false} showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Format</SectionTitle>
+ </SectionHeader>
<SectionContent>
<RadioGroup
value={format}
@@ -194,7 +186,9 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
- <SectionHeader title="Quality" />
+ <SectionHeader>
+ <SectionTitle>Quality</SectionTitle>
+ </SectionHeader>
<SectionContent>
<RadioGroup
value={quality}
@@ -219,7 +213,7 @@ function ExportPopover({
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
- Very High - Largest file size
+ Very high - Largest file size
</Label>
</div>
</RadioGroup>
@@ -227,15 +221,17 @@ function ExportPopover({
</Section>
<Section collapsible defaultOpen={false}>
- <SectionHeader title="Audio" />
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
<SectionContent>
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) =>
- setIncludeAudio(!!checked)
- }
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
/>
<Label htmlFor="include-audio">
Include audio in export
@@ -256,15 +252,15 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4 p-3">
- <div className="flex flex-col">
- <div className="flex items-center justify-between text-center">
- <p className="text-muted-foreground mb-2 text-sm">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-muted-foreground mb-2 text-sm">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
</div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
<Button
variant="outline"
@@ -282,14 +278,6 @@ function ExportPopover({
);
}
-function isExportFormat(value: string): value is ExportFormat {
- return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
-}
-
-function isExportQuality(value: string): value is ExportQuality {
- return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
-}
-
function ExportError({
error,
onRetry,
@@ -306,7 +294,7 @@ function ExportError({
};
return (
- <div className="space-y-4">
+ <div className="space-y-4 p-3">
<div className="flex flex-col gap-1.5">
<p className="text-destructive text-sm font-medium">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>
diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx
new file mode 100644
index 00000000..685ce178
--- /dev/null
+++ b/apps/web/src/components/editor/mobile-gate.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Button } from "../ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
+import { useRouter } from "next/navigation";
+
+const STORAGE_KEY = "mobile-acknowledged";
+
+interface MobileGateProps {
+ children: React.ReactNode;
+}
+
+export function MobileGate({ children }: MobileGateProps) {
+ const router = useRouter();
+ const [show, setShow] = useState<boolean | null>(null);
+
+ useEffect(() => {
+ const isMobile = window.innerWidth < 1024;
+ const acknowledged = localStorage.getItem(STORAGE_KEY) === "true";
+ setShow(isMobile && !acknowledged);
+ }, []);
+
+ if (show === null) return null;
+ if (!show) return <>{children}</>;
+
+ const handleContinue = () => {
+ localStorage.setItem(STORAGE_KEY, "true");
+ setShow(false);
+ };
+
+ const handleGoBack = () => {
+ router.back();
+ };
+
+ return (
+ <div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden">
+ <Button
+ variant="text"
+ className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
+ onClick={handleGoBack}
+ >
+ <HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
+ <span className=" text-sm">Go back</span>
+ </Button>
+
+ <div className="flex flex-1 flex-col justify-center gap-5 px-7">
+ <div className="flex flex-col gap-3">
+ <h1 className="text-foreground text-3xl font-bold tracking-tight">
+ Desktop only (for now)
+ </h1>
+ <p className="text-muted-foreground text-sm leading-relaxed">
+ OpenCut isn't optimized for mobile or iPad yet. Things will break
+ and the layout will be a mess. Come back on a desktop for the real
+ experience.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <Button variant="foreground" onClick={handleContinue}>
+ Take a look anyway
+ </Button>
+ <Button variant="ghost" asChild>
+ <Link href="/roadmap" className="flex items-center gap-1">
+ Roadmap
+ <HugeiconsIcon icon={ArrowRight01Icon} size={14} />
+ </Link>
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index 3600d00b..fe7db329 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -9,6 +9,7 @@ import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
import { TextView } from "./views/text";
+import { EffectsView } from "./views/effects";
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
@@ -18,11 +19,7 @@ export function AssetsPanel() {
sounds: <SoundsView />,
text: <TextView />,
stickers: <StickersView />,
- effects: (
- <div className="text-muted-foreground p-4">
- Effects view coming soon...
- </div>
- ),
+ effects: <EffectsView />,
transitions: (
<div className="text-muted-foreground p-4">
Transitions view coming soon...
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 808961f1..4e7ce5f6 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -31,7 +31,12 @@ import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
-import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import {
+ type MediaSortKey,
+ type MediaSortOrder,
+ type MediaViewMode,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
import { cn } from "@/utils/ui";
import {
@@ -50,8 +55,15 @@ export function MediaView() {
const mediaFiles = editor.media.getAssets();
const activeProject = editor.project.getActive();
- const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
- useAssetsPanelStore();
+ const {
+ mediaViewMode,
+ setMediaViewMode,
+ highlightMediaId,
+ clearHighlight,
+ mediaSortBy,
+ mediaSortOrder,
+ setMediaSort,
+ } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
@@ -59,10 +71,6 @@ export function MediaView() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
- "name",
- );
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const processFiles = async ({ files }: { files: FileList }) => {
if (!files || files.length === 0) return;
@@ -121,27 +129,12 @@ export function MediaView() {
});
};
- const addElementAtTime = ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }): boolean => {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const element = buildElementFromMedia({
- mediaId: asset.id,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- });
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- return true;
+ const handleSort = ({ key }: { key: MediaSortKey }) => {
+ if (mediaSortBy === key) {
+ setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setMediaSort(key, "asc");
+ }
};
const filteredMediaItems = useMemo(() => {
@@ -151,7 +144,7 @@ export function MediaView() {
let valueA: string | number;
let valueB: string | number;
- switch (sortBy) {
+ switch (mediaSortBy) {
case "name":
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
@@ -172,154 +165,13 @@ export function MediaView() {
return 0;
}
- if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
- if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
+ if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1;
+ if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1;
return 0;
});
return filtered;
- }, [mediaFiles, sortBy, sortOrder]);
-
- const previewComponents = useMemo(() => {
- const previews = new Map<string, React.ReactNode>();
-
- filteredMediaItems.forEach((item) => {
- previews.set(item.id, <MediaPreview item={item} />);
- previews.set(
- `compact-${item.id}`,
- <MediaPreview item={item} variant="compact" />,
- );
- });
-
- return previews;
- }, [filteredMediaItems]);
-
- const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
- const renderCompactPreview = (item: MediaAsset) =>
- previewComponents.get(`compact-${item.id}`);
-
- const mediaActions = (
- <div>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- size="icon"
- variant="ghost"
- onClick={() =>
- setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
- }
- disabled={isProcessing}
- className="items-center justify-center"
- >
- {mediaViewMode === "grid" ? (
- <HugeiconsIcon icon={LeftToRightListDashIcon} />
- ) : (
- <HugeiconsIcon icon={GridViewIcon} />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>
- {mediaViewMode === "grid"
- ? "Switch to list view"
- : "Switch to grid view"}
- </p>
- </TooltipContent>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="ghost"
- disabled={isProcessing}
- className="items-center justify-center"
- >
- <HugeiconsIcon icon={SortingOneNineIcon} />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <SortMenuItem
- label="Name"
- sortKey="name"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Type"
- sortKey="type"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Duration"
- sortKey="duration"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="File size"
- sortKey="size"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- </DropdownMenuContent>
- </DropdownMenu>
- <TooltipContent>
- <p>
- Sort by {sortBy} (
- {sortOrder === "asc" ? "ascending" : "descending"})
- </p>
- </TooltipContent>
- </Tooltip>
- </Tooltip>
- </TooltipProvider>
- <Button
- variant="outline"
- onClick={openFilePicker}
- disabled={isProcessing}
- size="sm"
- className="items-center justify-center gap-1.5 ml-1.5"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- );
+ }, [mediaFiles, mediaSortBy, mediaSortOrder]);
return (
<>
@@ -327,8 +179,18 @@ export function MediaView() {
<PanelView
title="Assets"
- actions={mediaActions}
- className={isDragOver ? "bg-accent/30" : ""}
+ actions={
+ <MediaActions
+ mediaViewMode={mediaViewMode}
+ setMediaViewMode={setMediaViewMode}
+ isProcessing={isProcessing}
+ sortBy={mediaSortBy}
+ sortOrder={mediaSortOrder}
+ onSort={handleSort}
+ onImport={openFilePicker}
+ />
+ }
+ className={cn(isDragOver && "bg-accent/30")}
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -338,21 +200,11 @@ export function MediaView() {
progress={progress}
onClick={openFilePicker}
/>
- ) : mediaViewMode === "grid" ? (
- <GridView
- items={filteredMediaItems}
- renderPreview={renderPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
) : (
- <ListView
+ <MediaItemList
items={filteredMediaItems}
- renderPreview={renderCompactPreview}
+ mode={mediaViewMode}
onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
highlightedId={highlightedId}
registerElement={registerElement}
/>
@@ -362,6 +214,67 @@ export function MediaView() {
);
}
+function MediaAssetDraggable({
+ item,
+ preview,
+ isHighlighted,
+ variant,
+ isRounded,
+}: {
+ item: MediaAsset;
+ preview: React.ReactNode;
+ isHighlighted: boolean;
+ variant: "card" | "compact";
+ isRounded?: boolean;
+}) {
+ const editor = useEditor();
+
+ const addElementAtTime = ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => {
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: asset.id,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <DraggableItem
+ name={item.name}
+ preview={preview}
+ dragData={{
+ id: item.id,
+ type: "media",
+ mediaType: item.type,
+ name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
+ }}
+ shouldShowPlusOnDrag={false}
+ onAddToTimeline={({ currentTime }) =>
+ addElementAtTime({ asset: item, startTime: currentTime })
+ }
+ variant={variant}
+ isRounded={isRounded}
+ isHighlighted={isHighlighted}
+ />
+ );
+}
+
function MediaItemWithContextMenu({
item,
children,
@@ -387,101 +300,41 @@ function MediaItemWithContextMenu({
);
}
-function GridView({
+function MediaItemList({
items,
- renderPreview,
+ mode,
onRemove,
- onAddToTimeline,
highlightedId,
registerElement,
}: {
items: MediaAsset[];
- renderPreview: (item: MediaAsset) => React.ReactNode;
+ mode: MediaViewMode;
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- onAddToTimeline: ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }) => boolean;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const isGrid = mode === "grid";
+
return (
<div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
+ className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")}
+ style={
+ isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined
+ }
>
{items.map((item) => (
- <div key={item.id} ref={(el) => registerElement(item.id, el)}>
+ <div key={item.id} ref={(element) => registerElement(item.id, element)}>
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
- <DraggableItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: "media",
- mediaType: item.type,
- name: item.name,
- }}
- shouldShowPlusOnDrag={false}
- onAddToTimeline={({ currentTime }) =>
- onAddToTimeline({ asset: item, startTime: currentTime })
- }
- isRounded={false}
- variant="card"
- isHighlighted={highlightedId === item.id}
- />
- </MediaItemWithContextMenu>
- </div>
- ))}
- </div>
- );
-}
-
-function ListView({
- items,
- renderPreview,
- onRemove,
- onAddToTimeline,
- highlightedId,
- registerElement,
-}: {
- items: MediaAsset[];
- renderPreview: (item: MediaAsset) => React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- onAddToTimeline: ({
- asset,
- startTime,
- }: {
- asset: MediaAsset;
- startTime: number;
- }) => boolean;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
-}) {
- return (
- <div className="space-y-1">
- {items.map((item) => (
- <div key={item.id} ref={(el) => registerElement(item.id, el)}>
- <MediaItemWithContextMenu item={item} onRemove={onRemove}>
- <DraggableItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: "media",
- mediaType: item.type,
- name: item.name,
- }}
- shouldShowPlusOnDrag={false}
- onAddToTimeline={({ currentTime }) =>
- onAddToTimeline({ asset: item, startTime: currentTime })
+ <MediaAssetDraggable
+ item={item}
+ preview={
+ <MediaPreview
+ item={item}
+ variant={isGrid ? "grid" : "compact"}
+ />
}
- variant="compact"
+ variant={isGrid ? "card" : "compact"}
+ isRounded={isGrid ? false : undefined}
isHighlighted={highlightedId === item.id}
/>
</MediaItemWithContextMenu>
@@ -491,11 +344,11 @@ function ListView({
);
}
-const formatDuration = ({ duration }: { duration: number }) => {
+export function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
-};
+}
function MediaDurationBadge({ duration }: { duration?: number }) {
if (!duration) return null;
@@ -613,6 +466,119 @@ function MediaPreview({
);
}
+function MediaActions({
+ mediaViewMode,
+ setMediaViewMode,
+ isProcessing,
+ sortBy,
+ sortOrder,
+ onSort,
+ onImport,
+}: {
+ mediaViewMode: MediaViewMode;
+ setMediaViewMode: (mode: MediaViewMode) => void;
+ isProcessing: boolean;
+ sortBy: MediaSortKey;
+ sortOrder: MediaSortOrder;
+ onSort: ({ key }: { key: MediaSortKey }) => void;
+ onImport: () => void;
+}) {
+ return (
+ <div className="flex gap-1.5">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ onClick={() =>
+ setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <DropdownMenu>
+ <TooltipTrigger asChild>
+ <DropdownMenuTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ <HugeiconsIcon icon={SortingOneNineIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={onSort}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={onImport}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Import
+ </Button>
+ </div>
+ );
+}
+
function SortMenuItem({
label,
sortKey,
@@ -621,10 +587,10 @@ function SortMenuItem({
onSort,
}: {
label: string;
- sortKey: "name" | "type" | "duration" | "size";
- currentSortBy: string;
- currentSortOrder: "asc" | "desc";
- onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
+ sortKey: MediaSortKey;
+ currentSortBy: MediaSortKey;
+ currentSortOrder: MediaSortOrder;
+ onSort: ({ key }: { key: MediaSortKey }) => void;
}) {
const isActive = currentSortBy === sortKey;
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
new file mode 100644
index 00000000..c9b8b2dd
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useEffect, useRef, useCallback } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import {
+ effectPreviewService,
+ onPreviewImageReady,
+} from "@/services/renderer/effect-preview";
+import { useEditor } from "@/hooks/use-editor";
+import { buildEffectElement } from "@/lib/timeline/element-utils";
+import type { EffectDefinition } from "@/types/effects";
+
+export function EffectsView() {
+ const effects = getAllEffects();
+
+ return (
+ <PanelView title="Effects">
+ <EffectsGrid effects={effects} />
+ </PanelView>
+ );
+}
+
+function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
+ return (
+ <div
+ className="grid gap-2"
+ style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
+ >
+ {effects.map((effect) => (
+ <EffectItem key={effect.type} effect={effect} />
+ ))}
+ </div>
+ );
+}
+
+function EffectPreviewCanvas({ effectType }: { effectType: string }) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+
+ useEffect(() => {
+ const render = () => {
+ if (canvasRef.current) {
+ effectPreviewService.renderPreview({
+ effectType,
+ params: {},
+ targetCanvas: canvasRef.current,
+ });
+ }
+ };
+
+ render();
+ return onPreviewImageReady({ callback: render });
+ }, [effectType]);
+
+ return <canvas ref={canvasRef} className="size-full" />;
+}
+
+function EffectItem({ effect }: { effect: EffectDefinition }) {
+ const editor = useEditor();
+
+ const handleAddToTimeline = useCallback(() => {
+ const currentTime = editor.playback.getCurrentTime();
+ const element = buildEffectElement({
+ effectType: effect.type,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto", trackType: "effect" },
+ element,
+ });
+ }, [editor, effect.type]);
+
+ const preview = <EffectPreviewCanvas effectType={effect.type} />;
+
+ return (
+ <DraggableItem
+ name={effect.name}
+ preview={preview}
+ dragData={{
+ id: effect.type,
+ name: effect.name,
+ type: "effect",
+ effectType: effect.type,
+ targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
+ }}
+ onAddToTimeline={handleAddToTimeline}
+ aspectRatio={1}
+ isRounded
+ variant="card"
+ containerClassName="w-full"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
index 7e10ac9d..d46e9d1f 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -16,6 +16,7 @@ import {
Section,
SectionContent,
SectionHeader,
+ SectionTitle,
} from "@/components/editor/panels/properties/section";
import { Label } from "@/components/ui/label";
import {
@@ -23,25 +24,46 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import { useState } from "react";
-export function SettingsView() {
- const [open, setOpen] = useState(false);
+const ORIGINAL_PRESET_VALUE = "original";
+export function findPresetIndexByAspectRatio({
+ presets,
+ targetAspectRatio,
+}: {
+ presets: Array<{ width: number; height: number }>;
+ targetAspectRatio: string;
+}) {
+ for (let index = 0; index < presets.length; index++) {
+ const preset = presets[index];
+ const presetAspectRatio = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ if (presetAspectRatio === targetAspectRatio) {
+ return index;
+ }
+ }
+ return -1;
+}
+
+export function SettingsView() {
return (
<PanelView contentClassName="px-0" hideHeader>
<div className="flex flex-col">
- <Section hasBorderTop={false}>
+ <Section showTopBorder={false}>
<SectionContent>
<ProjectInfoContent />
</SectionContent>
</Section>
- <Popover open={open} onOpenChange={setOpen}>
+ <Popover>
<Section className="cursor-pointer">
<PopoverTrigger asChild>
<div>
- <SectionHeader title="Background">
- <div className="size-4 rounded-sm bg-red-500" />
+ <SectionHeader
+ trailing={<div className="size-4 rounded-sm bg-red-500" />}
+ >
+ <SectionTitle>Background</SectionTitle>
</SectionHeader>
</div>
</PopoverTrigger>
@@ -60,26 +82,6 @@ function ProjectInfoContent() {
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
- const findPresetIndexByAspectRatio = ({
- presets,
- targetAspectRatio,
- }: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
- }) => {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
- };
-
const currentCanvasSize = activeProject.settings.canvasSize;
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
@@ -87,12 +89,11 @@ function ProjectInfoContent() {
presets: canvasPresets,
targetAspectRatio: currentAspectRatio,
});
- const originalPresetValue = "original";
const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+ presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === originalPresetValue) {
+ if (value === ORIGINAL_PRESET_VALUE) {
const canvasSize = originalCanvasSize ?? currentCanvasSize;
editor.project.updateSettings({
settings: { canvasSize },
@@ -106,7 +107,7 @@ function ProjectInfoContent() {
}
};
- const handleFpsChange = (value: string) => {
+ const handleFpsChange = ({ value }: { value: string }) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
};
@@ -129,7 +130,7 @@ function ProjectInfoContent() {
<SelectValue placeholder="Select an aspect ratio" />
</SelectTrigger>
<SelectContent>
- <SelectItem value={originalPresetValue}>Original</SelectItem>
+ <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
{canvasPresets.map((preset, index) => {
const label = dimensionToAspectRatio({
width: preset.width,
@@ -148,7 +149,7 @@ function ProjectInfoContent() {
<Label>Frame rate</Label>
<Select
value={activeProject.settings.fps.toString()}
- onValueChange={handleFpsChange}
+ onValueChange={(value) => handleFpsChange({ value })}
>
<SelectTrigger className="w-fit">
<SelectValue placeholder="Select a frame rate" />
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index fdd4055d..26649b26 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -56,7 +56,7 @@ export function TextEditOverlay({
}, [editor.timeline, trackId, elementId]);
const handleKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
+ ({ event }: { event: React.KeyboardEvent }) => {
const { key } = event;
if (key === "Escape") {
event.preventDefault();
@@ -98,10 +98,13 @@ export function TextEditOverlay({
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
- const backgroundColor =
- element.background.color === "transparent"
- ? "transparent"
- : element.background.color;
+ const shouldShowBackground =
+ element.background.enabled &&
+ element.background.color &&
+ element.background.color !== "transparent";
+ const backgroundColor = shouldShowBackground
+ ? element.background.color
+ : "transparent";
return (
<div
@@ -134,13 +137,12 @@ export function TextEditOverlay({
backgroundColor,
minHeight: displayFontSize * lineHeight,
textDecoration: element.textDecoration ?? "none",
- padding:
- backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
+ padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
minWidth: 1,
}}
onInput={handleInput}
onBlur={onCommit}
- onKeyDown={handleKeyDown}
+ onKeyDown={(event) => handleKeyDown({ event })}
>
{element.content || ""}
</div>
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 976c3019..2fcf8096 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -23,7 +23,7 @@ const CORNERS: Corner[] = [
"bottom-right",
];
-function getCornerPosition({
+export function getCornerPosition({
bounds,
corner,
}: {
@@ -47,7 +47,7 @@ function getCornerPosition({
};
}
-function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
+export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
x: number;
y: number;
} {
@@ -61,7 +61,7 @@ function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
};
}
-function getOverlayContext({
+export function getOverlayContext({
canvasRef,
containerRef,
}: {
@@ -196,7 +196,7 @@ function BoundingBoxOutline({
height: outlineHeight,
transform: `rotate(${rotation}deg)`,
transformOrigin: "center center",
- border: "1px solid white",
+ border: "1px dashed white",
boxSizing: "border-box",
opacity: 0.75,
}}
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
index 0b83a4b6..2ad46497 100644
--- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
@@ -1,5 +1,3 @@
-import type { AudioElement } from "@/types/timeline";
-
-export function AudioProperties({ _element }: { _element: AudioElement }) {
+export function AudioProperties() {
return <div className="space-y-4 p-5">Audio properties</div>;
}
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
new file mode 100644
index 00000000..1caf73eb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useState } from "react";
+import type { Effect } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertiesStore } from "@/stores/properties-store";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} from "@hugeicons/core-free-icons";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/utils/ui";
+export function ClipEffectsProperties({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const closeClipEffects = usePropertiesStore(
+ (state) => state.closeClipEffects,
+ );
+ const editor = useEditor();
+ const effects = element.effects ?? [];
+
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+
+ const handleDragStart = ({ index }: { index: number }) => {
+ setDragIndex(index);
+ };
+
+ const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex h-full flex-col">
+ <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={closeClipEffects}
+ aria-label="Back to properties"
+ >
+ <HugeiconsIcon icon={ArrowLeft01Icon} />
+ </Button>
+ <span className="text-sm font-medium">Effects</span>
+ </div>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {effects.map((effect, index) => (
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
+ <div
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group",
+ dragIndex === index && "opacity-40",
+ dropIndex === index &&
+ dragIndex !== null &&
+ dragIndex !== index &&
+ (index < dragIndex
+ ? "border-t-2 border-primary"
+ : "border-b-2 border-primary"),
+ )}
+ >
+ <ClipEffectSection
+ effect={effect}
+ element={element}
+ trackId={trackId}
+ />
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+ );
+}
+
+function ClipEffectSection({
+ effect,
+ element,
+ trackId,
+}: {
+ effect: Effect;
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = getEffect({ effectType: effect.type });
+
+ const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
+ const updatedEffects = (element.effects ?? []).map((existing) =>
+ existing.id !== effect.id
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { effects: updatedEffects },
+ },
+ ],
+ });
+ };
+
+ const commitParam = () => editor.timeline.commitPreview();
+
+ const toggleEffect = () =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ const removeEffect = () =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ });
+
+ return (
+ <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
+ <SectionHeader
+ className="cursor-move"
+ trailing={
+ <>
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={toggleEffect}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={removeEffect}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </>
+ }
+ >
+ <SectionTitle
+ className={cn(!effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ {effect.enabled && (
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ value={effect.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={commitParam}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ )}
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
new file mode 100644
index 00000000..408d430a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
+import { clamp } from "@/utils/math";
+import { SectionField } from "./section";
+import { Slider } from "@/components/ui/slider";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+
+export function EffectParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ return (
+ <SectionField label={param.label}>
+ <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
+ </SectionField>
+ );
+}
+
+function EffectParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: EffectParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <input
+ type="color"
+ className="h-8 w-full cursor-pointer rounded border"
+ value={String(value)}
+ onChange={(event) => onPreview(event.target.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberEffectParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step } = param;
+
+ const draft = usePropertyDraft({
+ displayValue: String(value),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min, max });
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <div className="flex items-center gap-3">
+ <Slider
+ className="flex-1"
+ min={min}
+ max={max}
+ step={step}
+ value={[value]}
+ onValueChange={([newValue]) => onPreview(newValue)}
+ onValueCommit={onCommit}
+ />
+ <NumberField
+ className="w-16 shrink-0"
+ value={draft.displayValue}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
new file mode 100644
index 00000000..75fc30a1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import type { EffectElement } from "@/types/timeline";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionFields,
+ SectionTitle,
+} from "./section";
+import { EffectParamField } from "./effect-param-field";
+
+export function EffectProperties({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = getEffect({ effectType: element.effectType });
+
+ const previewParam =
+ ({ key }: { key: string }) =>
+ (value: number | string | boolean) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, [key]: value } },
+ },
+ ],
+ });
+
+ return (
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ value={element.params[param.key] ?? param.default}
+ onPreview={previewParam({ key: param.key })}
+ onCommit={() => editor.timeline.commitPreview()}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
new file mode 100644
index 00000000..46e6cb6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -0,0 +1,24 @@
+import { useEditor } from "@/hooks/use-editor";
+import { getElementLocalTime } from "@/lib/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
+export function useElementPlayhead({
+ startTime,
+ duration,
+}: {
+ startTime: number;
+ duration: number;
+}) {
+ const editor = useEditor();
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
+
+ return { localTime, isPlayheadWithinElementRange };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
new file mode 100644
index 00000000..dbd19e4c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -0,0 +1,95 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
new file mode 100644
index 00000000..11aa049c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -0,0 +1,151 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue = ({ value }: { value: number }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse,
+ onPreview: (value) => previewValue({ value }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: valueAtPlayhead,
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 4f285f84..cb45996d 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -4,46 +4,79 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AudioProperties } from "./audio-properties";
import { VideoProperties } from "./video-properties";
import { TextProperties } from "./text-properties";
+import { EffectProperties } from "./effect-properties";
+import { ClipEffectsProperties } from "./clip-effects-properties";
import { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { usePropertiesStore } from "@/stores/properties-store";
+import { isVisualElement } from "@/lib/timeline";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+
+function ElementProperties({
+ track,
+ element,
+}: {
+ track: TimelineTrack;
+ element: TimelineElement;
+}) {
+ if (element.type === "text") {
+ return <TextProperties element={element} trackId={track.id} />;
+ }
+ if (element.type === "audio") {
+ return <AudioProperties />;
+ }
+ if (
+ element.type === "video" ||
+ element.type === "image" ||
+ element.type === "sticker"
+ ) {
+ return <VideoProperties element={element} trackId={track.id} />;
+ }
+ if (element.type === "effect") {
+ return <EffectProperties element={element} trackId={track.id} />;
+ }
+ return null;
+}
export function PropertiesPanel() {
const editor = useEditor();
const { selectedElements } = useElementSelection();
+ const clipEffectsTarget = usePropertiesStore(
+ (state) => state.clipEffectsTarget,
+ );
+
+ const clipEffectsTrack = clipEffectsTarget
+ ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
+ : null;
+ const clipEffectsElement = clipEffectsTrack?.elements.find(
+ (element) => element.id === clipEffectsTarget?.elementId,
+ );
+ const isShowingClipEffects =
+ clipEffectsTrack &&
+ clipEffectsElement &&
+ isVisualElement(clipEffectsElement);
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
return (
- <div className="panel bg-background h-full rounded-sm border border-t-0 overflow-hidden">
- {selectedElements.length > 0 ? (
+ <div className="panel bg-background h-full rounded-sm border overflow-hidden">
+ {isShowingClipEffects ? (
+ <ClipEffectsProperties
+ element={clipEffectsElement}
+ trackId={clipEffectsTrack.id}
+ />
+ ) : selectedElements.length > 0 ? (
<ScrollArea className="h-full scrollbar-hidden">
- {elementsWithTracks.map(({ track, element }) => {
- if (element.type === "text") {
- return (
- <div key={element.id}>
- <TextProperties element={element} trackId={track.id} />
- </div>
- );
- }
- if (element.type === "audio") {
- return <AudioProperties key={element.id} _element={element} />;
- }
- if (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "sticker"
- ) {
- return (
- <div key={element.id}>
- <VideoProperties element={element} trackId={track.id} />
- </div>
- );
- }
- return null;
- })}
+ {elementsWithTracks.map(({ track, element }) => (
+ <ElementProperties
+ key={element.id}
+ track={track}
+ element={element}
+ />
+ ))}
</ScrollArea>
) : (
<EmptyView />
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
new file mode 100644
index 00000000..b5b8685f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
@@ -0,0 +1,32 @@
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { KeyframeIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+export function KeyframeToggle({
+ isActive,
+ isDisabled = false,
+ title,
+ onToggle,
+}: {
+ isActive: boolean;
+ isDisabled?: boolean;
+ title: string;
+ onToggle: () => void;
+}) {
+ return (
+ <Button
+ variant="text"
+ aria-pressed={isActive}
+ disabled={isDisabled}
+ title={title}
+ onClick={onToggle}
+ className="[&>svg]:size-3.5 mb-0.5"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(isActive && "text-primary fill-primary")}
+ />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
index 72d78d80..5989108b 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -1,10 +1,12 @@
-import { createContext, useContext, useState } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
const sectionExpandedCache = new Map<string, boolean>();
+const mountedSectionKeys = new Set<string>();
interface SectionContext {
isOpen: boolean;
@@ -24,8 +26,8 @@ interface SectionProps {
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
- hasBorderTop?: boolean;
- hasBorderBottom?: boolean;
+ showTopBorder?: boolean;
+ showBottomBorder?: boolean;
}
export function Section({
@@ -34,12 +36,21 @@ export function Section({
defaultOpen = true,
sectionKey,
className,
- hasBorderTop = true,
- hasBorderBottom = true,
+ showTopBorder = true,
+ showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+ useEffect(() => {
+ if (!sectionKey) return;
+ if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
+ console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
+ }
+ mountedSectionKeys.add(sectionKey);
+ return () => { mountedSectionKeys.delete(sectionKey); };
+ }, [sectionKey]);
+
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
@@ -51,8 +62,8 @@ export function Section({
<div
className={cn(
"flex flex-col",
- hasBorderTop && "border-t",
- hasBorderBottom && "last:border-b",
+ showTopBorder && "border-t",
+ showBottomBorder && "last:border-b",
className,
)}
>
@@ -63,15 +74,19 @@ export function Section({
}
interface SectionHeaderProps {
- title: string;
children?: React.ReactNode;
+ trailing?: React.ReactNode;
+ leading?: React.ReactNode;
+ actions?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
- title,
children,
+ trailing,
+ leading,
+ actions,
onClick,
className,
}: SectionHeaderProps) {
@@ -79,57 +94,108 @@ export function SectionHeader({
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
-
const handleClick = isCollapsible ? ctx?.toggle : onClick;
- const content = (
+ const chevronIcon = isCollapsible ? (
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn(
+ "size-4 shrink-0 transition-transform duration-200 ease-out",
+ isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
+ )}
+ />
+ ) : null;
+
+ const headerContent = (
<>
- <span
- className={cn(
- "text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- )}
- >
- {title}
- </span>
- <div className="flex items-center gap-1">
- {children}
- {isCollapsible && (
- <HugeiconsIcon
- icon={ArrowDownIcon}
- className={cn(
- "size-3 shrink-0 transition-transform duration-200 ease-out",
- isOpen
- ? "rotate-0 text-foreground"
- : "-rotate-90 text-muted-foreground",
- )}
- />
- )}
- </div>
+ {leading}
+ <div className="min-w-0 flex-1">{children}</div>
+ {(trailing || chevronIcon) && (
+ <div className="flex items-center">
+ {trailing}
+ {chevronIcon && (
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ onClick={(event) => {
+ event.stopPropagation();
+ handleClick?.();
+ }}
+ >
+ {chevronIcon}
+ </Button>
+ )}
+ </div>
+ )}
+ {actions}
</>
);
- const baseClassName = cn(
- "flex w-full items-center justify-between h-11 px-3.5",
- className,
+ if (!isInteractive) {
+ return (
+ <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
+ {headerContent}
+ </div>
+ );
+ }
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
+ <div
+ role="button"
+ tabIndex={0}
+ className={cn(
+ "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
+ className,
+ )}
+ onClick={handleClick}
+ onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
+ >
+ {headerContent}
+ </div>
);
+}
- if (isInteractive) {
+export function SectionTitle({
+ children,
+ className,
+ onClick,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+}) {
+ const ctx = useSectionContext();
+ const isOpen = ctx?.isOpen ?? true;
+
+ if (onClick) {
return (
<button
type="button"
- className={cn(baseClassName, "cursor-pointer text-left")}
- onClick={(event) => {
- handleClick?.();
- event.currentTarget.blur();
- }}
+ className={cn(
+ "cursor-pointer text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ )}
+ onClick={onClick}
>
- {content}
+ {children}
</button>
);
}
- return <div className={baseClassName}>{content}</div>;
+ return (
+ <span
+ className={cn(
+ "text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ )}
+ >
+ {children}
+ </span>
+ );
}
export function SectionFields({
@@ -139,21 +205,28 @@ export function SectionFields({
children: React.ReactNode;
className?: string;
}) {
- return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
+ return (
+ <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
+ );
}
export function SectionField({
label,
+ beforeLabel,
children,
className,
}: {
label: string;
+ beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
- <Label>{label}</Label>
+ <div className="flex h-4 items-center gap-1.5">
+ {beforeLabel}
+ <Label>{label}</Label>
+ </div>
{children}
</div>
);
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
index 4343c692..a50e84e7 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -1,5 +1,4 @@
import { useEditor } from "@/hooks/use-editor";
-import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
@@ -8,7 +7,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader } from "../section";
+import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
import {
Select,
SelectContent,
@@ -19,14 +18,23 @@ import {
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
+import type { ElementAnimations } from "@/types/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { isPropertyAtDefault } from "./transform";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
@@ -74,7 +82,7 @@ export function BlendingSection({
committedBlendModeRef.current = blendMode;
}
- const previewBlendMode = (value: BlendMode) =>
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
@@ -105,28 +113,50 @@ export function BlendingSection({
}
};
- const opacity = usePropertyDraft({
- displayValue: Math.round(element.opacity * 100).toString(),
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { opacity: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOpacity,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader title="Blending" />
+ <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
- <SectionField label="Opacity" className="w-1/2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
+ }
+ >
<NumberField
className="w-full"
icon={
@@ -140,18 +170,14 @@ export function BlendingSection({
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: DEFAULT_OPACITY },
- },
- ],
- })
- }
- isDefault={element.opacity === DEFAULT_OPACITY}
+ onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULT_OPACITY,
+ })}
dragSensitivity="slow"
/>
</SectionField>
@@ -175,7 +201,7 @@ export function BlendingSection({
key={option.value}
value={option.value}
onPointerEnter={() =>
- previewBlendMode(option.value as BlendMode)
+ previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
index 21da36e9..6aaaa1fe 100644
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -1,9 +1,16 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
-import { usePropertyDraft } from "../hooks/use-property-draft";
-import { clamp } from "@/utils/math";
-import type { ElementType, Transform } from "@/types/timeline";
-import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "../section";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { VisualElement } from "@/types/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -13,71 +20,127 @@ import {
} from "@hugeicons/core-free-icons";
import { useState } from "react";
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
+import { KeyframeToggle } from "../keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-type TransformElement = {
- id: string;
- transform: Transform;
- type: ElementType;
-};
-
-function parseFloat_({ input }: { input: string }): number | null {
+export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
export function TransformSection({
element,
trackId,
+ showTopBorder = true,
}: {
- element: TransformElement;
+ element: VisualElement;
trackId: string;
+ showTopBorder?: boolean;
}) {
const editor = useEditor();
const [isScaleLocked, setIsScaleLocked] = useState(false);
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
- const previewTransform = (transform: Partial<Transform>) => {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { transform: { ...element.transform, ...transform } },
+ const positionX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.x",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.x).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.x,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: value,
},
- ],
- });
- };
-
- const commit = () => editor.timeline.commitPreview();
-
- const positionX = usePropertyDraft({
- displayValue: Math.round(element.transform.position.x).toString(),
- parse: (input) => parseFloat_({ input }),
- onPreview: (value) =>
- previewTransform({
- position: { ...element.transform.position, x: value },
- }),
- onCommit: commit,
+ },
+ }),
});
- const positionY = usePropertyDraft({
- displayValue: Math.round(element.transform.position.y).toString(),
- parse: (input) => parseFloat_({ input }),
- onPreview: (value) =>
- previewTransform({
- position: { ...element.transform.position, y: value },
- }),
- onCommit: commit,
+ const positionY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position.y",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.position.y).toString(),
+ parse: (input) => parseNumericInput({ input }),
+ valueAtPlayhead: resolvedTransform.position.y,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: value,
+ },
+ },
+ }),
});
- const scale = usePropertyDraft({
- displayValue: Math.round(element.transform.scale * 100).toString(),
+ const scale = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scale",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scale * 100).toString(),
parse: (input) => {
- const parsed = parseFloat_({ input });
+ const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return Math.max(parsed, 1) / 100;
},
- onPreview: (value) => previewTransform({ scale: value }),
- onCommit: commit,
+ valueAtPlayhead: resolvedTransform.scale,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scale: value,
+ },
+ }),
});
const scaleFieldProps = {
className: "flex-1",
@@ -88,66 +151,152 @@ export function TransformSection({
dragSensitivity: "slow" as const,
onScrub: scale.scrubTo,
onScrubEnd: scale.commitScrub,
- onReset: () =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- scale: DEFAULT_TRANSFORM.scale,
- },
- },
- },
- ],
- }),
- isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
+ onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scale,
+ staticValue: element.transform.scale,
+ defaultValue: DEFAULT_TRANSFORM.scale,
+ }),
};
- const rotation = usePropertyDraft({
- displayValue: Math.round(element.transform.rotate).toString(),
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
- const parsed = parseFloat_({ input });
+ const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
- onPreview: (value) => previewTransform({ rotate: value }),
- onCommit: commit,
+ valueAtPlayhead: resolvedTransform.rotate,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
});
+ const hasPositionKeyframe =
+ positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
+
+ const togglePositionKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
+ const keyframesToRemove: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }> = [];
+ if (positionX.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x" as const,
+ keyframeId: positionX.keyframeIdAtTime,
+ });
+ }
+ if (positionY.keyframeIdAtTime) {
+ keyframesToRemove.push({
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y" as const,
+ keyframeId: positionY.keyframeIdAtTime,
+ });
+ }
+
+ editor.timeline.removeKeyframes({
+ keyframes: keyframesToRemove,
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.x",
+ time: localTime,
+ value: resolvedTransform.position.x,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.position.y",
+ time: localTime,
+ value: resolvedTransform.position.y,
+ },
+ ],
+ });
+ };
+
return (
- <Section collapsible sectionKey={`${element.type}:transform`}>
- <SectionHeader title="Transform" />
- <SectionContent>
- <SectionFields>
- <SectionField label="Scale">
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
+ <Section
+ collapsible
+ sectionKey={`${element.type}:transform`}
+ showTopBorder={showTopBorder}
+ >
+ <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Scale"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scale.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={scale.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ {isScaleLocked ? (
+ <>
+ <NumberField icon="W" {...scaleFieldProps} />
+ <NumberField icon="H" {...scaleFieldProps} />
+ </>
+ ) : (
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldProps}
+ className="flex-1"
+ />
+ )}
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ </div>
+ </SectionField>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasPositionKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={togglePositionKeyframe}
/>
- )}
- <Button
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- </SectionField>
- <SectionField label="Position">
- <div className="flex items-center gap-2">
+ }
+ >
+ <div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
@@ -158,27 +307,15 @@ export function TransformSection({
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: DEFAULT_TRANSFORM.position.x,
- },
- },
- },
- },
- ],
- })
- }
- isDefault={
- element.transform.position.x === DEFAULT_TRANSFORM.position.x
+ positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULT_TRANSFORM.position.x,
+ })}
/>
<NumberField
icon="Y"
@@ -190,63 +327,56 @@ export function TransformSection({
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: DEFAULT_TRANSFORM.position.y,
- },
- },
- },
- },
- ],
- })
- }
- isDefault={
- element.transform.position.y === DEFAULT_TRANSFORM.position.y
+ positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULT_TRANSFORM.position.y,
+ })}
/>
- </div>
- </SectionField>
+ </div>
+ </SectionField>
- <SectionField label="Rotation">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- transform: {
- ...element.transform,
- rotate: DEFAULT_TRANSFORM.rotate,
- },
- },
- },
- ],
- })
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
}
- isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULT_TRANSFORM.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
</Section>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 1a17c26a..ae6de420 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -3,13 +3,23 @@ import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
-import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_BACKGROUND,
@@ -18,32 +28,21 @@ import {
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
+import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "./hooks/use-element-playhead";
import { TransformSection, BlendingSection } from "./sections";
+import { KeyframeToggle } from "./keyframe-toggle";
+import { isPropertyAtDefault } from "./sections/transform";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
-import { TextFontIcon } from "@hugeicons/core-free-icons";
+import {
+ TextFontIcon,
+ ViewIcon,
+ ViewOffSlashIcon,
+} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
-
-function createOffsetConverter({
- defaultValue,
- scale = 1,
- min,
-}: {
- defaultValue: number;
- scale?: number;
- min?: number;
-}) {
- return {
- toDisplay: (value: number) => Math.round((value - defaultValue) * scale),
- fromDisplay: (display: number) => {
- const stored = defaultValue + display / scale;
- return min !== undefined ? Math.max(min, stored) : stored;
- },
- };
-}
-
-const lineHeightConverter = createOffsetConverter({ defaultValue: DEFAULT_LINE_HEIGHT, scale: 10 });
-const paddingXConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, min: 0 });
-const paddingYConverter = createOffsetConverter({ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY, min: 0 });
+import { cn } from "@/utils/ui";
export function TextProperties({
element,
@@ -86,8 +85,10 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" hasBorderTop={false}>
- <SectionHeader title="Content" />
+ <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Content</SectionTitle>
+ </SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
@@ -110,6 +111,27 @@ function TypographySection({
trackId: string;
}) {
const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
const fontSize = usePropertyDraft({
displayValue: element.fontSize.toString(),
@@ -129,71 +151,73 @@ function TypographySection({
return (
<Section collapsible sectionKey="text:typography">
- <SectionHeader title="Typography" />
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField label="Color">
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- })
+ <SectionHeader>
+ <SectionTitle>Typography</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <NumberField
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onScrub={fontSize.scrubTo}
+ onScrubEnd={fontSize.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={textColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle text color keyframe"
+ onToggle={textColor.toggleKeyframe}
+ />
}
- onChangeEnd={() => editor.timeline.commitPreview()}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
+ >
+ <ColorPicker
+ value={uppercase({
+ string: resolvedTextColor.replace("#", ""),
+ })}
+ onChange={(color) => textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
</Section>
);
}
@@ -208,72 +232,100 @@ function SpacingSection({
const editor = useEditor();
const letterSpacing = usePropertyDraft({
- displayValue: Math.round(element.letterSpacing ?? DEFAULT_LETTER_SPACING).toString(),
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULT_LETTER_SPACING,
+ ).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
onPreview: (value) =>
editor.timeline.previewElements({
- updates: [{ trackId, elementId: element.id, updates: { letterSpacing: value } }],
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const lineHeight = usePropertyDraft({
- displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
+ displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
},
onPreview: (value) =>
editor.timeline.previewElements({
- updates: [{ trackId, elementId: element.id, updates: { lineHeight: value } }],
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
}),
onCommit: () => editor.timeline.commitPreview(),
});
return (
- <Section collapsible sectionKey="text:spacing" hasBorderBottom={false}>
- <SectionHeader title="Spacing" />
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { letterSpacing: DEFAULT_LETTER_SPACING } }],
- })
- }
- isDefault={(element.letterSpacing ?? DEFAULT_LETTER_SPACING) === DEFAULT_LETTER_SPACING}
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { lineHeight: DEFAULT_LINE_HEIGHT } }],
- })
- }
- isDefault={(element.lineHeight ?? DEFAULT_LINE_HEIGHT) === DEFAULT_LINE_HEIGHT}
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
+ <Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Spacing</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Letter spacing" className="w-1/2">
+ <NumberField
+ value={letterSpacing.displayValue}
+ onFocus={letterSpacing.onFocus}
+ onChange={letterSpacing.onChange}
+ onBlur={letterSpacing.onBlur}
+ onScrub={letterSpacing.scrubTo}
+ onScrubEnd={letterSpacing.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULT_LETTER_SPACING },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
+ DEFAULT_LETTER_SPACING
+ }
+ icon={<OcTextWidthIcon size={14} />}
+ />
+ </SectionField>
+ <SectionField label="Line height" className="w-1/2">
+ <NumberField
+ value={lineHeight.displayValue}
+ onFocus={lineHeight.onFocus}
+ onChange={lineHeight.onChange}
+ onBlur={lineHeight.onBlur}
+ onScrub={lineHeight.scrubTo}
+ onScrubEnd={lineHeight.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULT_LINE_HEIGHT },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
+ DEFAULT_LINE_HEIGHT
+ }
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
@@ -287,286 +339,390 @@ function BackgroundSection({
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
+
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
- const cornerRadius = usePropertyDraft({
- displayValue: Math.round(element.background.cornerRadius ?? 0).toString(),
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, cornerRadius: value },
- },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedPaddingX,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
});
- const paddingX = usePropertyDraft({
- displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, paddingX: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedPaddingY,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
});
- const paddingY = usePropertyDraft({
- displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, paddingY: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOffsetX,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
});
- const offsetX = usePropertyDraft({
- displayValue: Math.round(element.background.offsetX ?? 0).toString(),
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, offsetX: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOffsetY,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
});
- const offsetY = usePropertyDraft({
- displayValue: Math.round(element.background.offsetY ?? 0).toString(),
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, offsetY: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedCornerRadius,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
});
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
return (
- <Section collapsible sectionKey="text:background">
- <SectionHeader title="Background" />
- <SectionContent>
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey="text:background"
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
<SectionFields>
- <SectionField label="Color">
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={bgColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background color keyframe"
+ onToggle={bgColor.toggleKeyframe}
+ />
+ }
+ >
<ColorPicker
value={
+ !element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
- : element.background.color.replace("#", "")
+ : resolvedBgColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, color: hexColor },
- },
- },
- ],
- });
+ bgColor.onChange({ color: hexColor });
}}
- onChangeEnd={() => editor.timeline.commitPreview()}
- className={
- element.background.color === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
+ onChangeEnd={bgColor.onChangeEnd}
/>
</SectionField>
<div className="flex items-start gap-2">
- <SectionField label="Width" className="w-1/2">
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
+ <SectionField
+ label="Width"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background width keyframe"
+ onToggle={paddingX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
onFocus={paddingX.onFocus}
onChange={paddingX.onChange}
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
- },
- },
- },
- ],
- })
- }
- isDefault={
- (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) ===
- DEFAULT_TEXT_BACKGROUND.paddingX
- }
+ onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
+ })}
/>
</SectionField>
- <SectionField label="Height" className="w-1/2">
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
+ <SectionField
+ label="Height"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background height keyframe"
+ onToggle={paddingY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
onFocus={paddingY.onFocus}
onChange={paddingY.onChange}
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
- },
- },
- },
- ],
- })
- }
- isDefault={
- (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) ===
- DEFAULT_TEXT_BACKGROUND.paddingY
- }
+ onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
+ })}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
- <SectionField label="X-offset" className="w-1/2">
- <NumberField
- icon="X"
- value={offsetX.displayValue}
+ <SectionField
+ label="X-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle x-offset keyframe"
+ onToggle={offsetX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
onFocus={offsetX.onFocus}
onChange={offsetX.onChange}
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, offsetX: 0 },
- },
- },
- ],
- })
- }
- isDefault={(element.background.offsetX ?? 0) === 0}
+ onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
+ })}
/>
</SectionField>
- <SectionField label="Y-offset" className="w-1/2">
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
+ <SectionField
+ label="Y-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle y-offset keyframe"
+ onToggle={offsetY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
onFocus={offsetY.onFocus}
onChange={offsetY.onChange}
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, offsetY: 0 },
- },
- },
- ],
- })
- }
- isDefault={(element.background.offsetY ?? 0) === 0}
+ onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
+ })}
/>
</SectionField>
</div>
- <SectionField label="Corner Radius">
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={0}
+ <SectionField
+ label="Corner radius"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={cornerRadius.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle corner radius keyframe"
+ onToggle={cornerRadius.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
onFocus={cornerRadius.onFocus}
onChange={cornerRadius.onChange}
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- cornerRadius: 0,
- },
- },
- },
- ],
- })
- }
- isDefault={(element.background.cornerRadius ?? 0) === 0}
+ onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
/>
</SectionField>
</SectionFields>
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
index 3588cbb7..49dd8fe5 100644
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -1,4 +1,8 @@
-import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
+import type {
+ ImageElement,
+ StickerElement,
+ VideoElement,
+} from "@/types/timeline";
import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
@@ -10,7 +14,11 @@ export function VideoProperties({
}) {
return (
<div className="flex h-full flex-col">
- <TransformSection element={element} trackId={trackId} />
+ <TransformSection
+ element={element}
+ trackId={trackId}
+ showTopBorder={false}
+ />
<BlendingSection element={element} trackId={trackId} />
</div>
);
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index 64824f9a..864bb557 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,10 +5,7 @@ import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import {
- DEFAULT_BOOKMARK_COLOR,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
+import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -28,8 +25,8 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
+import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
-const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
const BOOKMARK_MARKER_HEIGHT_PX = 15;
@@ -139,10 +136,12 @@ function TimelineBookmark({
const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const durationWidth =
- bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
+ bookmarkDuration > 0
+ ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
+ : 0;
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
- const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
+ const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index f31c6e28..291a4633 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -21,7 +21,7 @@ export function DragLine({
return (
<div
- className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5"
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
style={{ top: `${lineTop}px` }}
/>
);
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 18f768a9..593976a8 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -27,7 +27,7 @@ import type { SnapPoint } from "@/lib/timeline/snap-utils";
import type { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_ICONS,
+ TRACK_CONFIG,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -55,16 +55,18 @@ import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+const TRACKS_CONTAINER_MAX_HEIGHT = 800;
+const FALLBACK_CONTAINER_WIDTH = 1000;
+
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: 800 };
- const { snappingEnabled } = useTimelineStore();
+ const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
const { clearElementSelection, setElementSelection } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
const tracks = timeline.getTracks();
const seek = (time: number) => editor.playback.seek({ time });
- // refs
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
@@ -74,7 +76,6 @@ export function Timeline() {
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- // state
const [isResizing, setIsResizing] = useState(false);
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
@@ -151,6 +152,7 @@ export function Timeline() {
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
headerRef: timelineHeaderRef,
+ tracksScrollRef,
zoomLevel,
});
@@ -161,6 +163,7 @@ export function Timeline() {
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
onSelectionComplete: (elements) => {
setElementSelection({ elements });
},
@@ -168,7 +171,7 @@ export function Timeline() {
zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -326,7 +329,7 @@ export function Timeline() {
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
- isVisible={isDragOver}
+ isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
@@ -421,7 +424,20 @@ export function Timeline() {
{tracks.length === 0 ? (
<div />
) : (
- tracks.map((track, index) => (
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (element) => element.id === dragState.elementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ })
+ .map(({ track, index }) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
@@ -453,56 +469,61 @@ export function Timeline() {
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? dropTarget?.targetElement?.elementId ?? null
+ : null
+ }
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(e) => {
- e.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(e) => {
- e.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
+ onClick={(event) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ <span>
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+ <HugeiconsIcon icon={ViewIcon} />
+ <span>
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </span>
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={(event) => {
+ event.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
variant="destructive"
>
<HugeiconsIcon icon={Delete02Icon} />
@@ -523,7 +544,7 @@ export function Timeline() {
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_ICONS[track.type]}</>;
+ return <>{TRACK_CONFIG[track.type].icon}</>;
}
function TrackToggleIcon({
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 54ce5237..927d336a 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -2,6 +2,10 @@
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+} from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface SnapIndicatorProps {
@@ -40,10 +44,10 @@ export function SnapIndicator({
<div
className="pointer-events-none absolute"
style={{
- left: `${leftPosition}px`,
+ left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
top: topPosition,
height: `${height}px`,
- width: "2px",
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 657e9a8b..d5b8cade 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -4,14 +4,21 @@ import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import AudioWaveform from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/hooks/timeline/element/use-keyframe-drag";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getElementKeyframes } from "@/lib/animation";
import {
getTrackClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import {
ContextMenu,
@@ -19,10 +26,11 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
+ VisualElement,
ElementDragState,
} from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
@@ -42,13 +50,111 @@ import {
VolumeMute02Icon,
Search01Icon,
Exchange01Icon,
+ KeyframeIcon,
+ MagicWand05Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
-import type { ComponentProps } from "react";
+import type { ComponentProps, ReactNode } from "react";
+import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
+import { cn } from "@/utils/ui";
+import { Button } from "@/components/ui/button";
+import { usePropertiesStore } from "@/stores/properties-store";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+const ELEMENT_RING_WIDTH_PX = 1.5;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+export function buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ keyframe: ElementKeyframe;
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}): {
+ time: number;
+ offsetPx: number;
+ keyframeRef: SelectedKeyframeRef;
+} {
+ const keyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframeId: keyframe.id,
+ };
+ const keyframeLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + keyframe.time,
+ zoomLevel,
+ });
+ return {
+ time: keyframe.time,
+ offsetPx: keyframeLeft - elementLeft,
+ keyframeRef,
+ };
+}
-function getDisplayShortcut(action: TAction) {
- const { defaultShortcuts } = getActionDefinition(action);
+export function getKeyframeIndicators({
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+}: {
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ elementWidth: number;
+}): KeyframeIndicator[] {
+ if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
+ return [];
+ }
+
+ const keyframesByTime = new Map<number, KeyframeIndicator>();
+ for (const keyframe of keyframes) {
+ const indicator = buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ });
+ const existingIndicator = keyframesByTime.get(indicator.time);
+ if (!existingIndicator) {
+ keyframesByTime.set(indicator.time, {
+ time: indicator.time,
+ offsetPx: indicator.offsetPx,
+ keyframes: [indicator.keyframeRef],
+ });
+ continue;
+ }
+
+ existingIndicator.keyframes.push(indicator.keyframeRef);
+ }
+
+ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
+}
+
+export function getDisplayShortcut({ action }: { action: TAction }) {
+ const { defaultShortcuts } = getActionDefinition({ action });
if (!defaultShortcuts?.length) {
return "";
}
@@ -66,11 +172,15 @@ interface TimelineElementProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
onResizeStateChange?: (params: { isResizing: boolean }) => void;
onElementMouseDown: (
- e: React.MouseEvent,
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (
+ event: React.MouseEvent,
element: TimelineElementType,
) => void;
- onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
+ isDropTarget?: boolean;
}
export function TimelineElement({
@@ -83,17 +193,18 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
+ isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
const { requestRevealMedia } = useAssetsPanelStore();
- const mediaAssets = editor.media.getAssets();
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
+ null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -123,10 +234,32 @@ export function TimelineElement({
: element.startTime;
const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
const displayedDuration = isResizing ? currentDuration : element.duration;
- const elementWidth =
- displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const elementLeft = displayedStartTime * 50 * zoomLevel;
+ const elementWidth = timelineTimeToPixels({
+ time: displayedDuration,
+ zoomLevel,
+ });
+ const elementLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime,
+ zoomLevel,
+ });
+ const keyframeIndicators = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
+ const {
+ keyframeDragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@@ -140,7 +273,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
- className={`absolute top-0 h-full select-none`}
+ className="absolute top-0 h-full select-none"
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@@ -154,13 +287,24 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
- hasAudio={hasAudio}
- isMuted={isMuted}
- mediaAssets={mediaAssets}
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
/>
+ {isSelected && (
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@@ -197,7 +341,9 @@ export function TimelineElement({
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event) => handleRevealInMedia({ event })}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
>
Reveal media
</ContextMenuItem>
@@ -225,22 +371,20 @@ function ElementInner({
element,
track,
isSelected,
- hasAudio,
- isMuted,
- mediaAssets,
onElementClick,
onElementMouseDown,
handleResizeStart,
+ isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
- hasAudio: boolean;
- isMuted: boolean;
- mediaAssets: MediaAsset[];
- onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
onElementMouseDown: (
- e: React.MouseEvent,
+ event: React.MouseEvent,
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
@@ -248,48 +392,62 @@ function ElementInner({
elementId: string;
side: "left" | "right";
}) => void;
+ isDropTarget?: boolean;
}) {
+ const opacityClass =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget
+ ? "opacity-50"
+ : "";
+ const closeClipEffects = usePropertiesStore(
+ (state) => state.closeClipEffects,
+ );
+
return (
<div
- className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
- {
- type: track.type,
- },
- )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ className="relative h-full cursor-pointer"
+ style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
>
- <button
- type="button"
- className="absolute inset-0 size-full cursor-pointer"
- onClick={(e) => onElementClick(e, element)}
- onMouseDown={(e) => onElementMouseDown(e, element)}
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getTrackClasses({ type: track.type }),
+ opacityClass,
+ )}
+ style={
+ isSelected
+ ? {
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
+ }
+ : undefined
+ }
>
- <div className="absolute inset-0 flex h-full items-center">
- <ElementContent
- element={element}
- track={track}
- isSelected={isSelected}
- mediaAssets={mediaAssets}
+ <button
+ type="button"
+ className="absolute inset-0 size-full cursor-pointer flex flex-col"
+ onClick={(event) => {
+ closeClipEffects();
+ onElementClick(event, element);
+ }}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ />
+ </div>
+ </button>
+ </div>
+
+ {element.type !== "audio" && element.type !== "effect" && (
+ <div className="sticky left-1 mt-1 ml-1 w-fit">
+ <EffectsButton
+ element={element as VisualElement}
+ trackId={track.id}
/>
</div>
-
- {(hasAudio
- ? isMuted
- : canElementBeHidden(element) && element.hidden) && (
- <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black">
- {hasAudio ? (
- <HugeiconsIcon
- icon={VolumeHighIcon}
- className="size-6 text-white"
- />
- ) : (
- <HugeiconsIcon
- icon={VolumeOffIcon}
- className="size-6 text-white"
- />
- )}
- </div>
- )}
- </button>
+ )}
{isSelected && (
<>
@@ -326,61 +484,242 @@ function ResizeHandle({
return (
<button
type="button"
- className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ className={cn(
+ "absolute top-0 bottom-0 w-2",
+ isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
+ )}
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
+ onClick={(event) => event.stopPropagation()}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
- >
- <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
- </button>
+ ></button>
);
}
-function ElementContent({
- element,
- track,
- isSelected,
- mediaAssets,
+function KeyframeIndicators({
+ indicators,
+ dragState,
+ displayedStartTime,
+ elementLeft,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
}: {
+ indicators: KeyframeIndicator[];
+ dragState: KeyframeDragState;
+ displayedStartTime: number;
+ elementLeft: number;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ const isBeingDragged = indicator.keyframes.some((kf) =>
+ dragState.draggingKeyframeIds.has(kf.keyframeId),
+ );
+ const visualOffsetPx = getVisualOffsetPx({
+ indicatorTime: indicator.time,
+ indicatorOffsetPx: indicator.offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
+ style={{ left: visualOffsetPx }}
+ onMouseDown={(event) =>
+ onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
+ }
+ onClick={(event) =>
+ onKeyframeClick({
+ event,
+ keyframes: indicator.keyframes,
+ orderedKeyframes,
+ indicatorTime: indicator.time,
+ })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 mt-1.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
+}
+
+interface ElementContentRendererProps extends ElementContentProps {
mediaAssets: MediaAsset[];
+}
+
+type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
+
+export function renderTiledMedia({
+ element,
+ imageUrl,
+ track,
+}: {
+ element: VisualElement;
+ imageUrl: string | undefined;
+ track: ElementContentProps["track"];
+}): ReactNode {
+ if (!imageUrl) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+
+ return (
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ );
+}
+
+function EffectsButton({
+ element,
+ trackId,
+ className,
+}: {
+ element: VisualElement;
+ trackId: string;
+ className?: string;
}) {
- if (element.type === "text") {
+ const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
+ const { selectElement } = useElementSelection();
+
+ if (!element.effects?.length) {
+ return null;
+ }
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ selectElement({ elementId: element.id, trackId });
+ openClipEffects({ elementId: element.id, trackId });
+ };
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ className={cn("rounded-sm !size-5 bg-background/50", className)}
+ onClick={handleClick}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+ <HugeiconsIcon icon={MagicWand05Icon} />
+ </Button>
+ );
+}
+
+const ELEMENT_CONTENT_RENDERERS: Record<
+ TimelineElementType["type"],
+ ElementContentRenderer
+> = {
+ text: ({ element }) => {
+ const textElement = element as Extract<
+ TimelineElementType,
+ { type: "text" }
+ >;
return (
<div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">{element.content}</span>
+ <span className="truncate text-xs text-white">
+ {textElement.content}
+ </span>
</div>
);
- }
-
- if (element.type === "sticker") {
+ },
+ effect: ({ element }) => (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white ml-1">{element.name}</span>
+ </div>
+ ),
+ sticker: ({ element }) => {
+ const stickerElement = element as Extract<
+ TimelineElementType,
+ { type: "sticker" }
+ >;
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
src={resolveStickerId({
- stickerId: element.stickerId,
+ stickerId: stickerElement.stickerId,
options: { width: 20, height: 20 },
})}
- alt={element.name}
+ alt={stickerElement.name}
className="size-5 shrink-0"
width={20}
height={20}
unoptimized
/>
- <span className="truncate text-xs text-white">{element.name}</span>
+ <span className="truncate text-xs text-white">
+ {stickerElement.name}
+ </span>
</div>
);
- }
-
- if (element.type === "audio") {
+ },
+ audio: ({ element, mediaAssets }) => {
+ const audioElement = element as Extract<
+ TimelineElementType,
+ { type: "audio" }
+ >;
const audioBuffer =
- element.sourceType === "library" ? element.buffer : undefined;
-
+ audioElement.sourceType === "library" ? audioElement.buffer : undefined;
const audioUrl =
- element.sourceType === "library"
- ? element.sourceUrl
- : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+ audioElement.sourceType === "library"
+ ? audioElement.sourceUrl
+ : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
if (audioBuffer || audioUrl) {
return (
@@ -399,53 +738,52 @@ function ElementContent({
return (
<span className="text-foreground/80 truncate text-xs">
- {element.name}
+ {audioElement.name}
</span>
);
- }
-
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- if (!mediaAsset) {
- return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
+ },
+ video: ({ element, track, mediaAssets }) => {
+ const videoElement = element as Extract<
+ TimelineElementType,
+ { type: "video" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === videoElement.mediaId,
);
- }
-
- if (
- mediaAsset.type === "image" ||
- (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
- ) {
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
- const imageUrl =
- mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
-
- return (
- <div className="flex size-full items-center justify-center">
- <div
- className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
- >
- <div
- className="absolute right-0 left-0"
- style={{
- backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- top: isSelected ? "0.25rem" : "0rem",
- bottom: isSelected ? "0.25rem" : "0rem",
- }}
- />
- </div>
- </div>
+ return renderTiledMedia({
+ element: videoElement,
+ imageUrl: mediaAsset?.thumbnailUrl,
+ track,
+ });
+ },
+ image: ({ element, track, mediaAssets }) => {
+ const imageElement = element as Extract<
+ TimelineElementType,
+ { type: "image" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === imageElement.mediaId,
);
- }
+ return renderTiledMedia({
+ element: imageElement,
+ imageUrl: mediaAsset?.url,
+ track,
+ });
+ },
+};
+function ElementContent({ element, track, isSelected }: ElementContentProps) {
+ const editor = useEditor();
+ const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ <>
+ {renderer({
+ element,
+ track,
+ isSelected,
+ mediaAssets: editor.media.getAssets(),
+ })}
+ </>
);
}
@@ -474,9 +812,9 @@ function MuteMenuItem({
return <HugeiconsIcon icon={VolumeMute02Icon} />;
}
return isMuted ? (
- <HugeiconsIcon icon={VolumeHighIcon} />
- ) : (
<HugeiconsIcon icon={VolumeOffIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeHighIcon} />
);
};
@@ -549,14 +887,15 @@ function ActionMenuItem({
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
action: TAction;
+ children: ReactNode;
}) {
return (
<ContextMenuItem
- onClick={(event) => {
+ onClick={(event: React.MouseEvent) => {
event.stopPropagation();
invokeAction(action);
}}
- textRight={getDisplayShortcut(action)}
+ textRight={getDisplayShortcut({ action })}
{...props}
>
{children}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index ebb2a37e..e290259e 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -1,7 +1,11 @@
"use client";
import { useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
@@ -43,9 +47,11 @@ export function TimelinePlayhead({
400;
const totalHeight = Math.max(0, timelineContainerHeight - 4);
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const leftPosition = timelinePosition;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time: playheadPosition,
+ zoomLevel,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@@ -77,7 +83,7 @@ export function TimelinePlayhead({
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
- width: "2px",
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
}}
onKeyDown={handlePlayheadKeyDown}
>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 9402d25e..22cd52cf 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -1,6 +1,6 @@
"use client";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
interface TimelineTickProps {
@@ -16,7 +16,7 @@ export function TimelineTick({
fps,
showLabel,
}: TimelineTickProps) {
- const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
if (showLabel) {
const label = formatRulerLabel({ timeInSeconds: time, fps });
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 03ce88eb..533ea63d 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -78,7 +78,7 @@ export function TimelineToolbar({
function ToolbarLeftSection() {
const editor = useEditor();
const currentTime = editor.playback.getCurrentTime();
- const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
const handleAction = ({
action,
@@ -116,7 +116,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<SplitSquareHorizontal />}
- tooltip="Coming soon" /* separate audio */
+ tooltip="Separate audio (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@@ -131,7 +131,7 @@ function ToolbarLeftSection() {
<ToolbarButton
icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Coming soon" /* freeze frame */
+ tooltip="Freeze frame (coming soon)"
disabled={true}
onClick={({ event: _event }) => {}}
/>
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={currentBookmarked}
- tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
@@ -191,12 +191,10 @@ function ToolbarRightSection({
onZoomChange: (zoom: number) => void;
onZoom: (options: { direction: "in" | "out" }) => void;
}) {
- const {
- snappingEnabled,
- rippleEditingEnabled,
- toggleSnapping,
- toggleRippleEditing,
- } = useTimelineStore();
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
return (
<div className="flex items-center gap-1">
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 42faadaf..81cda7a6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -32,6 +32,7 @@ interface TimelineTrackContentProps {
onTrackMouseDown?: (event: React.MouseEvent) => void;
onTrackClick?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
}
export function TimelineTrackContent({
@@ -48,9 +49,10 @@ export function TimelineTrackContent({
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
+ targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
- const { isElementSelected, clearElementSelection } = useElementSelection();
+ const { isElementSelected } = useElementSelection();
const duration = editor.timeline.getTotalDuration();
@@ -67,7 +69,6 @@ export function TimelineTrackContent({
className="size-full"
onClick={(event) => {
if (shouldIgnoreClick?.()) return;
- clearElementSelection();
onTrackClick?.(event);
}}
onMouseDown={(event) => {
@@ -102,6 +103,7 @@ export function TimelineTrackContent({
onElementClick({ event, element, track })
}
dragState={dragState}
+ isDropTarget={element.id === targetElementId}
/>
);
})
diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx
index 153a662a..fc65b7aa 100644
--- a/apps/web/src/components/gitHub-contribute-section.tsx
+++ b/apps/web/src/components/gitHub-contribute-section.tsx
@@ -23,7 +23,7 @@ export function GitHubContributeSection({
target="_blank"
rel="noopener noreferrer"
>
- <Button className="w-full" size="lg">
+ <Button variant="foreground" className="w-full" size="lg">
<HugeiconsIcon icon={GithubIcon} />
Start contributing
</Button>
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 95f08304..7fe0046f 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -18,6 +18,10 @@ export function Header() {
const links = [
{
+ label: "Roadmap",
+ href: "/roadmap",
+ },
+ {
label: "Contributors",
href: "/contributors",
},
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index a49d8a88..d8791bdf 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -31,7 +31,7 @@ const contextMenuItemVariants = cva(
default:
"focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
destructive:
- "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
+ "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
},
},
defaultVariants: {
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 311f6edb..1b57ea4d 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -40,7 +40,7 @@ const selectTriggerVariants = cva(
},
size: {
default: "",
- sm: "",
+ sm: "rounded-sm",
},
},
defaultVariants: {
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
index 57ddbcf0..700be644 100644
--- a/apps/web/src/components/ui/slider.tsx
+++ b/apps/web/src/components/ui/slider.tsx
@@ -7,7 +7,9 @@ import { cn } from "@/utils/ui";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
+ className?: string;
+ }
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
diff --git a/apps/web/src/constants/animation-constants.ts b/apps/web/src/constants/animation-constants.ts
new file mode 100644
index 00000000..44776d34
--- /dev/null
+++ b/apps/web/src/constants/animation-constants.ts
@@ -0,0 +1,2 @@
+export const TIME_EPSILON_SECONDS = 1 / 1000;
+export const MIN_TRANSFORM_SCALE = 0.01;
diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts
index 6b688ed1..6779670b 100644
--- a/apps/web/src/constants/text-constants.ts
+++ b/apps/web/src/constants/text-constants.ts
@@ -17,7 +17,11 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
export const DEFAULT_LETTER_SPACING = 0;
export const DEFAULT_LINE_HEIGHT = 1.2;
+export const CORNER_RADIUS_MIN = 0;
+export const CORNER_RADIUS_MAX = 100;
+
export const DEFAULT_TEXT_BACKGROUND = {
+ enabled: false,
color: "#000000",
cornerRadius: 0,
paddingX: 30,
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
index 5ac0913a..5bc938bb 100644
--- a/apps/web/src/constants/timeline-constants.tsx
+++ b/apps/web/src/constants/timeline-constants.tsx
@@ -3,6 +3,7 @@ import type { BlendMode } from "@/types/rendering";
import type { TrackType, Transform } from "@/types/timeline";
import {
Happy01Icon,
+ MagicWand05Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
@@ -19,26 +20,65 @@ export const DEFAULT_OPACITY = 1;
export const DEFAULT_BLEND_MODE: BlendMode = "normal";
export const DEFAULT_BOOKMARK_COLOR = "#009dff";
-export const TRACK_COLORS: Record<TrackType, { background: string }> = {
+export const TRACK_CONFIG: Record<
+ TrackType,
+ {
+ background: string;
+ height: number;
+ defaultName: string;
+ icon: React.ReactNode;
+ }
+> = {
video: {
background: "transparent",
+ height: 60,
+ defaultName: "Video track",
+ icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
},
text: {
background: "bg-[#5DBAA0]",
+ height: 25,
+ defaultName: "Text track",
+ icon: (
+ <HugeiconsIcon
+ icon={TextIcon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
},
audio: {
- background: "bg-[#915DBE]",
+ background: "bg-[#8F5DBA]",
+ height: 50,
+ defaultName: "Audio track",
+ icon: (
+ <HugeiconsIcon
+ icon={MusicNote03Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
},
sticker: {
- background: "bg-amber-500",
+ background: "bg-[#BA5D7A]",
+ height: 50,
+ defaultName: "Sticker track",
+ icon: (
+ <HugeiconsIcon
+ icon={Happy01Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ },
+ effect: {
+ background: "bg-[#5d93ba]",
+ height: 25,
+ defaultName: "Effect track",
+ icon: (
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
},
-} as const;
-
-export const TRACK_HEIGHTS: Record<TrackType, number> = {
- video: 60,
- text: 25,
- audio: 50,
- sticker: 50,
} as const;
export const TRACK_GAP = 4;
@@ -60,25 +100,3 @@ export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
scrollLeft: 0,
playheadTime: 0,
};
-
-export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
- video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
- text: (
- <HugeiconsIcon
- icon={TextIcon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- audio: (
- <HugeiconsIcon
- icon={MusicNote03Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- sticker: (
- <HugeiconsIcon
- icon={Happy01Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
-} as const;
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 4a8813f8..42ab3b87 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -8,6 +8,7 @@ import { CommandManager } from "./managers/commands";
import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
+import { registerDefaultEffects } from "@/lib/effects";
export class EditorCore {
private static instance: EditorCore | null = null;
@@ -24,6 +25,7 @@ export class EditorCore {
public readonly selection: SelectionManager;
private constructor() {
+ registerDefaultEffects();
this.command = new CommandManager();
this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 457e49d6..20b124b6 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -29,6 +29,7 @@ export class AudioManager {
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
+ private playbackLatencyCompensationSeconds = 0;
private unsubscribers: Array<() => void> = [];
constructor(private editor: EditorCore) {
@@ -136,6 +137,7 @@ export class AudioManager {
this.stopPlayback();
this.playbackSessionId++;
+ this.playbackLatencyCompensationSeconds = 0;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
@@ -224,13 +226,21 @@ export class AudioManager {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + clip.duration;
-
- const iteratorStartTime = Math.max(startTime, clipStart);
+ const playbackTimeAfterSinkReady = this.getPlaybackTime();
+ const iteratorStartTime = Math.max(
+ startTime,
+ clipStart,
+ playbackTimeAfterSinkReady,
+ );
+ if (iteratorStartTime >= clipEnd) {
+ return;
+ }
const sourceStartTime =
clip.trimStart + (iteratorStartTime - clip.startTime);
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
+ let consecutiveDroppedBufferCount = 0;
for await (const { buffer, timestamp } of iterator) {
if (!this.editor.playback.getIsPlaying()) return;
@@ -244,15 +254,41 @@ export class AudioManager {
node.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
- this.playbackStartContextTime + (timelineTime - this.playbackStartTime);
+ this.playbackStartContextTime +
+ this.playbackLatencyCompensationSeconds +
+ (timelineTime - this.playbackStartTime);
if (startTimestamp >= audioContext.currentTime) {
node.start(startTimestamp);
+ consecutiveDroppedBufferCount = 0;
} else {
const offset = audioContext.currentTime - startTimestamp;
if (offset < buffer.duration) {
node.start(audioContext.currentTime, offset);
+ consecutiveDroppedBufferCount = 0;
} else {
+ consecutiveDroppedBufferCount += 1;
+ if (consecutiveDroppedBufferCount >= 5) {
+ const nextCompensationSeconds = Math.max(
+ this.playbackLatencyCompensationSeconds,
+ Math.min(0.25, offset + 0.01),
+ );
+ if (
+ nextCompensationSeconds >
+ this.playbackLatencyCompensationSeconds + 0.001
+ ) {
+ this.playbackLatencyCompensationSeconds =
+ nextCompensationSeconds;
+ }
+ const resyncStartTime = this.getPlaybackTime();
+ this.clipIterators.delete(clip.id);
+ void this.runClipIterator({
+ clip,
+ startTime: resyncStartTime,
+ sessionId,
+ });
+ return;
+ }
continue;
}
}
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 2608b8c0..05889a94 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -7,7 +7,7 @@ import type {
TProjectSettings,
TTimelineViewState,
} from "@/types/project";
-import type { ExportOptions, ExportResult } from "@/types/export";
+import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -51,6 +51,12 @@ export class ProjectManager {
toVersion: null,
projectName: null,
};
+ private exportState: ExportState = {
+ isExporting: false,
+ progress: 0,
+ result: null,
+ };
+ private exportCancelRequested = false;
constructor(private editor: EditorCore) {}
@@ -191,7 +197,40 @@ export class ProjectManager {
}
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
- return this.editor.renderer.exportProject({ options });
+ this.exportCancelRequested = false;
+ this.exportState = { isExporting: true, progress: 0, result: null };
+ this.notify();
+
+ const result = await this.editor.renderer.exportProject({
+ options,
+ onProgress: ({ progress }) => {
+ this.exportState = { ...this.exportState, progress };
+ this.notify();
+ },
+ onCancel: () => this.exportCancelRequested,
+ });
+
+ this.exportState = {
+ isExporting: false,
+ progress: this.exportState.progress,
+ result,
+ };
+ this.notify();
+
+ return result;
+ }
+
+ cancelExport(): void {
+ this.exportCancelRequested = true;
+ }
+
+ clearExportState(): void {
+ this.exportState = { isExporting: false, progress: 0, result: null };
+ this.notify();
+ }
+
+ getExportState(): ExportState {
+ return this.exportState;
}
async loadAllProjects(): Promise<void> {
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index 03463b2c..601a007e 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -88,11 +88,14 @@ export class RendererManager {
async exportProject({
options,
+ onProgress,
+ onCancel,
}: {
options: ExportOptions;
+ onProgress?: ({ progress }: { progress: number }) => void;
+ onCancel?: () => boolean;
}): Promise<ExportResult> {
- const { format, quality, fps, includeAudio, onProgress, onCancel } =
- options;
+ const { format, quality, fps, includeAudio } = options;
try {
const tracks = this.editor.timeline.getTracks();
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index fa6a02bf..e4295c11 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,9 +1,12 @@
import type { EditorCore } from "@/core";
+import type { SelectedKeyframeRef } from "@/types/animation";
type ElementRef = { trackId: string; elementId: string };
export class SelectionManager {
private selectedElements: ElementRef[] = [];
+ private selectedKeyframes: SelectedKeyframeRef[] = [];
+ private keyframeSelectionAnchor: SelectedKeyframeRef | null = null;
private listeners = new Set<() => void>();
constructor(editor: EditorCore) {
@@ -14,13 +17,47 @@ export class SelectionManager {
return this.selectedElements;
}
+ getSelectedKeyframes(): SelectedKeyframeRef[] {
+ return this.selectedKeyframes;
+ }
+
+ getKeyframeSelectionAnchor(): SelectedKeyframeRef | null {
+ return this.keyframeSelectionAnchor;
+ }
+
setSelectedElements({ elements }: { elements: ElementRef[] }): void {
this.selectedElements = elements;
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ setSelectedKeyframes({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef | null;
+ }): void {
+ this.selectedKeyframes = keyframes;
+ if (anchorKeyframe !== undefined) {
+ this.keyframeSelectionAnchor = anchorKeyframe;
+ } else if (keyframes.length === 0) {
+ this.keyframeSelectionAnchor = null;
+ }
this.notify();
}
clearSelection(): void {
this.selectedElements = [];
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
+ this.notify();
+ }
+
+ clearKeyframeSelection(): void {
+ this.selectedKeyframes = [];
+ this.keyframeSelectionAnchor = null;
this.notify();
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index c8ac5a79..173f0744 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,10 +1,16 @@
import type { EditorCore } from "@/core";
+import type { EffectParamValues } from "@/types/effects";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/types/animation";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -24,6 +30,16 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
+ UpsertKeyframeCommand,
+ RemoveKeyframeCommand,
+ RetimeKeyframeCommand,
+ AddClipEffectCommand,
+ RemoveClipEffectCommand,
+ UpdateClipEffectParamsCommand,
+ ToggleClipEffectCommand,
+ ReorderClipEffectsCommand,
+ UpsertEffectParamKeyframeCommand,
+ RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
import { BatchCommand, PreviewTracker } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@@ -54,14 +70,27 @@ export class TimelineManager {
elementId,
trimStart,
trimEnd,
+ startTime,
+ duration,
pushHistory = true,
+ rippleEnabled = false,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
+ startTime?: number;
+ duration?: number;
pushHistory?: boolean;
+ rippleEnabled?: boolean;
}): void {
- const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
+ const command = new UpdateElementTrimCommand({
+ elementId,
+ trimStart,
+ trimEnd,
+ startTime,
+ duration,
+ rippleEnabled,
+ });
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@@ -80,11 +109,11 @@ export class TimelineManager {
duration: number;
pushHistory?: boolean;
}): void {
- const command = new UpdateElementDurationCommand(
+ const command = new UpdateElementDurationCommand({
trackId,
elementId,
duration,
- );
+ });
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@@ -99,7 +128,10 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
- const command = new UpdateElementStartTimeCommand(elements, startTime);
+ const command = new UpdateElementStartTimeCommand({
+ elements,
+ startTime,
+ });
this.editor.command.execute({ command });
}
@@ -109,20 +141,23 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
+ rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
+ rippleEnabled?: boolean;
}): void {
- const command = new MoveElementCommand(
+ const command = new MoveElementCommand({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
- );
+ rippleEnabled,
+ });
this.editor.command.execute({ command });
}
@@ -140,12 +175,19 @@ export class TimelineManager {
elements,
splitTime,
retainSide = "both",
+ rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
+ rippleEnabled?: boolean;
}): { trackId: string; elementId: string }[] {
- const command = new SplitElementsCommand(elements, splitTime, retainSide);
+ const command = new SplitElementsCommand({
+ elements,
+ splitTime,
+ retainSide,
+ rippleEnabled,
+ });
this.editor.command.execute({ command });
return command.getRightSideElements();
}
@@ -194,10 +236,12 @@ export class TimelineManager {
deleteElements({
elements,
+ rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
}): void {
- const command = new DeleteElementsCommand(elements);
+ const command = new DeleteElementsCommand({ elements, rippleEnabled });
this.editor.command.execute({ command });
}
@@ -208,13 +252,17 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
- updates: Partial<Record<string, unknown>>;
+ updates: Partial<TimelineElement>;
}>;
pushHistory?: boolean;
}): void {
const commands = updates.map(
({ trackId, elementId, updates: elementUpdates }) =>
- new UpdateElementCommand(trackId, elementId, elementUpdates),
+ new UpdateElementCommand({
+ trackId,
+ elementId,
+ updates: elementUpdates,
+ }),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
@@ -225,6 +273,252 @@ export class TimelineManager {
}
}
+ addClipEffect({
+ trackId,
+ elementId,
+ effectType,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }): string {
+ const command = new AddClipEffectCommand({
+ trackId,
+ elementId,
+ effectType,
+ });
+ this.editor.command.execute({ command });
+ return command.getEffectId() ?? "";
+ }
+
+ removeClipEffect({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }): void {
+ const command = new RemoveClipEffectCommand({
+ trackId,
+ elementId,
+ effectId,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ updateClipEffectParams({
+ trackId,
+ elementId,
+ effectId,
+ params,
+ pushHistory = true,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ params: Partial<EffectParamValues>;
+ pushHistory?: boolean;
+ }): void {
+ const command = new UpdateClipEffectParamsCommand({
+ trackId,
+ elementId,
+ effectId,
+ params,
+ });
+ if (pushHistory) {
+ this.editor.command.execute({ command });
+ } else {
+ command.execute();
+ }
+ }
+
+ toggleClipEffect({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }): void {
+ const command = new ToggleClipEffectCommand({
+ trackId,
+ elementId,
+ effectId,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ reorderClipEffects({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ }: {
+ trackId: string;
+ elementId: string;
+ fromIndex: number;
+ toIndex: number;
+ }): void {
+ const command = new ReorderClipEffectsCommand({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ upsertKeyframes({
+ keyframes,
+ }: {
+ keyframes: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }>;
+ }): void {
+ if (keyframes.length === 0) {
+ return;
+ }
+
+ const commands = keyframes.map(
+ ({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }) =>
+ new UpsertKeyframeCommand({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }),
+ );
+ const command =
+ commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ this.editor.command.execute({ command });
+ }
+
+ removeKeyframes({
+ keyframes,
+ }: {
+ keyframes: Array<{
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }>;
+ }): void {
+ if (keyframes.length === 0) {
+ return;
+ }
+
+ const commands = keyframes.map(
+ ({ trackId, elementId, propertyPath, keyframeId }) =>
+ new RemoveKeyframeCommand({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ }),
+ );
+ const command =
+ commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ this.editor.command.execute({ command });
+ }
+
+ retimeKeyframe({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ time,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ time: number;
+ }): void {
+ const command = new RetimeKeyframeCommand({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime: time,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ upsertEffectParamKeyframe({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+ }): void {
+ const command = new UpsertEffectParamKeyframeCommand({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ });
+ this.editor.command.execute({ command });
+ }
+
+ removeEffectParamKeyframe({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+ }): void {
+ const command = new RemoveEffectParamKeyframeCommand({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ });
+ this.editor.command.execute({ command });
+ }
+
isPreviewActive(): boolean {
return this.previewTracker.isActive();
}
@@ -235,7 +529,7 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
- updates: Partial<Record<string, unknown>>;
+ updates: Partial<TimelineElement>;
}>;
}): void {
const tracks = this.getTracks();
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index 1ce890cb..e4f378d6 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -4,13 +4,19 @@ import { useTimelineStore } from "@/stores/timeline-store";
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";
export function useEditorActions() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
- const { clipboard, setClipboard, toggleSnapping } = useTimelineStore();
+ const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
+ const clipboard = useTimelineStore((s) => s.clipboard);
+ const setClipboard = useTimelineStore((s) => s.setClipboard);
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
useActionHandler(
"toggle-play",
@@ -158,11 +164,21 @@ export function useEditorActions() {
if (elementsToSplit.length === 0) return;
- editor.timeline.splitElements({
+ const rightSideElements = editor.timeline.splitElements({
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "right",
+ rippleEnabled: rippleEditingEnabled,
});
+
+ if (rippleEditingEnabled && rightSideElements.length > 0) {
+ const firstRightElement = editor.timeline.getElementsWithTracks({
+ elements: [rightSideElements[0]],
+ })[0];
+ if (firstRightElement) {
+ editor.playback.seek({ time: firstRightElement.element.startTime });
+ }
+ }
},
undefined,
);
@@ -193,11 +209,17 @@ export function useEditorActions() {
useActionHandler(
"delete-selected",
() => {
+ if (selectedKeyframes.length > 0) {
+ editor.timeline.removeKeyframes({ keyframes: selectedKeyframes });
+ clearKeyframeSelection();
+ return;
+ }
if (selectedElements.length === 0) {
return;
}
editor.timeline.deleteElements({
elements: selectedElements,
+ rippleEnabled: rippleEditingEnabled,
});
editor.selection.clearSelection();
},
@@ -219,6 +241,19 @@ export function useEditorActions() {
);
useActionHandler(
+ "deselect-all",
+ () => {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLButtonElement) {
+ activeElement.blur();
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
"duplicate-selected",
() => {
editor.timeline.duplicateElements({
@@ -261,7 +296,7 @@ export function useEditorActions() {
elements: selectedElements,
});
const items = results.map(({ track, element }) => {
- const { ...elementWithoutId } = element;
+ const { id: _, ...elementWithoutId } = element;
return {
trackId: track.id,
trackType: track.type,
@@ -296,6 +331,14 @@ export function useEditorActions() {
);
useActionHandler(
+ "toggle-ripple-editing",
+ () => {
+ toggleRippleEditing();
+ },
+ undefined,
+ );
+
+ useActionHandler(
"undo",
() => {
editor.command.undo();
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index e7b93813..ea01c718 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -8,6 +8,7 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import {
DRAG_THRESHOLD_PX,
@@ -38,6 +39,8 @@ interface UseElementInteractionProps {
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
+const MOUSE_BUTTON_RIGHT = 2;
+
const initialDragState: ElementDragState = {
isDragging: false,
elementId: null,
@@ -162,6 +165,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const {
@@ -441,27 +445,33 @@ export function useElementInteraction({
return;
}
- if (dropTarget.isNewTrack) {
- const newTrackId = generateUUID();
+ if (dropTarget.isNewTrack) {
+ const newTrackId = generateUUID();
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: newTrackId,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
+ rippleEnabled: rippleEditingEnabled,
+ });
+ selectElement({ trackId: newTrackId, elementId: dragState.elementId });
+ } else {
+ const targetTrack = tracks[dropTarget.trackIndex];
+ if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
- targetTrackId: newTrackId,
+ targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
- createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
+ rippleEnabled: rippleEditingEnabled,
});
- } else {
- const targetTrack = tracks[dropTarget.trackIndex];
- if (targetTrack) {
- editor.timeline.moveElement({
- sourceTrackId: dragState.trackId,
- targetTrackId: targetTrack.id,
- elementId: dragState.elementId,
- newStartTime: snappedTime,
- });
+ if (targetTrack.id !== dragState.trackId) {
+ selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
}
}
+ }
endDrag();
onSnapPointChange?.(null);
@@ -483,6 +493,8 @@ export function useElementInteraction({
tracksContainerRef,
tracksScrollRef,
headerRef,
+ rippleEditingEnabled,
+ selectElement,
]);
useEffect(() => {
@@ -508,10 +520,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- const isRightClick = event.button === 2;
+ const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@@ -526,14 +538,12 @@ export function useElementInteraction({
return;
}
- // left-click: stop propagation for drag operations
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
- // multi-select: toggle selection
- if (isMultiSelect) {
+ if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -541,8 +551,7 @@ export function useElementInteraction({
});
}
- // start drag
- const clickOffsetTime = getClickOffsetTime({
+ const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -570,10 +579,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- event.stopPropagation();
+ event.stopPropagation();
- // was it a drag or a click?
- if (mouseDownLocationRef.current) {
+ if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
@@ -585,16 +593,18 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- // single click: select if not selected
- const alreadySelected = isElementSelected({
+ const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
if (!alreadySelected) {
selectElement({ trackId: track.id, elementId: element.id });
+ return;
}
+
+ editor.selection.clearKeyframeSelection();
},
- [isElementSelected, selectElement],
+ [editor.selection, isElementSelected, selectElement],
);
return {
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 2b303ad9..3f49e798 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -40,7 +40,9 @@ export function useTimelineElementResize({
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
-
+ const rippleEditingEnabled = useTimelineStore(
+ (state) => state.rippleEditingEnabled,
+ );
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@@ -86,12 +88,8 @@ export function useTimelineElementResize({
};
const canExtendElementDuration = useCallback(() => {
- if (element.type === "text" || element.type === "image") {
- return true;
- }
-
- return false;
- }, [element.type]);
+ return element.sourceDuration == null;
+ }, [element.sourceDuration]);
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
@@ -141,18 +139,52 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
+ const otherElements = track.elements.filter(({ id }) => id !== element.id);
+ const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
+
+ const rightNeighborBound =
+ resizing.side === "right"
+ ? otherElements
+ .filter(({ startTime }) => startTime >= initialEndTime)
+ .reduce((min, { startTime }) => Math.min(min, startTime), Infinity)
+ : Infinity;
+
+ const leftNeighborBound =
+ resizing.side === "left"
+ ? otherElements
+ .filter(
+ ({ startTime, duration }) =>
+ startTime + duration <= resizing.initialStartTime,
+ )
+ .reduce(
+ (max, { startTime, duration }) =>
+ Math.max(max, startTime + duration),
+ -Infinity,
+ )
+ : -Infinity;
+
if (resizing.side === "left") {
const sourceDuration =
resizing.initialTrimStart +
resizing.initialDuration +
resizing.initialTrimEnd;
+ const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
+ ? Math.max(
+ 0,
+ resizing.initialTrimStart +
+ (leftNeighborBound - resizing.initialStartTime),
+ )
+ : 0;
const maxAllowed =
sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
const calculated = resizing.initialTrimStart + deltaTime;
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
- time: Math.min(maxAllowed, calculated),
+ time: Math.min(
+ maxAllowed,
+ Math.max(minTrimStartForNeighbor, calculated),
+ ),
fps: projectFps,
});
const trimDelta = newTrimStart - resizing.initialTrimStart;
@@ -175,7 +207,16 @@ export function useTimelineElementResize({
if (canExtendElementDuration()) {
const extensionAmount = Math.abs(calculated);
const maxExtension = resizing.initialStartTime;
- const actualExtension = Math.min(extensionAmount, maxExtension);
+ const actualExtension = Math.max(
+ 0,
+ Number.isFinite(leftNeighborBound)
+ ? Math.min(
+ extensionAmount,
+ maxExtension,
+ resizing.initialStartTime - leftNeighborBound,
+ )
+ : Math.min(extensionAmount, maxExtension),
+ );
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime - actualExtension,
fps: projectFps,
@@ -192,7 +233,8 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
- const trimDelta = 0 - resizing.initialTrimStart;
+ const trimDelta =
+ minTrimStartForNeighbor - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@@ -202,10 +244,10 @@ export function useTimelineElementResize({
fps: projectFps,
});
- setCurrentTrimStart(0);
+ setCurrentTrimStart(minTrimStartForNeighbor);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
+ currentTrimStartRef.current = minTrimStartForNeighbor;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
@@ -216,6 +258,9 @@ export function useTimelineElementResize({
resizing.initialDuration +
resizing.initialTrimEnd;
const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ const maxAllowedDuration = Number.isFinite(rightNeighborBound)
+ ? rightNeighborBound - resizing.initialStartTime
+ : Infinity;
if (newTrimEnd < 0) {
if (canExtendElementDuration()) {
@@ -223,7 +268,7 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: baseDuration + extensionNeeded,
+ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
fps: projectFps,
});
@@ -234,7 +279,10 @@ export function useTimelineElementResize({
} else {
const extensionToLimit = resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + extensionToLimit,
+ time: Math.min(
+ resizing.initialDuration + extensionToLimit,
+ maxAllowedDuration,
+ ),
fps: projectFps,
});
@@ -244,9 +292,20 @@ export function useTimelineElementResize({
currentTrimEndRef.current = 0;
}
} else {
+ const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
+ ? Math.max(
+ 0,
+ resizing.initialDuration +
+ resizing.initialTrimEnd -
+ maxAllowedDuration,
+ )
+ : 0;
const maxTrimEnd =
sourceDuration - resizing.initialTrimStart - minDurationSeconds;
- const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
+ const clampedTrimEnd = Math.min(
+ maxTrimEnd,
+ Math.max(minTrimEndForNeighbor, newTrimEnd),
+ );
const finalTrimEnd = snapTimeToFrame({
time: clampedTrimEnd,
fps: projectFps,
@@ -271,6 +330,7 @@ export function useTimelineElementResize({
snappingEnabled,
editor,
element.id,
+ track.elements,
onSnapPointChange,
canExtendElementDuration,
isShiftHeldRef,
@@ -289,26 +349,14 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
- if (trimStartChanged || trimEndChanged) {
+ if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
trimEnd: finalTrimEnd,
- });
- }
-
- if (startTimeChanged) {
- editor.timeline.updateElementStartTime({
- elements: [{ trackId: track.id, elementId: element.id }],
- startTime: finalStartTime,
- });
- }
-
- if (durationChanged) {
- editor.timeline.updateElementDuration({
- trackId: track.id,
- elementId: element.id,
- duration: finalDuration,
+ startTime: startTimeChanged ? finalStartTime : undefined,
+ duration: durationChanged ? finalDuration : undefined,
+ rippleEnabled: rippleEditingEnabled,
});
}
@@ -319,9 +367,9 @@ export function useTimelineElementResize({
resizing,
editor.timeline,
element.id,
- track.id,
onResizeStateChange,
onSnapPointChange,
+ rippleEditingEnabled,
]);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
new file mode 100644
index 00000000..b18db6eb
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -0,0 +1,302 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useKeyframeSelection } from "./use-keyframe-selection";
+import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import {
+ DRAG_THRESHOLD_PX,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
+import { BatchCommand } from "@/lib/commands";
+import type { SelectedKeyframeRef } from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+import type { Command } from "@/lib/commands/base-command";
+export interface KeyframeDragState {
+ isDragging: boolean;
+ draggingKeyframeIds: Set<string>;
+ deltaTime: number;
+}
+
+const initialDragState: KeyframeDragState = {
+ isDragging: false,
+ draggingKeyframeIds: new Set(),
+ deltaTime: 0,
+};
+
+interface PendingKeyframeDrag {
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+}
+
+export function useKeyframeDrag({
+ zoomLevel,
+ element,
+ displayedStartTime,
+}: {
+ zoomLevel: number;
+ element: TimelineElement;
+ displayedStartTime: number;
+}) {
+ const editor = useEditor();
+ const {
+ selectedKeyframes,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ } = useKeyframeSelection();
+
+ const [dragState, setDragState] =
+ useState<KeyframeDragState>(initialDragState);
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+
+ const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
+ const mouseDownXRef = useRef<number | null>(null);
+
+ const activeProject = editor.project.getActive();
+ const fps = activeProject.settings.fps;
+
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ const endDrag = useCallback(() => {
+ setDragState(initialDragState);
+ }, []);
+
+ const commitDrag = useCallback(
+ ({
+ keyframeRefs,
+ deltaTime,
+ }: {
+ keyframeRefs: SelectedKeyframeRef[];
+ deltaTime: number;
+ }) => {
+ const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
+ const channel = element.animations?.channels[keyframeRef.propertyPath];
+ const keyframe = channel?.keyframes.find(
+ (keyframe) => keyframe.id === keyframeRef.keyframeId,
+ );
+ if (!keyframe) return [];
+ const nextTime = Math.max(
+ 0,
+ Math.min(element.duration, keyframe.time + deltaTime),
+ );
+ return [
+ new RetimeKeyframeCommand({
+ trackId: keyframeRef.trackId,
+ elementId: keyframeRef.elementId,
+ propertyPath: keyframeRef.propertyPath,
+ keyframeId: keyframeRef.keyframeId,
+ nextTime,
+ }),
+ ];
+ });
+
+ if (commands.length === 1) {
+ editor.command.execute({ command: commands[0] });
+ } else if (commands.length > 1) {
+ editor.command.execute({ command: new BatchCommand(commands) });
+ }
+ },
+ [editor.command, element],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ if (isPendingDrag && pendingDragRef.current) {
+ const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
+ if (deltaX <= DRAG_THRESHOLD_PX) return;
+
+ const pending = pendingDragRef.current;
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ setDragState({
+ isDragging: true,
+ draggingKeyframeIds: new Set(
+ pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
+ ),
+ deltaTime: 0,
+ });
+ return;
+ }
+
+ if (!dragState.isDragging) return;
+
+ const startX = mouseDownXRef.current ?? clientX;
+ const rawDelta = (clientX - startX) / pixelsPerSecond;
+ const snappedDelta = snapTimeToFrame({ time: rawDelta, fps });
+
+ setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ const draggingRefs = selectedKeyframes.filter(
+ (keyframe) =>
+ keyframe.elementId === element.id &&
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+
+ if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
+ commitDrag({
+ keyframeRefs: draggingRefs,
+ deltaTime: dragState.deltaTime,
+ });
+ }
+
+ endDrag();
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.draggingKeyframeIds,
+ dragState.deltaTime,
+ selectedKeyframes,
+ element.id,
+ commitDrag,
+ endDrag,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag]);
+
+ const handleKeyframeMouseDown = useCallback(
+ ({
+ event,
+ keyframes,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ mouseDownXRef.current = event.clientX;
+
+ const anySelected = keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+
+ const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
+ if (!anySelected && !isModifierKey) {
+ setKeyframeSelection({ keyframes });
+ }
+
+ const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
+
+ pendingDragRef.current = {
+ keyframeRefs: keyframeRefsToTrack,
+ startMouseX: event.clientX,
+ };
+ setIsPendingDrag(true);
+ },
+ [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
+);
+
+ const handleKeyframeClick = useCallback(
+ ({
+ event,
+ keyframes,
+ orderedKeyframes,
+ indicatorTime,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => {
+ event.stopPropagation();
+
+ const wasDrag =
+ mouseDownXRef.current !== null &&
+ Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
+ mouseDownXRef.current = null;
+
+ if (wasDrag) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const seekTime = getSnappedSeekTime({
+ rawTime: displayedStartTime + indicatorTime,
+ duration,
+ fps,
+ });
+ editor.playback.seek({ time: seekTime });
+
+ if (event.shiftKey) {
+ selectKeyframeRange({
+ orderedKeyframes,
+ targetKeyframes: keyframes,
+ isAdditive: event.metaKey || event.ctrlKey,
+ });
+ return;
+ }
+
+ toggleKeyframeSelection({
+ keyframes,
+ isMultiKey: event.metaKey || event.ctrlKey,
+ });
+ },
+ [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps],
+ );
+
+ const getVisualOffsetPx = useCallback(
+ ({
+ indicatorTime,
+ indicatorOffsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ }: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }): number => {
+ if (!isBeingDragged) return indicatorOffsetPx;
+ const clampedTime = Math.max(
+ 0,
+ Math.min(element.duration, indicatorTime + dragState.deltaTime),
+ );
+ return (
+ timelineTimeToSnappedPixels({
+ time: displayedStartTime + clampedTime,
+ zoomLevel,
+ }) - elementLeft
+ );
+ },
+ [dragState.deltaTime, element.duration, zoomLevel],
+ );
+
+ return {
+ keyframeDragState: dragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
new file mode 100644
index 00000000..0ab05fe3
--- /dev/null
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -0,0 +1,229 @@
+import { useCallback, useSyncExternalStore } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { SelectedKeyframeRef } from "@/types/animation";
+
+function getSelectedKeyframeId({
+ keyframe,
+}: {
+ keyframe: SelectedKeyframeRef;
+}): string {
+ return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
+}
+
+function mergeUniqueKeyframes({
+ keyframes,
+}: {
+ keyframes: SelectedKeyframeRef[];
+}): SelectedKeyframeRef[] {
+ const keyframesById = new Map<string, SelectedKeyframeRef>();
+ for (const keyframe of keyframes) {
+ keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
+ }
+ return [...keyframesById.values()];
+}
+
+export function useKeyframeSelection() {
+ const editor = useEditor();
+ const selectedKeyframes = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedKeyframes(),
+ );
+ const keyframeSelectionAnchor = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getKeyframeSelectionAnchor(),
+ );
+
+ const isKeyframeSelected = useCallback(
+ ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
+ const keyframeId = getSelectedKeyframeId({ keyframe });
+ return selectedKeyframes.some(
+ (selectedKeyframe) =>
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
+ );
+ },
+ [selectedKeyframes],
+ );
+
+ const setKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
+ editor.selection.setSelectedKeyframes({
+ keyframes: uniqueKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
+ });
+ },
+ [editor],
+ );
+
+ const addKeyframesToSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const mergedKeyframes = mergeUniqueKeyframes({
+ keyframes: [...selectedKeyframes, ...keyframes],
+ });
+ editor.selection.setSelectedKeyframes({
+ keyframes: mergedKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const removeKeyframesFromSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const keyframeIdsToRemove = new Set(
+ keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const nextKeyframes = selectedKeyframes.filter(
+ (selectedKeyframe) =>
+ !keyframeIdsToRemove.has(
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }),
+ ),
+ );
+ editor.selection.setSelectedKeyframes({
+ keyframes: nextKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const clearKeyframeSelection = useCallback(() => {
+ editor.selection.clearKeyframeSelection();
+ }, [editor]);
+
+ const toggleKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ isMultiKey,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ isMultiKey: boolean;
+ }) => {
+ const anchorKeyframe = keyframes[0];
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ if (!isMultiKey) {
+ setKeyframeSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ if (areAllKeyframesSelected) {
+ removeKeyframesFromSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ addKeyframesToSelection({ keyframes, anchorKeyframe });
+ },
+ [
+ setKeyframeSelection,
+ isKeyframeSelected,
+ removeKeyframesFromSelection,
+ addKeyframesToSelection,
+ ],
+ );
+
+ const selectKeyframeRange = useCallback(
+ ({
+ orderedKeyframes,
+ targetKeyframes,
+ isAdditive,
+ }: {
+ orderedKeyframes: SelectedKeyframeRef[];
+ targetKeyframes: SelectedKeyframeRef[];
+ isAdditive: boolean;
+ }) => {
+ if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
+ return;
+ }
+
+ const anchorKeyframe =
+ keyframeSelectionAnchor ??
+ selectedKeyframes[selectedKeyframes.length - 1] ??
+ targetKeyframes[0];
+ if (!anchorKeyframe) {
+ return;
+ }
+
+ const targetKeyframeIds = new Set(
+ targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
+ const anchorIndex = orderedKeyframes.findIndex(
+ (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
+ );
+ if (anchorIndex === -1) {
+ if (isAdditive) {
+ addKeyframesToSelection({
+ keyframes: targetKeyframes,
+ anchorKeyframe,
+ });
+ return;
+ }
+ setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
+ return;
+ }
+
+ const targetIndexes = orderedKeyframes
+ .map((keyframe, index) => ({
+ keyframeId: getSelectedKeyframeId({ keyframe }),
+ index,
+ }))
+ .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
+ .map(({ index }) => index);
+ if (targetIndexes.length === 0) {
+ return;
+ }
+
+ const rangeStart = Math.min(anchorIndex, ...targetIndexes);
+ const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
+ const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
+
+ if (isAdditive) {
+ addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ return;
+ }
+
+ setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ },
+ [
+ keyframeSelectionAnchor,
+ selectedKeyframes,
+ addKeyframesToSelection,
+ setKeyframeSelection,
+ ],
+ );
+
+ return {
+ selectedKeyframes,
+ keyframeSelectionAnchor,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ addKeyframesToSelection,
+ removeKeyframesFromSelection,
+ clearKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
index 12c6bf8a..c4f8d6ce 100644
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ b/apps/web/src/hooks/timeline/use-selection-box.ts
@@ -5,6 +5,7 @@ import { useEditor } from "../use-editor";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement | null>;
+ headerRef: React.RefObject<HTMLElement | null>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[],
) => void;
@@ -88,6 +89,7 @@ function isRectangleIntersecting({
export function useSelectionBox({
containerRef,
+ headerRef,
onSelectionComplete,
isEnabled = true,
tracksScrollRef,
@@ -131,6 +133,8 @@ export function useSelectionBox({
endPos,
});
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const timelineHeaderHeight =
+ headerRef.current?.getBoundingClientRect().height ?? 0;
const selectedElements: { trackId: string; elementId: string }[] = [];
for (const [trackIndex, track] of tracks.entries()) {
@@ -139,8 +143,9 @@ export function useSelectionBox({
trackIndex,
});
const trackHeight = getTrackHeight({ type: track.type });
- const elementTop = trackTop;
- const elementBottom = trackTop + trackHeight;
+ const elementTop =
+ timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
+ const elementBottom = elementTop + trackHeight;
for (const element of track.elements) {
const elementLeft = element.startTime * pixelsPerSecond;
@@ -168,7 +173,14 @@ export function useSelectionBox({
}
onSelectionComplete(selectedElements);
},
- [containerRef, onSelectionComplete, tracks, tracksScrollRef, zoomLevel],
+ [
+ containerRef,
+ headerRef,
+ onSelectionComplete,
+ tracks,
+ tracksScrollRef,
+ zoomLevel,
+ ],
);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index d78eb04d..f3e63894 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
import type { TimelineTrack } from "@/types/timeline";
interface UseSnapIndicatorPositionParams {
@@ -50,8 +50,10 @@ export function useSnapIndicatorPosition({
? trackLabelsRef.current.offsetWidth
: 0;
- const timelinePosition =
- (snapPoint?.time || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const timelinePosition = timelineTimeToSnappedPixels({
+ time: snapPoint?.time ?? 0,
+ zoomLevel,
+ });
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
return {
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 632d2c78..8a30785e 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -8,6 +8,7 @@ import {
buildTextElement,
buildStickerElement,
buildElementFromMedia,
+ buildEffectElement,
} from "@/lib/timeline/element-utils";
import type { Command } from "@/lib/commands/base-command";
import { AddMediaAssetCommand } from "@/lib/commands/media";
@@ -16,17 +17,23 @@ import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
-import type { MediaDragData, StickerDragData } from "@/types/drag";
+import type {
+ MediaDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/types/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
zoomLevel: number;
}
export function useTimelineDragDrop({
containerRef,
headerRef,
+ tracksScrollRef,
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
@@ -54,6 +61,7 @@ export function useTimelineDragDrop({
if (dragData.type === "text") return "text";
if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
return dragData.mediaType;
}
@@ -70,7 +78,11 @@ export function useTimelineDragDrop({
elementType: ElementType;
mediaId?: string;
}): number => {
- if (elementType === "text" || elementType === "sticker") {
+ if (
+ elementType === "text" ||
+ elementType === "sticker" ||
+ elementType === "effect"
+ ) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
@@ -94,11 +106,16 @@ export function useTimelineDragDrop({
(e: React.DragEvent) => {
e.preventDefault();
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
const hasFiles = e.dataTransfer.types.includes("Files");
const isExternal =
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
@@ -121,8 +138,15 @@ export function useTimelineDragDrop({
mediaId: dragData?.type === "media" ? dragData.id : undefined,
});
- const mouseX = e.clientX - rect.left;
- const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
+
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
const target = computeDropTarget({
elementType,
@@ -134,6 +158,7 @@ export function useTimelineDragDrop({
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
+ targetElementTypes,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
@@ -144,6 +169,7 @@ export function useTimelineDragDrop({
[
containerRef,
headerRef,
+ tracksScrollRef,
tracks,
currentTime,
zoomLevel,
@@ -182,19 +208,6 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: { name?: string; content?: string };
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "text",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
const element = buildTextElement({
raw: {
name: dragData.name ?? "",
@@ -203,12 +216,26 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeStickerDrop = useCallback(
@@ -219,52 +246,46 @@ export function useTimelineDragDrop({
target: DropTarget;
dragData: StickerDragData;
}) => {
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: "sticker",
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
-
const element = buildStickerElement({
stickerId: dragData.stickerId,
name: dragData.name,
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
+ placement: { mode: "explicit", trackId: track.id },
element,
});
},
- [editor.timeline, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeMediaDrop = useCallback(
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ if (target.targetElement) {
+ toast.info("Replace media source is coming soon!");
+ return;
+ }
+
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
const trackType: TrackType =
dragData.mediaType === "audio" ? "audio" : "video";
- let trackId: string;
-
- if (target.isNewTrack) {
- trackId = editor.timeline.addTrack({
- type: trackType,
- index: target.trackIndex,
- });
- } else {
- const track = tracks[target.trackIndex];
- if (!track) return;
- trackId = track.id;
- }
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
@@ -276,12 +297,80 @@ export function useTimelineDragDrop({
startTime: target.xPosition,
});
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor.command, editor.timeline, mediaAssets, tracks],
+ );
+
+ const executeEffectDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: EffectDragData;
+ }) => {
+ if (target.targetElement) {
+ editor.timeline.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack) {
+ trackId = effectTrack.id;
+ } else if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element: buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ }),
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track || track.type !== "effect") return;
+ trackId = track.id;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
},
- [editor.timeline, mediaAssets, tracks],
+ [editor.command, editor.timeline, tracks],
);
const executeFileDrop = useCallback(
@@ -384,16 +473,27 @@ export function useTimelineDragDrop({
executeTextDrop({ target: currentTarget, dragData });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "effect") {
+ executeEffectDrop({
+ target: currentTarget,
+ dragData: dragData as EffectDragData,
+ });
} else {
executeMediaDrop({ target: currentTarget, dragData });
}
} else if (hasFiles) {
- const rect = containerRef.current?.getBoundingClientRect();
- if (!rect) return;
- const mouseX = e.clientX - rect.left;
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
const headerHeight =
headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
await executeFileDrop({
files: Array.from(e.dataTransfer.files),
mouseX,
@@ -410,9 +510,11 @@ export function useTimelineDragDrop({
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
+ executeEffectDrop,
executeFileDrop,
containerRef,
headerRef,
+ tracksScrollRef,
],
);
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
new file mode 100644
index 00000000..a84781e0
--- /dev/null
+++ b/apps/web/src/hooks/use-effect-preview.ts
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from "react";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { EffectParamValues } from "@/types/effects";
+
+export function useEffectPreview({
+ effectType,
+ params,
+ canvasRef,
+ isActive,
+}: {
+ effectType: string;
+ params: EffectParamValues;
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ isActive: boolean;
+}): void {
+ const requestRef = useRef<number>(0);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (requestRef.current) {
+ cancelAnimationFrame(requestRef.current);
+ requestRef.current = 0;
+ }
+ return;
+ }
+
+ const loop = (): void => {
+ const canvas = canvasRef.current;
+ if (canvas) {
+ effectPreviewService.renderPreview({
+ effectType,
+ params,
+ targetCanvas: canvas,
+ });
+ }
+ requestRef.current = requestAnimationFrame(loop);
+ };
+
+ requestRef.current = requestAnimationFrame(loop);
+
+ return () => {
+ if (requestRef.current) {
+ cancelAnimationFrame(requestRef.current);
+ }
+ };
+ }, [effectType, params, canvasRef, isActive]);
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index 8be6420c..fb561396 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,7 +1,6 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useRef, useState, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import { useSyncExternalStore } from "react";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
@@ -18,7 +17,13 @@ import {
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+ setChannel,
+} from "@/lib/animation";
import type { Transform } from "@/types/timeline";
+import type { ElementAnimations } from "@/types/animation";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type HandleType = Corner | "rotation";
@@ -32,6 +37,8 @@ interface ScaleState {
initialBoundsCy: number;
baseWidth: number;
baseHeight: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
}
interface RotationState {
@@ -78,16 +85,16 @@ function getCornerDistance({
};
corner: Corner;
}): number {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
const angleRad = (bounds.rotation * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
const rotatedX = localX * cos - localY * sin;
const rotatedY = localX * sin + localY * cos;
@@ -114,6 +121,8 @@ export function useTransformHandles({
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
@@ -144,19 +153,41 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / element.transform.scale;
- const baseHeight = bounds.height / element.transform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scale;
+ const baseHeight = bounds.height / resolvedTransform.scale;
+ const shouldClearScaleAnimation =
+ !!element.animations?.channels["transform.scale"];
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scale",
+ channel: undefined,
+ })
+ : element.animations;
scaleStateRef.current = {
trackId,
elementId,
- initialTransform: element.transform,
+ initialTransform: resolvedTransform,
initialDistance,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
baseWidth,
baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
};
setActiveHandle(corner);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
@@ -172,19 +203,30 @@ export function useTransformHandles({
const { bounds, trackId, elementId, element } = selectedWithBounds;
if (!isVisualElement(element)) return;
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
const position = screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
canvas: canvasRef.current,
});
- const dx = position.x - bounds.cx;
- const dy = position.y - bounds.cy;
- const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
elementId,
- initialTransform: element.transform,
+ initialTransform: resolvedTransform,
initialAngle,
initialBoundsCx: bounds.cx,
initialBoundsCy: bounds.cy,
@@ -220,11 +262,13 @@ export function useTransformHandles({
initialBoundsCy,
baseWidth,
baseHeight,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
} = scaleStateRef.current;
- const dx = position.x - initialBoundsCx;
- const dy = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
const proposedScale = Math.max(
MIN_SCALE,
@@ -258,14 +302,22 @@ export function useTransformHandles({
setSnapLines(activeLines);
}
+ const updates: {
+ transform: Transform;
+ animations?: ElementAnimations;
+ } = {
+ transform: { ...initialTransform, scale: snappedScale },
+ };
+ if (shouldClearScaleAnimation) {
+ updates.animations = animationsWithoutScale;
+ }
+
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates: {
- transform: { ...initialTransform, scale: snappedScale },
- },
+ updates,
},
],
});
@@ -282,9 +334,9 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const dx = position.x - initialBoundsCx;
- const dy = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
index 83144a25..82319216 100644
--- a/apps/web/src/lib/actions/definitions.ts
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -105,11 +105,20 @@ export const ACTIONS = {
category: "editing",
defaultShortcuts: ["n"],
},
+ "toggle-ripple-editing": {
+ description: "Toggle ripple editing",
+ category: "editing",
+ },
"select-all": {
description: "Select all elements",
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
+ "deselect-all": {
+ description: "Deselect all elements",
+ category: "selection",
+ defaultShortcuts: ["escape"],
+ },
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
@@ -141,7 +150,7 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS;
-export function getActionDefinition(action: TAction): TActionDefinition {
+export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
return ACTIONS[action];
}
diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
new file mode 100644
index 00000000..629bd0d6
--- /dev/null
+++ b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
@@ -0,0 +1,313 @@
+import { describe, expect, test } from "bun:test";
+import type { ElementAnimations } from "@/types/animation";
+import {
+ clampAnimationsToDuration,
+ getElementKeyframes,
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ getChannelValueAtTime,
+ getElementLocalTime,
+ resolveTransformAtTime,
+ splitAnimationsAtTime,
+} from "@/lib/animation";
+
+describe("transform keyframe evaluation", () => {
+ test("uses fallback value when channel is missing", () => {
+ const value = getChannelValueAtTime({
+ channel: undefined,
+ time: 1,
+ fallbackValue: 42,
+ });
+ expect(value).toBe(42);
+ });
+
+ test("returns boundary value when time is within epsilon of first/last keyframe", () => {
+ const channel = {
+ valueKind: "number" as const,
+ keyframes: [
+ { id: "a", time: 0, value: 10, interpolation: "linear" as const },
+ { id: "b", time: 2, value: 30, interpolation: "linear" as const },
+ ],
+ };
+ expect(
+ getChannelValueAtTime({
+ channel,
+ time: 0.0008,
+ fallbackValue: 0,
+ }),
+ ).toBe(10);
+ expect(
+ getChannelValueAtTime({
+ channel,
+ time: 1.9992,
+ fallbackValue: 0,
+ }),
+ ).toBe(30);
+ });
+
+ test("interpolates linear channels", () => {
+ const value = getChannelValueAtTime({
+ channel: {
+ valueKind: "number",
+ keyframes: [
+ { id: "a", time: 0, value: 10, interpolation: "linear" },
+ { id: "b", time: 2, value: 30, interpolation: "linear" },
+ ],
+ },
+ time: 1,
+ fallbackValue: 0,
+ });
+ expect(value).toBe(20);
+ });
+
+ test("clamps local time to [0, duration]", () => {
+ expect(
+ getElementLocalTime({
+ timelineTime: 2,
+ elementStartTime: 5,
+ elementDuration: 4,
+ }),
+ ).toBe(0);
+ expect(
+ getElementLocalTime({
+ timelineTime: 12,
+ elementStartTime: 5,
+ elementDuration: 4,
+ }),
+ ).toBe(4);
+ expect(
+ getElementLocalTime({
+ timelineTime: 7,
+ elementStartTime: 5,
+ elementDuration: 4,
+ }),
+ ).toBe(2);
+ });
+
+ test("uses hold interpolation from the left keyframe", () => {
+ const value = getChannelValueAtTime({
+ channel: {
+ valueKind: "number",
+ keyframes: [
+ { id: "a", time: 0, value: 10, interpolation: "hold" },
+ { id: "b", time: 2, value: 30, interpolation: "linear" },
+ ],
+ },
+ time: 1,
+ fallbackValue: 0,
+ });
+ expect(value).toBe(10);
+ });
+
+ test("resolves transform by mixing animated and fallback properties", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.position.x": {
+ valueKind: "number",
+ keyframes: [
+ { id: "a", time: 0, value: 0, interpolation: "linear" },
+ { id: "b", time: 4, value: 80, interpolation: "linear" },
+ ],
+ },
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
+ },
+ },
+ };
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: {
+ position: { x: 10, y: 20 },
+ scale: 1,
+ rotate: 15,
+ },
+ animations,
+ localTime: 2,
+ });
+ expect(resolvedTransform).toEqual({
+ position: { x: 40, y: 20 },
+ scale: 2,
+ rotate: 15,
+ });
+ });
+});
+
+describe("transform keyframe mutation utilities", () => {
+ test("splits channels and rebases right side times", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [
+ { id: "a", time: 0, value: 1, interpolation: "linear" },
+ { id: "b", time: 2, value: 2, interpolation: "linear" },
+ { id: "c", time: 6, value: 4, interpolation: "linear" },
+ ],
+ },
+ },
+ };
+ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
+ animations,
+ splitTime: 4,
+ });
+
+ expect(
+ leftAnimations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 2, 4]);
+ expect(
+ rightAnimations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 2]);
+ expect(
+ rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
+ ).toBe(3);
+ });
+
+ test("clamps channels to updated element duration", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.rotate": {
+ valueKind: "number",
+ keyframes: [
+ { id: "a", time: 0, value: 0, interpolation: "linear" },
+ { id: "b", time: 2, value: 20, interpolation: "linear" },
+ { id: "c", time: 5, value: 50, interpolation: "linear" },
+ ],
+ },
+ },
+ };
+ const clampedAnimations = clampAnimationsToDuration({
+ animations,
+ duration: 2,
+ });
+ expect(
+ clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 2]);
+ });
+});
+
+describe("typed channel interpolation", () => {
+ test("interpolates color channels from hex keyframes", () => {
+ const value = getChannelValueAtTime({
+ channel: {
+ valueKind: "color",
+ keyframes: [
+ { id: "a", time: 0, value: "#000000", interpolation: "linear" },
+ { id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
+ ],
+ },
+ time: 0.5,
+ fallbackValue: "#000000",
+ });
+ expect(typeof value).toBe("string");
+ expect(value).toContain("rgba(");
+ });
+
+ test("uses hold behavior for discrete channels", () => {
+ const value = getChannelValueAtTime({
+ channel: {
+ valueKind: "discrete",
+ keyframes: [
+ { id: "a", time: 0, value: "normal", interpolation: "hold" },
+ { id: "b", time: 2, value: "multiply", interpolation: "hold" },
+ ],
+ },
+ time: 1.2,
+ fallbackValue: "normal",
+ });
+ expect(value).toBe("normal");
+ });
+});
+
+describe("keyframe query helpers", () => {
+ test("getElementKeyframes returns flat list of all keyframes across channels", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.position.x": {
+ valueKind: "number",
+ keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
+ },
+ opacity: {
+ valueKind: "number",
+ keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
+ },
+ },
+ };
+
+ const keyframes = getElementKeyframes({ animations });
+ expect(keyframes).toHaveLength(2);
+ expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
+ "opacity",
+ "transform.position.x",
+ ]);
+ });
+
+ test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
+ expect(getElementKeyframes({ animations: undefined })).toEqual([]);
+ expect(
+ getElementKeyframes({
+ animations: {
+ channels: { opacity: { valueKind: "number", keyframes: [] } },
+ },
+ }),
+ ).toEqual([]);
+ });
+
+ test("hasKeyframesForPath returns true only for paths with keyframes", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.position.x": {
+ valueKind: "number",
+ keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
+ },
+ "transform.position.y": {
+ valueKind: "number",
+ keyframes: [],
+ },
+ },
+ };
+
+ expect(
+ hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
+ ).toBe(true);
+ expect(
+ hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
+ ).toBe(false);
+ });
+
+ test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
+ const animations: ElementAnimations = {
+ channels: {
+ "transform.rotate": {
+ valueKind: "number",
+ keyframes: [
+ { id: "r-1", time: 1, value: 15, interpolation: "linear" },
+ { id: "r-2", time: 2, value: 30, interpolation: "linear" },
+ ],
+ },
+ },
+ };
+
+ const found = getKeyframeAtTime({
+ animations,
+ propertyPath: "transform.rotate",
+ time: 1.0008,
+ });
+ expect(found?.id).toBe("r-1");
+ expect(found?.value).toBe(15);
+ expect(found?.propertyPath).toBe("transform.rotate");
+
+ expect(
+ getKeyframeAtTime({
+ animations,
+ propertyPath: "transform.rotate",
+ time: 1.01,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
new file mode 100644
index 00000000..2a340f94
--- /dev/null
+++ b/apps/web/src/lib/animation/color-channel.ts
@@ -0,0 +1,19 @@
+import type {
+ AnimationPropertyPath,
+ ColorAnimationChannel,
+ ElementAnimations,
+} from "@/types/animation";
+
+export function getColorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): ColorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "color") {
+ return undefined;
+ }
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
new file mode 100644
index 00000000..07efe4d8
--- /dev/null
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -0,0 +1,117 @@
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type {
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/types/animation";
+import {
+ getChannel,
+ removeKeyframe,
+ setChannel,
+ upsertKeyframe,
+} from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+const EFFECT_PARAM_PATH_PREFIX = "effects.";
+const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+
+function buildEffectParamPath({
+ effectId,
+ paramKey,
+}: {
+ effectId: string;
+ paramKey: string;
+}): string {
+ return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
+}
+
+export function resolveEffectParamsAtTime({
+ effect,
+ animations,
+ localTime,
+}: {
+ effect: Effect;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): EffectParamValues {
+ const resolved: EffectParamValues = {};
+
+ for (const [paramKey, staticValue] of Object.entries(effect.params)) {
+ const path = buildEffectParamPath({ effectId: effect.id, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ if (channel && channel.keyframes.length > 0) {
+ resolved[paramKey] = getChannelValueAtTime({
+ channel,
+ time: localTime,
+ fallbackValue: staticValue,
+ }) as number | string | boolean;
+ } else {
+ resolved[paramKey] = staticValue;
+ }
+ }
+
+ return resolved;
+}
+
+const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
+ valueKind: "number",
+ keyframes: [],
+};
+
+export function upsertEffectParamKeyframe({
+ animations,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const path = buildEffectParamPath({ effectId, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ const targetChannel =
+ channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value,
+ interpolation: interpolation ?? "linear",
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath: path,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function removeEffectParamKeyframe({
+ animations,
+ effectId,
+ paramKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+}): ElementAnimations | undefined {
+ const path = buildEffectParamPath({ effectId, paramKey });
+ const channel = getChannel({ animations, propertyPath: path });
+ const updatedChannel = removeKeyframe({ channel, keyframeId });
+ return setChannel({
+ animations,
+ propertyPath: path,
+ channel: updatedChannel,
+ });
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
new file mode 100644
index 00000000..c4396bb7
--- /dev/null
+++ b/apps/web/src/lib/animation/index.ts
@@ -0,0 +1,41 @@
+export {
+ getChannelValueAtTime,
+ getNumberChannelValueAtTime,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setChannel,
+ splitAnimationsAtTime,
+ upsertElementKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+ resolveVolumeAtTime,
+} from "./resolve";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ supportsAnimationProperty,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export {
+ getElementKeyframes,
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "./keyframe-query";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
new file mode 100644
index 00000000..7a6fc6cf
--- /dev/null
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -0,0 +1,370 @@
+import type {
+ AnimationChannel,
+ AnimationValue,
+ ColorAnimationChannel,
+ DiscreteValue,
+ DiscreteAnimationChannel,
+ NumberAnimationChannel,
+} from "@/types/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
+function byTimeAscending({
+ leftTime,
+ rightTime,
+}: {
+ leftTime: number;
+ rightTime: number;
+}): number {
+ return leftTime - rightTime;
+}
+
+function isWithinTimePair({
+ time,
+ leftTime,
+ rightTime,
+}: {
+ time: number;
+ leftTime: number;
+ rightTime: number;
+}): boolean {
+ return (
+ time >= leftTime - TIME_EPSILON_SECONDS &&
+ time <= rightTime + TIME_EPSILON_SECONDS
+ );
+}
+
+function clamp01({ value }: { value: number }): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+function parseHexChannel({ hex }: { hex: string }): number | null {
+ const value = Number.parseInt(hex, 16);
+ return Number.isNaN(value) ? null : value;
+}
+
+function parseHexColor({
+ color,
+}: {
+ color: string;
+}): { red: number; green: number; blue: number; alpha: number } | null {
+ const trimmed = color.trim();
+ if (!trimmed.startsWith("#")) {
+ return null;
+ }
+
+ const rawHex = trimmed.slice(1);
+ if (rawHex.length === 3 || rawHex.length === 4) {
+ const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
+ const red = parseHexChannel({ hex: `${redHex}${redHex}` });
+ const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
+ const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
+ const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
+ if (
+ red === null ||
+ green === null ||
+ blue === null ||
+ alpha === null
+ ) {
+ return null;
+ }
+
+ return { red, green, blue, alpha: alpha / 255 };
+ }
+
+ if (rawHex.length === 6 || rawHex.length === 8) {
+ const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
+ const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
+ const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
+ const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
+ const alpha = parseHexChannel({ hex: alphaHex });
+ if (
+ red === null ||
+ green === null ||
+ blue === null ||
+ alpha === null
+ ) {
+ return null;
+ }
+
+ return { red, green, blue, alpha: alpha / 255 };
+ }
+
+ return null;
+}
+
+function formatRgbaColor({
+ red,
+ green,
+ blue,
+ alpha,
+}: {
+ red: number;
+ green: number;
+ blue: number;
+ alpha: number;
+}): string {
+ const roundedRed = Math.round(red);
+ const roundedGreen = Math.round(green);
+ const roundedBlue = Math.round(blue);
+ const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
+ return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
+}
+
+function lerpNumber({
+ leftValue,
+ rightValue,
+ progress,
+}: {
+ leftValue: number;
+ rightValue: number;
+ progress: number;
+}): number {
+ return leftValue + (rightValue - leftValue) * progress;
+}
+
+function interpolateColor({
+ leftColor,
+ rightColor,
+ progress,
+}: {
+ leftColor: string;
+ rightColor: string;
+ progress: number;
+}): string {
+ const leftParsed = parseHexColor({ color: leftColor });
+ const rightParsed = parseHexColor({ color: rightColor });
+ if (!leftParsed || !rightParsed) {
+ return progress >= 1 ? rightColor : leftColor;
+ }
+
+ return formatRgbaColor({
+ red: lerpNumber({
+ leftValue: leftParsed.red,
+ rightValue: rightParsed.red,
+ progress,
+ }),
+ green: lerpNumber({
+ leftValue: leftParsed.green,
+ rightValue: rightParsed.green,
+ progress,
+ }),
+ blue: lerpNumber({
+ leftValue: leftParsed.blue,
+ rightValue: rightParsed.blue,
+ progress,
+ }),
+ alpha: lerpNumber({
+ leftValue: leftParsed.alpha,
+ rightValue: rightParsed.alpha,
+ progress,
+ }),
+ });
+}
+
+export function normalizeChannel<TChannel extends AnimationChannel>({
+ channel,
+}: {
+ channel: TChannel;
+}): TChannel {
+ return {
+ ...channel,
+ keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
+ byTimeAscending({
+ leftTime: leftKeyframe.time,
+ rightTime: rightKeyframe.time,
+ }),
+ ),
+ } as TChannel;
+}
+
+function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
+ keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue,
+}: {
+ keyframes: TKeyframe[] | undefined;
+ time: number;
+ fallbackValue: TValue;
+ getInterpolatedValue: ({
+ leftKeyframe,
+ rightKeyframe,
+ progress,
+ }: {
+ leftKeyframe: TKeyframe;
+ rightKeyframe: TKeyframe;
+ progress: number;
+ }) => TValue;
+}): TValue {
+ if (!keyframes || keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ const firstKeyframe = keyframes[0];
+ const lastKeyframe = keyframes[keyframes.length - 1];
+ if (!firstKeyframe || !lastKeyframe) {
+ return fallbackValue;
+ }
+
+ if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
+ return firstKeyframe.value;
+ }
+
+ if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
+ return lastKeyframe.value;
+ }
+
+ for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
+ const leftKeyframe = keyframes[keyframeIndex];
+ const rightKeyframe = keyframes[keyframeIndex + 1];
+
+ if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
+ return rightKeyframe.value;
+ }
+
+ const isBetweenPair = isWithinTimePair({
+ time,
+ leftTime: leftKeyframe.time,
+ rightTime: rightKeyframe.time,
+ });
+ if (!isBetweenPair) {
+ continue;
+ }
+
+ const span = rightKeyframe.time - leftKeyframe.time;
+ if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ return rightKeyframe.value;
+ }
+
+ const progress = clamp01({
+ value: (time - leftKeyframe.time) / span,
+ });
+
+ return getInterpolatedValue({
+ leftKeyframe,
+ rightKeyframe,
+ progress,
+ });
+ }
+
+ return lastKeyframe.value;
+}
+
+export function getNumberChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: NumberAnimationChannel | undefined;
+ time: number;
+ fallbackValue: number;
+}): number {
+ return evaluateChannelValueAtTime({
+ keyframes: channel?.keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
+ if (leftKeyframe.interpolation === "hold") {
+ return leftKeyframe.value;
+ }
+
+ return lerpNumber({
+ leftValue: leftKeyframe.value,
+ rightValue: rightKeyframe.value,
+ progress,
+ });
+ },
+ });
+}
+
+export function getColorValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: ColorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: string;
+}): string {
+ return evaluateChannelValueAtTime({
+ keyframes: channel?.keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
+ if (leftKeyframe.interpolation === "hold") {
+ return leftKeyframe.value;
+ }
+
+ return interpolateColor({
+ leftColor: leftKeyframe.value,
+ rightColor: rightKeyframe.value,
+ progress,
+ });
+ },
+ });
+}
+
+function getDiscreteValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: DiscreteValue;
+}): DiscreteValue {
+ return evaluateChannelValueAtTime({
+ keyframes: channel?.keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
+ });
+}
+
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ fallbackValue: AnimationValue;
+}): AnimationValue {
+ if (!channel || channel.keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ if (channel.valueKind === "number") {
+ if (typeof fallbackValue !== "number") {
+ return fallbackValue;
+ }
+
+ return getNumberChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ });
+ }
+
+ if (channel.valueKind === "color") {
+ if (typeof fallbackValue !== "string") {
+ return fallbackValue;
+ }
+
+ return getColorValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ });
+ }
+
+ if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
+ return fallbackValue;
+ }
+
+ return getDiscreteValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ });
+}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
new file mode 100644
index 00000000..6626e3e8
--- /dev/null
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -0,0 +1,67 @@
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/types/animation";
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.channels).flatMap(
+ ([propertyPath, channel]) => {
+ if (!channel || channel.keyframes.length === 0) {
+ return [];
+ }
+
+ return channel.keyframes.map((keyframe) => ({
+ propertyPath: propertyPath as AnimationPropertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ }));
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const channel = animations?.channels[propertyPath];
+ return Boolean(channel && channel.keyframes.length > 0);
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+}): ElementKeyframe | null {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.keyframes.length === 0) return null;
+ const keyframe = channel.keyframes.find(
+ (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ );
+ if (!keyframe) return null;
+ return {
+ propertyPath,
+ id: keyframe.id,
+ time: keyframe.time,
+ value: keyframe.value,
+ interpolation: keyframe.interpolation,
+ };
+}
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
new file mode 100644
index 00000000..3e3613f2
--- /dev/null
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -0,0 +1,611 @@
+import type {
+ AnimationChannel,
+ AnimationInterpolation,
+ AnimationKeyframe,
+ AnimationPropertyPath,
+ AnimationValue,
+ AnimationValueKind,
+ ColorAnimationChannel,
+ DiscreteAnimationChannel,
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/types/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { generateUUID } from "@/utils/id";
+import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
+import {
+ coerceAnimationValueForProperty,
+ getDefaultInterpolationForProperty,
+ getAnimationPropertyDefinition,
+ isAnimationPropertyPath,
+} from "./property-registry";
+
+function isNearlySameTime({
+ leftTime,
+ rightTime,
+}: {
+ leftTime: number;
+ rightTime: number;
+}): boolean {
+ return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
+}
+
+function toAnimation({
+ channelEntries,
+}: {
+ channelEntries: Array<[string, AnimationChannel]>;
+}): ElementAnimations | undefined {
+ if (channelEntries.length === 0) {
+ return undefined;
+ }
+
+ return {
+ channels: Object.fromEntries(channelEntries),
+ };
+}
+
+function toChannel({
+ keyframes,
+ valueKind,
+}: {
+ keyframes: AnimationKeyframe[];
+ valueKind: AnimationValueKind;
+}): AnimationChannel {
+ return normalizeChannel({
+ channel: {
+ valueKind,
+ keyframes,
+ } as AnimationChannel,
+ });
+}
+
+export function getChannel({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: string;
+}): AnimationChannel | undefined {
+ return animations?.channels[propertyPath];
+}
+
+function getInterpolationForChannel({
+ channel,
+ interpolation,
+}: {
+ channel: AnimationChannel;
+ interpolation: AnimationInterpolation | undefined;
+}): AnimationInterpolation {
+ if (channel.valueKind === "discrete") {
+ return "hold";
+ }
+
+ if (interpolation === "linear" || interpolation === "hold") {
+ return interpolation;
+ }
+
+ return "linear";
+}
+
+function buildKeyframe({
+ channel,
+ id,
+ time,
+ value,
+ interpolation,
+}: {
+ channel: AnimationChannel;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}): AnimationKeyframe {
+ if (channel.valueKind === "number") {
+ if (typeof value !== "number") {
+ throw new Error("Number channel keyframes require numeric values");
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: interpolation === "hold" ? "hold" : "linear",
+ };
+ }
+
+ if (channel.valueKind === "color") {
+ if (typeof value !== "string") {
+ throw new Error("Color channel keyframes require string values");
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: interpolation === "hold" ? "hold" : "linear",
+ };
+ }
+
+ if (typeof value !== "string" && typeof value !== "boolean") {
+ throw new Error(
+ "Discrete channel keyframes require boolean or string values",
+ );
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: "hold",
+ };
+}
+
+function createEmptyChannel({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationChannel {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ if (propertyDefinition.valueKind === "number") {
+ return {
+ valueKind: "number",
+ keyframes: [],
+ } satisfies NumberAnimationChannel;
+ }
+
+ if (propertyDefinition.valueKind === "color") {
+ return {
+ valueKind: "color",
+ keyframes: [],
+ } satisfies ColorAnimationChannel;
+ }
+
+ return {
+ valueKind: "discrete",
+ keyframes: [],
+ } satisfies DiscreteAnimationChannel;
+}
+
+export function upsertKeyframe({
+ channel,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): AnimationChannel | undefined {
+ if (!channel) {
+ return undefined;
+ }
+
+ const currentKeyframes = channel.keyframes;
+ const nextKeyframes = [...currentKeyframes];
+ const nextInterpolation = getInterpolationForChannel({
+ channel,
+ interpolation,
+ });
+ if (keyframeId) {
+ const keyframeByIdIndex = nextKeyframes.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeByIdIndex >= 0) {
+ nextKeyframes[keyframeByIdIndex] = buildKeyframe({
+ channel,
+ id: nextKeyframes[keyframeByIdIndex].id,
+ time,
+ value,
+ interpolation: nextInterpolation,
+ });
+ return toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ });
+ }
+ }
+
+ const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
+ isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
+ );
+ if (keyframeAtTimeIndex >= 0) {
+ nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
+ channel,
+ id: nextKeyframes[keyframeAtTimeIndex].id,
+ time: nextKeyframes[keyframeAtTimeIndex].time,
+ value,
+ interpolation: nextInterpolation,
+ });
+ return toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ });
+ }
+
+ nextKeyframes.push(
+ buildKeyframe({
+ channel,
+ id: keyframeId ?? generateUUID(),
+ time,
+ value,
+ interpolation: nextInterpolation,
+ }),
+ );
+
+ return toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ });
+}
+
+export function removeKeyframe({
+ channel,
+ keyframeId,
+}: {
+ channel: AnimationChannel | undefined;
+ keyframeId: string;
+}): AnimationChannel | undefined {
+ if (!channel) {
+ return undefined;
+ }
+
+ const nextKeyframes = channel.keyframes.filter(
+ (keyframe) => keyframe.id !== keyframeId,
+ );
+ if (nextKeyframes.length === 0) {
+ return undefined;
+ }
+
+ return toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ });
+}
+
+export function retimeKeyframe({
+ channel,
+ keyframeId,
+ time,
+}: {
+ channel: AnimationChannel | undefined;
+ keyframeId: string;
+ time: number;
+}): AnimationChannel | undefined {
+ if (!channel) {
+ return undefined;
+ }
+
+ const keyframeByIdIndex = channel.keyframes.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeByIdIndex < 0) {
+ return channel;
+ }
+
+ const nextKeyframes = [...channel.keyframes];
+ nextKeyframes[keyframeByIdIndex] = {
+ ...nextKeyframes[keyframeByIdIndex],
+ time,
+ };
+
+ return toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ });
+}
+
+export function setChannel({
+ animations,
+ propertyPath,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: string;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const currentChannels = animations?.channels ?? {};
+
+ const nextChannelEntries = Object.entries(currentChannels)
+ .filter(([path]) => path !== propertyPath)
+ .filter(([, ch]) => ch && ch.keyframes.length > 0)
+ .map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
+
+ if (channel && channel.keyframes.length > 0) {
+ nextChannelEntries.push([propertyPath, channel]);
+ }
+
+ return toAnimation({
+ channelEntries: nextChannelEntries,
+ });
+}
+
+export function cloneAnimations({
+ animations,
+ shouldRegenerateKeyframeIds = false,
+}: {
+ animations: ElementAnimations | undefined;
+ shouldRegenerateKeyframeIds?: boolean;
+}): ElementAnimations | undefined {
+ if (!animations) {
+ return undefined;
+ }
+
+ const clonedEntries = Object.entries(animations.channels).flatMap(
+ ([propertyPath, channel]) => {
+ if (!channel || channel.keyframes.length === 0) {
+ return [];
+ }
+
+ const clonedKeyframes = channel.keyframes.map((keyframe) => ({
+ ...keyframe,
+ id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
+ }));
+
+ return [
+ [
+ propertyPath,
+ toChannel({
+ keyframes: clonedKeyframes,
+ valueKind: channel.valueKind,
+ }),
+ ] as [string, AnimationChannel],
+ ];
+ },
+ );
+
+ return toAnimation({
+ channelEntries: clonedEntries,
+ });
+}
+
+export function clampAnimationsToDuration({
+ animations,
+ duration,
+}: {
+ animations: ElementAnimations | undefined;
+ duration: number;
+}): ElementAnimations | undefined {
+ if (!animations) {
+ return undefined;
+ }
+
+ const clampedEntries = Object.entries(animations.channels).flatMap(
+ ([propertyPath, channel]) => {
+ if (!channel) {
+ return [];
+ }
+
+ const nextKeyframes = channel.keyframes.filter(
+ (keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
+ );
+ if (nextKeyframes.length === 0) {
+ return [];
+ }
+
+ return [
+ [
+ propertyPath,
+ toChannel({
+ keyframes: nextKeyframes,
+ valueKind: channel.valueKind,
+ }),
+ ] as [string, AnimationChannel],
+ ];
+ },
+ );
+
+ return toAnimation({
+ channelEntries: clampedEntries,
+ });
+}
+
+export function splitAnimationsAtTime({
+ animations,
+ splitTime,
+ shouldIncludeSplitBoundary = true,
+}: {
+ animations: ElementAnimations | undefined;
+ splitTime: number;
+ shouldIncludeSplitBoundary?: boolean;
+}): {
+ leftAnimations: ElementAnimations | undefined;
+ rightAnimations: ElementAnimations | undefined;
+} {
+ if (!animations) {
+ return { leftAnimations: undefined, rightAnimations: undefined };
+ }
+
+ const leftChannels: Array<[string, AnimationChannel]> = [];
+ const rightChannels: Array<[string, AnimationChannel]> = [];
+
+ for (const [propertyPath, channel] of Object.entries(animations.channels)) {
+ if (!channel || channel.keyframes.length === 0) {
+ continue;
+ }
+
+ const normalizedChannel = normalizeChannel({ channel });
+ let leftKeyframes = normalizedChannel.keyframes.filter(
+ (keyframe) => keyframe.time <= splitTime,
+ );
+ let rightKeyframes = normalizedChannel.keyframes
+ .filter((keyframe) => keyframe.time >= splitTime)
+ .map((keyframe) => ({
+ ...keyframe,
+ time: keyframe.time - splitTime,
+ }));
+
+ const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
+ isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
+ );
+ const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
+ isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
+ );
+ if (
+ shouldIncludeSplitBoundary &&
+ (!hasBoundaryOnLeft || !hasBoundaryOnRight)
+ ) {
+ const boundaryValue = getChannelValueAtTime({
+ channel: normalizedChannel,
+ time: splitTime,
+ fallbackValue: normalizedChannel.keyframes[0].value,
+ });
+ const knownPropertyPath = isAnimationPropertyPath({ propertyPath })
+ ? (propertyPath as AnimationPropertyPath)
+ : null;
+ const boundaryInterpolation = knownPropertyPath
+ ? getDefaultInterpolationForProperty({
+ propertyPath: knownPropertyPath,
+ })
+ : normalizedChannel.valueKind === "discrete"
+ ? "hold"
+ : "linear";
+
+ if (!hasBoundaryOnLeft) {
+ leftKeyframes = [
+ ...leftKeyframes,
+ buildKeyframe({
+ channel: normalizedChannel,
+ id: generateUUID(),
+ time: splitTime,
+ value: boundaryValue,
+ interpolation: boundaryInterpolation,
+ }),
+ ];
+ }
+
+ if (!hasBoundaryOnRight) {
+ rightKeyframes = [
+ buildKeyframe({
+ channel: normalizedChannel,
+ id: generateUUID(),
+ time: 0,
+ value: boundaryValue,
+ interpolation: boundaryInterpolation,
+ }),
+ ...rightKeyframes,
+ ];
+ }
+ }
+
+ const leftChannel = leftKeyframes.length
+ ? toChannel({
+ keyframes: leftKeyframes,
+ valueKind: normalizedChannel.valueKind,
+ })
+ : undefined;
+ const rightChannel = rightKeyframes.length
+ ? toChannel({
+ keyframes: rightKeyframes,
+ valueKind: normalizedChannel.valueKind,
+ })
+ : undefined;
+ if (leftChannel) {
+ leftChannels.push([propertyPath, leftChannel]);
+ }
+ if (rightChannel) {
+ rightChannels.push([propertyPath, rightChannel]);
+ }
+ }
+
+ return {
+ leftAnimations: toAnimation({ channelEntries: leftChannels }),
+ rightAnimations: toAnimation({ channelEntries: rightChannels }),
+ };
+}
+
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const defaultInterpolation = getDefaultInterpolationForProperty({
+ propertyPath,
+ });
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ const channel = getChannel({ animations, propertyPath });
+ const targetChannel =
+ channel && channel.valueKind === propertyDefinition.valueKind
+ ? channel
+ : createEmptyChannel({ propertyPath });
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value: coercedValue,
+ interpolation: interpolation ?? defaultInterpolation,
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function removeElementKeyframe({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): ElementAnimations | undefined {
+ const channel = getChannel({ animations, propertyPath });
+ const updatedChannel = removeKeyframe({
+ channel,
+ keyframeId,
+ });
+ return setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ });
+}
+
+export function retimeElementKeyframe({
+ animations,
+ propertyPath,
+ keyframeId,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ time: number;
+}): ElementAnimations | undefined {
+ const channel = getChannel({ animations, propertyPath });
+ const updatedChannel = retimeKeyframe({
+ channel,
+ keyframeId,
+ time,
+ });
+ return setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ });
+}
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
new file mode 100644
index 00000000..67166156
--- /dev/null
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -0,0 +1,20 @@
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ NumberAnimationChannel,
+} from "@/types/animation";
+
+export function getNumberChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): NumberAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "number") {
+ return undefined;
+ }
+
+ return channel;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
new file mode 100644
index 00000000..361ae3ba
--- /dev/null
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -0,0 +1,352 @@
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+ AnimationValueKind,
+ DiscreteValue,
+} from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ DEFAULT_TEXT_BACKGROUND,
+} from "@/constants/text-constants";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+
+interface NumericRange {
+ min?: number;
+ max?: number;
+}
+
+interface AnimationPropertyDefinition {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericRange;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.position.x": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.x : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, x: value as number },
+ },
+ }
+ : element,
+ },
+ "transform.position.y": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.y : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value as number },
+ },
+ }
+ : element,
+ },
+ "transform.scale": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scale : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scale: value as number },
+ }
+ : element,
+ },
+ "transform.rotate": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ },
+ opacity: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, max: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ },
+ volume: {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0, max: 1 },
+ supportsElement: ({ element }) => element.type === "audio",
+ getValue: ({ element }) =>
+ element.type === "audio" ? element.volume : null,
+ setValue: ({ element, value }) =>
+ element.type === "audio"
+ ? { ...element, volume: value as number }
+ : element,
+ },
+ color: {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ valueKind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ },
+ "background.paddingY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ },
+ "background.offsetX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ },
+ "background.offsetY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ },
+ "background.cornerRadius": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ },
+};
+
+export function isAnimationPropertyPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): boolean {
+ return propertyPath in ANIMATION_PROPERTY_REGISTRY;
+}
+
+export function getAnimationPropertyDefinition({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationPropertyDefinition {
+ return ANIMATION_PROPERTY_REGISTRY[propertyPath];
+}
+
+export function supportsAnimationProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): boolean {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.supportsElement({ element });
+}
+
+export function getElementBaseValueForProperty({
+ element,
+ propertyPath,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+}): AnimationValue | null {
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return null;
+ }
+ return definition.getValue({ element });
+}
+
+export function withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): TimelineElement {
+ const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
+ if (coercedValue === null) {
+ return element;
+ }
+ const definition = getAnimationPropertyDefinition({ propertyPath });
+ if (!definition.supportsElement({ element })) {
+ return element;
+ }
+ return definition.setValue({ element, value: coercedValue });
+}
+
+export function getDefaultInterpolationForProperty({
+ propertyPath,
+}: {
+ propertyPath: AnimationPropertyPath;
+}): AnimationInterpolation {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.defaultInterpolation;
+}
+
+function clampNumericRange({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericRange | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, value));
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+
+ if (propertyDefinition.valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return clampNumericRange({
+ value,
+ numericRange: propertyDefinition.numericRange,
+ });
+ }
+
+ if (propertyDefinition.valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (typeof value === "string" || typeof value === "boolean") {
+ return value as DiscreteValue;
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
new file mode 100644
index 00000000..147be1b3
--- /dev/null
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -0,0 +1,148 @@
+import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/types/timeline";
+import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
+import { getColorChannelForPath } from "./color-channel";
+import { getNumberChannelForPath } from "./number-channel";
+
+export function getElementLocalTime({
+ timelineTime,
+ elementStartTime,
+ elementDuration,
+}: {
+ timelineTime: number;
+ elementStartTime: number;
+ elementDuration: number;
+}): number {
+ const localTime = timelineTime - elementStartTime;
+ if (localTime <= 0) {
+ return 0;
+ }
+
+ if (localTime >= elementDuration) {
+ return elementDuration;
+ }
+
+ return localTime;
+}
+
+export function resolveTransformAtTime({
+ baseTransform,
+ animations,
+ localTime,
+}: {
+ baseTransform: Transform;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): Transform {
+ const safeLocalTime = Math.max(0, localTime);
+ return {
+ position: {
+ x: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.position.x",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.position.x,
+ }),
+ y: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.position.y",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.position.y,
+ }),
+ },
+ scale: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scale",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scale,
+ }),
+ rotate: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.rotate",
+ }),
+ time: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "opacity",
+ }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): string {
+ return getColorValueAtTime({
+ channel: getColorChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
+
+export function resolveVolumeAtTime({
+ baseVolume,
+ animations,
+ localTime,
+}: {
+ baseVolume: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "volume",
+ }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseVolume,
+ });
+}
diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
new file mode 100644
index 00000000..d2356a39
--- /dev/null
+++ b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
@@ -0,0 +1,433 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import type { TimelineTrack, VideoElement } from "@/types/timeline";
+import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
+import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
+import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
+import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
+import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
+import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
+import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
+
+type MockEditor = {
+ timeline: {
+ getTracks: () => TimelineTrack[];
+ updateTracks: (tracks: TimelineTrack[]) => void;
+ };
+ selection: {
+ getSelectedElements: () => { trackId: string; elementId: string }[];
+ setSelectedElements: ({
+ elements,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ }) => void;
+ };
+};
+
+const originalGetInstance = EditorCore.getInstance;
+
+function mockEditorCore({ editor }: { editor: MockEditor }): void {
+ (
+ EditorCore as unknown as {
+ getInstance: () => EditorCore;
+ }
+ ).getInstance = () => editor as unknown as EditorCore;
+}
+
+function restoreEditorCore(): void {
+ (
+ EditorCore as unknown as {
+ getInstance: typeof EditorCore.getInstance;
+ }
+ ).getInstance = originalGetInstance;
+}
+
+function buildVideoElement(): VideoElement {
+ return {
+ id: "element-1",
+ name: "Clip",
+ type: "video",
+ mediaId: "media-1",
+ duration: 8,
+ startTime: 1,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: DEFAULT_TRANSFORM,
+ opacity: 1,
+ animations: {
+ channels: {
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [
+ { id: "kf-a", time: 0, value: 1, interpolation: "linear" },
+ { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
+ { id: "kf-c", time: 6, value: 2, interpolation: "linear" },
+ ],
+ },
+ },
+ },
+ };
+}
+
+function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
+ return [
+ {
+ id: "track-1",
+ name: "Main",
+ type: "video",
+ elements: [element],
+ isMain: true,
+ muted: false,
+ hidden: false,
+ },
+ ];
+}
+
+afterEach(() => {
+ restoreEditorCore();
+});
+
+describe("keyframe-aware timeline commands", () => {
+ test("duration updates clamp keyframes beyond the new duration", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpdateElementDurationCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ duration: 3,
+ }).execute();
+
+ const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
+ expect(
+ updatedElement?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3]);
+ });
+
+ test("trim updates clamp keyframes when duration is changed", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpdateElementTrimCommand({
+ elementId: "element-1",
+ trimStart: 0,
+ trimEnd: 0,
+ startTime: 1,
+ duration: 2,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ expect(updatedElement.duration).toBe(2);
+ expect(
+ updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0]);
+ });
+
+ test("split rebases right-side keyframes and keeps continuity at split time", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new SplitElementsCommand({
+ elements: [{ trackId: "track-1", elementId: "element-1" }],
+ splitTime: 5,
+ }).execute();
+
+ const leftElement = updatedTracks[0].elements.find(
+ (element) => element.id === "element-1",
+ ) as VideoElement;
+ const rightElement = updatedTracks[0].elements.find(
+ (element) => element.id !== "element-1",
+ ) as VideoElement;
+
+ expect(
+ leftElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3, 4]);
+ expect(
+ rightElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 2]);
+ expect(
+ rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
+ ).toBeCloseTo(5 / 3, 4);
+ });
+
+ test("duplicate creates independent keyframe ids for copied element", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new DuplicateElementsCommand({
+ elements: [{ trackId: "track-1", elementId: "element-1" }],
+ }).execute();
+
+ const originalElement = updatedTracks.find(
+ (track) => track.id === "track-1",
+ )?.elements[0] as VideoElement;
+ const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
+ const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
+
+ expect(duplicatedElement).toBeDefined();
+ expect(
+ duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3, 6]);
+ expect(
+ duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
+ ).not.toBe(
+ originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
+ );
+ });
+});
+
+describe("generic keyframe commands", () => {
+ test("upsert adds or updates keyframe at target time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpsertKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ time: 2,
+ value: 2.5,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframes =
+ updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
+ const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
+ expect(atTwo?.value).toBe(2.5);
+ });
+
+ test("remove deletes keyframe by id", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RemoveKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "kf-b",
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframes =
+ updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
+ expect(keyframes).toHaveLength(2);
+ expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
+ expect(updatedElement.transform.scale).toBe(1);
+ });
+
+ test("remove persists value to base property when channel becomes empty", () => {
+ const element: VideoElement = {
+ ...buildVideoElement(),
+ transform: {
+ ...DEFAULT_TRANSFORM,
+ scale: 1,
+ },
+ animations: {
+ channels: {
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [
+ {
+ id: "only-scale",
+ time: 2,
+ value: 1.43,
+ interpolation: "linear",
+ },
+ ],
+ },
+ },
+ },
+ };
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RemoveKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "only-scale",
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ expect(updatedElement.transform.scale).toBe(1.43);
+ expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
+ });
+
+ test("upsert supports non-transform paths like opacity", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpsertKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "opacity",
+ time: 1,
+ value: 0.35,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const opacityChannel = updatedElement.animations?.channels.opacity;
+ expect(opacityChannel?.valueKind).toBe("number");
+ expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
+ });
+
+ test("retime moves keyframe to new time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RetimeKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "kf-b",
+ nextTime: 4,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
+ (existingKeyframe) => existingKeyframe.id === "kf-b",
+ );
+ expect(keyframe?.time).toBe(4);
+ });
+});
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 49554e0e..800cbb90 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -13,6 +13,7 @@ import {
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -176,6 +177,10 @@ function buildPastedElements({
...item.element,
id: newElementId,
startTime,
+ animations: cloneAnimations({
+ animations: item.element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
} as TimelineElement);
}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 91eb6397..7fb46c36 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,13 +1,23 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
-import { isMainTrack } from "@/lib/timeline";
+import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly rippleEnabled: boolean;
- constructor(private elements: { trackId: string; elementId: string }[]) {
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -16,23 +26,44 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
- const hasElementsToDelete = this.elements.some(
- (el) => el.trackId === track.id,
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
);
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
if (!hasElementsToDelete) {
return track;
}
- return {
- ...track,
- elements: track.elements.filter(
- (element) =>
- !this.elements.some(
- (el) => el.trackId === track.id && el.elementId === element.id,
- ),
- ),
- } as typeof track;
+ const deletedElementInfos = elementsToDeleteOnTrack
+ .map((target) =>
+ track.elements.find((element) => element.id === target.elementId),
+ )
+ .filter((element): element is NonNullable<typeof element> => element !== undefined)
+ .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+
+ let elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
+
+ if (this.rippleEnabled && deletedElementInfos.length > 0) {
+ const sortedByStartDesc = [...deletedElementInfos].sort(
+ (a, b) => b.startTime - a.startTime,
+ );
+ for (const { startTime, duration } of sortedByStartDesc) {
+ elements = rippleShiftElements({
+ elements,
+ afterTime: startTime,
+ shiftAmount: duration,
+ });
+ }
+ }
+
+ return { ...track, elements } as typeof track;
})
.filter((track) => track.elements.length > 0 || isMainTrack(track));
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index cf1bd94c..97b475f4 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -6,6 +6,7 @@ import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
@@ -114,5 +115,14 @@ function buildDuplicateElement({
id: string;
startTime: number;
}): TimelineElement {
- return { ...element, id, name: `${element.name} (copy)`, startTime };
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
new file mode 100644
index 00000000..5299749d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
@@ -0,0 +1,74 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+
+function addEffectToElement({
+ element,
+ effectType,
+}: {
+ element: VisualElement;
+ effectType: string;
+}): VisualElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ const currentEffects = element.effects ?? [];
+ return { ...element, effects: [...currentEffects, instance] };
+}
+
+export class AddClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private effectId: string | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectType: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectType,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectType = effectType;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const updated = addEffectToElement({
+ element: element as VisualElement,
+ effectType: this.effectType,
+ });
+ const effects = updated.effects ?? [];
+ this.effectId = effects[effects.length - 1]?.id ?? null;
+ return updated;
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getEffectId(): string | null {
+ return this.effectId;
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts
new file mode 100644
index 00000000..133ce661
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts
@@ -0,0 +1,5 @@
+export { AddClipEffectCommand } from "./add-effect";
+export { RemoveClipEffectCommand } from "./remove-effect";
+export { ToggleClipEffectCommand } from "./toggle-effect";
+export { UpdateClipEffectParamsCommand } from "./update-effect-params";
+export { ReorderClipEffectsCommand } from "./reorder-effect";
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
new file mode 100644
index 00000000..b475474f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
@@ -0,0 +1,65 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function removeEffectFromElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const filtered = currentEffects.filter((effect) => effect.id !== effectId);
+ return { ...element, effects: filtered };
+}
+
+export class RemoveClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return removeEffectFromElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
new file mode 100644
index 00000000..81c66503
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
@@ -0,0 +1,73 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function reorderEffectsOnElement({
+ element,
+ fromIndex,
+ toIndex,
+}: {
+ element: VisualElement;
+ fromIndex: number;
+ toIndex: number;
+}): VisualElement {
+ const effects = [...(element.effects ?? [])];
+ const [moved] = effects.splice(fromIndex, 1);
+ effects.splice(toIndex, 0, moved);
+ return { ...element, effects };
+}
+
+export class ReorderClipEffectsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly fromIndex: number;
+ private readonly toIndex: number;
+
+ constructor({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ }: {
+ trackId: string;
+ elementId: string;
+ fromIndex: number;
+ toIndex: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.fromIndex = fromIndex;
+ this.toIndex = toIndex;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return reorderEffectsOnElement({
+ element: element as VisualElement,
+ fromIndex: this.fromIndex,
+ toIndex: this.toIndex,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
new file mode 100644
index 00000000..70bf955d
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
@@ -0,0 +1,67 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+export function toggleEffectOnElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) =>
+ effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
+ );
+ return { ...element, effects: updated };
+}
+
+export class ToggleClipEffectCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return toggleEffectOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
new file mode 100644
index 00000000..d9bf0aad
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
@@ -0,0 +1,86 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
+import type { EffectParamValues } from "@/types/effects";
+import type { TimelineTrack, VisualElement } from "@/types/timeline";
+
+function updateEffectParamsOnElement({
+ element,
+ effectId,
+ params,
+}: {
+ element: VisualElement;
+ effectId: string;
+ params: Partial<EffectParamValues>;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) => {
+ if (effect.id !== effectId) {
+ return effect;
+ }
+
+ const nextParams = { ...effect.params };
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ nextParams[key] = value;
+ }
+ }
+
+ return { ...effect, params: nextParams };
+ });
+ return { ...element, effects: updated };
+}
+
+export class UpdateClipEffectParamsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly params: Partial<EffectParamValues>;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ params,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ params: Partial<EffectParamValues>;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.params = params;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 3f10baa4..413d1adf 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -9,3 +9,5 @@ export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
+export * from "./keyframes";
+export * from "./effects";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index e1551471..68fc9841 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
return true;
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
new file mode 100644
index 00000000..c03b14e9
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,5 @@
+export * from "./remove-effect-param-keyframe";
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-effect-param-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
new file mode 100644
index 00000000..d4922532
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -0,0 +1,68 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class RemoveEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const animations = removeEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
new file mode 100644
index 00000000..f12f9829
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -0,0 +1,141 @@
+import { EditorCore } from "@/core";
+import {
+ getChannel,
+ getChannelValueAtTime,
+ getElementBaseValueForProperty,
+ removeElementKeyframe,
+ supportsAnimationProperty,
+ withElementBaseValueForProperty,
+} from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+
+function sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): number | null {
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath,
+ });
+ const keyframe = channel?.keyframes.find(
+ (candidate) => candidate.id === keyframeId,
+ );
+ if (!channel || !keyframe) {
+ return null;
+ }
+
+ const baseValue = getElementBaseValueForProperty({ element, propertyPath });
+ if (baseValue === null || typeof baseValue !== "number") {
+ return null;
+ }
+
+ const sampled = getChannelValueAtTime({
+ channel,
+ time: keyframe.time,
+ fallbackValue: baseValue,
+ });
+ return typeof sampled === "number" ? sampled : null;
+}
+
+function removeKeyframeAndPersist({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): TimelineElement {
+ const valueBefore = sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+ });
+
+ const nextAnimations = removeElementKeyframe({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+
+ const isChannelNowEmpty =
+ getChannel({ animations: nextAnimations, propertyPath }) === undefined;
+ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
+
+ const baseElement = shouldPersistToBase
+ ? withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value: valueBefore,
+ })
+ : element;
+
+ return { ...baseElement, animations: nextAnimations };
+}
+
+export class RemoveKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) =>
+ removeKeyframeAndPersist({
+ element,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
new file mode 100644
index 00000000..5328419f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -0,0 +1,76 @@
+import { EditorCore } from "@/core";
+import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class RetimeKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+ private readonly nextTime: number;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ nextTime: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.nextTime = nextTime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ if (!Number.isFinite(boundedTime)) return element;
+ return {
+ ...element,
+ animations: retimeElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ time: boundedTime,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
new file mode 100644
index 00000000..f5236279
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -0,0 +1,84 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
+import { updateElementInTracks } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class UpsertEffectParamKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly time: number;
+ private readonly value: number;
+ private readonly interpolation: "linear" | "hold" | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number;
+ interpolation?: "linear" | "hold";
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const animations = upsertEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
new file mode 100644
index 00000000..370f6865
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -0,0 +1,89 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack } from "@/types/timeline";
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/types/animation";
+
+export class UpsertKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly time: number;
+ private readonly value: AnimationValue;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ return {
+ ...element,
+ animations: upsertElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 2fe06694..51e18c21 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -11,18 +11,39 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
+import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly sourceTrackId: string;
+ private readonly targetTrackId: string;
+ private readonly elementId: string;
+ private readonly newStartTime: number;
+ private readonly createTrack: { type: TrackType; index: number } | undefined;
+ private readonly rippleEnabled: boolean;
- constructor(
- private sourceTrackId: string,
- private targetTrackId: string,
- private elementId: string,
- private newStartTime: number,
- private createTrack?: { type: TrackType; index: number },
- ) {
+ constructor({
+ sourceTrackId,
+ targetTrackId,
+ elementId,
+ newStartTime,
+ createTrack,
+ rippleEnabled = false,
+ }: {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+ createTrack?: { type: TrackType; index: number };
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.sourceTrackId = sourceTrackId;
+ this.targetTrackId = targetTrackId;
+ this.elementId = elementId;
+ this.newStartTime = newStartTime;
+ this.createTrack = createTrack;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -30,18 +51,17 @@ export class MoveElementCommand extends Command {
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
- (t) => t.id === this.sourceTrackId,
+ (track) => track.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
- (el) => el.id === this.elementId,
+ (trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
- console.error("Source track or element not found");
- return;
+ throw new Error("Source track or element not found");
}
- let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
+ let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
@@ -53,8 +73,7 @@ export class MoveElementCommand extends Command {
targetTrack = newTrack;
}
if (!targetTrack) {
- console.error("Target track not found");
- return;
+ throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
@@ -63,8 +82,7 @@ export class MoveElementCommand extends Command {
});
if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
+ throw new Error(validation.errorMessage);
}
const adjustedStartTime = enforceMainTrackStart({
@@ -74,6 +92,7 @@ export class MoveElementCommand extends Command {
excludeElementId: this.elementId,
});
+ // keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
@@ -81,32 +100,39 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
- let updatedTracks = tracksToUpdate.map((track) => {
+ let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.map((el) =>
- el.id === this.elementId ? movedElement : el,
+ elements: track.elements.map((trackElement) =>
+ trackElement.id === this.elementId ? movedElement : trackElement,
),
- };
+ } as typeof track;
}
if (track.id === this.sourceTrackId) {
- return {
- ...track,
- elements: track.elements.filter((el) => el.id !== this.elementId),
- };
+ const remainingElements = track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ );
+ const shiftedElements = this.rippleEnabled
+ ? rippleShiftElements({
+ elements: remainingElements,
+ afterTime: element.startTime,
+ shiftAmount: element.duration,
+ })
+ : remainingElements;
+ return { ...track, elements: shiftedElements } as typeof track;
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
- };
+ } as typeof track;
}
return track;
- }) as TimelineTrack[];
+ });
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index abe133c9..37ccbb12 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -2,18 +2,34 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
+import { rippleShiftElements } from "@/lib/timeline";
+import { splitAnimationsAtTime } from "@/lib/animation";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly splitTime: number;
+ private readonly retainSide: "both" | "left" | "right";
+ private readonly rippleEnabled: boolean;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private splitTime: number,
- private retainSide: "both" | "left" | "right" = "both",
- ) {
+ constructor({
+ elements,
+ splitTime,
+ retainSide = "both",
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ splitTime: number;
+ retainSide?: "both" | "left" | "right";
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.splitTime = splitTime;
+ this.retainSide = retainSide;
+ this.rippleEnabled = rippleEnabled;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -28,92 +44,113 @@ export class SplitElementsCommand extends Command {
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
- (el) => el.trackId === track.id,
+ (target) => target.trackId === track.id,
);
if (elementsToSplit.length === 0) {
return track;
}
- return {
- ...track,
- elements: track.elements.flatMap((element) => {
- const shouldSplit = elementsToSplit.some(
- (el) => el.elementId === element.id,
- );
+ let leftVisibleDurationForRipple: number | null = null;
- if (!shouldSplit) {
- return [element];
- }
-
- const effectiveStart = element.startTime;
- const effectiveEnd = element.startTime + element.duration;
+ let elements = track.elements.flatMap((element) => {
+ const shouldSplit = elementsToSplit.some(
+ (target) => target.elementId === element.id,
+ );
- if (
- this.splitTime <= effectiveStart ||
- this.splitTime >= effectiveEnd
- ) {
- return [element];
- }
+ if (!shouldSplit) {
+ return [element];
+ }
- const relativeTime = this.splitTime - element.startTime;
- const leftVisibleDuration = relativeTime;
- const rightVisibleDuration = element.duration - relativeTime;
+ const effectiveStart = element.startTime;
+ const effectiveEnd = element.startTime + element.duration;
- if (this.retainSide === "left") {
- return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
- name: `${element.name} (left)`,
- },
- ];
- }
+ if (
+ this.splitTime <= effectiveStart ||
+ this.splitTime >= effectiveEnd
+ ) {
+ return [element];
+ }
- if (this.retainSide === "right") {
- const newId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: newId,
- });
- return [
- {
- ...element,
- id: newId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
- name: `${element.name} (right)`,
- },
- ];
- }
-
- // "both" - split into two pieces
- const secondElementId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: secondElementId,
- });
+ const relativeTime = this.splitTime - element.startTime;
+ const leftVisibleDuration = relativeTime;
+ const rightVisibleDuration = element.duration - relativeTime;
+ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
+ animations: element.animations,
+ splitTime: relativeTime,
+ shouldIncludeSplitBoundary: true,
+ });
+ if (this.retainSide === "left") {
return [
{
...element,
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
+ ];
+ }
+
+ if (this.retainSide === "right") {
+ if (this.rippleEnabled && elementsToSplit.length === 1) {
+ leftVisibleDurationForRipple = leftVisibleDuration;
+ }
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ return [
{
...element,
- id: secondElementId,
+ id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
- }),
- } as typeof track;
+ }
+
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightVisibleDuration,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftVisibleDuration,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ },
+ ];
+ });
+
+ if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
+ elements = rippleShiftElements({
+ elements,
+ afterTime: this.splitTime,
+ shiftAmount: leftVisibleDurationForRipple,
+ });
+ }
+
+ return { ...track, elements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
index 6b3a860b..3366548d 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -1,28 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly duration: number;
- constructor(
- private trackId: string,
- private elementId: string,
- private duration: number,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ duration,
+ }: {
+ trackId: string;
+ elementId: string;
+ duration: number;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.duration = duration;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, duration: this.duration } : el,
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id !== this.trackId) return track;
+ const newElements = track.elements.map((element) =>
+ element.id === this.elementId
+ ? {
+ ...element,
+ duration: this.duration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: this.duration,
+ }),
+ }
+ : element,
);
- return { ...t, elements: newElements } as typeof t;
+ return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
index 51dcb63d..e25bc0e3 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly startTime: number;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private startTime: number,
- ) {
+ constructor({
+ elements,
+ startTime,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ startTime: number;
+ }) {
super();
+ this.elements = elements;
+ this.startTime = startTime;
}
execute(): void {
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToUpdate) {
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
- (el) => el.elementId === element.id && el.trackId === track.id,
+ (elementEntry) =>
+ elementEntry.elementId === element.id &&
+ elementEntry.trackId === track.id,
);
if (!shouldUpdate) {
return element;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
index 99c63287..7548638a 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -1,18 +1,40 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
+import { rippleShiftElements } from "@/lib/timeline";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elementId: string;
+ private readonly trimStart: number;
+ private readonly trimEnd: number;
+ private readonly startTime: number | undefined;
+ private readonly duration: number | undefined;
+ private readonly rippleEnabled: boolean;
- constructor(
- private elementId: string,
- private trimStart: number,
- private trimEnd: number,
- private startTime?: number,
- private duration?: number,
- ) {
+ constructor({
+ elementId,
+ trimStart,
+ trimEnd,
+ startTime,
+ duration,
+ rippleEnabled = false,
+ }: {
+ elementId: string;
+ trimStart: number;
+ trimEnd: number;
+ startTime?: number;
+ duration?: number;
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elementId = elementId;
+ this.trimStart = trimStart;
+ this.trimEnd = trimEnd;
+ this.startTime = startTime;
+ this.duration = duration;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -20,20 +42,52 @@ export class UpdateElementTrimCommand extends Command {
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
- const newElements = track.elements.map((element) => {
- if (element.id !== this.elementId) {
- return element;
- }
+ const targetElement = track.elements.find(
+ (element) => element.id === this.elementId,
+ );
+ if (!targetElement) return track;
+ const nextDuration = this.duration ?? targetElement.duration;
+ const nextStartTime = this.startTime ?? targetElement.startTime;
+
+ const oldEndTime = targetElement.startTime + targetElement.duration;
+ const newEndTime = nextStartTime + nextDuration;
+ const shiftAmount = oldEndTime - newEndTime;
+
+ const updatedElement = {
+ ...targetElement,
+ trimStart: this.trimStart,
+ trimEnd: this.trimEnd,
+ startTime: nextStartTime,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: targetElement.animations,
+ duration: nextDuration,
+ }),
+ };
+
+ if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
+ const shiftedOthers = rippleShiftElements({
+ elements: track.elements.filter((element) => element.id !== this.elementId),
+ afterTime: oldEndTime,
+ shiftAmount,
+ });
return {
- ...element,
- trimStart: this.trimStart,
- trimEnd: this.trimEnd,
- startTime: this.startTime ?? element.startTime,
- duration: this.duration ?? element.duration,
- };
- });
- return { ...track, elements: newElements } as typeof track;
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === this.elementId
+ ? updatedElement
+ : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
+ ),
+ } as typeof track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === this.elementId ? updatedElement : element
+ ),
+ } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
index 4ee54726..643f8e4b 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,28 +1,38 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { updateElementInTracks } from "@/lib/timeline";
export class UpdateElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly updates: Partial<TimelineElement>;
- constructor(
- private trackId: string,
- private elementId: string,
- private updates: Partial<Record<string, unknown>>,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ updates,
+ }: {
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.updates = updates;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, ...this.updates } : el,
- );
- return { ...t, elements: newElements } as typeof t;
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
new file mode 100644
index 00000000..2c015725
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -0,0 +1,25 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_sigma;
+uniform vec2 u_direction;
+
+varying vec2 v_texCoord;
+
+void main() {
+ vec2 texelSize = 1.0 / u_resolution;
+
+ vec4 color = vec4(0.0);
+ float totalWeight = 0.0;
+
+ // step=1 texel — scaling step size instead causes discrete ghosting artifacts
+ for (int i = -30; i <= 30; i++) {
+ float fi = float(i);
+ float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ totalWeight += weight;
+ }
+
+ gl_FragColor = color / totalWeight;
+}
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
new file mode 100644
index 00000000..3a8f691a
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -0,0 +1,50 @@
+import type { EffectDefinition } from "@/types/effects";
+import blurFragmentShader from "./blur.frag.glsl";
+
+export const blurEffectDefinition: EffectDefinition = {
+ type: "blur",
+ name: "Blur",
+ keywords: ["blur", "soft", "defocus"],
+ params: [
+ {
+ key: "intensity",
+ label: "Intensity",
+ type: "number",
+ default: 15,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ ],
+ renderer: {
+ type: "webgl",
+ passes: [
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [1, 0],
+ };
+ },
+ },
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams }) => {
+ const intensity =
+ typeof effectParams.intensity === "number"
+ ? effectParams.intensity
+ : Number.parseFloat(String(effectParams.intensity));
+ return {
+ u_sigma: Math.max(intensity / 5, 0.001),
+ u_direction: [0, 1],
+ };
+ },
+ },
+ ],
+ },
+};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
new file mode 100644
index 00000000..1742b1d7
--- /dev/null
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -0,0 +1,13 @@
+import { hasEffect, registerEffect } from "../registry";
+import { blurEffectDefinition } from "./blur";
+
+const defaultEffects = [blurEffectDefinition];
+
+export function registerDefaultEffects(): void {
+ for (const definition of defaultEffects) {
+ if (hasEffect({ effectType: definition.type })) {
+ continue;
+ }
+ registerEffect({ definition });
+ }
+}
diff --git a/apps/web/src/lib/effects/effect.vert.glsl b/apps/web/src/lib/effects/effect.vert.glsl
new file mode 100644
index 00000000..5febc835
--- /dev/null
+++ b/apps/web/src/lib/effects/effect.vert.glsl
@@ -0,0 +1,7 @@
+attribute vec2 a_position;
+varying vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_position * 0.5 + 0.5;
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
new file mode 100644
index 00000000..14d06d84
--- /dev/null
+++ b/apps/web/src/lib/effects/index.ts
@@ -0,0 +1,34 @@
+import { generateUUID } from "@/utils/id";
+import { getEffect } from "./registry";
+import type { Effect, EffectParamValues } from "@/types/effects";
+import type { VisualElement } from "@/types/timeline";
+
+export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { registerDefaultEffects } from "./definitions";
+
+export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
+ "video",
+ "image",
+ "text",
+ "sticker",
+];
+
+export function buildDefaultEffectInstance({
+ effectType,
+}: {
+ effectType: string;
+}): Effect {
+ const definition = getEffect({ effectType });
+
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+
+ return {
+ id: generateUUID(),
+ type: effectType,
+ params,
+ enabled: true,
+ };
+}
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
new file mode 100644
index 00000000..568b8924
--- /dev/null
+++ b/apps/web/src/lib/effects/registry.ts
@@ -0,0 +1,31 @@
+import type { EffectDefinition } from "@/types/effects";
+
+const effectDefinitions = new Map<string, EffectDefinition>();
+
+export function registerEffect({
+ definition,
+}: {
+ definition: EffectDefinition;
+}): void {
+ effectDefinitions.set(definition.type, definition);
+}
+
+export function hasEffect({ effectType }: { effectType: string }): boolean {
+ return effectDefinitions.has(effectType);
+}
+
+export function getEffect({
+ effectType,
+}: {
+ effectType: string;
+}): EffectDefinition {
+ const definition = effectDefinitions.get(effectType);
+ if (!definition) {
+ throw new Error(`Unknown effect type: ${effectType}`);
+ }
+ return definition;
+}
+
+export function getAllEffects(): EffectDefinition[] {
+ return Array.from(effectDefinitions.values());
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index 67621e16..ae68144b 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -16,3 +16,23 @@ export function getExportFileExtension({
}): string {
return `.${format}`;
}
+
+export function downloadBuffer({
+ buffer,
+ filename,
+ mimeType,
+}: {
+ buffer: ArrayBuffer;
+ filename: string;
+ mimeType: string;
+}): void {
+ const blob = new Blob([buffer], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const downloadLink = document.createElement("a");
+ downloadLink.href = url;
+ downloadLink.download = filename;
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
+ URL.revokeObjectURL(url);
+}
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 9e58ec52..08ffbfa8 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -4,9 +4,15 @@ import { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
+ DEFAULT_TEXT_BACKGROUND,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+ resolveNumberAtTime,
+} from "@/lib/animation";
export interface ElementBounds {
cx: number;
@@ -62,17 +68,24 @@ export function getElementBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
+ localTime: number;
}): ElementBounds | null {
- if (element.type === "audio") return null;
+ if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
@@ -80,21 +93,31 @@ export function getElementBounds({
canvasHeight,
sourceWidth,
sourceHeight,
- transform: element.transform,
+ transform,
});
}
if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: 200,
sourceHeight: 200,
- transform: element.transform,
+ transform,
});
}
if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const letterSpacing = element.letterSpacing ?? 0;
@@ -129,40 +152,71 @@ export function getElementBounds({
fallbackFontSize: scaledFontSize,
});
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
+ const resolvedBackground = {
+ ...element.background,
+ paddingX: resolveNumberAtTime({
+ baseValue:
+ element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue:
+ element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue:
+ element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue:
+ element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ };
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
- background: element.background,
+ background: resolvedBackground,
fontSizeRatio,
});
measuredWidth = visualRect.width;
measuredHeight = visualRect.height;
const localCenterX = visualRect.left + visualRect.width / 2;
const localCenterY = visualRect.top + visualRect.height / 2;
- const scaledCenterX = localCenterX * element.transform.scale;
- const scaledCenterY = localCenterY * element.transform.scale;
- const rotationRad = (element.transform.rotate * Math.PI) / 180;
+ const scaledCenterX = localCenterX * transform.scale;
+ const scaledCenterY = localCenterY * transform.scale;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
return {
- cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX,
- cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY,
- width: measuredWidth * element.transform.scale,
- height: measuredHeight * element.transform.scale,
- rotation: element.transform.rotate,
+ cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
+ cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
+ width: measuredWidth * transform.scale,
+ height: measuredHeight * transform.scale,
+ rotation: transform.rotate,
};
}
- const width = measuredWidth * element.transform.scale;
- const height = measuredHeight * element.transform.scale;
+ const width = measuredWidth * transform.scale;
+ const height = measuredHeight * transform.scale;
return {
- cx: canvasWidth / 2 + element.transform.position.x,
- cy: canvasHeight / 2 + element.transform.position.y,
+ cx: canvasWidth / 2 + transform.position.x,
+ cy: canvasHeight / 2 + transform.position.y,
width,
height,
- rotation: element.transform.rotate,
+ rotation: transform.rotate,
};
}
@@ -206,6 +260,11 @@ export function getVisibleElementsWithBounds({
});
for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
@@ -214,6 +273,7 @@ export function getVisibleElementsWithBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
});
if (bounds) {
result.push({
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
index 8c38e9aa..3eadcec2 100644
--- a/apps/web/src/lib/text/layout.ts
+++ b/apps/web/src/lib/text/layout.ts
@@ -1,5 +1,5 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
-import type { TextElement } from "@/types/timeline";
+import type { TextBackground, TextElement } from "@/types/timeline";
type TextRect = {
left: number;
@@ -74,8 +74,12 @@ function getTextRect({
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
- const left =
- textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
+ const textAlignToLeft: Record<typeof textAlign, number> = {
+ left: 0,
+ right: -block.maxWidth,
+ center: -block.maxWidth / 2,
+ };
+ const left = textAlignToLeft[textAlign];
return {
left,
@@ -88,9 +92,13 @@ function getTextRect({
function isTextBackgroundVisible({
background,
}: {
- background: TextElement["background"];
+ background: TextBackground;
}): boolean {
- return Boolean(background.color) && background.color !== "transparent";
+ return (
+ background.enabled &&
+ Boolean(background.color) &&
+ background.color !== "transparent"
+ );
}
export function getTextBackgroundRect({
@@ -101,7 +109,7 @@ export function getTextBackgroundRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
- background: TextElement["background"];
+ background: TextBackground;
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
@@ -132,7 +140,7 @@ export function getTextVisualRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
- background: TextElement["background"];
+ background: TextBackground;
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index 9182892f..b502974d 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -1,9 +1,42 @@
-import type { TimelineTrack, ElementType } from "@/types/timeline";
-import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import type {
+ TimelineTrack,
+ ElementType,
+ TimelineElement,
+} from "@/types/timeline";
+import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = mouseX / (pixelsPerSecond * zoomLevel);
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
function getTrackAtY({
mouseY,
tracks,
@@ -16,7 +49,7 @@ function getTrackAtY({
let cumulativeHeight = 0;
for (let i = 0; i < tracks.length; i++) {
- const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackHeight = TRACK_CONFIG[tracks[i].type].height;
const trackTop = cumulativeHeight;
const trackBottom = trackTop + trackHeight;
@@ -55,6 +88,7 @@ function isCompatible({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
@@ -100,6 +134,8 @@ function findInsertIndex({
};
}
+const EMPTY_TARGET_ELEMENT = null;
+
export function computeDropTarget({
elementType,
mouseX,
@@ -113,6 +149,7 @@ export function computeDropTarget({
verticalDragDirection,
startTimeOverride,
excludeElementId,
+ targetElementTypes,
}: ComputeDropTargetParams): DropTarget {
const xPosition =
typeof startTimeOverride === "number"
@@ -130,9 +167,16 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
- return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
}
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
@@ -146,6 +190,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "below",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -155,6 +200,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -163,12 +209,37 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: "above",
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex];
- const trackHeight = TRACK_HEIGHTS[track.type];
+
+ if (
+ targetElementTypes &&
+ targetElementTypes.length > 0
+ ) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
@@ -200,6 +271,7 @@ export function computeDropTarget({
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -220,6 +292,7 @@ export function computeDropTarget({
isNewTrack: true,
insertPosition: position,
xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
};
}
@@ -237,7 +310,7 @@ export function getDropLineY({
let y = 0;
for (let i = 0; i < safeTrackIndex; i++) {
- y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
}
return y;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index c51667bf..11564376 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -6,22 +6,26 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
+ CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
CreateStickerElement,
CreateUploadAudioElement,
CreateLibraryAudioElement,
+ TextBackground,
TextElement,
TimelineElement,
TimelineTrack,
AudioElement,
VideoElement,
ImageElement,
- StickerElement,
+ VisualElement,
UploadAudioElement,
} from "@/types/timeline";
import type { MediaType } from "@/types/assets";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
element: TimelineElement,
@@ -31,7 +35,7 @@ export function canElementHaveAudio(
export function isVisualElement(
element: TimelineElement,
-): element is VideoElement | ImageElement | TextElement | StickerElement {
+): element is VisualElement {
return (
element.type === "video" ||
element.type === "image" ||
@@ -42,8 +46,8 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
-): element is VideoElement | ImageElement | TextElement | StickerElement {
- return element.type !== "audio";
+): element is VisualElement {
+ return isVisualElement(element);
}
export function hasMediaId(
@@ -124,13 +128,30 @@ export function wouldElementOverlap({
endTime: number;
excludeElementId?: string;
}): boolean {
- return elements.some((el) => {
- if (excludeElementId && el.id === excludeElementId) return false;
- const elEnd = el.startTime + el.duration;
- return startTime < elEnd && endTime > el.startTime;
+ 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 {
+ const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
+ const enabled =
+ typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
export function buildTextElement({
raw,
startTime,
@@ -154,14 +175,7 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
- background: {
- color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
- cornerRadius: t.background?.cornerRadius,
- paddingX: t.background?.paddingX,
- paddingY: t.background?.paddingY,
- offsetX: t.background?.offsetX,
- offsetY: t.background?.offsetY,
- },
+ background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -174,6 +188,28 @@ export function buildTextElement({
};
}
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
export function buildStickerElement({
stickerId,
name,
@@ -218,6 +254,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -274,6 +311,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -336,6 +374,7 @@ export function buildLibraryAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
index f3ace94f..887ff7ab 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -1,9 +1,12 @@
import type { TimelineTrack } from "@/types/timeline";
export * from "./track-utils";
+export * from "./track-element-update";
export * from "./element-utils";
export * from "./zoom-utils";
export * from "./ruler-utils";
+export * from "./ripple-utils";
+export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
new file mode 100644
index 00000000..4c0c243c
--- /dev/null
+++ b/apps/web/src/lib/timeline/pixel-utils.ts
@@ -0,0 +1,79 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
+
+function getDevicePixelRatio({
+ devicePixelRatio,
+}: {
+ devicePixelRatio?: number;
+}): number {
+ if (
+ typeof devicePixelRatio === "number" &&
+ Number.isFinite(devicePixelRatio) &&
+ devicePixelRatio > 0
+ ) {
+ return devicePixelRatio;
+ }
+
+ if (typeof window === "undefined") {
+ return 1;
+ }
+
+ if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
+ return window.devicePixelRatio;
+ }
+
+ return 1;
+}
+
+export function getTimelinePixelsPerSecond({
+ zoomLevel,
+}: {
+ zoomLevel: number;
+}): number {
+ return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+}
+
+export function timelineTimeToPixels({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}): number {
+ return time * getTimelinePixelsPerSecond({ zoomLevel });
+}
+
+export function snapPixelToDeviceGrid({
+ pixel,
+ devicePixelRatio,
+}: {
+ pixel: number;
+ devicePixelRatio?: number;
+}): number {
+ const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
+ return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
+}
+
+export function timelineTimeToSnappedPixels({
+ time,
+ zoomLevel,
+ devicePixelRatio,
+}: {
+ time: number;
+ zoomLevel: number;
+ devicePixelRatio?: number;
+}): number {
+ const rawPixel = timelineTimeToPixels({ time, zoomLevel });
+ return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
+}
+
+export function getCenteredLineLeft({
+ centerPixel,
+ lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
+}: {
+ centerPixel: number;
+ lineWidthPx?: number;
+}): number {
+ return centerPixel - lineWidthPx / 2;
+}
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts
new file mode 100644
index 00000000..5f37bf29
--- /dev/null
+++ b/apps/web/src/lib/timeline/ripple-utils.ts
@@ -0,0 +1,17 @@
+import type { TimelineElement } from "@/types/timeline";
+
+export function rippleShiftElements({
+ elements,
+ afterTime,
+ shiftAmount,
+}: {
+ elements: TimelineElement[];
+ afterTime: number;
+ shiftAmount: number;
+}): TimelineElement[] {
+ return elements.map((element) =>
+ element.startTime >= afterTime
+ ? { ...element, startTime: element.startTime - shiftAmount }
+ : element,
+ );
+}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
index a29737d0..c7fac090 100644
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -1,10 +1,11 @@
import type { Bookmark, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
+import { getElementKeyframes } from "@/lib/animation";
export interface SnapPoint {
time: number;
- type: "element-start" | "element-end" | "playhead" | "bookmark";
+ type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
elementId?: string;
trackId?: string;
}
@@ -26,6 +27,7 @@ export function findSnapPoints({
enableElementSnapping = true,
enablePlayheadSnapping = true,
enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
@@ -35,13 +37,15 @@ export function findSnapPoints({
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
}): SnapPoint[] {
const snapPoints: SnapPoint[] = [];
- if (enableElementSnapping) {
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.id === excludeElementId) continue;
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
snapPoints.push(
{
time: element.startTime,
@@ -57,6 +61,19 @@ export function findSnapPoints({
},
);
}
+
+ if (enableKeyframeSnapping) {
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
}
}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts
new file mode 100644
index 00000000..6118b327
--- /dev/null
+++ b/apps/web/src/lib/timeline/track-element-update.ts
@@ -0,0 +1,33 @@
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+
+export function updateElementInTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: TimelineTrack[];
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): TimelineTrack[] {
+ return tracks.map((track) => {
+ if (track.id !== trackId) {
+ return track;
+ }
+
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
+ }
+ return update(element);
+ });
+
+ return { ...track, elements: nextElements } as TimelineTrack;
+ });
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index 415d0ace..94ff21ba 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -6,11 +6,11 @@ import type {
AudioTrack,
StickerTrack,
TextTrack,
+ EffectTrack,
TimelineElement,
} from "@/types/timeline";
import {
- TRACK_COLORS,
- TRACK_HEIGHTS,
+ TRACK_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,21 +23,20 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
-): track is VideoTrack | TextTrack | StickerTrack {
+): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
return track.type !== "audio";
}
export function getTrackColor({ type }: { type: TrackType }) {
- return TRACK_COLORS[type];
+ return TRACK_CONFIG[type];
}
export function getTrackClasses({ type }: { type: TrackType }) {
- const colors = TRACK_COLORS[type];
- return `${colors.background}`.trim();
+ return TRACK_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
- return TRACK_HEIGHTS[type];
+ return TRACK_CONFIG[type].height;
}
export function getCumulativeHeightBefore({
@@ -77,17 +76,7 @@ export function buildEmptyTrack({
type: TrackType;
name?: string;
}): TimelineTrack {
- const trackName =
- name ??
- (type === "video"
- ? "Video track"
- : type === "text"
- ? "Text track"
- : type === "audio"
- ? "Audio track"
- : type === "sticker"
- ? "Sticker track"
- : "Track");
+ const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
@@ -124,6 +113,14 @@ export function buildEmptyTrack({
elements: [],
muted: false,
};
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
default:
throw new Error(`Unsupported track type: ${type}`);
}
@@ -140,6 +137,10 @@ export function getDefaultInsertIndexForTrack({
return tracks.length;
}
+ if (trackType === "effect") {
+ return 0;
+ }
+
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
@@ -216,6 +217,7 @@ export function canElementGoOnTrack({
if (elementType === "text") return trackType === "text";
if (elementType === "audio") return trackType === "audio";
if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "effect") return trackType === "effect";
if (elementType === "video" || elementType === "image") {
return trackType === "video";
}
diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts
new file mode 100644
index 00000000..093168e6
--- /dev/null
+++ b/apps/web/src/services/renderer/canvas-utils.ts
@@ -0,0 +1,16 @@
+export function createOffscreenCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ try {
+ return new OffscreenCanvas(width, height);
+ } catch {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+}
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
new file mode 100644
index 00000000..2cebec5d
--- /dev/null
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -0,0 +1,194 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+const PREVIEW_SIZE = 160;
+const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
+
+let previewGl: WebGLRenderingContext | null = null;
+let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+let previewImageElement: HTMLImageElement | null = null;
+const programCache = new Map<string, WebGLProgram>();
+const onReadyCallbacks = new Set<() => void>();
+
+export function onPreviewImageReady({
+ callback,
+}: {
+ callback: () => void;
+}): () => void {
+ onReadyCallbacks.add(callback);
+ return () => onReadyCallbacks.delete(callback);
+}
+
+function loadPreviewImage(): void {
+ if (typeof window === "undefined") return;
+ const image = new Image();
+ image.onload = () => {
+ testSourceCanvas = null;
+ for (const callback of onReadyCallbacks) {
+ callback();
+ }
+ };
+ image.src = PREVIEW_IMAGE_PATH;
+ previewImageElement = image;
+}
+
+loadPreviewImage();
+
+function buildDefaultParams({
+ effectType,
+}: {
+ effectType: string;
+}): EffectParamValues {
+ const definition = getEffect({ effectType });
+ const params: EffectParamValues = {};
+ for (const paramDef of definition.params) {
+ params[paramDef.key] = paramDef.default;
+ }
+ return params;
+}
+
+function createTestSource({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement | null {
+ const isImageReady =
+ previewImageElement?.complete &&
+ (previewImageElement.naturalWidth ?? 0) > 0;
+ if (!isImageReady || !previewImageElement) {
+ return null;
+ }
+
+ const canvas = createOffscreenCanvas({ width, height });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) {
+ throw new Error("failed to get 2d context for test source");
+ }
+ ctx.drawImage(previewImageElement, 0, 0, width, height);
+ return canvas;
+}
+
+function getOrCreatePreviewContext({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
+ if (!previewCanvas || !previewGl) {
+ previewCanvas = createOffscreenCanvas({ width, height });
+ previewGl = previewCanvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!previewGl) {
+ throw new Error("WebGL not supported");
+ }
+ }
+ if (previewCanvas.width !== width || previewCanvas.height !== height) {
+ previewCanvas.width = width;
+ previewCanvas.height = height;
+ }
+ return { canvas: previewCanvas, gl: previewGl };
+}
+
+function getTestSource({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): CanvasImageSource | null {
+ if (
+ !testSourceCanvas ||
+ testSourceCanvas.width !== width ||
+ testSourceCanvas.height !== height
+ ) {
+ testSourceCanvas = createTestSource({ width, height });
+ }
+ return testSourceCanvas;
+}
+
+function applyWebGlEffect({
+ source,
+ width,
+ height,
+ passes,
+}: {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}): OffscreenCanvas | HTMLCanvasElement {
+ const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height });
+
+ applyMultiPassEffect({ context: gl, source, width, height, passes, programCache });
+
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(glCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
+}
+
+export function renderPreview({
+ effectType,
+ params,
+ targetCanvas,
+}: {
+ effectType: string;
+ params: EffectParamValues;
+ targetCanvas: HTMLCanvasElement;
+}): void {
+ const size = PREVIEW_SIZE;
+ const source = getTestSource({ width: size, height: size });
+ if (!source) return;
+
+ const definition = getEffect({ effectType });
+ const resolvedParams =
+ Object.keys(params).length > 0
+ ? params
+ : buildDefaultParams({ effectType });
+
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: size,
+ height: size,
+ }),
+ }));
+ const result = applyWebGlEffect({
+ source,
+ width: size,
+ height: size,
+ passes,
+ });
+
+ const targetCtx = targetCanvas.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D | null;
+ if (targetCtx) {
+ targetCanvas.width = size;
+ targetCanvas.height = size;
+ targetCtx.drawImage(result, 0, 0, size, size);
+ }
+}
+
+export const effectPreviewService = {
+ renderPreview,
+ onPreviewImageReady,
+ PREVIEW_SIZE,
+};
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
deleted file mode 100644
index 504161d9..00000000
--- a/apps/web/src/services/renderer/nodes/blur-background-node.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { BaseNode } from "./base-node";
-
-export type BlurBackgroundNodeParams = {
- blurIntensity: number;
- contentNodes: BaseNode[];
-};
-
-export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
- private blurIntensity: number;
- private contentNodes: BaseNode[];
-
- constructor(params: BlurBackgroundNodeParams) {
- super(params);
- this.blurIntensity = params.blurIntensity;
- this.contentNodes = params.contentNodes;
- }
-
- async render({
- renderer,
- time,
- }: {
- renderer: CanvasRenderer;
- time: number;
- }): Promise<void> {
- let offscreen: OffscreenCanvas | HTMLCanvasElement;
- let offscreenCtx:
- | OffscreenCanvasRenderingContext2D
- | CanvasRenderingContext2D;
-
- try {
- offscreen = new OffscreenCanvas(renderer.width, renderer.height);
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get offscreen canvas context");
- }
- offscreenCtx = ctx;
- } catch {
- offscreen = document.createElement("canvas");
- offscreen.width = renderer.width;
- offscreen.height = renderer.height;
- const ctx = offscreen.getContext("2d");
- if (!ctx) {
- throw new Error("failed to get canvas context");
- }
- offscreenCtx = ctx;
- }
-
- const originalContext = renderer.context;
- renderer.context = offscreenCtx;
-
- for (const node of this.contentNodes) {
- await node.render({ renderer, time });
- }
-
- renderer.context = originalContext;
-
- const zoomScale = 1.4;
- const scaledWidth = renderer.width * zoomScale;
- const scaledHeight = renderer.height * zoomScale;
- const offsetX = (renderer.width - scaledWidth) / 2;
- const offsetY = (renderer.height - scaledHeight) / 2;
-
- renderer.context.save();
- renderer.context.filter = `blur(${this.blurIntensity}px)`;
- renderer.context.drawImage(
- offscreen as CanvasImageSource,
- offsetX,
- offsetY,
- scaledWidth,
- scaledHeight,
- );
- renderer.context.restore();
- }
-}
diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
new file mode 100644
index 00000000..3fbfffc1
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
@@ -0,0 +1,77 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+export type CompositeEffectNodeParams = {
+ contentNodes: BaseNode[];
+ effectType: string;
+ effectParams: EffectParamValues;
+ scale: number;
+};
+
+export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ const offscreen = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+ if (!offscreenCtx) {
+ throw new Error("failed to get offscreen canvas context");
+ }
+
+ const originalContext = renderer.context;
+ renderer.context = offscreenCtx;
+
+ for (const node of this.params.contentNodes) {
+ await node.render({ renderer, time });
+ }
+
+ renderer.context = originalContext;
+
+ const effectDefinition = getEffect({ effectType: this.params.effectType });
+ const scale = this.params.scale;
+ const scaledWidth = renderer.width * scale;
+ const scaledHeight = renderer.height * scale;
+ const offsetX = (renderer.width - scaledWidth) / 2;
+ const offsetY = (renderer.height - scaledHeight) / 2;
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source: offscreen as CanvasImageSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.save();
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ offsetX,
+ offsetY,
+ scaledWidth,
+ scaledHeight,
+ );
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
new file mode 100644
index 00000000..3d84be2b
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -0,0 +1,82 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { getEffect } from "@/lib/effects";
+import type { EffectParamValues } from "@/types/effects";
+import { BaseNode } from "./base-node";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+
+const TIME_EPSILON = 1e-6;
+
+export type EffectLayerNodeParams = {
+ effectType: string;
+ effectParams: EffectParamValues;
+ timeOffset: number;
+ duration: number;
+};
+
+function isInRange({
+ time,
+ timeOffset,
+ duration,
+}: {
+ time: number;
+ timeOffset: number;
+ duration: number;
+}): boolean {
+ return (
+ time >= timeOffset - TIME_EPSILON &&
+ time < timeOffset + duration + TIME_EPSILON
+ );
+}
+
+// snapshots whatever is currently on the canvas, applies the effect, draws it back
+export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
+ async render({
+ renderer,
+ time,
+ }: {
+ renderer: CanvasRenderer;
+ time: number;
+ }): Promise<void> {
+ if (
+ !isInRange({
+ time,
+ timeOffset: this.params.timeOffset,
+ duration: this.params.duration,
+ })
+ ) {
+ return;
+ }
+
+ const source = renderer.context.canvas as CanvasImageSource;
+
+ const effectDefinition = getEffect({
+ effectType: this.params.effectType,
+ });
+
+ const passes = effectDefinition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ const effectResult = webglEffectRenderer.applyEffect({
+ source,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.save();
+ renderer.context.clearRect(0, 0, renderer.width, renderer.height);
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ );
+ renderer.context.restore();
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index b3a0be74..6720a5e5 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
@@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
source,
sourceWidth: width || renderer.width,
sourceHeight: height || renderer.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index acf638eb..da271685 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
@@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
source,
sourceWidth: width,
sourceHeight: height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 76d78b31..86e24fdf 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,10 +1,14 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
+ DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
getMetricAscent,
@@ -12,6 +16,17 @@ import {
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
+import {
+ getElementLocalTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { clamp } from "@/utils/math";
function scaleFontSize({
fontSize,
@@ -27,6 +42,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
+const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
+const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
+
function drawTextDecoration({
ctx,
textDecoration,
@@ -46,7 +64,7 @@ function drawTextDecoration({
}): void {
if (textDecoration === "none" || !textDecoration) return;
- const thickness = Math.max(1, scaledFontSize * 0.07);
+ const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
@@ -60,7 +78,7 @@ function drawTextDecoration({
}
if (textDecoration === "line-through") {
- const strikeY = lineY - (ascent - descent) * 0.35;
+ const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
}
}
@@ -84,19 +102,24 @@ export class TextNode extends BaseNode<TextNodeParams> {
return;
}
- renderer.context.save();
-
- const x = this.params.transform.position.x + this.params.canvasCenter.x;
- const y = this.params.transform.position.y + this.params.canvasCenter.y;
+ const localTime = getElementLocalTime({
+ timelineTime: time,
+ elementStartTime: this.params.startTime,
+ elementDuration: this.params.duration,
+ });
+ const transform = resolveTransformAtTime({
+ baseTransform: this.params.transform,
+ animations: this.params.animations,
+ localTime,
+ });
+ const opacity = resolveOpacityAtTime({
+ baseOpacity: this.params.opacity,
+ animations: this.params.animations,
+ localTime,
+ });
- renderer.context.translate(x, y);
- renderer.context.scale(
- this.params.transform.scale,
- this.params.transform.scale,
- );
- if (this.params.transform.rotate) {
- renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
- }
+ const x = transform.position.x + this.params.canvasCenter.x;
+ const y = transform.position.y + this.params.canvasCenter.y;
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
@@ -105,83 +128,193 @@ export class TextNode extends BaseNode<TextNodeParams> {
canvasHeight: this.params.canvasHeight,
});
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
- renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- renderer.context.textAlign = this.params.textAlign;
- renderer.context.fillStyle = this.params.color;
-
+ const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
const letterSpacing = this.params.letterSpacing ?? 0;
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
- if ("letterSpacing" in renderer.context) {
- (
- renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
- ).letterSpacing = `${letterSpacing}px`;
- }
-
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
+ const blendMode = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
+ renderer.context.save();
+ renderer.context.font = fontString;
renderer.context.textBaseline = baseline;
+ if ("letterSpacing" in renderer.context) {
+ (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ renderer.context.restore();
+
const lineCount = lines.length;
+ const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize: scaledFontSize,
+ const textColor = resolveColorAtTime({
+ baseColor: this.params.color,
+ animations: this.params.animations,
+ propertyPath: "color",
+ localTime,
});
+ const bg = this.params.background;
+ const resolvedBackground = {
+ ...bg,
+ color: resolveColorAtTime({
+ baseColor: bg.color,
+ animations: this.params.animations,
+ propertyPath: "background.color",
+ localTime,
+ }),
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: this.params.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: this.params.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: this.params.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: this.params.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: this.params.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
- const prevAlpha = renderer.context.globalAlpha;
- renderer.context.globalCompositeOperation = (
- this.params.blendMode && this.params.blendMode !== "normal"
- ? this.params.blendMode
- : "source-over"
- ) as GlobalCompositeOperation;
- renderer.context.globalAlpha = this.params.opacity;
+ const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.font = fontString;
+ ctx.textAlign = this.params.textAlign;
+ ctx.textBaseline = baseline;
+ ctx.fillStyle = textColor;
+ if ("letterSpacing" in ctx) {
+ (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
- if (
- this.params.background.color &&
- this.params.background.color !== "transparent" &&
- lineCount > 0
- ) {
- const { color, cornerRadius = 0 } = this.params.background;
- const backgroundRect = getTextBackgroundRect({
- textAlign: this.params.textAlign,
- block,
- background: this.params.background,
- fontSizeRatio,
- });
- if (backgroundRect) {
- renderer.context.fillStyle = color;
- renderer.context.beginPath();
- renderer.context.roundRect(
- backgroundRect.left,
- backgroundRect.top,
- backgroundRect.width,
- backgroundRect.height,
- cornerRadius,
- );
- renderer.context.fill();
- renderer.context.fillStyle = this.params.color;
+ if (
+ this.params.background.enabled &&
+ this.params.background.color &&
+ this.params.background.color !== "transparent" &&
+ lineCount > 0
+ ) {
+ const backgroundRect = getTextBackgroundRect({
+ textAlign: this.params.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+ if (backgroundRect) {
+ const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
+ const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
+ ctx.fillStyle = resolvedBackground.color;
+ ctx.beginPath();
+ ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
+ ctx.fill();
+ ctx.fillStyle = textColor;
+ }
}
+
+ for (let i = 0; i < lineCount; i++) {
+ const lineY = i * lineHeightPx - block.visualCenterOffset;
+ ctx.fillText(lines[i], 0, lineY);
+ drawTextDecoration({
+ ctx,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
+ };
+
+ const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.translate(x, y);
+ ctx.scale(transform.scale, transform.scale);
+ if (transform.rotate) {
+ ctx.rotate((transform.rotate * Math.PI) / 180);
+ }
+ };
+
+ const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
+ }
+
+ // Effects path: render text to a same-size offscreen canvas so the blur
+ // can spread into the surrounding transparent area without hard clipping.
+ const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+
+ if (!offscreenCtx) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
}
- for (let i = 0; i < lineCount; i++) {
- const y = i * lineHeightPx - block.visualCenterOffset;
- renderer.context.fillText(lines[i], 0, y);
- drawTextDecoration({
- ctx: renderer.context,
- textDecoration: this.params.textDecoration ?? "none",
- lineWidth: lineMetrics[i].width,
- lineY: y,
- metrics: lineMetrics[i],
- scaledFontSize,
- textAlign: this.params.textAlign,
+ offscreenCtx.save();
+ applyTransform(offscreenCtx);
+ drawContent(offscreenCtx);
+ offscreenCtx.restore();
+
+ let currentSource: CanvasImageSource = offscreen;
+ for (const effect of enabledEffects) {
+ const resolvedParams = resolveEffectParamsAtTime({
+ effect,
+ animations: this.params.animations,
+ localTime,
+ });
+ const definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ currentSource = webglEffectRenderer.applyEffect({
+ source: currentSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
});
}
- renderer.context.globalAlpha = prevAlpha;
+ renderer.context.save();
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ renderer.context.drawImage(currentSource, 0, 0);
renderer.context.restore();
}
}
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 8e2ddccd..503c9c03 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
- const videoTime = this.getLocalTime(time);
+ const videoTime = this.getSourceLocalTime({ time });
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
@@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 7179369e..6c50aa12 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,9 +1,19 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
+import type { Effect } from "@/types/effects";
import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
-
-const VISUAL_EPSILON = 1 / 1000;
+import type { ElementAnimations } from "@/types/animation";
+import {
+ getElementLocalTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
export interface VisualNodeParams {
duration: number;
@@ -11,21 +21,31 @@ export interface VisualNodeParams {
trimStart: number;
trimEnd: number;
transform: Transform;
+ animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<Params> {
- protected getLocalTime(time: number): number {
+ protected getSourceLocalTime({ time }: { time: number }): number {
return time - this.params.timeOffset + this.params.trimStart;
}
- protected isInRange(time: number): boolean {
- const localTime = this.getLocalTime(time);
+ protected getAnimationLocalTime({ time }: { time: number }): number {
+ return getElementLocalTime({
+ timelineTime: time,
+ elementStartTime: this.params.timeOffset,
+ elementDuration: this.params.duration,
+ });
+ }
+
+ protected isInRange({ time }: { time: number }): boolean {
+ const localTime = this.getSourceLocalTime({ time });
return (
- localTime >= this.params.trimStart - VISUAL_EPSILON &&
+ localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
localTime < this.params.trimStart + this.params.duration
);
}
@@ -35,15 +55,27 @@ export abstract class VisualNode<
source,
sourceWidth,
sourceHeight,
+ timelineTime,
}: {
renderer: CanvasRenderer;
source: CanvasImageSource;
sourceWidth: number;
sourceHeight: number;
+ timelineTime: number;
}): void {
renderer.context.save();
- const { transform, opacity } = this.params;
+ const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime });
+ const transform = resolveTransformAtTime({
+ baseTransform: this.params.transform,
+ animations: this.params.animations,
+ localTime: animationLocalTime,
+ });
+ const opacity = resolveOpacityAtTime({
+ baseOpacity: this.params.opacity,
+ animations: this.params.animations,
+ localTime: animationLocalTime,
+ });
const containScale = Math.min(
renderer.width / sourceWidth,
renderer.height / sourceHeight,
@@ -68,7 +100,63 @@ export abstract class VisualNode<
renderer.context.translate(-centerX, -centerY);
}
- renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ const enabledEffects =
+ this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ const elementCanvas = createOffscreenCanvas({
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ });
+ const elementCtx = elementCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!elementCtx) {
+ renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
+
+ let currentResult: CanvasImageSource = elementCanvas;
+
+ for (const effect of enabledEffects) {
+ const resolvedParams = resolveEffectParamsAtTime({
+ effect,
+ animations: this.params.animations,
+ localTime: animationLocalTime,
+ });
+ const definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: scaledWidth,
+ height: scaledHeight,
+ }),
+ }));
+ currentResult = webglEffectRenderer.applyEffect({
+ source: currentResult,
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ passes,
+ });
+ }
+
+ renderer.context.drawImage(
+ currentResult,
+ x,
+ y,
+ scaledWidth,
+ scaledHeight,
+ );
renderer.context.restore();
}
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 530b2eb3..695e077f 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -6,51 +6,59 @@ import { ImageNode } from "./nodes/image-node";
import { TextNode } from "./nodes/text-node";
import { StickerNode } from "./nodes/sticker-node";
import { ColorNode } from "./nodes/color-node";
-import { BlurBackgroundNode } from "./nodes/blur-background-node";
+import { CompositeEffectNode } from "./nodes/composite-effect-node";
+import { EffectLayerNode } from "./nodes/effect-layer-node";
+import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
+const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
-export type BuildSceneParams = {
- canvasSize: TCanvasSize;
+function getVisibleSortedElements({
+ track,
+}: {
+ track: TimelineTrack;
+}) {
+ return track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+}
+
+function buildTrackNodes({
+ tracks,
+ mediaMap,
+ canvasSize,
+ isPreview,
+}: {
tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: number;
- background: TBackground;
+ mediaMap: Map<string, MediaAsset>;
+ canvasSize: TCanvasSize;
isPreview?: boolean;
-};
-
-export function buildScene(params: BuildSceneParams) {
- const { tracks, mediaAssets, duration, canvasSize, background } = params;
-
- const rootNode = new RootNode({ duration });
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+}): BaseNode[] {
+ const nodes: BaseNode[] = [];
- const visibleTracks = tracks.filter(
- (track) => !("hidden" in track && track.hidden),
- );
-
- const orderedTracksTopToBottom = [
- ...visibleTracks.filter((track) => !isMainTrack(track)),
- ...visibleTracks.filter((track) => isMainTrack(track)),
- ];
-
- const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
-
- const contentNodes = [];
-
- for (const track of orderedTracksBottomToTop) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
+ for (const track of tracks) {
+ const elements = getVisibleSortedElements({ track });
for (const element of elements) {
+ if (element.type === "effect") {
+ nodes.push(
+ new EffectLayerNode({
+ effectType: element.effectType,
+ effectParams: element.params,
+ timeOffset: element.startTime,
+ duration: element.duration,
+ }),
+ );
+ continue;
+ }
+
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset?.file || !mediaAsset?.url) {
@@ -58,7 +66,7 @@ export function buildScene(params: BuildSceneParams) {
}
if (mediaAsset.type === "video") {
- contentNodes.push(
+ nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
@@ -68,13 +76,15 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
if (mediaAsset.type === "image") {
- contentNodes.push(
+ nodes.push(
new ImageNode({
url: mediaAsset.url,
duration: element.duration,
@@ -82,9 +92,11 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
- ...(params.isPreview && {
+ effects: element.effects,
+ ...(isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
}),
@@ -93,18 +105,19 @@ export function buildScene(params: BuildSceneParams) {
}
if (element.type === "text") {
- contentNodes.push(
+ nodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height,
textBaseline: "middle",
+ effects: element.effects,
}),
);
}
if (element.type === "sticker") {
- contentNodes.push(
+ nodes.push(
new StickerNode({
stickerId: element.stickerId,
duration: element.duration,
@@ -112,31 +125,77 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
+ animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
+ effects: element.effects,
}),
);
}
}
}
+ return nodes;
+}
+
+export type BuildSceneParams = {
+ canvasSize: TCanvasSize;
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ background: TBackground;
+ isPreview?: boolean;
+};
+
+export function buildScene({
+ canvasSize,
+ tracks,
+ mediaAssets,
+ duration,
+ background,
+ isPreview,
+}: BuildSceneParams) {
+ const rootNode = new RootNode({ duration });
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+
+ const orderedTracksTopToBottom = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ];
+
+ const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+
+ const allNodes = buildTrackNodes({
+ tracks: orderedTracksBottomToTop,
+ mediaMap,
+ canvasSize,
+ isPreview,
+ });
+
if (background.type === "blur") {
rootNode.add(
- new BlurBackgroundNode({
- blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
- contentNodes,
+ new CompositeEffectNode({
+ contentNodes: allNodes.filter(
+ (node) => !(node instanceof EffectLayerNode),
+ ),
+ effectType: "blur",
+ effectParams: {
+ intensity:
+ background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
+ },
+ scale: BLUR_BACKGROUND_ZOOM_SCALE,
}),
);
- for (const node of contentNodes) {
- rootNode.add(node);
- }
- } else {
- if (background.type === "color" && background.color !== "transparent") {
- rootNode.add(new ColorNode({ color: background.color }));
- }
- for (const node of contentNodes) {
- rootNode.add(node);
- }
+ } else if (background.type === "color" && background.color !== "transparent") {
+ rootNode.add(new ColorNode({ color: background.color }));
+ }
+
+ for (const node of allNodes) {
+ rootNode.add(node);
}
return rootNode;
diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts
new file mode 100644
index 00000000..ad5cd40e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-effect-renderer.ts
@@ -0,0 +1,73 @@
+import { createOffscreenCanvas } from "./canvas-utils";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+export interface ApplyEffectParams {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}
+
+let gl: WebGLRenderingContext | null = null;
+let canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+const programCache = new Map<string, WebGLProgram>();
+
+function getOrCreateCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ if (!canvas) {
+ canvas = createOffscreenCanvas({ width, height });
+ gl = canvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!gl) {
+ throw new Error("WebGL not supported");
+ }
+ }
+ if (canvas.width !== width || canvas.height !== height) {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ return canvas;
+}
+
+function applyEffect({
+ source,
+ width,
+ height,
+ passes,
+}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement {
+ const targetCanvas = getOrCreateCanvas({ width, height });
+ const context = gl;
+ if (!context) {
+ throw new Error("WebGL context not initialized");
+ }
+
+ applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+ });
+
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(targetCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
+}
+
+export const webglEffectRenderer = {
+ applyEffect,
+};
diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl-utils.ts
new file mode 100644
index 00000000..834c652e
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl-utils.ts
@@ -0,0 +1,298 @@
+import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
+
+export interface EffectPassData {
+ fragmentShader: string;
+ uniforms: Record<string, number | number[]>;
+}
+
+export const QUAD_POSITIONS = new Float32Array([
+ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
+]);
+
+export function compileProgram({
+ context,
+ fragmentShaderSource,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ fragmentShaderSource: string;
+ programCache: Map<string, WebGLProgram>;
+}): WebGLProgram {
+ const cached = programCache.get(fragmentShaderSource);
+ if (cached) {
+ return cached;
+ }
+ const vertexShader = compileShader({
+ context,
+ source: VERTEX_SHADER_SOURCE,
+ type: context.VERTEX_SHADER,
+ });
+ const fragmentShader = compileShader({
+ context,
+ source: fragmentShaderSource,
+ type: context.FRAGMENT_SHADER,
+ });
+ const program = context.createProgram();
+ if (!program) {
+ throw new Error("Failed to create WebGL program");
+ }
+ context.attachShader(program, vertexShader);
+ context.attachShader(program, fragmentShader);
+ context.linkProgram(program);
+ if (!context.getProgramParameter(program, context.LINK_STATUS)) {
+ const info = context.getProgramInfoLog(program);
+ context.deleteProgram(program);
+ throw new Error(`WebGL program link failed: ${info}`);
+ }
+ context.deleteShader(vertexShader);
+ context.deleteShader(fragmentShader);
+ programCache.set(fragmentShaderSource, program);
+ return program;
+}
+
+export function compileShader({
+ context,
+ source,
+ type,
+}: {
+ context: WebGLRenderingContext;
+ source: string;
+ type: number;
+}): WebGLShader {
+ const shader = context.createShader(type);
+ if (!shader) {
+ throw new Error("Failed to create WebGL shader");
+ }
+ context.shaderSource(shader, source);
+ context.compileShader(shader);
+ if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
+ const info = context.getShaderInfoLog(shader);
+ context.deleteShader(shader);
+ throw new Error(`WebGL shader compile failed: ${info}`);
+ }
+ return shader;
+}
+
+export function createTexture({
+ context,
+ source,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+}): WebGLTexture {
+ const texture = context.createTexture();
+ if (!texture) {
+ throw new Error("Failed to create WebGL texture");
+ }
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ source as TexImageSource,
+ );
+ return texture;
+}
+
+export function setUniforms({
+ context,
+ program,
+ uniforms,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ uniforms: Record<string, number | number[]>;
+}): void {
+ for (const [name, value] of Object.entries(uniforms)) {
+ const location = context.getUniformLocation(program, name);
+ if (location === null) continue;
+
+ if (typeof value === "number") {
+ context.uniform1f(location, value);
+ } else if (Array.isArray(value)) {
+ if (value.length === 2) {
+ context.uniform2fv(location, new Float32Array(value));
+ } else if (value.length === 3) {
+ context.uniform3fv(location, new Float32Array(value));
+ } else if (value.length === 4) {
+ context.uniform4fv(location, new Float32Array(value));
+ }
+ }
+ }
+}
+
+export function drawFullscreenQuad({
+ context,
+ program,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ width: number;
+ height: number;
+}): void {
+ const positionLocation = context.getAttribLocation(program, "a_position");
+ const buffer = context.createBuffer();
+ context.bindBuffer(context.ARRAY_BUFFER, buffer);
+ context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
+ context.enableVertexAttribArray(positionLocation);
+ context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
+
+ context.viewport(0, 0, width, height);
+ context.clearColor(0, 0, 0, 0);
+ context.clear(context.COLOR_BUFFER_BIT);
+ context.drawArrays(context.TRIANGLES, 0, 6);
+}
+
+export function createFramebufferTexture({
+ context,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ width: number;
+ height: number;
+}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
+ const texture = context.createTexture();
+ if (!texture) throw new Error("Failed to create framebuffer texture");
+ context.bindTexture(context.TEXTURE_2D, texture);
+ context.texImage2D(
+ context.TEXTURE_2D,
+ 0,
+ context.RGBA,
+ width,
+ height,
+ 0,
+ context.RGBA,
+ context.UNSIGNED_BYTE,
+ null,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_S,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_WRAP_T,
+ context.CLAMP_TO_EDGE,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MIN_FILTER,
+ context.LINEAR,
+ );
+ context.texParameteri(
+ context.TEXTURE_2D,
+ context.TEXTURE_MAG_FILTER,
+ context.LINEAR,
+ );
+ context.bindTexture(context.TEXTURE_2D, null);
+
+ const framebuffer = context.createFramebuffer();
+ if (!framebuffer) throw new Error("Failed to create framebuffer");
+ context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
+ context.framebufferTexture2D(
+ context.FRAMEBUFFER,
+ context.COLOR_ATTACHMENT0,
+ context.TEXTURE_2D,
+ texture,
+ 0,
+ );
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+
+ return { texture, framebuffer };
+}
+
+export function applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+}: {
+ context: WebGLRenderingContext;
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+ programCache: Map<string, WebGLProgram>;
+}): void {
+ const sourceTexture = createTexture({ context, source });
+ let currentTexture: WebGLTexture = sourceTexture;
+
+ const intermediates: Array<{
+ texture: WebGLTexture;
+ framebuffer: WebGLFramebuffer;
+ }> = [];
+ for (let i = 0; i < passes.length - 1; i++) {
+ intermediates.push(createFramebufferTexture({ context, width, height }));
+ }
+
+ for (let i = 0; i < passes.length; i++) {
+ const pass = passes[i];
+ const program = compileProgram({
+ context,
+ fragmentShaderSource: pass.fragmentShader,
+ programCache,
+ });
+ const isLastPass = i === passes.length - 1;
+ const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
+
+ context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
+ // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
+ context.useProgram(program);
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, currentTexture);
+
+ const uTextureLocation = context.getUniformLocation(program, "u_texture");
+ if (uTextureLocation) {
+ context.uniform1i(uTextureLocation, 0);
+ }
+
+ setUniforms({
+ context,
+ program,
+ uniforms: { ...pass.uniforms, u_resolution: [width, height] },
+ });
+ drawFullscreenQuad({ context, program, width, height });
+
+ if (!isLastPass) {
+ currentTexture = intermediates[i].texture;
+ }
+ }
+
+ context.deleteTexture(sourceTexture);
+ for (const intermediate of intermediates) {
+ context.deleteTexture(intermediate.texture);
+ context.deleteFramebuffer(intermediate.framebuffer);
+ }
+ context.bindTexture(context.TEXTURE_2D, null);
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+}
diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts
new file mode 100644
index 00000000..51e91f78
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts
@@ -0,0 +1,175 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
+
+const v8ProjectWithText = {
+ id: "project-v8-text",
+ version: 8,
+ metadata: {
+ id: "project-v8-text",
+ name: "V8 Project with Text",
+ 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 scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-text",
+ type: "text",
+ name: "Text Track",
+ hidden: false,
+ elements: [
+ {
+ id: "el-1",
+ type: "text",
+ content: "With color",
+ startTime: 0,
+ duration: 5,
+ background: {
+ color: "#ff0000",
+ cornerRadius: 0,
+ paddingX: 8,
+ paddingY: 4,
+ },
+ },
+ {
+ id: "el-2",
+ type: "text",
+ content: "Transparent",
+ startTime: 5,
+ duration: 5,
+ background: {
+ color: "transparent",
+ paddingX: 30,
+ paddingY: 42,
+ },
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ ],
+} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
+
+describe("V8 to V9 Migration", () => {
+ test("adds background.enabled from color (transparent => false, otherwise true)", () => {
+ const result = transformProjectV8ToV9({ project: v8ProjectWithText });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(9);
+
+ const track = (
+ result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
+ )[0].tracks[0];
+ const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
+
+ expect(elements[0].background.enabled).toBe(true);
+ expect(elements[0].background.color).toBe("#ff0000");
+
+ expect(elements[1].background.enabled).toBe(false);
+ expect(elements[1].background.color).toBe("transparent");
+ });
+
+ test("preserves existing background.enabled if already present", () => {
+ const projectWithEnabled = {
+ ...v8ProjectWithText,
+ scenes: [
+ {
+ ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
+ tracks: [
+ {
+ id: "track-text",
+ type: "text",
+ name: "Text Track",
+ hidden: false,
+ elements: [
+ {
+ id: "el-1",
+ type: "text",
+ content: "Already has enabled",
+ startTime: 0,
+ duration: 5,
+ background: {
+ enabled: false,
+ color: "#00ff00",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ } as Parameters<typeof transformProjectV8ToV9>[0]["project"];
+
+ const result = transformProjectV8ToV9({ project: projectWithEnabled });
+
+ expect(result.skipped).toBe(false);
+ const elements = (
+ result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
+ )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
+ expect(elements[0].background.enabled).toBe(false);
+ });
+
+ test("skips non-text elements and tracks", () => {
+ const projectWithVideoOnly = {
+ ...v8ProjectWithText,
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-video",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ ],
+ } as Parameters<typeof transformProjectV8ToV9>[0]["project"];
+
+ const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(9);
+ });
+
+ test("skips projects that are already v9", () => {
+ const result = transformProjectV8ToV9({
+ project: { ...v8ProjectWithText, version: 9 },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v9");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV8ToV9({
+ project: {
+ version: 8,
+ scenes: [],
+ } as Parameters<typeof transformProjectV8ToV9>[0]["project"],
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index fd40f14c..bffacb66 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -6,10 +6,12 @@ import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
+import { V7toV8Migration } from "./v7-to-v8";
+import { V8toV9Migration } from "./v8-to-v9";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 7;
+export const CURRENT_PROJECT_VERSION = 9;
export const migrations = [
new V0toV1Migration(),
@@ -19,4 +21,6 @@ export const migrations = [
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
+ new V7toV8Migration(),
+ new V8toV9Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index 098101d6..297d1c58 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -388,7 +388,7 @@ async function transformMediaTrack({
);
const validElements = transformedElements.filter(
- (el): el is VideoElement | ImageElement => el !== null,
+ (element): element is VideoElement | ImageElement => element !== null,
);
return {
@@ -450,10 +450,18 @@ function transformTextTrack({
value: textElement.color,
fallback: "#000000",
}),
- backgroundColor: getStringValue({
- value: textElement.backgroundColor,
- fallback: "#FFFFFF",
- }),
+ background: {
+ enabled: false,
+ color: getStringValue({
+ value: textElement.backgroundColor,
+ fallback: "transparent",
+ }),
+ cornerRadius: 0,
+ paddingX: 8,
+ paddingY: 4,
+ offsetX: 0,
+ offsetY: 0,
+ },
textAlign: (getStringValue({
value: textElement.textAlign,
fallback: "left",
@@ -479,7 +487,7 @@ function transformTextTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
- .filter((el): el is TextElement => el !== null);
+ .filter((element): element is TextElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),
@@ -522,7 +530,7 @@ function transformAudioTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
- .filter((el): el is AudioElement => el !== null);
+ .filter((element): element is AudioElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),
diff --git a/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts
new file mode 100644
index 00000000..7393f776
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts
@@ -0,0 +1,96 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV7ToV8({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV8Project({ project })) {
+ return { project, skipped: true, reason: "already v8" };
+ }
+
+ const migratedProject = migrateProjectElements({ project });
+
+ return {
+ project: { ...migratedProject, version: 8 },
+ skipped: false,
+ };
+}
+
+function migrateProjectElements({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ let hasChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migrated = migrateSceneElements({ scene });
+ if (migrated !== scene) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return project;
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneElements({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ let hasChanges = false;
+ const migratedTracks = tracksValue.map((track) => {
+ const migrated = migrateTrackElements({ track });
+ if (migrated !== track) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return scene;
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackElements({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ let hasChanges = false;
+ const migratedElements = elementsValue.map((element) => {
+ const migrated = migrateElement({ element });
+ if (migrated !== element) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return track;
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+ if (element.type !== "video" && element.type !== "audio") return element;
+ if (typeof element.sourceDuration === "number") return element;
+
+ const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
+ const duration = typeof element.duration === "number" ? element.duration : 0;
+ const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
+
+ return {
+ ...element,
+ sourceDuration: trimStart + duration + trimEnd,
+ };
+}
+
+function isV8Project({ project }: { project: ProjectRecord }): boolean {
+ return typeof project.version === "number" && project.version >= 8;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts
new file mode 100644
index 00000000..1dc895bc
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v8-to-v9.ts
@@ -0,0 +1,99 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV8ToV9({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV9Project({ project })) {
+ return { project, skipped: true, reason: "already v9" };
+ }
+
+ const migratedProject = migrateProjectTextElements({ project });
+
+ return {
+ project: { ...migratedProject, version: 9 },
+ skipped: false,
+ };
+}
+
+function migrateProjectTextElements({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ let hasChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migrated = migrateSceneTextElements({ scene });
+ if (migrated !== scene) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return project;
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ let hasChanges = false;
+ const migratedTracks = tracksValue.map((track) => {
+ const migrated = migrateTrackTextElements({ track });
+ if (migrated !== track) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return scene;
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackTextElements({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+ if (track.type !== "text") return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ let hasChanges = false;
+ const migratedElements = elementsValue.map((element) => {
+ const migrated = migrateTextElement({ element });
+ if (migrated !== element) hasChanges = true;
+ return migrated;
+ });
+
+ if (!hasChanges) return track;
+ return { ...track, elements: migratedElements };
+}
+
+function migrateTextElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+ if (element.type !== "text") return element;
+
+ const bg = element.background;
+ if (!isRecord(bg)) return element;
+ if (typeof bg.enabled === "boolean") return element;
+
+ const color = typeof bg.color === "string" ? bg.color : "transparent";
+ const enabled = color !== "transparent";
+
+ return {
+ ...element,
+ background: { ...bg, enabled },
+ };
+}
+
+function isV9Project({ project }: { project: ProjectRecord }): boolean {
+ return typeof project.version === "number" && project.version >= 9;
+}
diff --git a/apps/web/src/services/storage/migrations/v7-to-v8.ts b/apps/web/src/services/storage/migrations/v7-to-v8.ts
new file mode 100644
index 00000000..33372c0a
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v7-to-v8.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV7ToV8 } from "./transformers/v7-to-v8";
+
+export class V7toV8Migration extends StorageMigration {
+ from = 7;
+ to = 8;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV7ToV8({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v8-to-v9.ts b/apps/web/src/services/storage/migrations/v8-to-v9.ts
new file mode 100644
index 00000000..6222eeac
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v8-to-v9.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
+
+export class V8toV9Migration extends StorageMigration {
+ from = 8;
+ to = 9;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV8ToV9({ project });
+ }
+}
diff --git a/apps/web/src/stores/assets-panel-store.tsx b/apps/web/src/stores/assets-panel-store.tsx
index 21ceb6a3..35466573 100644
--- a/apps/web/src/stores/assets-panel-store.tsx
+++ b/apps/web/src/stores/assets-panel-store.tsx
@@ -1,5 +1,6 @@
import type { ElementType } from "react";
import { create } from "zustand";
+import { persist } from "zustand/middleware";
import {
ArrowRightDoubleIcon,
ClosedCaptionIcon,
@@ -81,7 +82,9 @@ export const tabs = {
{ icon: ElementType<{ className?: string }>; label: string }
>;
-type MediaViewMode = "grid" | "list";
+export type MediaViewMode = "grid" | "list";
+export type MediaSortKey = "name" | "type" | "duration" | "size";
+export type MediaSortOrder = "asc" | "desc";
interface AssetsPanelStore {
activeTab: Tab;
@@ -93,15 +96,34 @@ interface AssetsPanelStore {
/* Media */
mediaViewMode: MediaViewMode;
setMediaViewMode: (mode: MediaViewMode) => void;
+ mediaSortBy: MediaSortKey;
+ mediaSortOrder: MediaSortOrder;
+ setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
}
-export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
- activeTab: "media",
- setActiveTab: (tab) => set({ activeTab: tab }),
- highlightMediaId: null,
- requestRevealMedia: (mediaId) =>
- set({ activeTab: "media", highlightMediaId: mediaId }),
- clearHighlight: () => set({ highlightMediaId: null }),
- mediaViewMode: "grid",
- setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
-}));
+export const useAssetsPanelStore = create<AssetsPanelStore>()(
+ persist(
+ (set) => ({
+ activeTab: "media",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+ highlightMediaId: null,
+ requestRevealMedia: (mediaId) =>
+ set({ activeTab: "media", highlightMediaId: mediaId }),
+ clearHighlight: () => set({ highlightMediaId: null }),
+ mediaViewMode: "grid",
+ setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
+ mediaSortBy: "name",
+ mediaSortOrder: "asc",
+ setMediaSort: (key, order) =>
+ set({ mediaSortBy: key, mediaSortOrder: order }),
+ }),
+ {
+ name: "assets-panel",
+ partialize: (state) => ({
+ mediaViewMode: state.mediaViewMode,
+ mediaSortBy: state.mediaSortBy,
+ mediaSortOrder: state.mediaSortOrder,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index fbbf169e..906e9aa8 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -39,8 +39,8 @@ interface KeybindingsState {
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
}
-function isDOMElement(el: EventTarget | null): el is HTMLElement {
- return !!el && (el instanceof Element || el instanceof HTMLElement);
+function isDOMElement(element: EventTarget | null): element is HTMLElement {
+ return element instanceof HTMLElement;
}
export const useKeybindingsStore = create<KeybindingsState>()(
@@ -90,11 +90,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
set({ keybindingsEnabled: false });
},
- importKeybindings: (config: KeybindingConfig) => {
- // Validate all keys and actions
- for (const [key] of Object.entries(config)) {
- // Validate the key format
- if (typeof key !== "string" || key.length === 0) {
+ importKeybindings: (config: KeybindingConfig) => {
+ for (const [key] of Object.entries(config)) {
+ if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
}
@@ -153,20 +151,13 @@ export const useKeybindingsStore = create<KeybindingsState>()(
),
);
-// Utility functions
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
const target = ev.target;
-
- // We may or may not have a modifier key
const modifierKey = getActiveModifier(ev);
-
- // We will always have a non-modifier key
const key = getPressedKey(ev);
if (!key) return null;
- // All key combos backed by modifiers are valid shortcuts (whether currently typing or not)
if (modifierKey) {
- // If the modifier is shift and the target is an input, we ignore
if (
modifierKey === "shift" &&
isDOMElement(target) &&
@@ -178,61 +169,44 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
return `${modifierKey}+${key}` as ShortcutKey;
}
- // no modifier key here then we do not do anything while on input
- if (
- isDOMElement(target) &&
- isTypableDOMElement({ element: target as HTMLElement })
- )
+ if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement }))
return null;
- // single key while not input
return `${key}` as ShortcutKey;
}
function getPressedKey(ev: KeyboardEvent): string | null {
- // Sometimes the property code is not available on the KeyboardEvent object
const key = (ev.key ?? "").toLowerCase();
const code = ev.code ?? "";
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
return "space";
- // Check arrow keys
- if (key.startsWith("arrow")) {
- return key.slice(5);
- }
+ if (key.startsWith("arrow")) return key.slice(5);
- // Check for special keys
+ if (key === "escape") return "escape";
if (key === "tab") return "tab";
if (key === "home") return "home";
if (key === "end") return "end";
if (key === "delete") return "delete";
if (key === "backspace") return "backspace";
- // Check letter keys
if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase();
- if (letter.length === 1 && letter >= "a" && letter <= "z") {
- return letter;
- }
+ if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
}
- // Check number keys using physical position for AZERTY support
+ // Use physical key position for AZERTY and other non-QWERTY layouts
if (code.startsWith("Digit")) {
const digit = code.slice(5);
- if (digit.length === 1 && digit >= "0" && digit <= "9") {
- return digit;
- }
+ if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
}
- // Fallback for other layouts
const isDigit = key.length === 1 && key >= "0" && key <= "9";
if (isDigit) return key;
- // Check if slash, period or enter
if (key === "/" || key === "." || key === "enter") return key;
- // If no other cases match, this is not a valid key
return null;
}
@@ -243,8 +217,6 @@ function getActiveModifier(ev: KeyboardEvent): string | null {
shift: ev.shiftKey,
};
- // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift
- // modiferKeys object's keys are sorted to match the above order
const activeModifier = Object.keys(modifierKeys)
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
.join("+");
diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts
index 64d86ffa..e6641f41 100644
--- a/apps/web/src/stores/keybindings/migrations/index.ts
+++ b/apps/web/src/stores/keybindings/migrations/index.ts
@@ -1,18 +1,16 @@
import { v2ToV3 } from "./v2-to-v3";
import { v3ToV4 } from "./v3-to-v4";
+import { v4ToV5 } from "./v4-to-v5";
type MigrationFn = ({ state }: { state: unknown }) => unknown;
-/**
- * key = version we're migrating from
- * value = migration function
- */
const migrations: Record<number, MigrationFn> = {
2: v2ToV3,
3: v3ToV4,
+ 4: v4ToV5,
};
-export const CURRENT_VERSION = 4;
+export const CURRENT_VERSION = 5;
export function runMigrations({
state,
diff --git a/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts
new file mode 100644
index 00000000..2ab39247
--- /dev/null
+++ b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts
@@ -0,0 +1,17 @@
+import type { KeybindingConfig } from "@/types/keybinding";
+
+interface V4State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v4ToV5({ state }: { state: unknown }): unknown {
+ const v4 = state as V4State;
+ const keybindings = { ...v4.keybindings };
+
+ if (!keybindings.escape) {
+ keybindings.escape = "deselect-all";
+ }
+
+ return { ...v4, keybindings };
+}
diff --git a/apps/web/src/stores/properties-store.ts b/apps/web/src/stores/properties-store.ts
new file mode 100644
index 00000000..f45f176d
--- /dev/null
+++ b/apps/web/src/stores/properties-store.ts
@@ -0,0 +1,19 @@
+import { create } from "zustand";
+
+interface ClipEffectsTarget {
+ elementId: string;
+ trackId: string;
+}
+
+interface PropertiesState {
+ clipEffectsTarget: ClipEffectsTarget | null;
+ openClipEffects: ({ elementId, trackId }: ClipEffectsTarget) => void;
+ closeClipEffects: () => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ clipEffectsTarget: null,
+ openClipEffects: ({ elementId, trackId }) =>
+ set({ clipEffectsTarget: { elementId, trackId } }),
+ closeClipEffects: () => set({ clipEffectsTarget: null }),
+}));
diff --git a/apps/web/src/types/animation.ts b/apps/web/src/types/animation.ts
new file mode 100644
index 00000000..825894c5
--- /dev/null
+++ b/apps/web/src/types/animation.ts
@@ -0,0 +1,97 @@
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.position.x",
+ "transform.position.y",
+ "transform.scale",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+] as const;
+
+export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+
+export type AnimationValueKind = "number" | "color" | "discrete";
+export type DiscreteValue = boolean | string;
+export type AnimationValue = number | string | boolean;
+
+export type ContinuousKeyframeInterpolation = "linear" | "hold";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | ContinuousKeyframeInterpolation
+ | DiscreteKeyframeInterpolation;
+
+interface BaseAnimationKeyframe<
+ TValue extends AnimationValue,
+ TInterpolation extends AnimationInterpolation,
+> {
+ id: string;
+ time: number; // relative to element start time
+ value: TValue;
+ interpolation: TInterpolation;
+}
+
+export interface NumberKeyframe
+ extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
+
+export interface ColorKeyframe
+ extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
+
+export interface DiscreteKeyframe
+ extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
+
+export type AnimationKeyframe =
+ | NumberKeyframe
+ | ColorKeyframe
+ | DiscreteKeyframe;
+
+interface BaseAnimationChannel<
+ TValueKind extends AnimationValueKind,
+ TKeyframe extends AnimationKeyframe,
+> {
+ valueKind: TValueKind;
+ keyframes: TKeyframe[];
+}
+
+export interface NumberAnimationChannel
+ extends BaseAnimationChannel<"number", NumberKeyframe> {}
+
+export interface ColorAnimationChannel
+ extends BaseAnimationChannel<"color", ColorKeyframe> {}
+
+export interface DiscreteAnimationChannel
+ extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
+
+export type AnimationChannel =
+ | NumberAnimationChannel
+ | ColorAnimationChannel
+ | DiscreteAnimationChannel;
+
+export type ElementAnimationChannelMap = Record<
+ string,
+ AnimationChannel | undefined
+>;
+
+export interface ElementAnimations {
+ channels: ElementAnimationChannelMap;
+}
+
+export interface ElementKeyframe {
+ propertyPath: AnimationPropertyPath;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}
+
+export interface SelectedKeyframeRef {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}
diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts
index 61aba9b5..4e10d767 100644
--- a/apps/web/src/types/drag.ts
+++ b/apps/web/src/types/drag.ts
@@ -1,3 +1,5 @@
+import type { VisualElement } from "./timeline";
+
interface BaseDragData {
id: string;
name: string;
@@ -6,6 +8,7 @@ interface BaseDragData {
export interface MediaDragData extends BaseDragData {
type: "media";
mediaType: "image" | "video" | "audio";
+ targetElementTypes?: ("video" | "image")[];
}
export interface TextDragData extends BaseDragData {
@@ -18,4 +21,14 @@ export interface StickerDragData extends BaseDragData {
stickerId: string;
}
-export type TimelineDragData = MediaDragData | TextDragData | StickerDragData;
+export interface EffectDragData extends BaseDragData {
+ type: "effect";
+ effectType: string;
+ targetElementTypes: VisualElement["type"][];
+}
+
+export type TimelineDragData =
+ | MediaDragData
+ | TextDragData
+ | StickerDragData
+ | EffectDragData;
diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts
new file mode 100644
index 00000000..6c96dfb2
--- /dev/null
+++ b/apps/web/src/types/effects.ts
@@ -0,0 +1,69 @@
+export interface Effect {
+ id: string;
+ type: string;
+ params: EffectParamValues;
+ enabled: boolean;
+}
+
+export type EffectParamType = "number" | "boolean" | "select" | "color";
+
+export type EffectParamValues = Record<string, number | string | boolean>;
+
+interface BaseEffectParamDefinition {
+ key: string;
+ label: string;
+}
+
+export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
+ type: "number";
+ default: number;
+ min: number;
+ max: number;
+ step: number;
+}
+
+interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
+ type: "boolean";
+ default: boolean;
+}
+
+interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
+ type: "select";
+ default: string;
+ options: Array<{ value: string; label: string }>;
+}
+
+interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
+ type: "color";
+ default: string;
+}
+
+export type EffectParamDefinition =
+ | NumberEffectParamDefinition
+ | BooleanEffectParamDefinition
+ | SelectEffectParamDefinition
+ | ColorEffectParamDefinition;
+
+export interface WebGLEffectPass {
+ fragmentShader: string;
+ uniforms(params: {
+ effectParams: EffectParamValues;
+ width: number;
+ height: number;
+ }): Record<string, number | number[]>;
+}
+
+export interface WebGLEffectRenderer {
+ type: "webgl";
+ passes: WebGLEffectPass[];
+}
+
+export type EffectRenderer = WebGLEffectRenderer;
+
+export interface EffectDefinition {
+ type: string;
+ name: string;
+ keywords: string[];
+ params: EffectParamDefinition[];
+ renderer: EffectRenderer;
+}
diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts
index 351f5fc4..2c2408d8 100644
--- a/apps/web/src/types/export.ts
+++ b/apps/web/src/types/export.ts
@@ -15,8 +15,6 @@ export interface ExportOptions {
quality: ExportQuality;
fps?: number;
includeAudio?: boolean;
- onProgress?: ({ progress }: { progress: number }) => void;
- onCancel?: () => boolean;
}
export interface ExportResult {
@@ -25,3 +23,9 @@ export interface ExportResult {
error?: string;
cancelled?: boolean;
}
+
+export interface ExportState {
+ isExporting: boolean;
+ progress: number;
+ result: ExportResult | null;
+}
diff --git a/apps/web/src/types/glsl.d.ts b/apps/web/src/types/glsl.d.ts
new file mode 100644
index 00000000..4e93dbe5
--- /dev/null
+++ b/apps/web/src/types/glsl.d.ts
@@ -0,0 +1,4 @@
+declare module "*.glsl" {
+ const value: string;
+ export default value;
+}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 5a98bd31..15696b34 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,3 +1,5 @@
+import type { ElementAnimations } from "./animation";
+import type { Effect, EffectParamValues } from "./effects";
import type { BlendMode, Transform } from "./rendering";
export interface Bookmark {
@@ -17,7 +19,7 @@ export interface TScene {
updatedAt: Date;
}
-export type TrackType = "video" | "text" | "audio" | "sticker";
+export type TrackType = "video" | "text" | "audio" | "sticker" | "effect";
interface BaseTrack {
id: string;
@@ -50,7 +52,18 @@ export interface StickerTrack extends BaseTrack {
hidden: boolean;
}
-export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack;
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | StickerTrack
+ | EffectTrack;
export type { Transform } from "./rendering";
@@ -80,6 +93,8 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
}
export interface VideoElement extends BaseTimelineElement {
@@ -90,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface ImageElement extends BaseTimelineElement {
@@ -99,6 +115,17 @@ export interface ImageElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
}
export interface TextElement extends BaseTimelineElement {
@@ -107,14 +134,7 @@ export interface TextElement extends BaseTimelineElement {
fontSize: number;
fontFamily: string;
color: string;
- background: {
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
- };
+ background: TextBackground;
textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
@@ -125,6 +145,7 @@ export interface TextElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface StickerElement extends BaseTimelineElement {
@@ -134,14 +155,33 @@ export interface StickerElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: EffectParamValues;
}
+export type VisualElement =
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement;
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
export type TimelineElement =
| AudioElement
| VideoElement
| ImageElement
| TextElement
- | StickerElement;
+ | StickerElement
+ | EffectElement;
export type ElementType = TimelineElement["type"];
@@ -154,12 +194,14 @@ export type CreateVideoElement = Omit<VideoElement, "id">;
export type CreateImageElement = Omit<ImageElement, "id">;
export type CreateTextElement = Omit<TextElement, "id">;
export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
export type CreateTimelineElement =
| CreateAudioElement
| CreateVideoElement
| CreateImageElement
| CreateTextElement
- | CreateStickerElement;
+ | CreateStickerElement
+ | CreateEffectElement;
export interface ElementDragState {
isDragging: boolean;
@@ -178,6 +220,7 @@ export interface DropTarget {
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
}
export interface ComputeDropTargetParams {
@@ -193,6 +236,7 @@ export interface ComputeDropTargetParams {
verticalDragDirection?: "up" | "down" | null;
startTimeOverride?: number;
excludeElementId?: string;
+ targetElementTypes?: string[];
}
export interface ClipboardItem {
diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts
index ac82ca17..d45e71c0 100644
--- a/apps/web/src/utils/math.ts
+++ b/apps/web/src/utils/math.ts
@@ -10,6 +10,18 @@ export function clamp({
return Math.max(min, Math.min(max, value));
}
+export function isNearlyEqual({
+ leftValue,
+ rightValue,
+ epsilon = 0.0001,
+}: {
+ leftValue: number;
+ rightValue: number;
+ epsilon?: number;
+}): boolean {
+ return Math.abs(leftValue - rightValue) <= epsilon;
+}
+
export function evaluateMathExpression({
input,
}: {