summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
committercashmere <cashmere@autistici.org>2026-08-09 02:37:37 +0200
commitc555f1eaebff90749bdc543592cfa80a060b29e2 (patch)
treee6a04adb212871063653b66c17eb1b8c17c7d22f /apps
parentdf82306a86d14bca0149e6d756ba82b75659af43 (diff)
downloadopencut-c555f1eaebff90749bdc543592cfa80a060b29e2.tar.gz
feat: lens effects tab, standalone motion effects, clip/asset rename, preview popout
- New Lens category in the assets panel: Blur, Motion Blur, Zoom In/Out, Pan x4, Shake, Pulse, Spin, Custom Zoom. EffectDefinition gains a category field; tabs with an empty category are hidden, and the properties-panel 'Open effects' button opens the first non-empty one. - Motion effects work standalone: dropping them on the timeline creates an effect-track element that animates (scale/translate/rotate) every track below it, computed purely in the TS frame descriptor - no wasm changes, identical in preview and export. supportsStandalone removed. - Motion Blur: directional gaussian smear (amount + angle) reusing the existing WGSL shader with arbitrary u_direction. - Custom Zoom: start/end scale params (keyframable clip-level). - MotionOffset gains optional rotate, accumulated clip-level and applied to standalone layer quads via rotationDegrees. - Rename clips (timeline context menu) and media assets (media panel context menu) via a shared RenameDialog; asset rename persists through storageService with rollback on error. - Preview pops out into a Document Picture-in-Picture window and docks back; button hidden where the API is unsupported. - Export keeps running when the export popover is closed; only the explicit Cancel button aborts. - Captions and Adjustment tabs removed from the assets panel.
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/app/editor/page.tsx43
-rw-r--r--apps/web/src/components/editor/export-button.tsx5
-rw-r--r--apps/web/src/components/editor/panels/assets/assets-panel-store.tsx27
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx10
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx23
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx59
-rw-r--r--apps/web/src/components/editor/rename-dialog.tsx88
-rw-r--r--apps/web/src/core/managers/media-manager.ts39
-rw-r--r--apps/web/src/effects/__tests__/lens-effects.test.ts349
-rw-r--r--apps/web/src/effects/__tests__/motion.test.ts164
-rw-r--r--apps/web/src/effects/components/assets-view.tsx54
-rw-r--r--apps/web/src/effects/components/effects-tab.tsx7
-rw-r--r--apps/web/src/effects/definitions/blur.ts1
-rw-r--r--apps/web/src/effects/definitions/index.ts10
-rw-r--r--apps/web/src/effects/definitions/motion-blur.ts67
-rw-r--r--apps/web/src/effects/definitions/motion.ts115
-rw-r--r--apps/web/src/effects/motion.ts4
-rw-r--r--apps/web/src/effects/types.ts5
-rw-r--r--apps/web/src/preview/components/toolbar.tsx55
-rw-r--r--apps/web/src/preview/popout-store.ts76
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts40
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts3
-rw-r--r--apps/web/src/services/renderer/resolve.ts26
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx35
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts5
-rw-r--r--apps/web/src/types/document-picture-in-picture.d.ts15
26 files changed, 1228 insertions, 97 deletions
diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx
index 8229774a..5719be94 100644
--- a/apps/web/src/app/editor/page.tsx
+++ b/apps/web/src/app/editor/page.tsx
@@ -17,7 +17,8 @@ import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
-import { Suspense, useMemo, useState } from "react";
+import { Suspense, useEffect, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
import { useEditor } from "@/editor/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -29,6 +30,7 @@ import {
mergePreviewOverlaySources,
} from "@/preview/overlays";
import { usePreviewStore } from "@/preview/preview-store";
+import { usePreviewPopoutStore } from "@/preview/popout-store";
import { getGuidePreviewOverlaySource } from "@/guides";
import {
bookmarkNotesPreviewOverlay,
@@ -98,6 +100,14 @@ function EditorLayout() {
const setOverlayVisibility = usePreviewStore(
(state) => state.setOverlayVisibility,
);
+ const popoutContainer = usePreviewPopoutStore((state) => state.container);
+ const dockPopout = usePreviewPopoutStore((state) => state.dockPopout);
+
+ useEffect(() => {
+ return () => {
+ usePreviewPopoutStore.getState().dockPopout();
+ };
+ }, []);
const showBookmarkNotes = isPreviewOverlayVisible({
overlay: bookmarkNotesPreviewOverlay,
overlays,
@@ -182,11 +192,32 @@ function EditorLayout() {
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
- <PreviewPanel
- overlayControls={overlayControls}
- overlayInstances={overlaySource.instances}
- onOverlayVisibilityChange={setOverlayVisibility}
- />
+ {popoutContainer ? (
+ <>
+ {createPortal(
+ <PreviewPanel
+ overlayControls={overlayControls}
+ overlayInstances={overlaySource.instances}
+ onOverlayVisibilityChange={setOverlayVisibility}
+ />,
+ popoutContainer,
+ )}
+ <div className="flex size-full flex-col items-center justify-center gap-3">
+ <span className="text-muted-foreground text-sm">
+ Preview is popped out
+ </span>
+ <Button variant="secondary" onClick={dockPopout}>
+ Dock preview
+ </Button>
+ </div>
+ </>
+ ) : (
+ <PreviewPanel
+ overlayControls={overlayControls}
+ overlayInstances={overlaySource.instances}
+ onOverlayVisibilityChange={setOverlayVisibility}
+ />
+ )}
</ResizablePanel>
<ResizableHandle withHandle />
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index 5e724384..9b9d5b8e 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -47,15 +47,10 @@ function isExportQuality(value: string): value is ExportQuality {
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
- const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActiveOrNull());
const hasProject = !!activeProject;
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
- if (!open) {
- editor.project.cancelExport();
- editor.project.clearExportState();
- }
setIsExportPopoverOpen(open);
};
diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
index b412de7b..9e9f77c6 100644
--- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
+++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
@@ -3,17 +3,16 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
ArrowRightDoubleIcon,
- ClosedCaptionIcon,
+ CameraLensIcon,
Folder03Icon,
Happy01Icon,
HeadphonesIcon,
MagicWand05Icon,
TextIcon,
Settings01Icon,
- SlidersHorizontalIcon,
- ColorsIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { effectsRegistry } from "@/effects";
export const TAB_KEYS = [
"media",
@@ -21,9 +20,8 @@ export const TAB_KEYS = [
"text",
"stickers",
"effects",
+ "lens",
"transitions",
- "captions",
- "adjustment",
"settings",
] as const;
@@ -56,18 +54,14 @@ export const tabs = {
icon: createHugeiconsIcon({ icon: MagicWand05Icon }),
label: "Effects",
},
+ lens: {
+ icon: createHugeiconsIcon({ icon: CameraLensIcon }),
+ label: "Lens",
+ },
transitions: {
icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }),
label: "Transitions",
},
- captions: {
- icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }),
- label: "Captions",
- },
- adjustment: {
- icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }),
- label: "Adjustment",
- },
settings: {
icon: createHugeiconsIcon({ icon: Settings01Icon }),
label: "Settings",
@@ -81,6 +75,13 @@ export type MediaViewMode = "grid" | "list";
export type MediaSortKey = "name" | "type" | "duration" | "size";
export type MediaSortOrder = "asc" | "desc";
+export function getDefaultEffectsTab(): Tab {
+ const hasLensEffects = effectsRegistry
+ .getAll()
+ .some((definition) => definition.category === "lens");
+ return hasLensEffects ? "lens" : "effects";
+}
+
interface AssetsPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index 09d64eed..c0204b12 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -3,7 +3,6 @@
import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
import { TabBar } from "./tabbar";
-import { Captions } from "@/subtitles/components/assets-view";
import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "@/sounds/components/assets-view";
@@ -20,18 +19,13 @@ export function AssetsPanel() {
sounds: FEATURES.sounds ? <SoundsView /> : null,
text: <TextView />,
stickers: FEATURES.stickers ? <StickersView /> : null,
- effects: FEATURES.effects ? <EffectsView /> : null,
+ effects: FEATURES.effects ? <EffectsView category="effect" /> : null,
+ lens: FEATURES.effects ? <EffectsView category="lens" /> : null,
transitions: (
<div className="text-muted-foreground p-4">
Transitions view coming soon...
</div>
),
- captions: <Captions />,
- adjustment: (
- <div className="text-muted-foreground p-4">
- Adjustment view coming soon...
- </div>
- ),
settings: <SettingsView />,
};
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index b468b5bb..9b029e3d 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -14,13 +14,22 @@ import {
useAssetsPanelStore,
} from "@/components/editor/panels/assets/assets-panel-store";
import { FEATURES } from "@/features";
+import { effectsRegistry } from "@/effects";
-const VISIBLE_TAB_KEYS = TAB_KEYS.filter((tabKey) => {
- if (tabKey === "sounds") return FEATURES.sounds;
- if (tabKey === "stickers") return FEATURES.stickers;
- if (tabKey === "effects") return FEATURES.effects;
- return true;
-});
+function getVisibleTabKeys() {
+ const effectCategories = new Set(
+ effectsRegistry.getAll().map((definition) => definition.category),
+ );
+ return TAB_KEYS.filter((tabKey) => {
+ if (tabKey === "sounds") return FEATURES.sounds;
+ if (tabKey === "stickers") return FEATURES.stickers;
+ if (tabKey === "effects")
+ return FEATURES.effects && effectCategories.has("effect");
+ if (tabKey === "lens")
+ return FEATURES.effects && effectCategories.has("lens");
+ return true;
+ });
+}
export function TabBar() {
const { activeTab, setActiveTab } = useAssetsPanelStore();
@@ -59,7 +68,7 @@ export function TabBar() {
ref={scrollRef}
className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
>
- {VISIBLE_TAB_KEYS.map((tabKey) => {
+ {getVisibleTabKeys().map((tabKey) => {
const tab = tabs[tabKey];
return (
<Tooltip key={tabKey} delayDuration={10}>
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 4dbed41e..049cb5f4 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -48,8 +48,10 @@ import {
import { MASKABLE_ELEMENT_TYPES } from "@/timeline";
import type { MediaAsset } from "@/media/types";
import { cn } from "@/utils/ui";
+import { RenameDialog } from "@/components/editor/rename-dialog";
import {
CloudUploadIcon,
+ Edit02Icon,
GridViewIcon,
LeftToRightListDashIcon,
SortingOneNineIcon,
@@ -316,25 +318,54 @@ function MediaItemWithContextMenu({
}) => void;
}) {
const { isSelected, selectedIds } = useSelection();
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
const deleteLabel =
idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
return (
- <ContextMenu>
- <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(event: React.MouseEvent<HTMLDivElement>) =>
- onRemove({ event, ids: idsToDelete })
- }
- >
- {deleteLabel}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
+ <>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Edit02Icon} />}
+ onClick={(event: React.MouseEvent<HTMLDivElement>) => {
+ event.stopPropagation();
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </ContextMenuItem>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(event: React.MouseEvent<HTMLDivElement>) =>
+ onRemove({ event, ids: idsToDelete })
+ }
+ >
+ {deleteLabel}
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ <RenameDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ currentName={item.name}
+ title="Rename asset"
+ onConfirm={(newName) => {
+ if (!activeProject) return;
+ editor.media.renameMediaAsset({
+ projectId: activeProject.metadata.id,
+ id: item.id,
+ name: newName,
+ });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+ </>
);
}
diff --git a/apps/web/src/components/editor/rename-dialog.tsx b/apps/web/src/components/editor/rename-dialog.tsx
new file mode 100644
index 00000000..ad9833c0
--- /dev/null
+++ b/apps/web/src/components/editor/rename-dialog.tsx
@@ -0,0 +1,88 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+import { Label } from "@/components/ui/label";
+
+export function RenameDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ currentName,
+ title,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (newName: string) => void;
+ currentName: string;
+ title: string;
+}) {
+ const [name, setName] = useState(currentName);
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(currentName);
+ }
+ onOpenChange(open);
+ };
+
+ const trimmedName = name.trim();
+ const isConfirmDisabled =
+ trimmedName.length === 0 || trimmedName === currentName;
+
+ const handleConfirm = () => {
+ if (isConfirmDisabled) {
+ return;
+ }
+ onConfirm(trimmedName);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="gap-3">
+ <Label>New name</Label>
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleConfirm();
+ }
+ }}
+ onFocus={(e) => e.target.select()}
+ placeholder="Enter a new name"
+ />
+ </DialogBody>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button disabled={isConfirmDisabled} onClick={handleConfirm}>
+ Rename
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts
index 739fd3fa..7764329f 100644
--- a/apps/web/src/core/managers/media-manager.ts
+++ b/apps/web/src/core/managers/media-manager.ts
@@ -51,6 +51,45 @@ export class MediaManager {
}
}
+ async renameMediaAsset({
+ projectId,
+ id,
+ name,
+ }: {
+ projectId: string;
+ id: string;
+ name: string;
+ }): Promise<void> {
+ const asset = this.assets.find((asset) => asset.id === id);
+ if (!asset || asset.name === name) {
+ return;
+ }
+
+ const previousName = asset.name;
+ const updatedAsset: MediaAsset = { ...asset, name };
+ this.assets = this.assets.map((asset) =>
+ asset.id === id ? updatedAsset : asset,
+ );
+ this.notify();
+
+ try {
+ await storageService.saveMediaAsset({
+ projectId,
+ mediaAsset: updatedAsset,
+ });
+ } catch (error) {
+ console.error("Failed to rename media asset:", error);
+ this.assets = this.assets.map((asset) =>
+ asset.id === id ? { ...asset, name: previousName } : asset,
+ );
+ this.notify();
+ toast.error("Failed to rename media asset", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ }
+
removeMediaAsset({ projectId, id }: { projectId: string; id: string }): void {
this.removeMediaAssets({ projectId, ids: [id] });
}
diff --git a/apps/web/src/effects/__tests__/lens-effects.test.ts b/apps/web/src/effects/__tests__/lens-effects.test.ts
new file mode 100644
index 00000000..39578514
--- /dev/null
+++ b/apps/web/src/effects/__tests__/lens-effects.test.ts
@@ -0,0 +1,349 @@
+import { describe, expect, test } from "bun:test";
+import { registerDefaultEffects } from "@/effects";
+import { buildDefaultEffectInstance } from "@/effects";
+import { effectsRegistry, resolveEffectPasses } from "@/effects";
+import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { MotionOffset } from "@/effects/types";
+import type { Transform } from "@/rendering";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
+import { ColorNode } from "@/services/renderer/nodes/color-node";
+import { EffectLayerNode } from "@/services/renderer/nodes/effect-layer-node";
+import { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildFrameDescriptor } from "@/services/renderer/compositor/frame-descriptor";
+import { resolveRenderTree } from "@/services/renderer/resolve";
+import type { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+
+registerDefaultEffects();
+
+const baseTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+function evaluateMotion({
+ effectType,
+ intensity = 100,
+ progress,
+}: {
+ effectType: string;
+ intensity?: number;
+ progress: number;
+}): MotionOffset {
+ const definition = effectsRegistry.get(effectType);
+ if (!definition.motion) {
+ throw new Error(`${effectType} has no motion config`);
+ }
+ return definition.motion.evaluate({
+ effectParams: { intensity, easing: "linear" },
+ progress,
+ });
+}
+
+describe("shake definition", () => {
+ test("translate stays within the scale headroom across the whole cycle", () => {
+ for (let step = 0; step <= 100; step++) {
+ const offset = evaluateMotion({
+ effectType: "shake",
+ progress: step / 100,
+ });
+ const headroom = (offset.scale - 1) / 2;
+ expect(offset.scale).toBeCloseTo(1.08, 5);
+ expect(Math.abs(offset.translateX)).toBeLessThanOrEqual(0.025 + 1e-9);
+ expect(Math.abs(offset.translateY)).toBeLessThanOrEqual(0.02 + 1e-9);
+ expect(Math.abs(offset.translateX)).toBeLessThanOrEqual(headroom);
+ expect(Math.abs(offset.translateY)).toBeLessThanOrEqual(headroom);
+ }
+ });
+
+ test("intensity scales travel and headroom proportionally", () => {
+ const full = evaluateMotion({ effectType: "shake", progress: 0.25 });
+ const half = evaluateMotion({
+ effectType: "shake",
+ intensity: 50,
+ progress: 0.25,
+ });
+ expect(half.scale).toBeCloseTo(1.04, 5);
+ expect(half.translateX).toBeCloseTo(full.translateX / 2, 8);
+ expect(half.translateY).toBeCloseTo(full.translateY / 2, 8);
+ });
+});
+
+describe("pulse definition", () => {
+ test("scale oscillates around 1 within the pulse range", () => {
+ for (let step = 0; step <= 100; step++) {
+ const offset = evaluateMotion({
+ effectType: "pulse",
+ progress: step / 100,
+ });
+ expect(offset.scale).toBeGreaterThanOrEqual(1 - 0.12 - 1e-9);
+ expect(offset.scale).toBeLessThanOrEqual(1 + 0.12 + 1e-9);
+ expect(offset.translateX).toBe(0);
+ expect(offset.translateY).toBe(0);
+ }
+ });
+
+ test("completes three full cycles", () => {
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 0 }).scale,
+ ).toBeCloseTo(1, 5);
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 1 / 12 }).scale,
+ ).toBeCloseTo(1.12, 5);
+ expect(
+ evaluateMotion({ effectType: "pulse", progress: 1 }).scale,
+ ).toBeCloseTo(1, 5);
+ });
+});
+
+describe("spin definition", () => {
+ test("rotate spans zero to a full turn scaled by intensity", () => {
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 0 }).rotate,
+ ).toBeCloseTo(0, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 0.5 }).rotate,
+ ).toBeCloseTo(180, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", progress: 1 }).rotate,
+ ).toBeCloseTo(360, 5);
+ expect(
+ evaluateMotion({ effectType: "spin", intensity: 50, progress: 1 })
+ .rotate,
+ ).toBeCloseTo(180, 5);
+ });
+
+ test("does not scale or translate", () => {
+ const offset = evaluateMotion({ effectType: "spin", progress: 0.7 });
+ expect(offset.scale).toBe(1);
+ expect(offset.translateX).toBe(0);
+ expect(offset.translateY).toBe(0);
+ });
+
+ test("applyMotionEffects adds rotation onto the base transform", () => {
+ const effect = buildDefaultEffectInstance({ effectType: "spin" });
+ effect.params.easing = "linear";
+ const resolved = applyMotionEffects({
+ baseTransform: { ...baseTransform, rotate: 15 },
+ effects: [effect],
+ animations: undefined,
+ localTime: 5,
+ duration: 10,
+ canvasWidth: 1920,
+ canvasHeight: 1080,
+ });
+ const easedProgress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === "linear")?.value ?? [
+ 0.25, 0.1, 0.25, 1,
+ ],
+ x: 0.5,
+ });
+ expect(resolved.rotate).toBeCloseTo(15 + easedProgress * 360, 5);
+ expect(resolved.scaleX).toBeCloseTo(1, 5);
+ });
+});
+
+describe("standalone spin effect layer", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function expectedEasedRotation({ linearProgress }: { linearProgress: number }) {
+ const progress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === "linear")?.value ?? [
+ 0.25, 0.1, 0.25, 1,
+ ],
+ x: linearProgress,
+ });
+ return progress * 360;
+ }
+
+ test("frame descriptor rotates lower layer quads by the eased rotation", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "spin",
+ effectParams: { intensity: 100, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform.rotationDegrees).toBeCloseTo(
+ expectedEasedRotation({ linearProgress: 0.5 }),
+ 5,
+ );
+ expect(layer.transform.centerX).toBeCloseTo(renderer.width / 2, 5);
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(renderer.width, 5);
+ });
+
+ test("zero intensity leaves lower layer quads untouched", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "spin",
+ effectParams: { intensity: 0, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform).toEqual({
+ centerX: renderer.width / 2,
+ centerY: renderer.height / 2,
+ width: renderer.width,
+ height: renderer.height,
+ rotationDegrees: 0,
+ flipX: false,
+ flipY: false,
+ });
+ });
+});
+
+describe("motion-blur definition", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function buildPasses({ amount, angle }: { amount: number; angle?: number }) {
+ return resolveEffectPasses({
+ definition: effectsRegistry.get("motion-blur"),
+ effectParams: { amount, angle: angle ?? 0 },
+ width: renderer.width,
+ height: renderer.height,
+ });
+ }
+
+ test("zero amount produces no passes", () => {
+ expect(buildPasses({ amount: 0 })).toEqual([]);
+ });
+
+ test("positive amount produces exactly one gaussian pass", () => {
+ const passes = buildPasses({ amount: 40 });
+ expect(passes).toHaveLength(1);
+ expect(passes.at(0)?.shader).toBe("gaussian-blur");
+ });
+
+ test("direction is a unit vector along the angle", () => {
+ for (const angle of [0, 37, 90, -120, 180]) {
+ const pass = buildPasses({ amount: 40, angle }).at(0);
+ const direction = pass?.uniforms.u_direction;
+ if (!Array.isArray(direction)) {
+ throw new Error("expected u_direction to be a vec2");
+ }
+ const [dx, dy] = direction;
+ expect(dx * dx + dy * dy).toBeCloseTo(1, 8);
+ expect(Math.atan2(dy, dx)).toBeCloseTo((angle * Math.PI) / 180, 8);
+ }
+ expect(buildPasses({ amount: 40, angle: 0 }).at(0)?.uniforms.u_direction).toEqual([1, 0]);
+ });
+
+ test("sigma stays within single-pass limits and grows with amount", () => {
+ const low = buildPasses({ amount: 20 }).at(0);
+ const high = buildPasses({ amount: 80 }).at(0);
+ if (!low || !high) {
+ throw new Error("expected passes for positive amounts");
+ }
+ const [lowUniforms, highUniforms] = [low, high].map((pass) => {
+ const sigma = pass.uniforms.u_sigma;
+ const step = pass.uniforms.u_step;
+ if (typeof sigma !== "number" || typeof step !== "number") {
+ throw new Error("expected numeric sigma and step");
+ }
+ expect(sigma).toBeLessThanOrEqual(10);
+ expect(sigma).toBeGreaterThan(0);
+ expect(step).toBeGreaterThanOrEqual(1);
+ return { sigma, step };
+ });
+ expect(highUniforms.sigma * highUniforms.step).toBeGreaterThan(
+ lowUniforms.sigma * lowUniforms.step,
+ );
+ });
+});
+
+describe("custom-zoom definition", () => {
+ function evaluateCustomZoom({
+ startScale,
+ endScale,
+ progress,
+ }: {
+ startScale: number;
+ endScale: number;
+ progress: number;
+ }): MotionOffset {
+ const definition = effectsRegistry.get("custom-zoom");
+ if (!definition.motion) {
+ throw new Error("custom-zoom has no motion config");
+ }
+ return definition.motion.evaluate({
+ effectParams: { startScale, endScale, easing: "linear" },
+ progress,
+ });
+ }
+
+ test("lerps exactly between start and end scale", () => {
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 0 })
+ .scale,
+ ).toBeCloseTo(1, 5);
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 1 })
+ .scale,
+ ).toBeCloseTo(2, 5);
+ expect(
+ evaluateCustomZoom({ startScale: 100, endScale: 200, progress: 0.5 })
+ .scale,
+ ).toBeCloseTo(1.5, 5);
+ });
+
+ test("supports zoom-out by inverting start and end", () => {
+ expect(
+ evaluateCustomZoom({ startScale: 200, endScale: 100, progress: 0.25 })
+ .scale,
+ ).toBeCloseTo(1.75, 5);
+ });
+
+ test("falls back to 100 percent for missing params", () => {
+ const definition = effectsRegistry.get("custom-zoom");
+ if (!definition.motion) {
+ throw new Error("custom-zoom has no motion config");
+ }
+ const offset = definition.motion.evaluate({
+ effectParams: {},
+ progress: 0.5,
+ });
+ expect(offset.scale).toBeCloseTo(1, 5);
+ });
+});
+
+describe("registry", () => {
+ test("registers all lens effects", () => {
+ for (const type of [
+ "shake",
+ "pulse",
+ "spin",
+ "motion-blur",
+ "custom-zoom",
+ ]) {
+ expect(effectsRegistry.get(type).category).toBe("lens");
+ }
+ });
+});
diff --git a/apps/web/src/effects/__tests__/motion.test.ts b/apps/web/src/effects/__tests__/motion.test.ts
index 907dd6a9..aeeaf6ab 100644
--- a/apps/web/src/effects/__tests__/motion.test.ts
+++ b/apps/web/src/effects/__tests__/motion.test.ts
@@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test";
import { registerDefaultEffects } from "@/effects";
import { buildDefaultEffectInstance } from "@/effects";
+import { effectsRegistry } from "@/effects";
import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
+import type { MotionOffset } from "@/effects/types";
+import type { ParamValues } from "@/params";
import type { Transform } from "@/rendering";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
import { GraphicNode } from "@/services/renderer/nodes/graphic-node";
+import { ColorNode } from "@/services/renderer/nodes/color-node";
+import { EffectLayerNode } from "@/services/renderer/nodes/effect-layer-node";
+import { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildFrameDescriptor } from "@/services/renderer/compositor/frame-descriptor";
import { resolveRenderTree } from "@/services/renderer/resolve";
import type { CanvasRenderer } from "@/services/renderer/canvas-renderer";
@@ -180,3 +188,159 @@ describe("renderer wiring", () => {
expect(node.resolved?.transform.scaleX).toBeCloseTo(1.175, 5);
});
});
+
+describe("standalone motion effect layers", () => {
+ const renderer = { width: 1920, height: 1080 } as CanvasRenderer;
+
+ function evaluateExpectedOffset({
+ effectType,
+ effectParams,
+ linearProgress,
+ }: {
+ effectType: string;
+ effectParams: ParamValues;
+ linearProgress: number;
+ }): MotionOffset {
+ const definition = effectsRegistry.get(effectType);
+ if (!definition.motion) {
+ throw new Error(`${effectType} has no motion config`);
+ }
+ const progress = sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find((preset) => preset.id === effectParams.easing)
+ ?.value ?? [0.25, 0.1, 0.25, 1],
+ x: linearProgress,
+ });
+ return definition.motion.evaluate({ effectParams, progress });
+ }
+
+ test("standalone zoom-in resolves motion state from definition math", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const node = new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ });
+
+ await resolveRenderTree({ node, renderer, time: 5 });
+
+ const expected = evaluateExpectedOffset({
+ effectType: "zoom-in",
+ effectParams,
+ linearProgress: 0.5,
+ });
+ expect(node.resolved?.passes).toEqual([]);
+ expect(node.resolved?.motion?.scale).toBeCloseTo(expected.scale, 5);
+ expect(node.resolved?.motion?.scale).toBeGreaterThan(1);
+ expect(node.resolved?.motion?.translateX).toBeCloseTo(0, 5);
+ expect(node.resolved?.motion?.translateY).toBeCloseTo(0, 5);
+ });
+
+ test("frame descriptor transforms lower layer quads without a sceneEffect item", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ const expected = evaluateExpectedOffset({
+ effectType: "zoom-in",
+ effectParams,
+ linearProgress: 0.5,
+ });
+ expect(layer.transform.centerX).toBeCloseTo(renderer.width / 2, 5);
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(
+ renderer.width * expected.scale,
+ 5,
+ );
+ expect(layer.transform.height).toBeCloseTo(
+ renderer.height * expected.scale,
+ 5,
+ );
+ });
+
+ test("frame descriptor translates lower layers for pan motion", async () => {
+ const effectParams = { intensity: 100, easing: "linear" };
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "pan-right",
+ effectParams,
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 7.5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ const expected = evaluateExpectedOffset({
+ effectType: "pan-right",
+ effectParams,
+ linearProgress: 0.75,
+ });
+ expect(expected.translateX).toBeGreaterThan(0);
+ expect(layer.transform.centerX).toBeCloseTo(
+ renderer.width / 2 + expected.translateX * renderer.width,
+ 5,
+ );
+ expect(layer.transform.centerY).toBeCloseTo(renderer.height / 2, 5);
+ expect(layer.transform.width).toBeCloseTo(
+ renderer.width * expected.scale,
+ 5,
+ );
+ });
+
+ test("identity motion leaves lower layer quads untouched", async () => {
+ const root = new RootNode({ duration: 10 });
+ root.add(new ColorNode({ color: "#ff0000" }));
+ root.add(
+ new EffectLayerNode({
+ effectType: "zoom-in",
+ effectParams: { intensity: 0, easing: "linear" },
+ timeOffset: 0,
+ duration: 10,
+ }),
+ );
+
+ await resolveRenderTree({ node: root, renderer, time: 5 });
+ const { frame } = await buildFrameDescriptor({ node: root, renderer });
+
+ expect(frame.items).toHaveLength(1);
+ const layer = frame.items.at(0);
+ if (layer?.type !== "layer") {
+ throw new Error("expected a single layer item");
+ }
+ expect(layer.transform).toEqual({
+ centerX: renderer.width / 2,
+ centerY: renderer.height / 2,
+ width: renderer.width,
+ height: renderer.height,
+ rotationDegrees: 0,
+ flipX: false,
+ flipY: false,
+ });
+ });
+});
diff --git a/apps/web/src/effects/components/assets-view.tsx b/apps/web/src/effects/components/assets-view.tsx
index 8c2ec85b..54817f54 100644
--- a/apps/web/src/effects/components/assets-view.tsx
+++ b/apps/web/src/effects/components/assets-view.tsx
@@ -7,13 +7,16 @@ import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/effects";
import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/editor/use-editor";
import { buildEffectElement } from "@/timeline/element-utils";
-import type { EffectDefinition } from "@/effects/types";
+import type { EffectCategory, EffectDefinition } from "@/effects/types";
-export function EffectsView() {
- const effects = effectsRegistry.getAll();
+export function EffectsView({ category }: { category?: EffectCategory }) {
+ const all = effectsRegistry.getAll();
+ const effects = category
+ ? all.filter((effect) => effect.category === category)
+ : all;
return (
- <PanelView title="Effects">
+ <PanelView title={category === "lens" ? "Lens" : "Effects"}>
<EffectsGrid effects={effects} />
</PanelView>
);
@@ -124,18 +127,33 @@ function MotionPreviewCanvas({ effectType }: { effectType: string }) {
return;
}
- const inward = effectType === "zoom-out";
- const spread = size * 0.16;
- const reach = size * 0.3;
- for (const [dx, dy] of [
- [1, 1],
- [-1, -1],
- ] as const) {
- const innerX = center + dx * (inward ? reach : spread);
- const innerY = center + dy * (inward ? reach : spread) * 0.7;
- const outerX = center + dx * (inward ? spread : reach);
- const outerY = center + dy * (inward ? spread : reach) * 0.7;
- drawArrow({ context, fromX: innerX, fromY: innerY, toX: outerX, toY: outerY });
+ if (effectType === "zoom-in" || effectType === "zoom-out") {
+ const inward = effectType === "zoom-out";
+ const spread = size * 0.16;
+ const reach = size * 0.3;
+ for (const [dx, dy] of [
+ [1, 1],
+ [-1, -1],
+ ] as const) {
+ const innerX = center + dx * (inward ? reach : spread);
+ const innerY = center + dy * (inward ? reach : spread) * 0.7;
+ const outerX = center + dx * (inward ? spread : reach);
+ const outerY = center + dy * (inward ? spread : reach) * 0.7;
+ drawArrow({
+ context,
+ fromX: innerX,
+ fromY: innerY,
+ toX: outerX,
+ toY: outerY,
+ });
+ }
+ return;
+ }
+
+ for (const radius of [size * 0.14, size * 0.26]) {
+ context.beginPath();
+ context.arc(center, center, radius, 0, Math.PI * 2);
+ context.stroke();
}
}, [effectType]);
@@ -182,9 +200,7 @@ function EffectItem({ effect }: { effect: EffectDefinition }) {
effectType: effect.type,
targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
}}
- onAddToTimeline={
- effect.supportsStandalone === false ? undefined : handleAddToTimeline
- }
+ onAddToTimeline={handleAddToTimeline}
aspectRatio={1}
isRounded
variant="card"
diff --git a/apps/web/src/effects/components/effects-tab.tsx b/apps/web/src/effects/components/effects-tab.tsx
index 102d818a..91ad1f42 100644
--- a/apps/web/src/effects/components/effects-tab.tsx
+++ b/apps/web/src/effects/components/effects-tab.tsx
@@ -25,7 +25,10 @@ import {
} from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { Separator } from "@/components/ui/separator";
-import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
+import {
+ getDefaultEffectsTab,
+ useAssetsPanelStore,
+} from "@/components/editor/panels/assets/assets-panel-store";
export function StandaloneEffectTab({
element,
@@ -222,7 +225,7 @@ function EmptyView() {
<Button
variant="default"
size="sm"
- onClick={() => setActiveTab("effects")}
+ onClick={() => setActiveTab(getDefaultEffectsTab())}
>
Open effects
</Button>
diff --git a/apps/web/src/effects/definitions/blur.ts b/apps/web/src/effects/definitions/blur.ts
index 194196e9..672e0cd4 100644
--- a/apps/web/src/effects/definitions/blur.ts
+++ b/apps/web/src/effects/definitions/blur.ts
@@ -69,6 +69,7 @@ export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
keywords: ["blur", "soft", "defocus"],
+ category: "lens",
params: [
{
key: "intensity",
diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts
index e38e5710..d2961c13 100644
--- a/apps/web/src/effects/definitions/index.ts
+++ b/apps/web/src/effects/definitions/index.ts
@@ -1,8 +1,14 @@
import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
-import { motionEffectDefinitions } from "./motion";
+import { motionBlurEffectDefinition } from "./motion-blur";
+import { motionEffectDefinitions, customZoomEffectDefinition } from "./motion";
-const defaultEffects = [blurEffectDefinition, ...motionEffectDefinitions];
+const defaultEffects = [
+ blurEffectDefinition,
+ motionBlurEffectDefinition,
+ customZoomEffectDefinition,
+ ...motionEffectDefinitions,
+];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
diff --git a/apps/web/src/effects/definitions/motion-blur.ts b/apps/web/src/effects/definitions/motion-blur.ts
new file mode 100644
index 00000000..b20e6c26
--- /dev/null
+++ b/apps/web/src/effects/definitions/motion-blur.ts
@@ -0,0 +1,67 @@
+import type { EffectDefinition } from "@/effects/types";
+import { GAUSSIAN_BLUR_SHADER, intensityToSigma } from "./blur";
+
+const MAX_SINGLE_PASS_SIGMA = 10;
+const MIN_SIGMA = 0.001;
+
+function parseNumericParam({
+ effectParams,
+ key,
+}: {
+ effectParams: Record<string, unknown>;
+ key: string;
+}): number {
+ const raw = effectParams[key];
+ return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+}
+
+export const motionBlurEffectDefinition: EffectDefinition = {
+ type: "motion-blur",
+ name: "Motion Blur",
+ keywords: ["motion blur", "smear", "directional", "speed", "streak"],
+ category: "lens",
+ params: [
+ {
+ key: "amount",
+ label: "Amount",
+ type: "number",
+ default: 40,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ {
+ key: "angle",
+ label: "Angle",
+ type: "number",
+ default: 0,
+ min: -180,
+ max: 180,
+ step: 1,
+ },
+ ],
+ renderer: {
+ passes: [],
+ buildPasses: ({ effectParams, width }) => {
+ const sigma = intensityToSigma({
+ intensity: parseNumericParam({ effectParams, key: "amount" }),
+ resolution: width,
+ reference: 1920,
+ });
+ if (sigma < MIN_SIGMA) return [];
+
+ const angleRad =
+ (parseNumericParam({ effectParams, key: "angle" }) * Math.PI) / 180;
+ return [
+ {
+ shader: GAUSSIAN_BLUR_SHADER,
+ uniforms: {
+ u_sigma: Math.min(sigma, MAX_SINGLE_PASS_SIGMA),
+ u_step: Math.max(1, sigma / MAX_SINGLE_PASS_SIGMA),
+ u_direction: [Math.cos(angleRad), Math.sin(angleRad)],
+ },
+ },
+ ];
+ },
+ },
+};
diff --git a/apps/web/src/effects/definitions/motion.ts b/apps/web/src/effects/definitions/motion.ts
index 8c81d801..2e3292a2 100644
--- a/apps/web/src/effects/definitions/motion.ts
+++ b/apps/web/src/effects/definitions/motion.ts
@@ -5,6 +5,76 @@ import { MOTION_EASING_OPTIONS, getMotionIntensity } from "@/effects/motion";
const ZOOM_RANGE = 0.35;
const PAN_ZOOM_HEADROOM = 0.15;
const PAN_TRAVEL = 0.06;
+const SHAKE_SCALE_HEADROOM = 0.08;
+const SHAKE_TRAVEL_X = 0.025;
+const SHAKE_TRAVEL_Y = 0.02;
+const SHAKE_FREQUENCY = 6;
+const SHAKE_Y_DETUNE = 1.31;
+const PULSE_RANGE = 0.12;
+const PULSE_FREQUENCY = 3;
+const SPIN_FULL_TURN_DEGREES = 360;
+const CUSTOM_ZOOM_MIN_SCALE = 25;
+const CUSTOM_ZOOM_MAX_SCALE = 400;
+
+function readScaleParam({
+ effectParams,
+ key,
+}: {
+ effectParams: ParamValues;
+ key: string;
+}): number {
+ const raw = effectParams[key];
+ const value = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+ return Number.isFinite(value) ? value : 100;
+}
+
+export const customZoomEffectDefinition: EffectDefinition = {
+ type: "custom-zoom",
+ name: "Custom Zoom",
+ keywords: ["zoom", "ken burns", "custom", "scale", "start", "end"],
+ category: "lens",
+ params: [
+ {
+ key: "startScale",
+ label: "Start scale",
+ type: "number",
+ default: 100,
+ min: CUSTOM_ZOOM_MIN_SCALE,
+ max: CUSTOM_ZOOM_MAX_SCALE,
+ step: 1,
+ keyframable: true,
+ },
+ {
+ key: "endScale",
+ label: "End scale",
+ type: "number",
+ default: 135,
+ min: CUSTOM_ZOOM_MIN_SCALE,
+ max: CUSTOM_ZOOM_MAX_SCALE,
+ step: 1,
+ keyframable: true,
+ },
+ {
+ key: "easing",
+ label: "Easing",
+ type: "select",
+ default: "smooth",
+ options: MOTION_EASING_OPTIONS,
+ },
+ ],
+ renderer: { passes: [] },
+ motion: {
+ evaluate: ({ effectParams, progress }) => {
+ const startScale = readScaleParam({ effectParams, key: "startScale" });
+ const endScale = readScaleParam({ effectParams, key: "endScale" });
+ return {
+ scale: (startScale + (endScale - startScale) * progress) / 100,
+ translateX: 0,
+ translateY: 0,
+ };
+ },
+ },
+};
function motionParams(): ParamDefinition[] {
return [
@@ -79,10 +149,10 @@ function buildMotionDefinition({
type,
name,
keywords,
+ category: "lens",
params: motionParams(),
renderer: { passes: [] },
motion: { evaluate },
- supportsStandalone: false,
};
}
@@ -129,4 +199,47 @@ export const motionEffectDefinitions: EffectDefinition[] = [
evaluate: ({ effectParams, progress }) =>
panOffset({ effectParams, progress, axis: "y", direction: 1 }),
}),
+ buildMotionDefinition({
+ type: "shake",
+ name: "Shake",
+ keywords: ["shake", "camera", "handheld", "wobble", "jitter"],
+ evaluate: ({ effectParams, progress }) => {
+ const intensity = getMotionIntensity({ effectParams });
+ const angle = progress * Math.PI * 2 * SHAKE_FREQUENCY;
+ return {
+ scale: 1 + SHAKE_SCALE_HEADROOM * intensity,
+ translateX: Math.sin(angle) * SHAKE_TRAVEL_X * intensity,
+ translateY:
+ Math.cos(angle * SHAKE_Y_DETUNE) * SHAKE_TRAVEL_Y * intensity,
+ };
+ },
+ }),
+ buildMotionDefinition({
+ type: "pulse",
+ name: "Pulse",
+ keywords: ["pulse", "beat", "pump", "scale", "bounce"],
+ evaluate: ({ effectParams, progress }) => ({
+ scale:
+ 1 +
+ Math.sin(progress * Math.PI * 2 * PULSE_FREQUENCY) *
+ PULSE_RANGE *
+ getMotionIntensity({ effectParams }),
+ translateX: 0,
+ translateY: 0,
+ }),
+ }),
+ buildMotionDefinition({
+ type: "spin",
+ name: "Spin",
+ keywords: ["spin", "rotate", "turn", "twirl"],
+ evaluate: ({ effectParams, progress }) => ({
+ scale: 1,
+ translateX: 0,
+ translateY: 0,
+ rotate:
+ progress *
+ SPIN_FULL_TURN_DEGREES *
+ getMotionIntensity({ effectParams }),
+ }),
+ }),
];
diff --git a/apps/web/src/effects/motion.ts b/apps/web/src/effects/motion.ts
index ab51002a..8734d343 100644
--- a/apps/web/src/effects/motion.ts
+++ b/apps/web/src/effects/motion.ts
@@ -94,6 +94,7 @@ export function applyMotionEffects({
let scale = 1;
let translateX = 0;
let translateY = 0;
+ let rotate = 0;
for (const effect of motionEffects) {
const definition = effectsRegistry.get(effect.type);
@@ -117,6 +118,7 @@ export function applyMotionEffects({
scale *= offset.scale;
translateX += offset.translateX;
translateY += offset.translateY;
+ rotate += offset.rotate ?? 0;
}
return {
@@ -126,6 +128,6 @@ export function applyMotionEffects({
x: baseTransform.position.x + translateX * canvasWidth,
y: baseTransform.position.y + translateY * canvasHeight,
},
- rotate: baseTransform.rotate,
+ rotate: baseTransform.rotate + rotate,
};
}
diff --git a/apps/web/src/effects/types.ts b/apps/web/src/effects/types.ts
index 88c32207..9b243e9b 100644
--- a/apps/web/src/effects/types.ts
+++ b/apps/web/src/effects/types.ts
@@ -27,6 +27,7 @@ export interface MotionOffset {
scale: number;
translateX: number;
translateY: number;
+ rotate?: number;
}
export interface EffectMotionConfig {
@@ -45,12 +46,14 @@ export interface EffectRendererConfig {
}) => EffectPass[];
}
+export type EffectCategory = "lens" | "effect";
+
export interface EffectDefinition {
type: string;
name: string;
keywords: string[];
+ category: EffectCategory;
params: ParamDefinition[];
renderer: EffectRendererConfig;
motion?: EffectMotionConfig;
- supportsStandalone?: boolean;
}
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
index 1a5ce1bc..5f50e581 100644
--- a/apps/web/src/preview/components/toolbar.tsx
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -1,13 +1,15 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useSyncExternalStore } from "react";
import { useEditor } from "@/editor/use-editor";
import { formatTimecode } from "opencut-wasm";
import { invokeAction } from "@/actions";
import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
+ ArrowExpandDiagonal01Icon,
FullScreenIcon,
+ Minimize01Icon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
@@ -21,6 +23,12 @@ import {
SelectSeparator,
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { usePreviewPopoutStore } from "@/preview/popout-store";
import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store";
@@ -51,6 +59,7 @@ export function PreviewToolbar({
)}
</Button>
</GridPopover> */}
+ <PopoutButton />
<Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
@@ -59,6 +68,50 @@ export function PreviewToolbar({
);
}
+function PopoutButton() {
+ const isPoppedOut = usePreviewPopoutStore(
+ (state) => state.container !== null,
+ );
+ const openPopout = usePreviewPopoutStore((state) => state.openPopout);
+ const dockPopout = usePreviewPopoutStore((state) => state.dockPopout);
+ const isSupported = useSyncExternalStore(
+ () => () => {},
+ () => "documentPictureInPicture" in window,
+ () => false,
+ );
+
+ if (!isSupported) {
+ return null;
+ }
+
+ const togglePopout = () => {
+ if (isPoppedOut) {
+ dockPopout();
+ } else {
+ openPopout();
+ }
+ };
+
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ onClick={togglePopout}
+ aria-label={isPoppedOut ? "Dock preview" : "Pop out preview"}
+ >
+ <HugeiconsIcon
+ icon={isPoppedOut ? Minimize01Icon : ArrowExpandDiagonal01Icon}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPoppedOut ? "Dock preview" : "Pop out preview"}
+ </TooltipContent>
+ </Tooltip>
+ );
+}
+
function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
diff --git a/apps/web/src/preview/popout-store.ts b/apps/web/src/preview/popout-store.ts
new file mode 100644
index 00000000..1b91f6a2
--- /dev/null
+++ b/apps/web/src/preview/popout-store.ts
@@ -0,0 +1,76 @@
+import { create } from "zustand";
+
+interface PreviewPopoutState {
+ pipWindow: Window | null;
+ container: HTMLElement | null;
+ openPopout: () => Promise<void>;
+ dockPopout: () => void;
+}
+
+function copyStylesInto({ pipWindow }: { pipWindow: Window }) {
+ const styleNodes = document.head.querySelectorAll(
+ "style, link[rel='stylesheet']",
+ );
+ for (const node of styleNodes) {
+ if (node instanceof HTMLStyleElement) {
+ pipWindow.document.head.appendChild(node.cloneNode(true));
+ } else if (node instanceof HTMLLinkElement) {
+ const link = pipWindow.document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = node.href;
+ pipWindow.document.head.appendChild(link);
+ }
+ }
+}
+
+function preparePipDocument({ pipWindow }: { pipWindow: Window }) {
+ pipWindow.document.documentElement.className =
+ document.documentElement.className;
+ pipWindow.document.documentElement.style.height = "100%";
+ pipWindow.document.body.className = document.body.className;
+ pipWindow.document.body.style.margin = "0";
+ pipWindow.document.body.style.height = "100%";
+ pipWindow.document.body.style.overflow = "hidden";
+ pipWindow.document.title = "Preview";
+ copyStylesInto({ pipWindow });
+}
+
+export const usePreviewPopoutStore = create<PreviewPopoutState>()(
+ (set, get) => ({
+ pipWindow: null,
+ container: null,
+ openPopout: async () => {
+ if (get().container !== null) {
+ return;
+ }
+ if (!("documentPictureInPicture" in window)) {
+ return;
+ }
+ const pip = window.documentPictureInPicture;
+ if (!pip) {
+ return;
+ }
+ const pipWindow = await pip.requestWindow();
+ preparePipDocument({ pipWindow });
+ const container = pipWindow.document.createElement("div");
+ container.style.width = "100%";
+ container.style.height = "100%";
+ pipWindow.document.body.appendChild(container);
+ pipWindow.addEventListener(
+ "pagehide",
+ () => {
+ set({ pipWindow: null, container: null });
+ },
+ { once: true },
+ );
+ set({ pipWindow, container });
+ },
+ dockPopout: () => {
+ const { pipWindow } = get();
+ if (pipWindow && !pipWindow.closed) {
+ pipWindow.close();
+ }
+ set({ pipWindow: null, container: null });
+ },
+ }),
+);
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index d5ab88c3..d2bff525 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -121,13 +121,43 @@ async function collectNode({
}
if (node instanceof EffectLayerNode) {
- if (!node.resolved || node.resolved.passes.length === 0) {
+ if (!node.resolved) {
return;
}
- items.push({
- type: "sceneEffect",
- effectPassGroups: [node.resolved.passes],
- });
+ const { motion, passes } = node.resolved;
+ if (
+ motion &&
+ (motion.scale !== 1 ||
+ motion.translateX !== 0 ||
+ motion.translateY !== 0 ||
+ (motion.rotate ?? 0) !== 0)
+ ) {
+ const { width, height } = renderer;
+ for (const item of items) {
+ if (item.type !== "layer") continue;
+ item.transform = {
+ ...item.transform,
+ centerX:
+ width / 2 +
+ (item.transform.centerX - width / 2) * motion.scale +
+ motion.translateX * width,
+ centerY:
+ height / 2 +
+ (item.transform.centerY - height / 2) * motion.scale +
+ motion.translateY * height,
+ width: item.transform.width * motion.scale,
+ height: item.transform.height * motion.scale,
+ rotationDegrees:
+ item.transform.rotationDegrees + (motion.rotate ?? 0),
+ };
+ }
+ }
+ if (passes.length > 0) {
+ items.push({
+ type: "sceneEffect",
+ effectPassGroups: [passes],
+ });
+ }
return;
}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
index d1b51fa3..791d20ce 100644
--- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -1,4 +1,4 @@
-import type { EffectPass } from "@/effects/types";
+import type { EffectPass, MotionOffset } from "@/effects/types";
import type { ParamValues } from "@/params";
import { BaseNode } from "./base-node";
@@ -11,6 +11,7 @@ export type EffectLayerNodeParams = {
export type ResolvedEffectLayerNodeState = {
passes: EffectPass[];
+ motion?: MotionOffset;
};
export class EffectLayerNode extends BaseNode<
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index a4c8d303..12f57e30 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -6,8 +6,10 @@ import {
intensityToSigma,
} from "@/effects/definitions/blur";
import { effectsRegistry, resolveEffectPasses } from "@/effects";
-import { applyMotionEffects } from "@/effects/motion";
+import { applyMotionEffects, sampleEasingBezier } from "@/effects/motion";
import type { Effect, EffectPass } from "@/effects/types";
+import { BUILTIN_PRESETS } from "@/timeline/components/graph-editor/easing-presets";
+import { clamp } from "@/utils/math";
import { getSourceTimeAtClipTime } from "@/retime";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
@@ -479,11 +481,25 @@ function resolveEffectLayerNode({
width: context.renderer.width,
height: context.renderer.height,
});
- if (passes.length === 0) {
+
+ const motion = definition.motion?.evaluate({
+ effectParams: node.params.effectParams,
+ progress: sampleEasingBezier({
+ bezier:
+ BUILTIN_PRESETS.find(
+ (entry) => entry.id === node.params.effectParams.easing,
+ )?.value ?? [0.25, 0.1, 0.25, 1],
+ x: clamp({
+ value: (time - node.params.timeOffset) / node.params.duration,
+ min: 0,
+ max: 1,
+ }),
+ }),
+ });
+
+ if (passes.length === 0 && !motion) {
return null;
}
- return {
- passes,
- };
+ return motion ? { passes, motion } : { passes };
}
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 5c412435..70161054 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -68,6 +68,7 @@ import {
ScissorIcon,
Delete02Icon,
Copy01Icon,
+ Edit02Icon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
@@ -80,7 +81,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
-import { useMemo, type ComponentProps, type ReactNode } from "react";
+import { useMemo, useState, type ComponentProps, type ReactNode } from "react";
import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types";
import { cn } from "@/utils/ui";
import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
@@ -92,6 +93,7 @@ import {
getExpansionHeight,
type ExpandedRow,
} from "./expanded-layout";
+import { RenameDialog } from "@/components/editor/rename-dialog";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
@@ -233,7 +235,9 @@ export function TimelineElement({
isDropTarget = false,
}: TimelineElementProps) {
const mediaAssets = useEditor((e) => e.media.getAssets());
+ const editor = useEditor();
const { selectedElements } = useElementSelection();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
const { renderElement } = useElementPreview({
trackId: track.id,
@@ -348,6 +352,19 @@ export function TimelineElement({
: "Extract audio";
const isElementSourceAudioSeparated =
element.type === "video" && isSourceAudioSeparated({ element });
+ const handleRenameConfirm = (newName: string) => {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId: track.id,
+ elementId: element.id,
+ patch: { name: newName },
+ },
+ ],
+ });
+ setIsRenameDialogOpen(false);
+ };
+
const hasKeyframes = elementKeyframes.length > 0;
const expansionHeight = getExpansionHeight({ rows: expandedRows });
const baseTrackHeight = getTrackHeight({ type: track.type });
@@ -425,6 +442,15 @@ export function TimelineElement({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Edit02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </ContextMenuItem>
<ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
@@ -509,6 +535,13 @@ export function TimelineElement({
/>
</ContextMenuContent>
</ContextMenu>
+ <RenameDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleRenameConfirm}
+ currentName={element.name}
+ title="Rename clip"
+ />
</PixelsPerSecondContext.Provider>
);
}
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
index 6c48428a..a419101f 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -15,7 +15,6 @@ import {
buildEffectElement,
} from "@/timeline/element-utils";
import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
-import { effectsRegistry } from "@/effects";
import { BatchCommand } from "@/commands";
import type { Command } from "@/commands/base-command";
import { computeDropTarget } from "@/timeline/components/drop-target";
@@ -471,10 +470,6 @@ export class DragDropController {
return;
}
- if (effectsRegistry.get(dragData.effectType).supportsStandalone === false) {
- return;
- }
-
const element = buildEffectElement({
effectType: dragData.effectType,
startTime: target.xPosition,
diff --git a/apps/web/src/types/document-picture-in-picture.d.ts b/apps/web/src/types/document-picture-in-picture.d.ts
new file mode 100644
index 00000000..68cfa5c3
--- /dev/null
+++ b/apps/web/src/types/document-picture-in-picture.d.ts
@@ -0,0 +1,15 @@
+interface DocumentPictureInPictureOptions {
+ width?: number;
+ height?: number;
+}
+
+interface DocumentPictureInPicture extends EventTarget {
+ requestWindow(
+ options?: DocumentPictureInPictureOptions,
+ ): Promise<Window>;
+ readonly window: Window;
+}
+
+interface Window {
+ documentPictureInPicture?: DocumentPictureInPicture;
+}