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 (
-
-
-
-
-
- );
-}
-
-function EditorLayout() {
- usePasteMedia();
- const { panels, setPanel } = usePanelStore();
-
- return (
- {
- setPanel("mainContent", sizes[0] ?? panels.mainContent);
- setPanel("timeline", sizes[1] ?? panels.timeline);
- }}
- >
-
- {
- setPanel("tools", sizes[0] ?? panels.tools);
- setPanel("preview", sizes[1] ?? panels.preview);
- setPanel("properties", sizes[2] ?? panels.properties);
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
+"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 (
+
+
+
+
+
+ );
+}
+
+function DegradedRendererBanner() {
+ const isDegraded = useEditor((e) => e.renderer.isDegraded);
+ const [dismissed, setDismissed] = useState(false);
+ if (!isDegraded || dismissed) return null;
+
+ return (
+
+
+ For the best experience, open OpenCut in Chrome.
+
+
+
+ );
+}
+
+function EditorLayout() {
+ usePasteMedia();
+ const { panels, setPanel } = usePanelStore();
+
+ return (
+ {
+ setPanel("mainContent", sizes[0] ?? panels.mainContent);
+ setPanel("timeline", sizes[1] ?? panels.timeline);
+ }}
+ >
+
+ {
+ setPanel("tools", sizes[0] ?? panels.tools);
+ setPanel("preview", sizes[1] ?? panels.preview);
+ setPanel("properties", sizes[2] ?? panels.properties);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 */}
- {/* */}
>
)}
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([]);
- 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 (
-
-
e.preventDefault()}
- onContextMenu={handleContextMenu}
- />
- {editingText ? (
-
- ) : isMaskMode ? (
-
- ) : (
-
- )}
-
-
- );
-}
+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
([]);
+ 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 (
+
+
e.preventDefault()}
+ />
+ {editingText ? (
+
+ ) : isMaskMode ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
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
(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 (
-
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
-
handleKeyDown({ event })}
- >
- {element.content || ""}
-
-
- );
-}
+"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(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 (
+
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+
handleKeyDown({ event })}
+ >
+ {element.content || ""}
+
+
+ );
+}
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 (
-
-
- {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 (
-
- 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 (
- handleEdgePointerDown({ event, edge })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- handleRotationPointerDown({ event })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
-
- );
-}
+"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 (
+
+
+ {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 (
+
+ 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 (
+ handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ handleRotationPointerDown({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+
+ );
+}
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();
+
+ 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();
private initPromises = new Map>();
+ private frameChain = new Map>();
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 {
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=="],