summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/package.json4
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx255
-rw-r--r--apps/web/src/app/layout.tsx7
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx220
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx363
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx285
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx3
-rw-r--r--apps/web/src/core/managers/playback-manager.ts124
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts11
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts60
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts18
-rw-r--r--apps/web/src/lib/background/constants.ts6
-rw-r--r--apps/web/src/lib/text/layout.ts51
-rw-r--r--apps/web/src/lib/text/measure-element.ts289
-rw-r--r--apps/web/src/services/video-cache/service.ts16
-rw-r--r--bun.lock10
16 files changed, 919 insertions, 803 deletions
diff --git a/apps/web/package.json b/apps/web/package.json
index 7ee5fe1a..06d9babd 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -19,7 +19,7 @@
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
- "@hugeicons/core-free-icons": "^3.1.1",
+ "@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opennextjs/cloudflare": "^1.18.0",
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.3",
+ "opencut-wasm": "file:../../rust/wasm/pkg",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 38a25d4c..8456d624 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,113 +1,142 @@
-"use client";
-
-import { useParams } from "next/navigation";
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable";
-import { AssetsPanel } from "@/components/editor/panels/assets";
-import { PropertiesPanel } from "@/components/editor/panels/properties";
-import { Timeline } from "@/components/editor/panels/timeline";
-import { PreviewPanel } from "@/components/editor/panels/preview";
-import { EditorHeader } from "@/components/editor/editor-header";
-import { EditorProvider } from "@/components/providers/editor-provider";
-import { Onboarding } from "@/components/editor/onboarding";
-import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
-import { usePanelStore } from "@/stores/panel-store";
-import { usePasteMedia } from "@/hooks/use-paste-media";
-import { MobileGate } from "@/components/editor/mobile-gate";
-
-export default function Editor() {
- const params = useParams();
- const projectId = params.project_id as string;
-
- return (
- <MobileGate>
- <EditorProvider projectId={projectId}>
- <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
- <EditorHeader />
- <div className="min-h-0 min-w-0 flex-1">
- <EditorLayout />
- </div>
- <Onboarding />
- <MigrationDialog />
- </div>
- </EditorProvider>
- </MobileGate>
- );
-}
-
-function EditorLayout() {
- usePasteMedia();
- const { panels, setPanel } = usePanelStore();
-
- return (
- <ResizablePanelGroup
- direction="vertical"
- className="size-full gap-[0.18rem]"
- onLayout={(sizes) => {
- setPanel("mainContent", sizes[0] ?? panels.mainContent);
- setPanel("timeline", sizes[1] ?? panels.timeline);
- }}
- >
- <ResizablePanel
- defaultSize={panels.mainContent}
- minSize={30}
- maxSize={85}
- className="min-h-0"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="size-full gap-[0.19rem] px-3"
- onLayout={(sizes) => {
- setPanel("tools", sizes[0] ?? panels.tools);
- setPanel("preview", sizes[1] ?? panels.preview);
- setPanel("properties", sizes[2] ?? panels.properties);
- }}
- >
- <ResizablePanel
- defaultSize={panels.tools}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <AssetsPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.preview}
- minSize={30}
- className="min-h-0 min-w-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.properties}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.timeline}
- minSize={15}
- maxSize={70}
- className="min-h-0 px-3 pb-3"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- );
-}
+"use client";
+
+import { useParams } from "next/navigation";
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable";
+import { AssetsPanel } from "@/components/editor/panels/assets";
+import { PropertiesPanel } from "@/components/editor/panels/properties";
+import { Timeline } from "@/components/editor/panels/timeline";
+import { PreviewPanel } from "@/components/editor/panels/preview";
+import { EditorHeader } from "@/components/editor/editor-header";
+import { EditorProvider } from "@/components/providers/editor-provider";
+import { Onboarding } from "@/components/editor/onboarding";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { usePanelStore } from "@/stores/panel-store";
+import { usePasteMedia } from "@/hooks/use-paste-media";
+import { MobileGate } from "@/components/editor/mobile-gate";
+import { useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { Cancel01Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Button } from "@/components/ui/button";
+
+export default function Editor() {
+ const params = useParams();
+ const projectId = params.project_id as string;
+
+ return (
+ <MobileGate>
+ <EditorProvider projectId={projectId}>
+ <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
+ <DegradedRendererBanner />
+ <EditorHeader />
+ <div className="min-h-0 min-w-0 flex-1">
+ <EditorLayout />
+ </div>
+ <Onboarding />
+ <MigrationDialog />
+ </div>
+ </EditorProvider>
+ </MobileGate>
+ );
+}
+
+function DegradedRendererBanner() {
+ const isDegraded = useEditor((e) => e.renderer.isDegraded);
+ const [dismissed, setDismissed] = useState(false);
+ if (!isDegraded || dismissed) return null;
+
+ return (
+ <div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
+ <span>
+ For the best experience, open OpenCut in Chrome.
+ </span>
+ <Button
+ variant="text"
+ size="icon"
+ className="p-0 w-auto [&_svg]:size-3.5"
+ onClick={() => setDismissed(true)}
+ aria-label="Dismiss"
+ >
+ <HugeiconsIcon icon={Cancel01Icon} />
+ </Button>
+ </div>
+ );
+}
+
+function EditorLayout() {
+ usePasteMedia();
+ const { panels, setPanel } = usePanelStore();
+
+ return (
+ <ResizablePanelGroup
+ direction="vertical"
+ className="size-full gap-[0.18rem]"
+ onLayout={(sizes) => {
+ setPanel("mainContent", sizes[0] ?? panels.mainContent);
+ setPanel("timeline", sizes[1] ?? panels.timeline);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.mainContent}
+ minSize={30}
+ maxSize={85}
+ className="min-h-0"
+ >
+ <ResizablePanelGroup
+ direction="horizontal"
+ className="size-full gap-[0.19rem] px-3"
+ onLayout={(sizes) => {
+ setPanel("tools", sizes[0] ?? panels.tools);
+ setPanel("preview", sizes[1] ?? panels.preview);
+ setPanel("properties", sizes[2] ?? panels.properties);
+ }}
+ >
+ <ResizablePanel
+ defaultSize={panels.tools}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0"
+ >
+ <AssetsPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.preview}
+ minSize={30}
+ className="min-h-0 min-w-0 flex-1"
+ >
+ <PreviewPanel />
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.properties}
+ minSize={15}
+ maxSize={40}
+ className="min-w-0"
+ >
+ <PropertiesPanel />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ defaultSize={panels.timeline}
+ minSize={15}
+ maxSize={70}
+ className="min-h-0 px-3 pb-3"
+ >
+ <Timeline />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ );
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index b65e028d..d10dc9d3 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -35,13 +35,6 @@ export default function RootLayout({
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
-
- {/* code to figma */}
- {/* <script
- dangerouslySetInnerHTML={{
- __html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`,
- }}
- /> */}
</>
)}
</head>
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index 515cb91a..6ee4b584 100644
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -1,117 +1,103 @@
-import { useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { TransformHandles } from "./transform-handles";
-import { MaskHandles } from "./mask-handles";
-import { SnapGuides } from "./snap-guides";
-import { TextEditOverlay } from "./text-edit-overlay";
-import { usePropertiesStore } from "../properties/stores/properties-store";
-import { useEditor } from "@/hooks/use-editor";
-
-export function PreviewInteractionOverlay() {
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
-
- const selectedRef =
- selectedElements.length === 1 ? selectedElements[0] : null;
- const activeTrack = selectedRef
- ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
- : null;
- const activeElement =
- activeTrack?.elements.find(
- (element) => element.id === selectedRef?.elementId,
- ) ?? null;
- const isMaskMode = activeElement
- ? activeTabPerType[activeElement.type] === "masks"
- : false;
-
- const {
- onPointerDown,
- onPointerMove,
- onPointerUp,
- onDoubleClick,
- editingText,
- commitTextEdit,
- } = usePreviewInteraction({
- onSnapLinesChange: setSnapLines,
- isMaskMode,
- });
-
- const handlePointerDown = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerDown({ event })) {
- return;
- }
-
- onPointerDown(event);
- };
-
- const handlePointerMove = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerMove({ event })) {
- return;
- }
-
- onPointerMove(event);
- };
-
- const handlePointerUp = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerUp({ event })) {
- return;
- }
-
- onPointerUp(event);
- };
-
- const handleContextMenu = (e: React.MouseEvent) => {
- const x = e.nativeEvent.offsetX;
- const y = e.nativeEvent.offsetY;
- const outsideCanvas =
- x < viewport.sceneLeft ||
- x > viewport.sceneLeft + viewport.sceneWidth ||
- y < viewport.sceneTop ||
- y > viewport.sceneTop + viewport.sceneHeight;
- if (outsideCanvas) {
- e.stopPropagation();
- }
- };
-
- return (
- <div className="absolute inset-0">
- <div
- className="absolute inset-0 pointer-events-auto"
- role="application"
- aria-label="Preview canvas"
- style={{
- cursor: viewport.isPanning
- ? "grabbing"
- : viewport.canPan
- ? "default"
- : undefined,
- }}
- onPointerDown={handlePointerDown}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- onDoubleClick={onDoubleClick}
- onDragStart={(e) => e.preventDefault()}
- onContextMenu={handleContextMenu}
- />
- {editingText ? (
- <TextEditOverlay
- trackId={editingText.trackId}
- elementId={editingText.elementId}
- element={editingText.element}
- onCommit={commitTextEdit}
- />
- ) : isMaskMode ? (
- <MaskHandles onSnapLinesChange={setSnapLines} />
- ) : (
- <TransformHandles onSnapLinesChange={setSnapLines} />
- )}
- <SnapGuides lines={snapLines} />
- </div>
- );
-}
+import { useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
+import { SnapGuides } from "./snap-guides";
+import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "../properties/stores/properties-store";
+import { useEditor } from "@/hooks/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
+
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ editingText,
+ commitTextEdit,
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ return (
+ <div className="absolute inset-0">
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ role="application"
+ aria-label="Preview canvas"
+ style={{
+ cursor: viewport.isPanning
+ ? "grabbing"
+ : viewport.canPan
+ ? "default"
+ : undefined,
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
+ onDoubleClick={onDoubleClick}
+ onDragStart={(e) => e.preventDefault()}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ />
+ ) : isMaskMode ? (
+ <MaskHandles onSnapLinesChange={setSnapLines} />
+ ) : (
+ <TransformHandles onSnapLinesChange={setSnapLines} />
+ )}
+ <SnapGuides lines={snapLines} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index aff77f0f..78114b27 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -1,212 +1,151 @@
-"use client";
-
-import { useCallback, useEffect, useRef } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/lib/timeline";
-import {
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getMetricAscent,
- getMetricDescent,
- setCanvasLetterSpacing,
-} from "@/lib/text/layout";
-
-let cachedCanvas: HTMLCanvasElement | null = null;
-
-function getMeasurementContext(): CanvasRenderingContext2D | null {
- if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
- return cachedCanvas.getContext("2d");
-}
-
-/**
- * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
- * actualBoundingBox to model where the browser places the baseline within the
- * line box, then measures actual glyph bounds to find the visual center.
- */
-function measureCSSVisualCenterOffset({
- lines,
- fontString,
- letterSpacingPx,
- lineHeightPx,
- displayFontSize,
-}: {
- lines: string[];
- fontString: string;
- letterSpacingPx: number;
- lineHeightPx: number;
- displayFontSize: number;
-}): number {
- const ctx = getMeasurementContext();
- if (!ctx) return 0;
-
- ctx.font = fontString;
- ctx.textBaseline = "alphabetic";
- setCanvasLetterSpacing({ ctx, letterSpacingPx });
-
- const probe = ctx.measureText("M");
- const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
- const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
- const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
-
- let visualTop = Number.POSITIVE_INFINITY;
- let visualBottom = Number.NEGATIVE_INFINITY;
-
- for (let i = 0; i < lines.length; i++) {
- const metrics = ctx.measureText(lines[i] || " ");
- const baseline = i * lineHeightPx + halfLeading + fontAscent;
- visualTop = Math.min(
- visualTop,
- baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
- );
- visualBottom = Math.max(
- visualBottom,
- baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
- );
- }
-
- const cssBlockHeight = lines.length * lineHeightPx;
- return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
-}
-
-export function TextEditOverlay({
- trackId,
- elementId,
- element,
- onCommit,
-}: {
- trackId: string;
- elementId: string;
- element: TextElement;
- onCommit: () => void;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const divRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- const div = divRef.current;
- if (!div) return;
- div.focus();
- const range = document.createRange();
- range.selectNodeContents(div);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }, []);
-
- const handleInput = useCallback(() => {
- const div = divRef.current;
- if (!div) return;
- const text = div.innerText;
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: { content: text } }],
- });
- }, [editor.timeline, trackId, elementId]);
-
- const handleKeyDown = useCallback(
- ({ event }: { event: React.KeyboardEvent }) => {
- const { key } = event;
- if (key === "Escape") {
- event.preventDefault();
- onCommit();
- return;
- }
- },
- [onCommit],
- );
-
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- if (!canvasSize) return null;
-
- const { x: posX, y: posY } = viewport.positionToOverlay({
- positionX: element.transform.position.x,
- positionY: element.transform.position.y,
- });
-
- const { x: displayScaleX } = viewport.getDisplayScale();
-
- const displayFontSize =
- element.fontSize *
- (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
- displayScaleX;
-
- const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
- const lineHeightPx = displayFontSize * lineHeight;
- const lines = (element.content || "").split("\n");
- const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
-
- const cssVisualCenterOffset = measureCSSVisualCenterOffset({
- lines,
- fontString,
- letterSpacingPx: displayLetterSpacing,
- lineHeightPx,
- displayFontSize,
- });
-
- const bg = element.background;
- const shouldShowBackground =
- bg.enabled && bg.color && bg.color !== "transparent";
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const displayPaddingX = shouldShowBackground
- ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
- fontSizeRatio *
- displayScaleX
- : 0;
- const displayPaddingY = shouldShowBackground
- ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
- fontSizeRatio *
- displayScaleX
- : 0;
-
- return (
- <div
- className="absolute"
- style={{
- left: posX,
- top: posY - cssVisualCenterOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
- transformOrigin: "center center",
- }}
- >
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
- <div
- ref={divRef}
- contentEditable
- suppressContentEditableWarning
- tabIndex={0}
- role="textbox"
- aria-label="Edit text"
- className="cursor-text select-text outline-none whitespace-pre"
- style={{
- fontSize: displayFontSize,
- fontFamily: element.fontFamily,
- fontWeight,
- fontStyle,
- textAlign: element.textAlign,
- letterSpacing: `${displayLetterSpacing}px`,
- lineHeight,
- color: element.color,
- backgroundColor: shouldShowBackground ? bg.color : "transparent",
- minHeight: lineHeightPx,
- textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground
- ? `${displayPaddingY}px ${displayPaddingX}px`
- : 0,
- minWidth: 1,
- }}
- onInput={handleInput}
- onBlur={onCommit}
- onKeyDown={(event) => handleKeyDown({ event })}
- >
- {element.content || ""}
- </div>
- </div>
- );
-}
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import type { TextElement } from "@/lib/timeline";
+import {
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+
+export function TextEditOverlay({
+ trackId,
+ elementId,
+ element,
+ onCommit,
+}: {
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ onCommit: () => void;
+}) {
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const divRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = divRef.current;
+ if (!div) return;
+ div.focus();
+ const range = document.createRange();
+ range.selectNodeContents(div);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }, []);
+
+ const handleInput = useCallback(() => {
+ const div = divRef.current;
+ if (!div) return;
+ const text = div.innerText;
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: { content: text } }],
+ });
+ }, [editor.timeline, trackId, elementId]);
+
+ const handleKeyDown = useCallback(
+ ({ event }: { event: React.KeyboardEvent }) => {
+ const { key } = event;
+ if (key === "Escape") {
+ event.preventDefault();
+ onCommit();
+ return;
+ }
+ },
+ [onCommit],
+ );
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!canvasSize) return null;
+
+ const currentTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const { x: posX, y: posY } = viewport.positionToOverlay({
+ positionX: transform.position.x,
+ positionY: transform.position.y,
+ });
+
+ const { x: displayScaleX } = viewport.getDisplayScale();
+
+ const scaledFontSize =
+ element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
+
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const canvasLetterSpacing = element.letterSpacing ?? 0;
+ const lineHeightPx = scaledFontSize * lineHeight;
+
+ const bg = element.background;
+ const shouldShowBackground =
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const canvasPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
+ : 0;
+ const canvasPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
+ : 0;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY,
+ transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: scaledFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${canvasLetterSpacing}px`,
+ lineHeight,
+ color: "transparent",
+ caretColor: element.color,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
+ textDecoration: element.textDecoration ?? "none",
+ padding: shouldShowBackground
+ ? `${canvasPaddingY}px ${canvasPaddingX}px`
+ : 0,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={(event) => handleKeyDown({ event })}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 141359c8..2b81b557 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -1,140 +1,145 @@
-"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getCornerPosition,
- getEdgeHandlePosition,
- getRotationHandlePosition,
- type Corner,
- type Edge,
-} from "@/lib/preview/element-bounds";
-import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
-import {
- BoundingBoxOutline,
- CornerHandle,
- EdgeHandle,
- IconHandle,
- getResizeCursor,
-} from "./handle-primitives";
-import { Rotate01Icon } from "@hugeicons/core-free-icons";
-
-const CORNERS: Corner[] = [
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-];
-const EDGES: Edge[] = ["right", "left", "bottom"];
-
-export function TransformHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
-}) {
- const viewport = usePreviewViewport();
- const {
- selectedWithBounds,
- hasVisualSelection,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
- } = useTransformHandles({ onSnapLinesChange });
-
- if (!hasVisualSelection || !selectedWithBounds) return null;
-
- const { bounds, element } = selectedWithBounds;
- if (!isVisualElement(element)) return null;
-
- const displayScale = viewport.getDisplayScale();
-
- const toOverlay = ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) =>
- viewport.canvasToOverlay({
- canvasX,
- canvasY,
- });
-
- const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = Math.abs(bounds.width) * displayScale.x;
- const outlineHeight = Math.abs(bounds.height) * displayScale.y;
-
- const rotationHandleCanvas = getRotationHandlePosition({ bounds });
- const rotationHandleScreen = toOverlay({
- canvasX: rotationHandleCanvas.x,
- canvasY: rotationHandleCanvas.y,
- });
-
- const onPointerMove = (event: React.PointerEvent) =>
- handlePointerMove({ event });
- const onPointerUp = () => handlePointerUp();
-
- return (
- <div
- className="pointer-events-none absolute inset-0 overflow-hidden"
- aria-hidden
- >
- <BoundingBoxOutline
- center={center}
- outlineWidth={outlineWidth}
- outlineHeight={outlineHeight}
- rotation={bounds.rotation}
- />
- {CORNERS.map((corner) => {
- const cornerPosition = getCornerPosition({ bounds, corner });
- const screen = toOverlay({
- canvasX: cornerPosition.x,
- canvasY: cornerPosition.y,
- });
- const angleDeg =
- Math.atan2(screen.y - center.y, screen.x - center.x) *
- (180 / Math.PI);
- return (
- <CornerHandle
- key={corner}
- cursor={getResizeCursor({ angleDeg })}
- screen={screen}
- onPointerDown={(event) =>
- handleCornerPointerDown({ event, corner })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- {EDGES.map((edge) => {
- const edgePosition = getEdgeHandlePosition({ bounds, edge });
- const screen = toOverlay({
- canvasX: edgePosition.x,
- canvasY: edgePosition.y,
- });
- return (
- <EdgeHandle
- key={edge}
- edge={edge}
- screen={screen}
- rotation={bounds.rotation}
- onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- <IconHandle
- icon={Rotate01Icon}
- screen={rotationHandleScreen}
- onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-}
+"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useTransformHandles } from "@/hooks/use-transform-handles";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ ROTATION_HANDLE_OFFSET,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
+import { Rotate01Icon } from "@hugeicons/core-free-icons";
+
+const CORNERS: Corner[] = [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+];
+const EDGES: Edge[] = ["right", "left", "bottom"];
+
+export function TransformHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithBounds,
+ hasVisualSelection,
+ handleCornerPointerDown,
+ handleEdgePointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useTransformHandles({ onSnapLinesChange });
+
+ if (!hasVisualSelection || !selectedWithBounds) return null;
+
+ const { bounds, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return null;
+
+ const displayScale = viewport.getDisplayScale();
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
+
+ const rotationAngleRad = (bounds.rotation * Math.PI) / 180;
+ const topCenterLocalY = -bounds.height / 2;
+ const topCenterScreen = toOverlay({
+ canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad),
+ canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad),
+ });
+ const rotationHandleScreen = {
+ x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
+ y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
+ };
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
+ return (
+ <CornerHandle
+ key={corner}
+ cursor={getResizeCursor({ angleDeg })}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ <IconHandle
+ icon={Rotate01Icon}
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 12b64853..cac954ba 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -10,7 +10,7 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
-import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
+import { initializeGpuRenderer, isGpuAvailable } from "@/services/renderer/gpu-renderer";
interface EditorProviderProps {
projectId: string;
@@ -36,6 +36,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
try {
setIsLoading(true);
await initializeGpuRenderer();
+ editor.renderer.setDegraded(!isGpuAvailable());
await editor.project.loadProject({ id: projectId });
if (cancelled) return;
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index 47909bfe..341a0eae 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -13,24 +13,32 @@ export class PlaybackManager {
private playbackTimer: number | null = null;
private playbackStartWallTime = 0;
private playbackStartTime = 0;
+ private timelineScopeBound = false;
- constructor(private editor: EditorCore) {
- this.editor.timeline.subscribe(() => {
- const maxTime = this.editor.timeline.getTotalDuration();
- if (this.currentTime > maxTime && maxTime > 0) {
- this.currentTime = maxTime;
- this.notify();
- }
- });
+ constructor(private editor: EditorCore) {}
+
+ bindTimelineScope(): void {
+ if (this.timelineScopeBound) {
+ return;
+ }
+
+ const reconcile = () => {
+ this.reconcileTimelineScope();
+ };
+ this.editor.timeline.subscribe(reconcile);
+ this.editor.scenes.subscribe(reconcile);
+ this.timelineScopeBound = true;
+ this.reconcileTimelineScope();
}
play(): void {
const maxTime = this.editor.timeline.getTotalDuration();
+ if (maxTime <= 0) {
+ return;
+ }
- if (maxTime > 0) {
- if (this.currentTime >= maxTime) {
- this.seek({ time: 0 });
- }
+ if (this.currentTime >= maxTime) {
+ this.seek({ time: 0 });
}
this.isPlaying = true;
@@ -53,19 +61,13 @@ export class PlaybackManager {
}
seek({ time }: { time: number }): void {
- const maxTime = this.editor.timeline.getTotalDuration();
- this.currentTime = Math.max(0, Math.min(maxTime, time));
+ this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime;
}
this.notify();
-
- window.dispatchEvent(
- new CustomEvent("playback-seek", {
- detail: { time: this.currentTime },
- }),
- );
+ this.dispatchSeekEvent(this.currentTime);
}
setVolume({ volume }: { volume: number }): void {
@@ -131,6 +133,29 @@ export class PlaybackManager {
return () => this.listeners.delete(listener);
}
+ private reconcileTimelineScope(): void {
+ const maxTime = this.editor.timeline.getTotalDuration();
+ const nextTime = this.clampTimeToTimeline(this.currentTime);
+ const shouldPause = this.isPlaying && nextTime >= maxTime;
+ const timeChanged = nextTime !== this.currentTime;
+
+ if (!timeChanged && !shouldPause) {
+ return;
+ }
+
+ if (shouldPause) {
+ this.isPlaying = false;
+ this.stopTimer();
+ }
+
+ this.currentTime = nextTime;
+ this.notify();
+
+ if (timeChanged) {
+ this.dispatchSeekEvent(this.currentTime);
+ }
+ }
+
private notify(): void {
this.listeners.forEach((fn) => {
fn();
@@ -158,31 +183,54 @@ export class PlaybackManager {
if (!this.isPlaying) return;
const fps = this.editor.project.getActive()?.settings.fps;
- const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000;
- const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
- const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime;
+ const elapsedSeconds =
+ (performance.now() - this.playbackStartWallTime) / 1000;
+ const rawTime =
+ this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
+ const newTime = fps
+ ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
+ : rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
- if (maxTime > 0 && newTime >= maxTime) {
+ if (newTime >= maxTime) {
this.pause();
this.currentTime = maxTime;
this.notify();
-
- window.dispatchEvent(
- new CustomEvent("playback-seek", {
- detail: { time: maxTime },
- }),
- );
- } else {
- this.currentTime = newTime;
-
- window.dispatchEvent(
- new CustomEvent("playback-update", {
- detail: { time: newTime },
- }),
- );
+ this.dispatchSeekEvent(maxTime);
+ return;
}
+ this.currentTime = newTime;
+ this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
+
+ private clampTimeToTimeline(time: number): number {
+ const maxTime = this.editor.timeline.getTotalDuration();
+ return Math.max(0, Math.min(maxTime, time));
+ }
+
+ private dispatchSeekEvent(time: number): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", {
+ detail: { time },
+ }),
+ );
+ }
+
+ private dispatchUpdateEvent(time: number): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("playback-update", {
+ detail: { time },
+ }),
+ );
+ }
}
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index eec4bf37..495292e6 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -15,10 +15,21 @@ type SnapshotResult =
export class RendererManager {
private renderTree: RootNode | null = null;
+ private _isDegraded = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
+ get isDegraded(): boolean {
+ return this._isDegraded;
+ }
+
+ setDegraded(degraded: boolean): void {
+ if (this._isDegraded === degraded) return;
+ this._isDegraded = degraded;
+ this.notify();
+ }
+
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 74870a95..70075b98 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -5,7 +5,6 @@ import type {
TrackType,
TimelineTrack,
TimelineElement,
- ClipboardItem,
RetimeConfig,
} from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
@@ -23,6 +22,11 @@ import type {
AnimationValue,
ScalarCurveKeyframePatch,
} from "@/lib/animation/types";
+import {
+ getElementLocalTime,
+ resolveAnimationTarget,
+ resolveAnimationPathValueAtTime,
+} from "@/lib/animation";
import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/lib/commands";
import {
@@ -35,7 +39,6 @@ import {
DuplicateElementsCommand,
UpdateElementsCommand,
SplitElementsCommand,
- PasteCommand,
MoveElementCommand,
TracksSnapshotCommand,
UpsertKeyframeCommand,
@@ -244,18 +247,6 @@ export class TimelineManager {
return result;
}
- pasteAtTime({
- time,
- clipboardItems,
- }: {
- time: number;
- clipboardItems: ClipboardItem[];
- }): { trackId: string; elementId: string }[] {
- const command = new PasteCommand({ time, clipboardItems });
- this.editor.command.execute({ command });
- return command.getPastedElements();
- }
-
deleteElements({
elements,
}: {
@@ -492,6 +483,45 @@ export class TimelineManager {
return;
}
+ // Pre-sample values at playhead for each (element, property) pair.
+ // This preserves "what you see is what you get" when all keyframes are deleted.
+ const playheadTime = this.editor.playback.getCurrentTime();
+ const valueAtPlayheadMap = new Map<string, AnimationValue | null>();
+
+ for (const { trackId, elementId, propertyPath } of keyframes) {
+ const key = `${elementId}:${propertyPath}`;
+ if (valueAtPlayheadMap.has(key)) {
+ continue;
+ }
+
+ const element = this.getElementByRef({ trackId, elementId });
+ if (!element) {
+ valueAtPlayheadMap.set(key, null);
+ continue;
+ }
+
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ const baseValue = target?.getBaseValue() ?? null;
+ if (baseValue === null) {
+ valueAtPlayheadMap.set(key, null);
+ continue;
+ }
+
+ const value = resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath,
+ localTime,
+ fallbackValue: baseValue,
+ });
+ valueAtPlayheadMap.set(key, value);
+ }
+
const commands = keyframes.map(
({ trackId, elementId, propertyPath, keyframeId }) =>
new RemoveKeyframeCommand({
@@ -499,6 +529,8 @@ export class TimelineManager {
elementId,
propertyPath,
keyframeId,
+ valueAtPlayhead:
+ valueAtPlayheadMap.get(`${elementId}:${propertyPath}`) ?? null,
}),
);
const command =
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index 2a3e5c28..cfb3d527 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -134,15 +134,6 @@ export function usePreviewInteraction({
const current = editingTextRef.current;
if (!current) return;
editingTextRef.current = null;
- editor.timeline.previewElements({
- updates: [
- {
- trackId: current.trackId,
- elementId: current.elementId,
- updates: { opacity: current.originalOpacity },
- },
- ],
- });
editor.timeline.commitPreview();
setEditingText(null);
}, [editor.timeline]);
@@ -207,15 +198,6 @@ export function usePreviewInteraction({
if (!hit || hit.element.type !== "text") return;
const textElement = hit.element as TextElement;
- editor.timeline.previewElements({
- updates: [
- {
- trackId: hit.trackId,
- elementId: hit.elementId,
- updates: { opacity: 0 },
- },
- ],
- });
setEditingText({
trackId: hit.trackId,
elementId: hit.elementId,
diff --git a/apps/web/src/lib/background/constants.ts b/apps/web/src/lib/background/constants.ts
index 654154a5..b682c260 100644
--- a/apps/web/src/lib/background/constants.ts
+++ b/apps/web/src/lib/background/constants.ts
@@ -2,9 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
label: string;
value: number;
}> = [
- { label: "Light", value: 10 },
- { label: "Medium", value: 50 },
- { label: "Heavy", value: 100 },
+ { label: "Light", value: 100 },
+ { label: "Medium", value: 200 },
+ { label: "Heavy", value: 500 },
] as const;
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts
index 75fa7f09..a3badfdc 100644
--- a/apps/web/src/lib/text/layout.ts
+++ b/apps/web/src/lib/text/layout.ts
@@ -14,15 +14,18 @@ export interface TextBlockMeasurement {
maxWidth: number;
}
-type CanvasContext =
+export type TextCanvasContext =
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
+const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
+const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
+
export function setCanvasLetterSpacing({
ctx,
letterSpacingPx,
}: {
- ctx: CanvasContext;
+ ctx: TextCanvasContext;
letterSpacingPx: number;
}): void {
if ("letterSpacing" in ctx) {
@@ -177,3 +180,47 @@ export function getTextVisualRect({
height: bottom - top,
};
}
+
+export function drawTextDecoration({
+ ctx,
+ textDecoration,
+ lineWidth,
+ lineY,
+ metrics,
+ scaledFontSize,
+ textAlign,
+}: {
+ ctx: TextCanvasContext;
+ textDecoration: string;
+ lineWidth: number;
+ lineY: number;
+ metrics: TextMetrics;
+ scaledFontSize: number;
+ textAlign: CanvasTextAlign;
+}): void {
+ if (textDecoration === "none" || !textDecoration) return;
+
+ const thickness = Math.max(
+ 1,
+ scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO,
+ );
+ const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
+ const descent = getMetricDescent({
+ metrics,
+ fallbackFontSize: scaledFontSize,
+ });
+
+ let xStart = -lineWidth / 2;
+ if (textAlign === "left") xStart = 0;
+ if (textAlign === "right") xStart = -lineWidth;
+
+ if (textDecoration === "underline") {
+ const underlineY = lineY + descent + thickness;
+ ctx.fillRect(xStart, underlineY, lineWidth, thickness);
+ }
+
+ if (textDecoration === "line-through") {
+ const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
+ ctx.fillRect(xStart, strikeY, lineWidth, thickness);
+ }
+}
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts
index 77efe1b8..99b8a7ba 100644
--- a/apps/web/src/lib/text/measure-element.ts
+++ b/apps/web/src/lib/text/measure-element.ts
@@ -1,131 +1,158 @@
-import {
- CORNER_RADIUS_MIN,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import {
- measureTextBlock,
- setCanvasLetterSpacing,
- getTextVisualRect,
- type TextBlockMeasurement,
-} from "./layout";
-
-export interface ResolvedTextBackground extends TextBackground {
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export interface MeasuredTextElement {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
- fontSizeRatio: number;
- resolvedBackground: ResolvedTextBackground;
- visualRect: { left: number; top: number; width: number; height: number };
-}
-
-/**
- * Shared text measurement used by both the renderer and preview bounds.
- * Accepts the canvas context to measure on so callers can reuse an existing
- * context (e.g. the renderer's) rather than creating a throwaway canvas.
- * The context state is preserved via save/restore.
- */
-export function measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
-}: {
- element: TextElement;
- canvasHeight: number;
- localTime: number;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
-}): MeasuredTextElement {
- const scaledFontSize =
- element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const letterSpacing = element.letterSpacing ?? 0;
- const lineHeightPx =
- scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const lines = element.content.split("\n");
-
- ctx.save();
- ctx.font = fontString;
- ctx.textBaseline = "middle";
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- ctx.restore();
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- });
-
- const bg = element.background;
- const resolvedBackground: ResolvedTextBackground = {
- ...bg,
- paddingX: resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- cornerRadius: resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- }),
- };
-
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
-
- return {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- visualRect,
- };
-}
+import {
+ CORNER_RADIUS_MIN,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { TextBackground, TextElement } from "@/lib/timeline";
+import {
+ measureTextBlock,
+ setCanvasLetterSpacing,
+ getTextVisualRect,
+ type TextBlockMeasurement,
+} from "./layout";
+
+export interface ResolvedTextBackground extends TextBackground {
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export interface MeasuredTextElement {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+ fontSizeRatio: number;
+ resolvedBackground: ResolvedTextBackground;
+ visualRect: { left: number; top: number; width: number; height: number };
+}
+
+let textMeasurementContext:
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null = null;
+
+export function getTextMeasurementContext():
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D {
+ if (textMeasurementContext) {
+ return textMeasurementContext;
+ }
+
+ if (typeof OffscreenCanvas !== "undefined") {
+ const canvas = new OffscreenCanvas(1, 1);
+ const context = canvas.getContext("2d");
+ if (context) {
+ textMeasurementContext = context;
+ return context;
+ }
+ }
+
+ if (typeof document !== "undefined") {
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+ if (context) {
+ textMeasurementContext = context;
+ return context;
+ }
+ }
+
+ throw new Error("Failed to create text measurement context");
+}
+
+export function measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+}: {
+ element: TextElement;
+ canvasHeight: number;
+ localTime: number;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+}): MeasuredTextElement {
+ const scaledFontSize =
+ element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
+ const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
+ const letterSpacing = element.letterSpacing ?? 0;
+ const lineHeightPx =
+ scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const lines = element.content.split("\n");
+
+ ctx.save();
+ ctx.font = fontString;
+ ctx.textBaseline = "middle";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+ ctx.restore();
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ });
+
+ const bg = element.background;
+ const resolvedBackground: ResolvedTextBackground = {
+ ...bg,
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
+
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+
+ return {
+ scaledFontSize,
+ fontString,
+ letterSpacing,
+ lineHeightPx,
+ lines,
+ lineMetrics,
+ block,
+ fontSizeRatio,
+ resolvedBackground,
+ visualRect,
+ };
+}
diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts
index 36441981..e5f3991d 100644
--- a/apps/web/src/services/video-cache/service.ts
+++ b/apps/web/src/services/video-cache/service.ts
@@ -19,6 +19,7 @@ interface VideoSinkData {
export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
+ private frameChain = new Map<string, Promise<unknown>>();
async getFrameAt({
mediaId,
@@ -34,6 +35,21 @@ export class VideoCache {
const sinkData = this.sinks.get(mediaId);
if (!sinkData) return null;
+ const previous = this.frameChain.get(mediaId) ?? Promise.resolve();
+ const current = previous.then(() =>
+ this.resolveFrame({ sinkData, time }),
+ );
+ this.frameChain.set(mediaId, current.catch(() => {}));
+ return current;
+ }
+
+ private async resolveFrame({
+ sinkData,
+ time,
+ }: {
+ sinkData: VideoSinkData;
+ time: number;
+ }): Promise<WrappedCanvas | null> {
if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) {
sinkData.currentFrame = sinkData.nextFrame;
sinkData.nextFrame = null;
diff --git a/bun.lock b/bun.lock
index c8eceb53..acb2f6e8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,7 +21,7 @@
"version": "0.1.0",
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
- "@hugeicons/core-free-icons": "^3.1.1",
+ "@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opennextjs/cloudflare": "^1.18.0",
@@ -54,7 +54,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.3",
+ "opencut-wasm": "file:../../rust/wasm/pkg",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -349,7 +349,7 @@
"@hello-pangea/dnd": ["@hello-pangea/dnd@18.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "css-box-model": "^1.2.1", "raf-schd": "^4.0.3", "react-redux": "^9.2.0", "redux": "^5.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ=="],
- "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@3.1.1", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="],
+ "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@3.3.0", "", {}, "sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ=="],
"@hugeicons/react": ["@hugeicons/react@1.1.6", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-c2LhXJMAW5wN1pC/smBXG0YPqUON6ceR/ZdXHCjEI9KvB+hjtqYjmzIxok5hAQOeXGz0WtORgCQMzqewFKAZwg=="],
@@ -1359,8 +1359,6 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
- "opencut-wasm": ["opencut-wasm@0.2.3", "", {}, "sha512-SaLe2fgvLK+EcW7qqggmcfXaQwo6SOh83/h3dtEEbdqEka7gn1PGJECKnTZq4OWhkNEgf8SKJy/HiJOQvyZC+Q=="],
-
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@@ -1723,6 +1721,8 @@
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
+ "@opencut/web/opencut-wasm": ["opencut-wasm@file:rust/wasm/pkg", {}],
+
"@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],