diff options
Diffstat (limited to 'apps/web')
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; +} |
