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/page.tsx142
-rw-r--r--apps/web/src/app/privacy/page.tsx2
-rw-r--r--apps/web/src/app/roadmap/page.tsx8
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx7
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx95
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx1
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx103
-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.tsx61
-rw-r--r--apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx35
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx495
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx560
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx13
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx14
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx371
-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-track.tsx5
-rw-r--r--apps/web/src/components/landing/hero.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx18
-rw-r--r--apps/web/src/components/ui/font-picker.tsx4
-rw-r--r--apps/web/src/components/ui/number-field.tsx7
-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.ts11
-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/selection-manager.ts37
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts136
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts21
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts17
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts20
-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.ts69
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts64
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts185
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts16
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts11
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/index.ts39
-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.ts593
-rw-r--r--apps/web/src/lib/animation/number-channel.ts20
-rw-r--r--apps/web/src/lib/animation/property-registry.ts230
-rw-r--r--apps/web/src/lib/animation/resolve.ts111
-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.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-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.ts3
-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-keyframe.ts89
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts51
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts43
-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.ts38
-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/media/audio.ts68
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts93
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts14
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts28
-rw-r--r--apps/web/src/lib/text/layout.ts167
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts85
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts43
-rw-r--r--apps/web/src/lib/timeline/index.ts2
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts79
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts172
-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.ts83
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts56
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts147
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts5
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts98
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts165
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts4
-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/index.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts11
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts106
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v7-to-v8.ts96
-rw-r--r--apps/web/src/services/storage/migrations/v6-to-v7.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v7-to-v8.ts16
-rw-r--r--apps/web/src/types/animation.ts89
-rw-r--r--apps/web/src/types/drag.ts15
-rw-r--r--apps/web/src/types/effects.ts45
-rw-r--r--apps/web/src/types/glsl.d.ts4
-rw-r--r--apps/web/src/types/rendering.ts9
-rw-r--r--apps/web/src/types/timeline.ts69
-rw-r--r--apps/web/src/utils/math.ts12
118 files changed, 7727 insertions, 1251 deletions
diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx
new file mode 100644
index 00000000..a9072485
--- /dev/null
+++ b/apps/web/src/app/changelog/page.tsx
@@ -0,0 +1,142 @@
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+import { allChangelogs } from "content-collections";
+
+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",
+ },
+};
+
+const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
+
+const knownSectionTitles: Record<string, string> = {
+ new: "Features",
+ improved: "Improvements",
+ fixed: "Fixes",
+ breaking: "Breaking Changes",
+};
+
+function getSectionTitle(type: string): string {
+ return (
+ knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
+ );
+}
+
+export default function ChangelogPage() {
+ const releases = [...allChangelogs].sort((a, b) =>
+ b.version.localeCompare(a.version, undefined, { numeric: true }),
+ );
+
+ 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 && (
+ // ml-1.5 aligns with the center of the 11px timeline dot
+ <Separator className="my-10 sm:ml-1.5" />
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </BasePage>
+ );
+}
+
+type Change = { type: string; text: string };
+type Release = (typeof allChangelogs)[number];
+
+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 };
+}
+
+function ReleaseEntry({ release }: { release: Release }) {
+ const { grouped: groupedChanges, orderedTypes } = groupAndOrderChanges({
+ changes: release.changes,
+ });
+
+ 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]",
+ release.isLatest
+ ? "border-foreground bg-foreground"
+ : "border-muted-foreground/30 bg-background",
+ )}
+ />
+ </div>
+
+ <div className="flex flex-col gap-5">
+ <div className="flex items-center gap-2.5 flex-wrap">
+ <span className="text-sm font-medium tracking-widest text-muted-foreground">
+ {release.version} - {release.date}
+ </span>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ <h2 className="text-2xl font-bold tracking-tight">{release.title}</h2>
+ {release.description && (
+ <p className="text-base text-foreground leading-relaxed max-w-xl">
+ {release.description}
+ </p>
+ )}
+ </div>
+
+ <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">
+ {groupedChanges[type].map((change) => (
+ <li
+ key={change.text}
+ className="text-base text-foreground leading-relaxed"
+ >
+ {change.text}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+ </article>
+ );
+}
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index b71b8d30..4b06405a 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -166,7 +166,7 @@ export default function PrivacyPage() {
<a
href="https://www.databuddy.cc"
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
Databuddy
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index 5227f808..77d9a0cc 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -5,6 +5,8 @@ import { Badge } from "@/components/ui/badge";
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import { cn } from "@/utils/ui";
+const LAST_UPDATED = "February 25, 2026";
+
type StatusType = "complete" | "pending" | "default" | "info";
interface Status {
@@ -47,9 +49,9 @@ const roadmapItems: RoadmapItem[] = [
},
},
{
- title: "Badge (potentially)",
+ title: "Native app (mobile/desktop)",
description:
- 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
+ "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
status: {
text: "Not started",
type: "default",
@@ -88,7 +90,7 @@ export default function RoadmapPage() {
return (
<BasePage
title="Roadmap"
- description="What's coming next for OpenCut (last updated: July 14, 2025)"
+ description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
>
<div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="flex flex-col gap-6">
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..4c6922ff 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -426,6 +426,9 @@ function GridView({
type: "media",
mediaType: item.type,
name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
@@ -476,6 +479,9 @@ function ListView({
type: "media",
mediaType: item.type,
name: item.name,
+ ...(item.type !== "audio" && {
+ targetElementTypes: ["video", "image"] as const,
+ }),
}}
shouldShowPlusOnDrag={false}
onAddToTimeline={({ currentTime }) =>
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/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 90cb11a3..df60db01 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -69,6 +69,7 @@ function RenderTreeController() {
duration,
canvasSize: { width, height },
background: activeProject.settings.background,
+ isPreview: true,
});
editor.renderer.setRenderTree({ renderTree });
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 265e972d..fdd4055d 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
@@ -99,9 +99,9 @@ export function TextEditOverlay({
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = element.letterSpacing ?? 0;
const backgroundColor =
- element.backgroundColor === "transparent"
+ element.background.color === "transparent"
? "transparent"
- : element.backgroundColor;
+ : element.background.color;
return (
<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..bf137291
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import type { EffectElement } from "@/types/timeline";
+import type { EffectParamDefinition } from "@/types/effects";
+import { getEffect } from "@/lib/effects/registry";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { Section, SectionContent, SectionHeader, SectionField, SectionFields } from "./section";
+import { Slider } from "@/components/ui/slider";
+import { NumberField } from "@/components/ui/number-field";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+
+function EffectParamField({
+ param,
+ element,
+ trackId,
+}: {
+ param: EffectParamDefinition;
+ element: EffectElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const currentValue = Number(element.params[param.key] ?? param.default);
+ const min = param.min ?? 0;
+ const max = param.max ?? 100;
+ const step = param.step ?? 1;
+
+ const updateParam = (value: number) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, [param.key]: value } },
+ },
+ ],
+ });
+
+ const commitParam = () => editor.timeline.commitPreview();
+
+ const draft = usePropertyDraft({
+ displayValue: String(currentValue),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min, max });
+ },
+ onPreview: updateParam,
+ onCommit: commitParam,
+ });
+
+ return (
+ <SectionField label={param.label}>
+ <div className="flex items-center gap-3">
+ <Slider
+ className="flex-1"
+ min={min}
+ max={max}
+ step={step}
+ value={[currentValue]}
+ onValueChange={([value]) => updateParam(value)}
+ onValueCommit={commitParam}
+ />
+ <NumberField
+ className="w-16 shrink-0"
+ value={draft.displayValue}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ />
+ </div>
+ </SectionField>
+ );
+}
+
+export function EffectProperties({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const definition = getEffect({ effectType: element.effectType });
+
+ return (
+ <Section hasBorderTop={false}>
+ <SectionHeader title={definition.name} />
+ <SectionContent>
+ <SectionFields>
+ {definition.params.map((param) => (
+ <EffectParamField
+ key={param.key}
+ param={param}
+ element={element}
+ trackId={trackId}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
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..cd62ca75
--- /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 { ElementUpdatePatch } 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 }) => ElementUpdatePatch;
+}) {
+ 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 05ad5396..75dd81a9 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -4,9 +4,37 @@ 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 { EmptyView } from "./empty-view";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+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 _element={element} />;
+ }
+ 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();
@@ -16,30 +44,19 @@ export function PropertiesPanel() {
elements: selectedElements,
});
+ const hasSelection = selectedElements.length > 0;
+
return (
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
- {selectedElements.length > 0 ? (
- <ScrollArea className="h-full">
- {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") {
- return (
- <div key={element.id}>
- <VideoProperties _element={element} />
- </div>
- );
- }
- return null;
- })}
+ {hasSelection ? (
+ <ScrollArea className="h-full scrollbar-hidden">
+ {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 0b45065f..3edd17b5 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -2,6 +2,7 @@ import { createContext, useContext, 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";
const sectionExpandedCache = new Map<string, boolean>();
@@ -131,6 +132,40 @@ export function SectionHeader({
return <div className={baseClassName}>{content}</div>;
}
+export function SectionFields({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ 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)}>
+ <div className="flex items-center gap-1.5">
+ {beforeLabel}
+ <Label>{label}</Label>
+ </div>
+ {children}
+ </div>
+ );
+}
+
export function SectionContent({
children,
className,
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 430efa25..4343c692 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -8,7 +8,7 @@ import {
} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionHeader } from "../section";
+import { Section, SectionContent, SectionField, SectionHeader } from "../section";
import {
Select,
SelectContent,
@@ -124,73 +124,73 @@ export function BlendingSection({
return (
<Section collapsible sectionKey={`${element.type}:blending`}>
<SectionHeader title="Blending" />
- <SectionContent>
- <div className="flex items-start gap-2">
- <div className="w-1/2 space-y-1.5">
- <NumberField
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Opacity" className="w-1/2">
+ <NumberField
+ className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ 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}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- 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}
- dragSensitivity="slow"
- />
- </div>
- <div className="w-1/2 space-y-1.5">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
>
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36">
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode(option.value as BlendMode)
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- </SectionContent>
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36">
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode(option.value as BlendMode)
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
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 17a54b4c..3fdb47dc 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,15 @@
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, 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,
+} from "../section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -13,71 +19,125 @@ 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 {
+function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
+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,
}: {
- element: TransformElement;
+ element: VisualElement;
trackId: string;
}) {
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,160 +148,227 @@ 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>
- <div className="flex flex-col gap-2">
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
+ <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}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
<NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
+ icon="X"
className="flex-1"
+ value={positionX.displayValue}
+ onFocus={positionX.onFocus}
+ onChange={positionX.onChange}
+ onBlur={positionX.onBlur}
+ onScrub={positionX.scrubTo}
+ onScrubEnd={positionX.commitScrub}
+ onReset={() =>
+ 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,
+ })}
/>
- )}
- <Button
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- 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
- }
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- 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
- }
- />
- </div>
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={positionY.displayValue}
+ onFocus={positionY.onFocus}
+ onChange={positionY.onChange}
+ onBlur={positionY.onBlur}
+ onScrub={positionY.scrubTo}
+ onScrubEnd={positionY.commitScrub}
+ onReset={() =>
+ 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 className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-1"
- 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,
- },
- },
- },
- ],
- })
- }
- isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
- />
- </div>
- </div>
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <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 cfca52dc..1a17c26a 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -1,31 +1,49 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import type { TextElement } from "@/types/timeline";
-import { Slider } from "@/components/ui/slider";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
-import { Section, SectionContent, SectionHeader } from "./section";
+import { Section, SectionContent, SectionField, SectionFields, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
+ DEFAULT_LETTER_SPACING,
+ DEFAULT_LINE_HEIGHT,
+ DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
import { TransformSection, BlendingSection } from "./sections";
-import {
- Select,
- SelectTrigger,
- SelectValue,
- SelectContent,
- SelectItem,
-} from "@/components/ui/select";
-import { OcFontWeightIcon } from "@opencut/ui/icons";
-import { Label } from "@/components/ui/label";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { TextFontIcon } 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 });
export function TextProperties({
element,
@@ -39,8 +57,9 @@ export function TextProperties({
<ContentSection element={element} trackId={trackId} />
<TransformSection element={element} trackId={trackId} />
<BlendingSection element={element} trackId={trackId} />
- <FontSection element={element} trackId={trackId} />
- <ColorSection element={element} trackId={trackId} />
+ <TypographySection element={element} trackId={trackId} />
+ <SpacingSection element={element} trackId={trackId} />
+ <BackgroundSection element={element} trackId={trackId} />
</div>
);
}
@@ -83,7 +102,7 @@ function ContentSection({
);
}
-function FontSection({
+function TypographySection({
element,
trackId,
}: {
@@ -109,10 +128,11 @@ function FontSection({
});
return (
- <Section collapsible sectionKey="text:font">
- <SectionHeader title="Font" />
- <SectionContent>
- <div className="flex flex-col gap-2">
+ <Section collapsible sectionKey="text:typography">
+ <SectionHeader title="Typography" />
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value) =>
@@ -127,52 +147,409 @@ function FontSection({
})
}
/>
- <Select value={element.fontWeight}>
- <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
- <SelectValue placeholder="Select weight" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="100">Thin</SelectItem>
- <SelectItem value="200">Extra Light</SelectItem>
- <SelectItem value="300">Light</SelectItem>
- <SelectItem value="400">Normal</SelectItem>
- <SelectItem value="500">Medium</SelectItem>
- <SelectItem value="600">Semi Bold</SelectItem>
- <SelectItem value="700">Bold</SelectItem>
- <SelectItem value="800">Extra Bold</SelectItem>
- <SelectItem value="900">Black</SelectItem>
- </SelectContent>
- </Select>
+ </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}` },
+ },
+ ],
+ })
+ }
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ 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 } }],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const lineHeight = usePropertyDraft({
+ displayValue: lineHeightConverter.toDisplay(element.lineHeight ?? DEFAULT_LINE_HEIGHT).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : lineHeightConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ 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>
+ );
+}
- <Label>Font size</Label>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- step={1}
- onValueChange={([value]) =>
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ const cornerRadius = usePropertyDraft({
+ displayValue: Math.round(element.background.cornerRadius ?? 0).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(),
+ });
+
+ const paddingX = usePropertyDraft({
+ displayValue: paddingXConverter.toDisplay(element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : paddingXConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { background: { ...element.background, paddingX: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const paddingY = usePropertyDraft({
+ displayValue: paddingYConverter.toDisplay(element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : paddingYConverter.fromDisplay(Math.round(parsed));
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { background: { ...element.background, paddingY: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const offsetX = usePropertyDraft({
+ displayValue: Math.round(element.background.offsetX ?? 0).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(),
+ });
+
+ const offsetY = usePropertyDraft({
+ displayValue: Math.round(element.background.offsetY ?? 0).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, offsetY: value } },
+ },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey="text:background">
+ <SectionHeader title="Background" />
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Color">
+ <ColorPicker
+ value={
+ element.background.color === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.background.color.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { fontSize: value },
+ updates: {
+ background: { ...element.background, color: hexColor },
+ },
},
],
- })
+ });
+ }}
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ className={
+ element.background.color === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
}
- onValueCommit={() => editor.timeline.commitPreview()}
- className="w-full"
/>
+ </SectionField>
+ <div className="flex items-start gap-2">
+ <SectionField label="Width" className="w-1/2">
<NumberField
- className="w-18 shrink-0"
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
+ 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
+ }
+ />
+ </SectionField>
+ <SectionField label="Height" className="w-1/2">
+ <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
+ }
+ />
+ </SectionField>
+ </div>
+ <div className="flex items-start gap-2">
+ <SectionField label="X-offset" className="w-1/2">
+ <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}
+ />
+ </SectionField>
+ <SectionField label="Y-offset" className="w-1/2">
+ <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}
+ />
+ </SectionField>
+ </div>
+ <SectionField label="Corner Radius">
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={0}
+ onFocus={cornerRadius.onFocus}
+ onChange={cornerRadius.onChange}
+ onBlur={cornerRadius.onBlur}
+ onScrub={cornerRadius.scrubTo}
+ onScrubEnd={cornerRadius.commitScrub}
onReset={() =>
editor.timeline.updateElements({
updates: [
@@ -180,82 +557,19 @@ function FontSection({
trackId,
elementId: element.id,
updates: {
- fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
+ background: {
+ ...element.background,
+ cornerRadius: 0,
+ },
},
},
],
})
}
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ isDefault={(element.background.cornerRadius ?? 0) === 0}
/>
- </div>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function ColorSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_COLOR);
-
- return (
- <Section collapsible sectionKey="text:color" hasBorderBottom={false}>
- <SectionHeader title="Color" />
- <SectionContent>
- <div className="flex flex-col gap-6">
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- })
- }
- onChangeEnd={() => editor.timeline.commitPreview()}
- />
- <ColorPicker
- value={
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : element.backgroundColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: hexColor },
- },
- ],
- });
- }}
- onChangeEnd={() => editor.timeline.commitPreview()}
- className={
- element.backgroundColor === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
- />
- </div>
+ </SectionField>
+ </SectionFields>
</SectionContent>
</Section>
);
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 bf6d92d8..3588cbb7 100644
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -1,9 +1,17 @@
-import type { ImageElement, VideoElement } from "@/types/timeline";
+import type { ImageElement, StickerElement, VideoElement } from "@/types/timeline";
+import { BlendingSection, TransformSection } from "./sections";
export function VideoProperties({
- _element,
+ element,
+ trackId,
}: {
- _element: VideoElement | ImageElement;
+ element: VideoElement | ImageElement | StickerElement;
+ trackId: string;
}) {
- return <div className="space-y-4 p-5">Video properties</div>;
+ return (
+ <div className="flex h-full flex-col">
+ <TransformSection element={element} trackId={trackId} />
+ <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/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 7e6ff326..d89d270d 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -23,11 +23,11 @@ import { TimelinePlayhead } from "./timeline-playhead";
import { SelectionBox } from "../../selection-box";
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
import { SnapIndicator } from "./snap-indicator";
-import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+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 {
@@ -161,6 +161,7 @@ export function Timeline() {
shouldIgnoreClick,
} = useSelectionBox({
containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
onSelectionComplete: (elements) => {
setElementSelection({ elements });
},
@@ -326,7 +327,7 @@ export function Timeline() {
<DragLine
dropTarget={dropTarget}
tracks={timeline.getTracks()}
- isVisible={isDragOver}
+ isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
@@ -453,6 +454,11 @@ export function Timeline() {
}}
onTrackClick={handleTracksClick}
shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? dropTarget?.targetElement?.elementId ?? null
+ : null
+ }
/>
</div>
</ContextMenuTrigger>
@@ -523,7 +529,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 e70f0cd7..927d336a 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -1,7 +1,11 @@
"use client";
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
-import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+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 628855f6..38cf953b 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,17 @@ 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 type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { getElementKeyframes } from "@/lib/animation";
import {
getTrackClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
} from "@/lib/timeline";
import {
ContextMenu,
@@ -19,7 +22,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
@@ -42,10 +45,105 @@ 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";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+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 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);
+}
function getDisplayShortcut(action: TAction) {
const { defaultShortcuts } = getActionDefinition(action);
@@ -71,6 +169,7 @@ interface TimelineElementProps {
) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
dragState: ElementDragState;
+ isDropTarget?: boolean;
}
export function TimelineElement({
@@ -83,6 +182,7 @@ export function TimelineElement({
onElementMouseDown,
onElementClick,
dragState,
+ isDropTarget = false,
}: TimelineElementProps) {
const editor = useEditor();
const { selectedElements } = useElementSelection();
@@ -123,10 +223,25 @@ 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 handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
event.stopPropagation();
if (hasMediaId(element)) {
@@ -160,7 +275,9 @@ export function TimelineElement({
onElementClick={onElementClick}
onElementMouseDown={onElementMouseDown}
handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
/>
+ {isSelected && <KeyframeIndicators indicators={keyframeIndicators} />}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
@@ -197,7 +314,9 @@ export function TimelineElement({
<>
<ContextMenuItem
icon={<HugeiconsIcon icon={Search01Icon} />}
- onClick={(event) => handleRevealInMedia({ event })}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
>
Reveal media
</ContextMenuItem>
@@ -231,6 +350,7 @@ function ElementInner({
onElementClick,
onElementMouseDown,
handleResizeStart,
+ isDropTarget = false,
}: {
element: TimelineElementType;
track: TimelineTrack;
@@ -248,14 +368,20 @@ function ElementInner({
elementId: string;
side: "left" | "right";
}) => void;
+ isDropTarget?: boolean;
}) {
+ const opacityClass =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget
+ ? "opacity-50"
+ : "";
+
return (
<div
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
{
type: track.type,
},
- )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ )} ${opacityClass}`}
>
<button
type="button"
@@ -271,7 +397,6 @@ function ElementInner({
mediaAssets={mediaAssets}
/>
</div>
-
{(hasAudio
? isMuted
: canElementBeHidden(element) && element.hidden) && (
@@ -335,52 +460,142 @@ function ResizeHandle({
);
}
-function ElementContent({
- element,
- track,
- isSelected,
- mediaAssets,
+function KeyframeIndicators({
+ indicators,
}: {
+ indicators: KeyframeIndicator[];
+}) {
+ const { isKeyframeSelected, toggleKeyframeSelection, selectKeyframeRange } =
+ useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ const handleKeyframeMouseDown = ({ event }: { event: React.MouseEvent }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleKeyframeClick = ({
+ event,
+ keyframes,
+ }: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => {
+ event.stopPropagation();
+ if (event.shiftKey) {
+ selectKeyframeRange({
+ orderedKeyframes,
+ targetKeyframes: keyframes,
+ isAdditive: event.metaKey || event.ctrlKey,
+ });
+ return;
+ }
+
+ toggleKeyframeSelection({
+ keyframes,
+ isMultiKey: event.metaKey || event.ctrlKey,
+ });
+ };
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer"
+ style={{ left: indicator.offsetPx }}
+ onMouseDown={(event) => handleKeyframeMouseDown({ event })}
+ onClick={(event) =>
+ handleKeyframeClick({ event, keyframes: indicator.keyframes })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
mediaAssets: MediaAsset[];
-}) {
- if (element.type === "text") {
+}
+
+type ElementContentRenderer = (props: ElementContentProps) => ReactNode;
+
+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>
+ <div className="flex size-full items-center justify-start pl-3">
+ <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">{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,28 +614,78 @@ function ElementContent({
return (
<span className="text-foreground/80 truncate text-xs">
- {element.name}
+ {audioElement.name}
</span>
);
- }
+ },
+ video: ({ element, track, isSelected, mediaAssets }) => {
+ const videoElement = element as Extract<
+ TimelineElementType,
+ { type: "video" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === videoElement.mediaId,
+ );
+
+ if (!mediaAsset) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {videoElement.name}
+ </span>
+ );
+ }
+
+ if (mediaAsset.thumbnailUrl) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+
+ 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: `url(${mediaAsset.thumbnailUrl})`,
+ 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>
+ );
+ }
- const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
- if (!mediaAsset) {
return (
<span className="text-foreground/80 truncate text-xs">
- {element.name}
+ {videoElement.name}
</span>
);
- }
+ },
+ image: ({ element, track, isSelected, mediaAssets }) => {
+ const imageElement = element as Extract<
+ TimelineElementType,
+ { type: "image" }
+ >;
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === imageElement.mediaId,
+ );
+
+ if (!mediaAsset?.url) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {imageElement.name}
+ </span>
+ );
+ }
- 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">
@@ -430,7 +695,7 @@ function ElementContent({
<div
className="absolute right-0 left-0"
style={{
- backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
+ backgroundImage: `url(${mediaAsset.url})`,
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
@@ -442,11 +707,12 @@ function ElementContent({
</div>
</div>
);
- }
+ },
+};
- return (
- <span className="text-foreground/80 truncate text-xs">{element.name}</span>
- );
+function ElementContent(props: ElementContentProps) {
+ const renderer = ELEMENT_CONTENT_RENDERERS[props.element.type];
+ return <>{renderer(props)}</>;
}
function CopyMenuItem() {
@@ -549,10 +815,11 @@ 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);
}}
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-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index b7b06d5b..fbc7cb3f 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
-import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import type { ElementDragState } from "@/types/timeline";
@@ -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,6 +49,7 @@ export function TimelineTrackContent({
onTrackMouseDown,
onTrackClick,
shouldIgnoreClick,
+ targetElementId = null,
}: TimelineTrackContentProps) {
const editor = useEditor();
const { isElementSelected, clearElementSelection } = useElementSelection();
@@ -102,6 +104,7 @@ export function TimelineTrackContent({
onElementClick({ event, element, track })
}
dragState={dragState}
+ isDropTarget={element.id === targetElementId}
/>
);
})
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 6f11ad7c..a8f3f70d 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -14,7 +14,7 @@ export function Hero() {
src="/landing-page-dark.png"
height={1903.5}
width={1269}
- alt="landing-page.bg"
+ alt="OpenCut video editor landing page background"
/>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center">
<div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]">
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index d58b6144..fbf9bbd7 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -280,13 +280,21 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
)}
{...props}
>
- <PopoverTrigger asChild>
- <button
- className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
+ type="button"
+ >
+ <span
+ className="absolute inset-0"
+ style={checkerboardStyle}
+ />
+ <span
+ className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
- type="button"
/>
- </PopoverTrigger>
+ </button>
+ </PopoverTrigger>
<div className="flex flex-1 items-center">
<Input
className={cn(
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index d5d56255..09ba0a9a 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -26,7 +26,7 @@ import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui";
import { ChevronDown, Search, Upload } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
-import { TextFontIcon } from "@hugeicons/core-free-icons";
+import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
@@ -139,7 +139,7 @@ export function FontPicker({
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
- <HugeiconsIcon icon={TextFontIcon} />
+ <HugeiconsIcon icon={TextIcon} />
</span>
<span className="truncate" style={{ fontFamily: defaultValue }}>
{defaultValue ?? "Select a font"}
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 35cce81b..48297dc0 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -71,9 +71,12 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
+ const sensitivity =
+ typeof dragSensitivity === "number"
+ ? dragSensitivity
+ : DRAG_SENSITIVITIES[dragSensitivity];
const newValue =
- startValueRef.current +
- cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
+ startValueRef.current + cumulativeDeltaRef.current * sensitivity;
onScrub(newValue);
};
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 f0b2143d..6b688ed1 100644
--- a/apps/web/src/constants/text-constants.ts
+++ b/apps/web/src/constants/text-constants.ts
@@ -17,6 +17,15 @@ export const FONT_SIZE_SCALE_REFERENCE = 90;
export const DEFAULT_LETTER_SPACING = 0;
export const DEFAULT_LINE_HEIGHT = 1.2;
+export const DEFAULT_TEXT_BACKGROUND = {
+ color: "#000000",
+ cornerRadius: 0,
+ paddingX: 30,
+ paddingY: 42,
+ offsetX: 0,
+ offsetY: 0,
+};
+
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
@@ -24,7 +33,7 @@ export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
fontSize: 15,
fontFamily: "Arial",
color: "#ffffff",
- backgroundColor: "#000000",
+ background: DEFAULT_TEXT_BACKGROUND,
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
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/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 cf906aee..f6a1744a 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -5,6 +5,11 @@ import type {
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/types/animation";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -24,6 +29,9 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
+ UpsertKeyframeCommand,
+ RemoveKeyframeCommand,
+ RetimeKeyframeCommand,
} from "@/lib/commands/timeline";
import { BatchCommand, PreviewTracker } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
@@ -61,7 +69,11 @@ export class TimelineManager {
trimEnd: number;
pushHistory?: boolean;
}): void {
- const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
+ const command = new UpdateElementTrimCommand({
+ elementId,
+ trimStart,
+ trimEnd,
+ });
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@@ -80,11 +92,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 +111,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 });
}
@@ -116,13 +131,13 @@ export class TimelineManager {
newStartTime: number;
createTrack?: { type: TrackType; index: number };
}): void {
- const command = new MoveElementCommand(
+ const command = new MoveElementCommand({
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
- );
+ });
this.editor.command.execute({ command });
}
@@ -147,12 +162,12 @@ export class TimelineManager {
retainSide?: "both" | "left" | "right";
rippleEnabled?: boolean;
}): { trackId: string; elementId: string }[] {
- const command = new SplitElementsCommand(
+ const command = new SplitElementsCommand({
elements,
splitTime,
retainSide,
rippleEnabled,
- );
+ });
this.editor.command.execute({ command });
return command.getRightSideElements();
}
@@ -206,7 +221,7 @@ export class TimelineManager {
elements: { trackId: string; elementId: string }[];
rippleEnabled?: boolean;
}): void {
- const command = new DeleteElementsCommand(elements, rippleEnabled);
+ const command = new DeleteElementsCommand({ elements, rippleEnabled });
this.editor.command.execute({ command });
}
@@ -217,13 +232,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);
@@ -234,6 +253,99 @@ export class TimelineManager {
}
}
+ 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 });
+ }
+
isPreviewActive(): boolean {
return this.previewTracker.isActive();
}
@@ -244,7 +356,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/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 79f1272c..3831a86f 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -17,14 +17,16 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
-import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
+import {
+ snapElementEdge,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
} from "@/types/timeline";
-import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
interface UseElementInteractionProps {
zoomLevel: number;
@@ -162,7 +164,6 @@ export function useElementInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
- const { snapElementEdge } = useTimelineSnapping();
const {
isElementSelected,
selectElement,
@@ -255,14 +256,7 @@ export function useElementInteraction({
snapPoint: snapResult.snapPoint,
};
},
- [
- snappingEnabled,
- editor.playback,
- snapElementEdge,
- tracks,
- zoomLevel,
- isShiftHeldRef,
- ],
+ [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
@@ -598,9 +592,12 @@ export function useElementInteraction({
});
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 14693fff..2340c9b2 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -5,9 +5,10 @@ import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
- useTimelineSnapping,
+ findSnapPoints,
+ snapToNearestPoint,
type SnapPoint,
-} from "@/hooks/timeline/use-timeline-snapping";
+} from "@/lib/timeline/snap-utils";
import { useTimelineStore } from "@/stores/timeline-store";
export interface ResizeState {
@@ -39,7 +40,7 @@ export function useTimelineElementResize({
const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
- const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
+
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@@ -85,12 +86,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 }) => {
@@ -269,8 +266,6 @@ export function useTimelineElementResize({
activeProject.settings.fps,
snappingEnabled,
editor,
- findSnapPoints,
- snapToNearestPoint,
element.id,
onSnapPointChange,
canExtendElementDuration,
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..50def392
--- /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];
+ if (!isMultiKey) {
+ setKeyframeSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ 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-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index faf28e40..0e7fabd5 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -10,9 +10,12 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
-import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
+import {
+ findSnapPoints,
+ snapToNearestPoint,
+ type SnapPoint,
+} from "@/lib/timeline/snap-utils";
import type { Bookmark } from "@/types/timeline";
-import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
export interface BookmarkDragState {
isDragging: boolean;
@@ -47,8 +50,6 @@ export function useBookmarkDrag({
const playheadTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
- const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
-
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
@@ -112,16 +113,7 @@ export function useBookmarkDrag({
snapPoint: result.snapPoint,
};
},
- [
- snappingEnabled,
- findSnapPoints,
- snapToNearestPoint,
- tracks,
- playheadTime,
- bookmarks,
- zoomLevel,
- isShiftHeldRef,
- ],
+ [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
);
useEffect(() => {
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..ee739298 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,7 +17,11 @@ 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>;
@@ -54,6 +59,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 +76,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) {
@@ -124,6 +134,13 @@ export function useTimelineDragDrop({
const mouseX = e.clientX - rect.left;
const mouseY = Math.max(0, e.clientY - rect.top - headerHeight);
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
+
const target = computeDropTarget({
elementType,
mouseX,
@@ -134,6 +151,7 @@ export function useTimelineDragDrop({
elementDuration: duration,
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
zoomLevel,
+ targetElementTypes,
});
target.xPosition = getSnappedTime({ time: target.xPosition });
@@ -248,6 +266,11 @@ export function useTimelineDragDrop({
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;
@@ -284,6 +307,42 @@ export function useTimelineDragDrop({
[editor.timeline, mediaAssets, tracks],
);
+ const executeEffectDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: EffectDragData }) => {
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack && !target.targetElement) {
+ trackId = effectTrack.id;
+ } else if (target.targetElement) {
+ trackId = effectTrack?.id ?? editor.timeline.addTrack({
+ type: "effect",
+ index: 0,
+ });
+ } else if (target.isNewTrack) {
+ trackId = editor.timeline.addTrack({
+ type: "effect",
+ index: target.trackIndex,
+ });
+ } 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, tracks],
+ );
+
const executeFileDrop = useCallback(
async ({
files,
@@ -384,6 +443,11 @@ 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 });
}
@@ -410,6 +474,7 @@ export function useTimelineDragDrop({
executeTextDrop,
executeStickerDrop,
executeMediaDrop,
+ executeEffectDrop,
executeFileDrop,
containerRef,
headerRef,
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 44e12ab1..1426a003 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -4,9 +4,9 @@ import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
- useTimelineSnapping,
- type SnapPoint,
-} from "@/hooks/timeline/use-timeline-snapping";
+ findSnapPoints,
+ snapToNearestPoint,
+} from "@/lib/timeline/snap-utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -31,10 +31,6 @@ export function useTimelinePlayhead({
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
- const { snapToNearestPoint } = useTimelineSnapping({
- enableElementSnapping: false,
- enablePlayheadSnapping: false,
- });
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@@ -51,7 +47,13 @@ export function useTimelinePlayhead({
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
const handleScrub = useCallback(
- ({ event }: { event: MouseEvent | React.MouseEvent }) => {
+ ({
+ event,
+ snappingEnabled = true,
+ }: {
+ event: MouseEvent | React.MouseEvent;
+ snappingEnabled?: boolean;
+ }) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rulerRect = ruler.getBoundingClientRect();
@@ -80,21 +82,25 @@ export function useTimelinePlayhead({
fps: framesPerSecond,
});
- const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
- const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
- time: bookmark.time,
- type: "bookmark",
- }));
- const shouldSnapToBookmark =
- !isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
- const snapResult = shouldSnapToBookmark
- ? snapToNearestPoint({
- targetTime: frameTime,
- snapPoints: bookmarkSnapPoints,
- zoomLevel,
- })
- : null;
- const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ const time = (() => {
+ if (!shouldSnap) return frameTime;
+ const tracks = editor.timeline.getTracks();
+ const bookmarks =
+ editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime: frameTime,
+ bookmarks,
+ enablePlayheadSnapping: false,
+ });
+ const snapResult = snapToNearestPoint({
+ targetTime: frameTime,
+ snapPoints,
+ zoomLevel,
+ });
+ return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
+ })();
setScrubTime(time);
seek({ time });
@@ -109,7 +115,7 @@ export function useTimelinePlayhead({
activeProject.settings.fps,
isShiftHeldRef,
editor.scenes,
- snapToNearestPoint,
+ editor.timeline,
],
);
@@ -133,10 +139,10 @@ export function useTimelinePlayhead({
setIsDraggingRuler(true);
setHasDraggedRuler(false);
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event });
- },
- [handleScrub, playheadRef, editor.playback],
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -184,7 +190,7 @@ export function useTimelinePlayhead({
if (isDraggingRuler) {
setIsDraggingRuler(false);
if (!hasDraggedRuler) {
- handleScrub({ event });
+ handleScrub({ event, snappingEnabled: false });
}
setHasDraggedRuler(false);
}
diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
deleted file mode 100644
index 5de75448..00000000
--- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { useCallback } from "react";
-import type { Bookmark, TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-
-export interface SnapPoint {
- time: number;
- type: "element-start" | "element-end" | "playhead" | "bookmark";
- elementId?: string;
- trackId?: string;
-}
-
-export interface SnapResult {
- snappedTime: number;
- snapPoint: SnapPoint | null;
- snapDistance: number;
-}
-
-export interface UseTimelineSnappingOptions {
- snapThreshold?: number;
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
- enableBookmarkSnapping?: boolean;
-}
-
-export function useTimelineSnapping({
- snapThreshold = 10,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
- enableBookmarkSnapping = true,
-}: UseTimelineSnappingOptions = {}) {
- const findSnapPoints = useCallback(
- ({
- tracks,
- playheadTime,
- excludeElementId,
- bookmarks = [],
- excludeBookmarkTime,
- }: {
- tracks: Array<TimelineTrack>;
- playheadTime: number;
- excludeElementId?: string;
- bookmarks?: Array<Bookmark>;
- excludeBookmarkTime?: number;
- }): SnapPoint[] => {
- const snapPoints: SnapPoint[] = [];
-
- if (enableElementSnapping) {
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.id === excludeElementId) continue;
-
- const elementStart = element.startTime;
- const elementEnd = element.startTime + element.duration;
-
- snapPoints.push(
- {
- time: elementStart,
- type: "element-start",
- elementId: element.id,
- trackId: track.id,
- },
- {
- time: elementEnd,
- type: "element-end",
- elementId: element.id,
- trackId: track.id,
- },
- );
- }
- }
- }
-
- if (enablePlayheadSnapping) {
- snapPoints.push({
- time: playheadTime,
- type: "playhead",
- });
- }
-
- if (enableBookmarkSnapping) {
- for (const bookmark of bookmarks) {
- if (
- excludeBookmarkTime != null &&
- Math.abs(bookmark.time - excludeBookmarkTime) <
- BOOKMARK_TIME_EPSILON
- ) {
- continue;
- }
- snapPoints.push({
- time: bookmark.time,
- type: "bookmark",
- });
- }
- }
-
- return snapPoints;
- },
- [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
- );
-
- const snapToNearestPoint = useCallback(
- ({
- targetTime,
- snapPoints,
- zoomLevel,
- }: {
- targetTime: number;
- snapPoints: Array<SnapPoint>;
- zoomLevel: number;
- }): SnapResult => {
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const thresholdInSeconds = snapThreshold / pixelsPerSecond;
-
- let closestSnapPoint: SnapPoint | null = null;
- let closestDistance = Infinity;
-
- for (const snapPoint of snapPoints) {
- const distance = Math.abs(targetTime - snapPoint.time);
- if (distance < thresholdInSeconds && distance < closestDistance) {
- closestDistance = distance;
- closestSnapPoint = snapPoint;
- }
- }
-
- return {
- snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
- snapPoint: closestSnapPoint,
- snapDistance: closestDistance,
- };
- },
- [snapThreshold],
- );
-
- const snapElementEdge = useCallback(
- ({
- targetTime,
- elementDuration,
- tracks,
- playheadTime,
- zoomLevel,
- excludeElementId,
- snapToStart = true,
- bookmarks = [],
- }: {
- targetTime: number;
- elementDuration: number;
- tracks: Array<TimelineTrack>;
- playheadTime: number;
- zoomLevel: number;
- excludeElementId?: string;
- snapToStart?: boolean;
- bookmarks?: Array<Bookmark>;
- }): SnapResult => {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- bookmarks,
- });
-
- const effectiveTargetTime = snapToStart
- ? targetTime
- : targetTime + elementDuration;
- const snapResult = snapToNearestPoint({
- targetTime: effectiveTargetTime,
- snapPoints,
- zoomLevel,
- });
-
- if (!snapToStart && snapResult.snapPoint) {
- snapResult.snappedTime = snapResult.snappedTime - elementDuration;
- }
-
- return snapResult;
- },
- [findSnapPoints, snapToNearestPoint],
- );
-
- return {
- snapElementEdge,
- findSnapPoints,
- snapToNearestPoint,
- };
-}
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-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index 2bb33f21..d29f2a4b 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -4,9 +4,16 @@ import { useShiftKey } from "@/hooks/use-shift-key";
import type { TextElement, Transform } from "@/types/timeline";
import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
import { hitTest } from "@/lib/preview/hit-test";
-import { screenToCanvas } from "@/lib/preview/preview-coords";
+import {
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ snapPosition,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
const MIN_DRAG_DISTANCE = 0.5;
@@ -230,11 +237,16 @@ export function usePreviewInteraction({
};
const shouldSnap = !isShiftHeldRef.current;
+ const snapThreshold = screenPixelsToLogicalThreshold({
+ canvas: canvasRef.current,
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
elementSize: dragStateRef.current.bounds,
+ snapThreshold,
})
: {
snappedPosition: proposedPosition,
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index 37ef6d5f..8be6420c 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -6,9 +6,13 @@ import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
-import { screenToCanvas } from "@/lib/preview/preview-coords";
+import {
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
import {
MIN_SCALE,
+ SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
type SnapLine,
@@ -228,6 +232,10 @@ export function useTransformHandles({
);
const canvasSize = editor.project.getActive().settings.canvasSize;
+ const snapThreshold = screenPixelsToLogicalThreshold({
+ canvas: canvasRef.current,
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
const shouldSnap = !isShiftHeldRef.current;
const { snappedScale, activeLines } = shouldSnap
? snapScale({
@@ -236,6 +244,7 @@ export function useTransformHandles({
baseWidth,
baseHeight,
canvasSize,
+ snapThreshold,
})
: { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
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/index.ts b/apps/web/src/lib/animation/index.ts
new file mode 100644
index 00000000..c41c1a7e
--- /dev/null
+++ b/apps/web/src/lib/animation/index.ts
@@ -0,0 +1,39 @@
+export {
+ getChannelValueAtTime,
+ getNumberChannelValueAtTime,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setChannel,
+ splitAnimationsAtTime,
+ upsertElementKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ 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..d3f68010
--- /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,
+ });
+ },
+ });
+}
+
+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..56f156a4
--- /dev/null
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -0,0 +1,593 @@
+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..44f5d638
--- /dev/null
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -0,0 +1,230 @@
+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 { 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 }) => number | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: number;
+ }) => 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 },
+ },
+ }
+ : 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 },
+ },
+ }
+ : 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 } }
+ : 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 } }
+ : 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 } : 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 } : 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 || typeof coercedValue !== "number") {
+ 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..58f292b1
--- /dev/null
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -0,0 +1,111 @@
+import type { ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/types/timeline";
+import { getNumberChannelValueAtTime } from "./interpolation";
+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 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 9e6ed8cd..7fb46c36 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -5,12 +5,19 @@ 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 }[],
- private rippleEnabled = false,
- ) {
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
- if (!hasElementsToDelete) {
- return track;
- }
+ if (!hasElementsToDelete) {
+ return track;
+ }
- const deletedElementInfos = elementsToDeleteOnTrack
- .map((target) =>
- track.elements.find((element) => element.id === target.elementId),
- )
- .filter((element): element is NonNullable<typeof element> => element !== undefined)
- .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+ 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,
- ),
- );
+ 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(
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/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 3f10baa4..69d94c24 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -9,3 +9,4 @@ 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";
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..f221e33a
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,3 @@
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-keyframe";
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-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..6aadcf16 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -14,15 +14,31 @@ import {
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;
- 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,
+ }: {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+ createTrack?: { type: TrackType; index: number };
+ }) {
super();
+ this.sourceTrackId = sourceTrackId;
+ this.targetTrackId = targetTrackId;
+ this.elementId = elementId;
+ this.newStartTime = newStartTime;
+ this.createTrack = createTrack;
}
execute(): void {
@@ -30,10 +46,10 @@ 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) {
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
return;
}
- 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({
@@ -74,6 +90,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,12 +98,12 @@ 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,
),
};
}
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
if (track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.filter((el) => el.id !== this.elementId),
+ elements: track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ ),
};
}
@@ -105,8 +124,8 @@ export class MoveElementCommand extends Command {
};
}
- return track;
- }) as TimelineTrack[];
+ return track;
+ });
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 05a23fff..37ccbb12 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -3,19 +3,33 @@ 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",
- private rippleEnabled = false,
- ) {
+ 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 }[] {
@@ -61,6 +75,11 @@ export class SplitElementsCommand extends Command {
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 [
@@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
];
}
@@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
}
- const secondElementId = generateUUID();
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
{
...element,
@@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
});
- if (
- this.rippleEnabled &&
- leftVisibleDurationForRipple !== null
- ) {
+ if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
elements = rippleShiftElements({
elements,
afterTime: this.splitTime,
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..6afcb9d0 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,35 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
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;
- constructor(
- private elementId: string,
- private trimStart: number,
- private trimEnd: number,
- private startTime?: number,
- private duration?: number,
- ) {
+ constructor({
+ elementId,
+ trimStart,
+ trimEnd,
+ startTime,
+ duration,
+ }: {
+ elementId: string;
+ trimStart: number;
+ trimEnd: number;
+ startTime?: number;
+ duration?: number;
+ }) {
super();
+ this.elementId = elementId;
+ this.trimStart = trimStart;
+ this.trimEnd = trimEnd;
+ this.startTime = startTime;
+ this.duration = duration;
}
execute(): void {
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
return element;
}
+ const nextDuration = this.duration ?? element.duration;
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
- duration: this.duration ?? element.duration,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
};
});
return { ...track, elements: newElements } as typeof track;
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/media/audio.ts b/apps/web/src/lib/media/audio.ts
index 0b71cbaa..6f789301 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
+
+const MAX_AUDIO_CHANNELS = 2;
+const EXPORT_SAMPLE_RATE = 44100;
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
> & { buffer: AudioBuffer };
-export function createAudioContext(): AudioContext {
+export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
- return new AudioContextConstructor();
+ return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
}
export interface DecodedAudio {
@@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({
mediaAsset: MediaAsset;
audioContext: AudioContext;
}): Promise<AudioBuffer | null> {
+ const input = new Input({
+ source: new BlobSource(mediaAsset.file),
+ formats: ALL_FORMATS,
+ });
+
try {
- const arrayBuffer = await mediaAsset.file.arrayBuffer();
- return await audioContext.decodeAudioData(arrayBuffer.slice(0));
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) return null;
+
+ const sink = new AudioBufferSink(audioTrack);
+ const targetSampleRate = audioContext.sampleRate;
+
+ const chunks: AudioBuffer[] = [];
+ let totalSamples = 0;
+
+ for await (const { buffer } of sink.buffers(0)) {
+ chunks.push(buffer);
+ totalSamples += buffer.length;
+ }
+
+ if (chunks.length === 0) return null;
+
+ const nativeSampleRate = chunks[0].sampleRate;
+ const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
+
+ const nativeChannels = Array.from(
+ { length: numChannels },
+ () => new Float32Array(totalSamples),
+ );
+ let offset = 0;
+ for (const chunk of chunks) {
+ for (let channel = 0; channel < numChannels; channel++) {
+ const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
+ nativeChannels[channel].set(sourceData, offset);
+ }
+ offset += chunk.length;
+ }
+
+ // use OfflineAudioContext for high-quality resampling to target rate
+ const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
+ const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
+
+ const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
+ for (let ch = 0; ch < numChannels; ch++) {
+ nativeBuffer.copyToChannel(nativeChannels[ch], ch);
+ }
+
+ const sourceNode = offlineContext.createBufferSource();
+ sourceNode.buffer = nativeBuffer;
+ sourceNode.connect(offlineContext.destination);
+ sourceNode.start(0);
+
+ return await offlineContext.startRendering();
} catch (error) {
console.warn("Failed to decode video audio:", error);
return null;
+ } finally {
+ input.dispose();
}
}
@@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
- sampleRate = 44100,
+ sampleRate = EXPORT_SAMPLE_RATE,
audioContext,
}: {
tracks: TimelineTrack[];
@@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({
sampleRate?: number;
audioContext?: AudioContext;
}): Promise<AudioBuffer | null> {
- const context = audioContext ?? createAudioContext();
+ const context = audioContext ?? createAudioContext({ sampleRate });
const audioElements = await collectAudioElements({
tracks,
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index ec26c14d..5b1f301d 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -2,9 +2,12 @@ import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline";
import {
+ DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
+import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
export interface ElementBounds {
cx: number;
@@ -60,17 +63,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({
@@ -78,21 +88,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;
@@ -121,37 +141,46 @@ export function getElementBounds({
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
-
- let top = Number.POSITIVE_INFINITY;
- let bottom = Number.NEGATIVE_INFINITY;
- let maxWidth = 0;
-
- for (let i = 0; i < lineMetrics.length; i++) {
- const metrics = lineMetrics[i];
- const y = i * lineHeightPx;
- top = Math.min(
- top,
- y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
- );
- bottom = Math.max(
- bottom,
- y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
- );
- maxWidth = Math.max(maxWidth, metrics.width);
- }
-
- measuredWidth = maxWidth;
- measuredHeight = bottom - top;
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+ const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: element.background,
+ fontSizeRatio,
+ });
+ measuredWidth = visualRect.width;
+ measuredHeight = visualRect.height;
+ const localCenterX = visualRect.left + visualRect.width / 2;
+ const localCenterY = visualRect.top + visualRect.height / 2;
+ 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 + 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,
};
}
@@ -195,6 +224,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)
@@ -203,6 +237,7 @@ export function getVisibleElementsWithBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
});
if (bounds) {
result.push({
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
index 08c16735..dcc87282 100644
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -74,3 +74,17 @@ export function getDisplayScale({
y: canvasRect.height / canvasSize.height,
};
}
+
+export function screenPixelsToLogicalThreshold({
+ canvas,
+ screenPixels,
+}: {
+ canvas: HTMLCanvasElement;
+ screenPixels: number;
+}): { x: number; y: number } {
+ const canvasRect = canvas.getBoundingClientRect();
+ return {
+ x: screenPixels * (canvas.width / canvasRect.width),
+ y: screenPixels * (canvas.height / canvasRect.height),
+ };
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index bd53f0ca..36efc3ce 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -3,10 +3,10 @@ export interface SnapLine {
position: number;
}
-const SNAP_THRESHOLD = 10;
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
export interface SnapResult {
snappedPosition: { x: number; y: number };
@@ -17,10 +17,12 @@ export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
+ snapThreshold,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
@@ -41,11 +43,13 @@ export function snapPosition({
function getClosestAxisSnap({
candidates,
+ threshold,
}: {
candidates: AxisSnapCandidate[];
+ threshold: number;
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= SNAP_THRESHOLD,
+ (candidate) => candidate.distance <= threshold,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
@@ -95,8 +99,14 @@ export function snapPosition({
});
}
- const closestX = getClosestAxisSnap({ candidates: xCandidates });
- const closestY = getClosestAxisSnap({ candidates: yCandidates });
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
@@ -124,12 +134,14 @@ export function snapScale({
baseWidth,
baseHeight,
canvasSize,
+ snapThreshold,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
@@ -162,7 +174,7 @@ export function snapScale({
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= SNAP_THRESHOLD) {
+ if (distanceLeft <= snapThreshold.x) {
const scale = (2 * (position.x - target.position)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -173,7 +185,7 @@ export function snapScale({
}
}
const distanceRight = Math.abs(rightEdge - target.position);
- if (distanceRight <= SNAP_THRESHOLD) {
+ if (distanceRight <= snapThreshold.x) {
const scale = (2 * (target.position - position.x)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -199,7 +211,7 @@ export function snapScale({
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= SNAP_THRESHOLD) {
+ if (distanceTop <= snapThreshold.y) {
const scale = (2 * (position.y - target.position)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
@@ -210,7 +222,7 @@ export function snapScale({
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
- if (distanceBottom <= SNAP_THRESHOLD) {
+ if (distanceBottom <= snapThreshold.y) {
const scale = (2 * (target.position - position.y)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
new file mode 100644
index 00000000..8c38e9aa
--- /dev/null
+++ b/apps/web/src/lib/text/layout.ts
@@ -0,0 +1,167 @@
+import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
+import type { TextElement } from "@/types/timeline";
+
+type TextRect = {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+};
+
+export interface TextBlockMeasurement {
+ visualCenterOffset: number;
+ height: number;
+ maxWidth: number;
+}
+
+export function getMetricAscent({
+ metrics,
+ fallbackFontSize,
+}: {
+ metrics: TextMetrics;
+ fallbackFontSize: number;
+}): number {
+ return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
+}
+
+export function getMetricDescent({
+ metrics,
+ fallbackFontSize,
+}: {
+ metrics: TextMetrics;
+ fallbackFontSize: number;
+}): number {
+ return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
+}
+
+export function measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize,
+}: {
+ lineMetrics: TextMetrics[];
+ lineHeightPx: number;
+ fallbackFontSize: number;
+}): TextBlockMeasurement {
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let index = 0; index < lineMetrics.length; index++) {
+ const metrics = lineMetrics[index];
+ const lineY = index * lineHeightPx;
+ top = Math.min(
+ top,
+ lineY - getMetricAscent({ metrics, fallbackFontSize }),
+ );
+ bottom = Math.max(
+ bottom,
+ lineY + getMetricDescent({ metrics, fallbackFontSize }),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ const height = bottom - top;
+ const visualCenterOffset = (top + bottom) / 2;
+
+ return { visualCenterOffset, height, maxWidth };
+}
+
+function getTextRect({
+ textAlign,
+ block,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+}): TextRect {
+ const left =
+ textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
+
+ return {
+ left,
+ top: -block.height / 2,
+ width: block.maxWidth,
+ height: block.height,
+ };
+}
+
+function isTextBackgroundVisible({
+ background,
+}: {
+ background: TextElement["background"];
+}): boolean {
+ return Boolean(background.color) && background.color !== "transparent";
+}
+
+export function getTextBackgroundRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio = 1,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+ background: TextElement["background"];
+ fontSizeRatio?: number;
+}): TextRect | null {
+ if (!isTextBackgroundVisible({ background })) {
+ return null;
+ }
+
+ const textRect = getTextRect({ textAlign, block });
+ const paddingX =
+ (background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio;
+ const paddingY =
+ (background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio;
+ const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX;
+ const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY;
+
+ return {
+ left: textRect.left - paddingX + offsetX,
+ top: textRect.top - paddingY + offsetY,
+ width: textRect.width + paddingX * 2,
+ height: textRect.height + paddingY * 2,
+ };
+}
+
+export function getTextVisualRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio = 1,
+}: {
+ textAlign: TextElement["textAlign"];
+ block: TextBlockMeasurement;
+ background: TextElement["background"];
+ fontSizeRatio?: number;
+}): TextRect {
+ const textRect = getTextRect({ textAlign, block });
+ const backgroundRect = getTextBackgroundRect({
+ textAlign,
+ block,
+ background,
+ fontSizeRatio,
+ });
+
+ if (!backgroundRect) {
+ return textRect;
+ }
+
+ const left = Math.min(textRect.left, backgroundRect.left);
+ const top = Math.min(textRect.top, backgroundRect.top);
+ const right = Math.max(
+ textRect.left + textRect.width,
+ backgroundRect.left + backgroundRect.width,
+ );
+ const bottom = Math.max(
+ textRect.top + textRect.height,
+ backgroundRect.top + backgroundRect.height,
+ );
+
+ return {
+ left,
+ top,
+ width: right - left,
+ height: bottom - top,
+ };
+}
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 fa74b14e..8c02c097 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -6,6 +6,7 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import type {
+ CreateEffectElement,
CreateTimelineElement,
CreateVideoElement,
CreateImageElement,
@@ -18,10 +19,12 @@ import type {
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 +34,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,7 +45,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
-): element is VideoElement | ImageElement | TextElement | StickerElement {
+): element is VisualElement {
return element.type !== "audio";
}
@@ -154,7 +157,14 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
- backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
+ 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,
+ },
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
@@ -167,6 +177,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,
@@ -211,6 +243,7 @@ export function buildVideoElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
muted: false,
hidden: false,
transform: { ...DEFAULT_TRANSFORM },
@@ -267,6 +300,7 @@ export function buildUploadAudioElement({
startTime,
trimStart: 0,
trimEnd: 0,
+ sourceDuration: duration,
volume: 1,
muted: false,
};
@@ -329,6 +363,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 b5e74a45..887ff7ab 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -1,10 +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/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
new file mode 100644
index 00000000..c7fac090
--- /dev/null
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -0,0 +1,172 @@
+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" | "keyframe";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+const DEFAULT_SNAP_THRESHOLD_PX = 10;
+
+export function findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
+}: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+
+ 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,
+ });
+ }
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({ time: playheadTime, type: "playhead" });
+ }
+
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({ time: bookmark.time, type: "bookmark" });
+ }
+ }
+
+ return snapPoints;
+}
+
+export function snapToNearestPoint({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
+}: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ snapThreshold?: number;
+}): SnapResult {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
+
+export function snapElementEdge({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ bookmarks = [],
+}: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
+}): SnapResult {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+}
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 49e84ebe..6720a5e5 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -3,49 +3,88 @@ import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface ImageNodeParams extends VisualNodeParams {
url: string;
+ maxSourceSize?: number;
}
-export class ImageNode extends VisualNode<ImageNodeParams> {
- private image?: HTMLImageElement;
- private readyPromise: Promise<void>;
+interface CachedImageSource {
+ source: HTMLImageElement | OffscreenCanvas;
+ width: number;
+ height: number;
+}
- constructor(params: ImageNodeParams) {
- super(params);
- this.readyPromise = this.load();
- }
+const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
+
+function loadImageSource(
+ url: string,
+ maxSourceSize?: number,
+): Promise<CachedImageSource> {
+ const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
- private async load() {
+ const cached = imageSourceCache.get(cacheKey);
+ if (cached) return cached;
+
+ const promise = (async (): Promise<CachedImageSource> => {
const image = new Image();
- this.image = image;
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("Image load failed"));
- image.src = this.params.url;
+ image.src = url;
});
- }
- async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
- await super.render({ renderer, time });
+ const naturalWidth = image.naturalWidth;
+ const naturalHeight = image.naturalHeight;
+ const exceedsLimit =
+ maxSourceSize &&
+ (naturalWidth > maxSourceSize || naturalHeight > maxSourceSize);
- if (!this.isInRange(time)) {
- return;
+ if (exceedsLimit) {
+ const scale = Math.min(
+ maxSourceSize / naturalWidth,
+ maxSourceSize / naturalHeight,
+ );
+ const scaledWidth = Math.round(naturalWidth * scale);
+ const scaledHeight = Math.round(naturalHeight * scale);
+
+ const offscreen = new OffscreenCanvas(scaledWidth, scaledHeight);
+ const ctx = offscreen.getContext("2d");
+
+ if (ctx) {
+ ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
+ return { source: offscreen, width: scaledWidth, height: scaledHeight };
+ }
}
- await this.readyPromise;
+ return { source: image, width: naturalWidth, height: naturalHeight };
+ })();
+
+ imageSourceCache.set(cacheKey, promise);
+ return promise;
+}
+
+export class ImageNode extends VisualNode<ImageNodeParams> {
+ private cachedSource: Promise<CachedImageSource>;
+
+ constructor(params: ImageNodeParams) {
+ super(params);
+ this.cachedSource = loadImageSource(params.url, params.maxSourceSize);
+ }
+
+ async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
+ await super.render({ renderer, time });
- if (!this.image) {
+ if (!this.isInRange({ time })) {
return;
}
- const mediaW = this.image.naturalWidth || renderer.width;
- const mediaH = this.image.naturalHeight || renderer.height;
+ const { source, width, height } = await this.cachedSource;
this.renderVisual({
renderer,
- source: this.image,
- sourceWidth: mediaW,
- sourceHeight: mediaH,
+ 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 a2fb0170..da271685 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -6,49 +6,63 @@ export interface StickerNodeParams extends VisualNodeParams {
stickerId: string;
}
-export class StickerNode extends VisualNode<StickerNodeParams> {
- private image?: HTMLImageElement;
- private readyPromise: Promise<void>;
+interface CachedStickerSource {
+ source: HTMLImageElement;
+ width: number;
+ height: number;
+}
- constructor(params: StickerNodeParams) {
- super(params);
- this.readyPromise = this.load();
- }
+const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>();
- private async load() {
- const image = new Image();
- this.image = image;
+function loadStickerSource(stickerId: string): Promise<CachedStickerSource> {
+ const cached = stickerSourceCache.get(stickerId);
+ if (cached) return cached;
+
+ const promise = (async (): Promise<CachedStickerSource> => {
const url = resolveStickerId({
- stickerId: this.params.stickerId,
+ stickerId,
options: { width: 200, height: 200 },
});
+ const image = new Image();
+
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
- reject(new Error(`Failed to load sticker: ${this.params.stickerId}`));
+ reject(new Error(`Failed to load sticker: ${stickerId}`));
image.src = url;
});
+
+ return { source: image, width: 200, height: 200 };
+ })();
+
+ stickerSourceCache.set(stickerId, promise);
+ return promise;
+}
+
+export class StickerNode extends VisualNode<StickerNodeParams> {
+ private cachedSource: Promise<CachedStickerSource>;
+
+ constructor(params: StickerNodeParams) {
+ super(params);
+ this.cachedSource = loadStickerSource(params.stickerId);
}
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
- if (!this.isInRange(time)) {
+ if (!this.isInRange({ time })) {
return;
}
- await this.readyPromise;
-
- if (!this.image) {
- return;
- }
+ const { source, width, height } = await this.cachedSource;
this.renderVisual({
renderer,
- source: this.image,
- sourceWidth: 200,
- sourceHeight: 200,
+ 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 b7e36d64..0ddb4eae 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -2,9 +2,21 @@ import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
+ DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ getTextBackgroundRect,
+ measureTextBlock,
+} from "@/lib/text/layout";
+import {
+ getElementLocalTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
function scaleFontSize({
fontSize,
@@ -20,65 +32,6 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
-function getMetricAscent({
- metrics,
- fallback,
-}: {
- metrics: TextMetrics;
- fallback: number;
-}): number {
- return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
-}
-
-function getMetricDescent({
- metrics,
- fallback,
-}: {
- metrics: TextMetrics;
- fallback: number;
-}): number {
- return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
-}
-
-interface TextBlockMeasurement {
- visualCenterOffset: number;
- height: number;
- maxWidth: number;
-}
-
-function measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize,
-}: {
- lineMetrics: TextMetrics[];
- lineHeightPx: number;
- fallbackFontSize: number;
-}): TextBlockMeasurement {
- let top = Number.POSITIVE_INFINITY;
- let bottom = Number.NEGATIVE_INFINITY;
- let maxWidth = 0;
-
- for (let i = 0; i < lineMetrics.length; i++) {
- const metrics = lineMetrics[i];
- const y = i * lineHeightPx;
- top = Math.min(
- top,
- y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
- );
- bottom = Math.max(
- bottom,
- y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
- );
- maxWidth = Math.max(maxWidth, metrics.width);
- }
-
- const height = bottom - top;
- const visualCenterOffset = (top + bottom) / 2;
-
- return { visualCenterOffset, height, maxWidth };
-}
-
function drawTextDecoration({
ctx,
textDecoration,
@@ -99,8 +52,8 @@ function drawTextDecoration({
if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07);
- const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
- const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
+ const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
+ const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
let xStart = -lineWidth / 2;
if (textAlign === "left") xStart = 0;
@@ -138,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> {
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,
+ });
+ const x = transform.position.x + this.params.canvasCenter.x;
+ const y = transform.position.y + this.params.canvasCenter.y;
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);
+ renderer.context.scale(transform.scale, transform.scale);
+ if (transform.rotate) {
+ renderer.context.rotate((transform.rotate * Math.PI) / 180);
}
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
@@ -171,6 +136,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
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";
renderer.context.textBaseline = baseline;
@@ -189,24 +155,33 @@ export class TextNode extends BaseNode<TextNodeParams> {
? this.params.blendMode
: "source-over"
) as GlobalCompositeOperation;
- renderer.context.globalAlpha = this.params.opacity;
+ renderer.context.globalAlpha = opacity;
- if (this.params.backgroundColor && lineCount > 0) {
- const padX = 8;
- const padY = 4;
- renderer.context.fillStyle = this.params.backgroundColor;
- let bgLeft = -block.maxWidth / 2;
- if (renderer.context.textAlign === "left") bgLeft = 0;
- if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
-
- renderer.context.fillRect(
- bgLeft - padX,
- -block.height / 2 - padY,
- block.maxWidth + padX * 2,
- block.height + padY * 2,
- );
-
- renderer.context.fillStyle = this.params.color;
+ 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;
+ }
}
for (let i = 0; i < lineCount; i++) {
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..15a7b081 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,9 +1,18 @@
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 { 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 +20,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 +54,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 +99,58 @@ 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 definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: effect.params,
+ 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 ee7f3d6c..695e077f 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -6,48 +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";
-export type BuildSceneParams = {
- canvasSize: TCanvasSize;
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: number;
- background: TBackground;
-};
-
-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]));
-
- const visibleTracks = tracks.filter(
- (track) => !("hidden" in track && track.hidden),
- );
+const PREVIEW_MAX_IMAGE_SIZE = 2048;
+const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
- const orderedTracksTopToBottom = [
- ...visibleTracks.filter((track) => !isMainTrack(track)),
- ...visibleTracks.filter((track) => isMainTrack(track)),
- ];
-
- const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+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);
+ });
+}
- const contentNodes = [];
+function buildTrackNodes({
+ tracks,
+ mediaMap,
+ canvasSize,
+ isPreview,
+}: {
+ tracks: TimelineTrack[];
+ mediaMap: Map<string, MediaAsset>;
+ canvasSize: TCanvasSize;
+ isPreview?: boolean;
+}): BaseNode[] {
+ const nodes: BaseNode[] = [];
- 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) {
@@ -55,7 +66,7 @@ export function buildScene(params: BuildSceneParams) {
}
if (mediaAsset.type === "video") {
- contentNodes.push(
+ nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
@@ -65,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,
@@ -79,26 +92,32 @@ 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,
+ ...(isPreview && {
+ maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
+ }),
}),
);
}
}
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,
@@ -106,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/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index 39e94e55..a715efe7 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -13,11 +13,9 @@ import {
QUALITY_VERY_HIGH,
} from "mediabunny";
import type { RootNode } from "./nodes/root-node";
+import type { ExportFormat, ExportQuality } from "@/types/export";
import { CanvasRenderer } from "./canvas-renderer";
-export type ExportFormat = "mp4" | "webm";
-export type ExportQuality = "low" | "medium" | "high" | "very_high";
-
type ExportParams = {
width: number;
height: number;
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/index.ts b/apps/web/src/services/storage/migrations/index.ts
index 00c009c7..86a202b2 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -5,10 +5,12 @@ import { V2toV3Migration } from "./v2-to-v3";
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";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 6;
+export const CURRENT_PROJECT_VERSION = 8;
export const migrations = [
new V0toV1Migration(),
@@ -17,4 +19,6 @@ export const migrations = [
new V3toV4Migration(),
new V4toV5Migration(),
new V5toV6Migration(),
+ new V6toV7Migration(),
+ new V7toV8Migration(),
];
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..d16106d9 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
@@ -450,10 +450,17 @@ function transformTextTrack({
value: textElement.color,
fallback: "#000000",
}),
- backgroundColor: getStringValue({
+ background: {
+ color: getStringValue({
value: textElement.backgroundColor,
- fallback: "#FFFFFF",
+ fallback: "transparent",
}),
+ cornerRadius: 0,
+ paddingX: 8,
+ paddingY: 4,
+ offsetX: 0,
+ offsetY: 0,
+ },
textAlign: (getStringValue({
value: textElement.textAlign,
fallback: "left",
diff --git a/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts
new file mode 100644
index 00000000..6f4848d4
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v6-to-v7.ts
@@ -0,0 +1,106 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV6ToV7({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV7Project({ project })) {
+ return { project, skipped: true, reason: "already v7" };
+ }
+
+ const migratedProject = migrateProjectTextElements({ project });
+
+ return {
+ project: { ...migratedProject, version: 7 },
+ 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;
+
+ 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;
+ if (isRecord(element.background)) return element;
+
+ const backgroundColor =
+ typeof element.backgroundColor === "string"
+ ? element.backgroundColor
+ : "transparent";
+
+ const { backgroundColor: _removed, ...rest } = element;
+
+ return {
+ ...rest,
+ background: {
+ color: backgroundColor,
+ cornerRadius: 0,
+ paddingX: 8,
+ paddingY: 4,
+ offsetX: 0,
+ offsetY: 0,
+ },
+ };
+}
+
+function isV7Project({ project }: { project: ProjectRecord }): boolean {
+ return typeof project.version === "number" && project.version >= 7;
+}
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/v6-to-v7.ts b/apps/web/src/services/storage/migrations/v6-to-v7.ts
new file mode 100644
index 00000000..0556b4d5
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v6-to-v7.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV6ToV7 } from "./transformers/v6-to-v7";
+
+export class V6toV7Migration extends StorageMigration {
+ from = 6;
+ to = 7;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV6ToV7({ project });
+ }
+}
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/types/animation.ts b/apps/web/src/types/animation.ts
new file mode 100644
index 00000000..b9ca91af
--- /dev/null
+++ b/apps/web/src/types/animation.ts
@@ -0,0 +1,89 @@
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.position.x",
+ "transform.position.y",
+ "transform.scale",
+ "transform.rotate",
+ "opacity",
+ "volume",
+] 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 NumberKeyframeInterpolation = "linear" | "hold";
+export type ColorKeyframeInterpolation = "linear" | "hold";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | NumberKeyframeInterpolation
+ | ColorKeyframeInterpolation
+ | 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, NumberKeyframeInterpolation> {}
+
+export interface ColorKeyframe
+ extends BaseAnimationKeyframe<string, ColorKeyframeInterpolation> {}
+
+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..cc369450
--- /dev/null
+++ b/apps/web/src/types/effects.ts
@@ -0,0 +1,45 @@
+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>;
+
+export interface EffectParamDefinition {
+ key: string;
+ label: string;
+ type: EffectParamType;
+ default: number | string | boolean;
+ min?: number;
+ max?: number;
+ step?: number;
+ options?: Array<{ value: string; label: string }>;
+}
+
+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/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/rendering.ts b/apps/web/src/types/rendering.ts
index f5e18502..8c9763df 100644
--- a/apps/web/src/types/rendering.ts
+++ b/apps/web/src/types/rendering.ts
@@ -1,3 +1,12 @@
+export interface Transform {
+ scale: number;
+ position: {
+ x: number;
+ y: number;
+ };
+ rotate: number;
+}
+
export type BlendMode =
| "normal"
| "darken"
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index da96c7de..0c138f56 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,4 +1,6 @@
-import type { BlendMode } from "./rendering";
+import type { ElementAnimations } from "./animation";
+import type { Effect, EffectParamValues } from "./effects";
+import type { BlendMode, Transform } from "./rendering";
export interface Bookmark {
time: number;
@@ -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,17 +52,21 @@ export interface StickerTrack extends BaseTrack {
hidden: boolean;
}
-export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack;
-
-export interface Transform {
- scale: number;
- position: {
- x: number;
- y: number;
- };
- rotate: number;
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
}
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | StickerTrack
+ | EffectTrack;
+
+export type { Transform } from "./rendering";
+
interface BaseAudioElement extends BaseTimelineElement {
type: "audio";
volume: number;
@@ -87,6 +93,8 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
}
export interface VideoElement extends BaseTimelineElement {
@@ -97,6 +105,7 @@ export interface VideoElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface ImageElement extends BaseTimelineElement {
@@ -106,6 +115,7 @@ export interface ImageElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface TextElement extends BaseTimelineElement {
@@ -114,7 +124,14 @@ export interface TextElement extends BaseTimelineElement {
fontSize: number;
fontFamily: string;
color: string;
- backgroundColor: string;
+ background: {
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+ };
textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
@@ -125,6 +142,7 @@ export interface TextElement extends BaseTimelineElement {
transform: Transform;
opacity: number;
blendMode?: BlendMode;
+ effects?: Effect[];
}
export interface StickerElement extends BaseTimelineElement {
@@ -134,14 +152,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 +191,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 +217,7 @@ export interface DropTarget {
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
}
export interface ComputeDropTargetParams {
@@ -193,6 +233,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,
}: {