fix: text measurement, rotation handle, video cache races, playback sync

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-13 04:41:33 +02:00
parent cad88ee4d1
commit 7d4e166678
16 changed files with 919 additions and 803 deletions

View File

@ -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",

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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 },
}),
);
}
}

View File

@ -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();

View File

@ -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 =

View File

@ -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,

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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=="],