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": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@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", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
@ -52,7 +52,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "16.1.3", "next": "16.1.3",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"opencut-wasm": "^0.2.3", "opencut-wasm": "file:../../rust/wasm/pkg",
"pg": "^8.16.2", "pg": "^8.16.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",

View File

@ -1,113 +1,142 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,
ResizableHandle, ResizableHandle,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets"; import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties"; import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/panels/timeline"; import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview"; import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header"; import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider"; import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding"; import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media"; import { usePasteMedia } from "@/hooks/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate"; import { MobileGate } from "@/components/editor/mobile-gate";
import { useState } from "react";
export default function Editor() { import { useEditor } from "@/hooks/use-editor";
const params = useParams(); import { Cancel01Icon } from "@hugeicons/core-free-icons";
const projectId = params.project_id as string; import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@/components/ui/button";
return (
<MobileGate> export default function Editor() {
<EditorProvider projectId={projectId}> const params = useParams();
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> const projectId = params.project_id as string;
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1"> return (
<EditorLayout /> <MobileGate>
</div> <EditorProvider projectId={projectId}>
<Onboarding /> <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<MigrationDialog /> <DegradedRendererBanner />
</div> <EditorHeader />
</EditorProvider> <div className="min-h-0 min-w-0 flex-1">
</MobileGate> <EditorLayout />
); </div>
} <Onboarding />
<MigrationDialog />
function EditorLayout() { </div>
usePasteMedia(); </EditorProvider>
const { panels, setPanel } = usePanelStore(); </MobileGate>
);
return ( }
<ResizablePanelGroup
direction="vertical" function DegradedRendererBanner() {
className="size-full gap-[0.18rem]" const isDegraded = useEditor((e) => e.renderer.isDegraded);
onLayout={(sizes) => { const [dismissed, setDismissed] = useState(false);
setPanel("mainContent", sizes[0] ?? panels.mainContent); if (!isDegraded || dismissed) return null;
setPanel("timeline", sizes[1] ?? panels.timeline);
}} return (
> <div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<ResizablePanel <span>
defaultSize={panels.mainContent} For the best experience, open OpenCut in Chrome.
minSize={30} </span>
maxSize={85} <Button
className="min-h-0" variant="text"
> size="icon"
<ResizablePanelGroup className="p-0 w-auto [&_svg]:size-3.5"
direction="horizontal" onClick={() => setDismissed(true)}
className="size-full gap-[0.19rem] px-3" aria-label="Dismiss"
onLayout={(sizes) => { >
setPanel("tools", sizes[0] ?? panels.tools); <HugeiconsIcon icon={Cancel01Icon} />
setPanel("preview", sizes[1] ?? panels.preview); </Button>
setPanel("properties", sizes[2] ?? panels.properties); </div>
}} );
> }
<ResizablePanel
defaultSize={panels.tools} function EditorLayout() {
minSize={15} usePasteMedia();
maxSize={40} const { panels, setPanel } = usePanelStore();
className="min-w-0"
> return (
<AssetsPanel /> <ResizablePanelGroup
</ResizablePanel> direction="vertical"
className="size-full gap-[0.18rem]"
<ResizableHandle withHandle /> onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent);
<ResizablePanel setPanel("timeline", sizes[1] ?? panels.timeline);
defaultSize={panels.preview} }}
minSize={30} >
className="min-h-0 min-w-0 flex-1" <ResizablePanel
> defaultSize={panels.mainContent}
<PreviewPanel /> minSize={30}
</ResizablePanel> maxSize={85}
className="min-h-0"
<ResizableHandle withHandle /> >
<ResizablePanelGroup
<ResizablePanel direction="horizontal"
defaultSize={panels.properties} className="size-full gap-[0.19rem] px-3"
minSize={15} onLayout={(sizes) => {
maxSize={40} setPanel("tools", sizes[0] ?? panels.tools);
className="min-w-0" setPanel("preview", sizes[1] ?? panels.preview);
> setPanel("properties", sizes[2] ?? panels.properties);
<PropertiesPanel /> }}
</ResizablePanel> >
</ResizablePanelGroup> <ResizablePanel
</ResizablePanel> defaultSize={panels.tools}
minSize={15}
<ResizableHandle withHandle /> maxSize={40}
className="min-w-0"
<ResizablePanel >
defaultSize={panels.timeline} <AssetsPanel />
minSize={15} </ResizablePanel>
maxSize={70}
className="min-h-0 px-3 pb-3" <ResizableHandle withHandle />
>
<Timeline /> <ResizablePanel
</ResizablePanel> defaultSize={panels.preview}
</ResizablePanelGroup> 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" crossOrigin="anonymous"
strategy="beforeInteractive" 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> </head>

View File

@ -1,117 +1,103 @@
import { useState } from "react"; import { useState } from "react";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
import type { SnapLine } from "@/lib/preview/preview-snap"; import type { SnapLine } from "@/lib/preview/preview-snap";
import { TransformHandles } from "./transform-handles"; import { TransformHandles } from "./transform-handles";
import { MaskHandles } from "./mask-handles"; import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides"; import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay"; import { TextEditOverlay } from "./text-edit-overlay";
import { usePropertiesStore } from "../properties/stores/properties-store"; import { usePropertiesStore } from "../properties/stores/properties-store";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
export function PreviewInteractionOverlay() { export function PreviewInteractionOverlay() {
const [snapLines, setSnapLines] = useState<SnapLine[]>([]); const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const editor = useEditor(); const editor = useEditor();
const viewport = usePreviewViewport(); const viewport = usePreviewViewport();
const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
const selectedRef = const selectedRef =
selectedElements.length === 1 ? selectedElements[0] : null; selectedElements.length === 1 ? selectedElements[0] : null;
const activeTrack = selectedRef const activeTrack = selectedRef
? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
: null; : null;
const activeElement = const activeElement =
activeTrack?.elements.find( activeTrack?.elements.find(
(element) => element.id === selectedRef?.elementId, (element) => element.id === selectedRef?.elementId,
) ?? null; ) ?? null;
const isMaskMode = activeElement const isMaskMode = activeElement
? activeTabPerType[activeElement.type] === "masks" ? activeTabPerType[activeElement.type] === "masks"
: false; : false;
const { const {
onPointerDown, onPointerDown,
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onDoubleClick, onDoubleClick,
editingText, editingText,
commitTextEdit, commitTextEdit,
} = usePreviewInteraction({ } = usePreviewInteraction({
onSnapLinesChange: setSnapLines, onSnapLinesChange: setSnapLines,
isMaskMode, isMaskMode,
}); });
const handlePointerDown = (event: React.PointerEvent) => { const handlePointerDown = (event: React.PointerEvent) => {
if (viewport.handlePanPointerDown({ event })) { if (viewport.handlePanPointerDown({ event })) {
return; return;
} }
onPointerDown(event); onPointerDown(event);
}; };
const handlePointerMove = (event: React.PointerEvent) => { const handlePointerMove = (event: React.PointerEvent) => {
if (viewport.handlePanPointerMove({ event })) { if (viewport.handlePanPointerMove({ event })) {
return; return;
} }
onPointerMove(event); onPointerMove(event);
}; };
const handlePointerUp = (event: React.PointerEvent) => { const handlePointerUp = (event: React.PointerEvent) => {
if (viewport.handlePanPointerUp({ event })) { if (viewport.handlePanPointerUp({ event })) {
return; return;
} }
onPointerUp(event); onPointerUp(event);
}; };
const handleContextMenu = (e: React.MouseEvent) => { return (
const x = e.nativeEvent.offsetX; <div className="absolute inset-0">
const y = e.nativeEvent.offsetY; <div
const outsideCanvas = className="absolute inset-0 pointer-events-auto"
x < viewport.sceneLeft || role="application"
x > viewport.sceneLeft + viewport.sceneWidth || aria-label="Preview canvas"
y < viewport.sceneTop || style={{
y > viewport.sceneTop + viewport.sceneHeight; cursor: viewport.isPanning
if (outsideCanvas) { ? "grabbing"
e.stopPropagation(); : viewport.canPan
} ? "default"
}; : undefined,
}}
return ( onPointerDown={handlePointerDown}
<div className="absolute inset-0"> onPointerMove={handlePointerMove}
<div onPointerUp={handlePointerUp}
className="absolute inset-0 pointer-events-auto" onPointerCancel={handlePointerUp}
role="application" onDoubleClick={onDoubleClick}
aria-label="Preview canvas" onDragStart={(e) => e.preventDefault()}
style={{ />
cursor: viewport.isPanning {editingText ? (
? "grabbing" <TextEditOverlay
: viewport.canPan trackId={editingText.trackId}
? "default" elementId={editingText.elementId}
: undefined, element={editingText.element}
}} onCommit={commitTextEdit}
onPointerDown={handlePointerDown} />
onPointerMove={handlePointerMove} ) : isMaskMode ? (
onPointerUp={handlePointerUp} <MaskHandles onSnapLinesChange={setSnapLines} />
onPointerCancel={handlePointerUp} ) : (
onDoubleClick={onDoubleClick} <TransformHandles onSnapLinesChange={setSnapLines} />
onDragStart={(e) => e.preventDefault()} )}
onContextMenu={handleContextMenu} <SnapGuides lines={snapLines} />
/> </div>
{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"; "use client";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import type { TextElement } from "@/lib/timeline"; import type { TextElement } from "@/lib/timeline";
import { import {
FONT_SIZE_SCALE_REFERENCE, FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants"; } from "@/constants/text-constants";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { import {
getMetricAscent, getElementLocalTime,
getMetricDescent, resolveTransformAtTime,
setCanvasLetterSpacing, } from "@/lib/animation";
} from "@/lib/text/layout";
export function TextEditOverlay({
let cachedCanvas: HTMLCanvasElement | null = null; trackId,
elementId,
function getMeasurementContext(): CanvasRenderingContext2D | null { element,
if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); onCommit,
return cachedCanvas.getContext("2d"); }: {
} trackId: string;
elementId: string;
/** element: TextElement;
* Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than onCommit: () => void;
* actualBoundingBox to model where the browser places the baseline within the }) {
* line box, then measures actual glyph bounds to find the visual center. const editor = useEditor();
*/ const viewport = usePreviewViewport();
function measureCSSVisualCenterOffset({ const divRef = useRef<HTMLDivElement>(null);
lines,
fontString, useEffect(() => {
letterSpacingPx, const div = divRef.current;
lineHeightPx, if (!div) return;
displayFontSize, div.focus();
}: { const range = document.createRange();
lines: string[]; range.selectNodeContents(div);
fontString: string; const selection = window.getSelection();
letterSpacingPx: number; selection?.removeAllRanges();
lineHeightPx: number; selection?.addRange(range);
displayFontSize: number; }, []);
}): number {
const ctx = getMeasurementContext(); const handleInput = useCallback(() => {
if (!ctx) return 0; const div = divRef.current;
if (!div) return;
ctx.font = fontString; const text = div.innerText;
ctx.textBaseline = "alphabetic"; editor.timeline.previewElements({
setCanvasLetterSpacing({ ctx, letterSpacingPx }); updates: [{ trackId, elementId, updates: { content: text } }],
});
const probe = ctx.measureText("M"); }, [editor.timeline, trackId, elementId]);
const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; const handleKeyDown = useCallback(
const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; ({ event }: { event: React.KeyboardEvent }) => {
const { key } = event;
let visualTop = Number.POSITIVE_INFINITY; if (key === "Escape") {
let visualBottom = Number.NEGATIVE_INFINITY; event.preventDefault();
onCommit();
for (let i = 0; i < lines.length; i++) { return;
const metrics = ctx.measureText(lines[i] || " "); }
const baseline = i * lineHeightPx + halfLeading + fontAscent; },
visualTop = Math.min( [onCommit],
visualTop, );
baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
); const canvasSize = editor.project.getActive().settings.canvasSize;
visualBottom = Math.max(
visualBottom, if (!canvasSize) return null;
baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
); const currentTime = editor.playback.getCurrentTime();
} const localTime = getElementLocalTime({
timelineTime: currentTime,
const cssBlockHeight = lines.length * lineHeightPx; elementStartTime: element.startTime,
return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; elementDuration: element.duration,
} });
const transform = resolveTransformAtTime({
export function TextEditOverlay({ baseTransform: element.transform,
trackId, animations: element.animations,
elementId, localTime,
element, });
onCommit,
}: { const { x: posX, y: posY } = viewport.positionToOverlay({
trackId: string; positionX: transform.position.x,
elementId: string; positionY: transform.position.y,
element: TextElement; });
onCommit: () => void;
}) { const { x: displayScaleX } = viewport.getDisplayScale();
const editor = useEditor();
const viewport = usePreviewViewport(); const scaledFontSize =
const divRef = useRef<HTMLDivElement>(null); element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
useEffect(() => { const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const div = divRef.current; const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
if (!div) return; const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
div.focus(); const canvasLetterSpacing = element.letterSpacing ?? 0;
const range = document.createRange(); const lineHeightPx = scaledFontSize * lineHeight;
range.selectNodeContents(div);
const selection = window.getSelection(); const bg = element.background;
selection?.removeAllRanges(); const shouldShowBackground =
selection?.addRange(range); bg.enabled && bg.color && bg.color !== "transparent";
}, []); const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
const canvasPaddingX = shouldShowBackground
const handleInput = useCallback(() => { ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
const div = divRef.current; : 0;
if (!div) return; const canvasPaddingY = shouldShowBackground
const text = div.innerText; ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
editor.timeline.previewElements({ : 0;
updates: [{ trackId, elementId, updates: { content: text } }],
}); return (
}, [editor.timeline, trackId, elementId]); <div
className="absolute"
const handleKeyDown = useCallback( style={{
({ event }: { event: React.KeyboardEvent }) => { left: posX,
const { key } = event; top: posY,
if (key === "Escape") { transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`,
event.preventDefault(); transformOrigin: "center center",
onCommit(); }}
return; >
} {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
}, <div
[onCommit], ref={divRef}
); contentEditable
suppressContentEditableWarning
const canvasSize = editor.project.getActive().settings.canvasSize; tabIndex={0}
role="textbox"
if (!canvasSize) return null; aria-label="Edit text"
className="cursor-text select-text outline-none whitespace-pre"
const { x: posX, y: posY } = viewport.positionToOverlay({ style={{
positionX: element.transform.position.x, fontSize: scaledFontSize,
positionY: element.transform.position.y, fontFamily: element.fontFamily,
}); fontWeight,
fontStyle,
const { x: displayScaleX } = viewport.getDisplayScale(); textAlign: element.textAlign,
letterSpacing: `${canvasLetterSpacing}px`,
const displayFontSize = lineHeight,
element.fontSize * color: "transparent",
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * caretColor: element.color,
displayScaleX; backgroundColor: shouldShowBackground ? bg.color : "transparent",
minHeight: lineHeightPx,
const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; textDecoration: element.textDecoration ?? "none",
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; padding: shouldShowBackground
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; ? `${canvasPaddingY}px ${canvasPaddingX}px`
const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; : 0,
const lineHeightPx = displayFontSize * lineHeight; minWidth: 1,
const lines = (element.content || "").split("\n"); }}
const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; onInput={handleInput}
onBlur={onCommit}
const cssVisualCenterOffset = measureCSSVisualCenterOffset({ onKeyDown={(event) => handleKeyDown({ event })}
lines, >
fontString, {element.content || ""}
letterSpacingPx: displayLetterSpacing, </div>
lineHeightPx, </div>
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>
);
}

View File

@ -1,140 +1,145 @@
"use client"; "use client";
import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles"; import { useTransformHandles } from "@/hooks/use-transform-handles";
import { isVisualElement } from "@/lib/timeline/element-utils"; import { isVisualElement } from "@/lib/timeline/element-utils";
import { import {
getCornerPosition, getCornerPosition,
getEdgeHandlePosition, getEdgeHandlePosition,
getRotationHandlePosition, ROTATION_HANDLE_OFFSET,
type Corner, type Corner,
type Edge, type Edge,
} from "@/lib/preview/element-bounds"; } from "@/lib/preview/element-bounds";
import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import { import {
BoundingBoxOutline, BoundingBoxOutline,
CornerHandle, CornerHandle,
EdgeHandle, EdgeHandle,
IconHandle, IconHandle,
getResizeCursor, getResizeCursor,
} from "./handle-primitives"; } from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons"; import { Rotate01Icon } from "@hugeicons/core-free-icons";
const CORNERS: Corner[] = [ const CORNERS: Corner[] = [
"top-left", "top-left",
"top-right", "top-right",
"bottom-left", "bottom-left",
"bottom-right", "bottom-right",
]; ];
const EDGES: Edge[] = ["right", "left", "bottom"]; const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({ export function TransformHandles({
onSnapLinesChange, onSnapLinesChange,
}: { }: {
onSnapLinesChange?: OnSnapLinesChange; onSnapLinesChange?: OnSnapLinesChange;
}) { }) {
const viewport = usePreviewViewport(); const viewport = usePreviewViewport();
const { const {
selectedWithBounds, selectedWithBounds,
hasVisualSelection, hasVisualSelection,
handleCornerPointerDown, handleCornerPointerDown,
handleEdgePointerDown, handleEdgePointerDown,
handleRotationPointerDown, handleRotationPointerDown,
handlePointerMove, handlePointerMove,
handlePointerUp, handlePointerUp,
} = useTransformHandles({ onSnapLinesChange }); } = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null; if (!hasVisualSelection || !selectedWithBounds) return null;
const { bounds, element } = selectedWithBounds; const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null; if (!isVisualElement(element)) return null;
const displayScale = viewport.getDisplayScale(); const displayScale = viewport.getDisplayScale();
const toOverlay = ({ const toOverlay = ({
canvasX, canvasX,
canvasY, canvasY,
}: { }: {
canvasX: number; canvasX: number;
canvasY: number; canvasY: number;
}) => }) =>
viewport.canvasToOverlay({ viewport.canvasToOverlay({
canvasX, canvasX,
canvasY, canvasY,
}); });
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
const outlineWidth = Math.abs(bounds.width) * displayScale.x; const outlineWidth = Math.abs(bounds.width) * displayScale.x;
const outlineHeight = Math.abs(bounds.height) * displayScale.y; const outlineHeight = Math.abs(bounds.height) * displayScale.y;
const rotationHandleCanvas = getRotationHandlePosition({ bounds }); const rotationAngleRad = (bounds.rotation * Math.PI) / 180;
const rotationHandleScreen = toOverlay({ const topCenterLocalY = -bounds.height / 2;
canvasX: rotationHandleCanvas.x, const topCenterScreen = toOverlay({
canvasY: rotationHandleCanvas.y, canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad),
}); canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad),
});
const onPointerMove = (event: React.PointerEvent) => const rotationHandleScreen = {
handlePointerMove({ event }); x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
const onPointerUp = () => handlePointerUp(); y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET,
};
return (
<div const onPointerMove = (event: React.PointerEvent) =>
className="pointer-events-none absolute inset-0 overflow-hidden" handlePointerMove({ event });
aria-hidden const onPointerUp = () => handlePointerUp();
>
<BoundingBoxOutline return (
center={center} <div
outlineWidth={outlineWidth} className="pointer-events-none absolute inset-0 overflow-hidden"
outlineHeight={outlineHeight} aria-hidden
rotation={bounds.rotation} >
/> <BoundingBoxOutline
{CORNERS.map((corner) => { center={center}
const cornerPosition = getCornerPosition({ bounds, corner }); outlineWidth={outlineWidth}
const screen = toOverlay({ outlineHeight={outlineHeight}
canvasX: cornerPosition.x, rotation={bounds.rotation}
canvasY: cornerPosition.y, />
}); {CORNERS.map((corner) => {
const angleDeg = const cornerPosition = getCornerPosition({ bounds, corner });
Math.atan2(screen.y - center.y, screen.x - center.x) * const screen = toOverlay({
(180 / Math.PI); canvasX: cornerPosition.x,
return ( canvasY: cornerPosition.y,
<CornerHandle });
key={corner} const angleDeg =
cursor={getResizeCursor({ angleDeg })} Math.atan2(screen.y - center.y, screen.x - center.x) *
screen={screen} (180 / Math.PI);
onPointerDown={(event) => return (
handleCornerPointerDown({ event, corner }) <CornerHandle
} key={corner}
onPointerMove={onPointerMove} cursor={getResizeCursor({ angleDeg })}
onPointerUp={onPointerUp} screen={screen}
/> onPointerDown={(event) =>
); handleCornerPointerDown({ event, corner })
})} }
{EDGES.map((edge) => { onPointerMove={onPointerMove}
const edgePosition = getEdgeHandlePosition({ bounds, edge }); onPointerUp={onPointerUp}
const screen = toOverlay({ />
canvasX: edgePosition.x, );
canvasY: edgePosition.y, })}
}); {EDGES.map((edge) => {
return ( const edgePosition = getEdgeHandlePosition({ bounds, edge });
<EdgeHandle const screen = toOverlay({
key={edge} canvasX: edgePosition.x,
edge={edge} canvasY: edgePosition.y,
screen={screen} });
rotation={bounds.rotation} return (
onPointerDown={(event) => handleEdgePointerDown({ event, edge })} <EdgeHandle
onPointerMove={onPointerMove} key={edge}
onPointerUp={onPointerUp} edge={edge}
/> screen={screen}
); rotation={bounds.rotation}
})} onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
<IconHandle onPointerMove={onPointerMove}
icon={Rotate01Icon} onPointerUp={onPointerUp}
screen={rotationHandleScreen} />
onPointerDown={(event) => handleRotationPointerDown({ event })} );
onPointerMove={onPointerMove} })}
onPointerUp={onPointerUp} <IconHandle
/> icon={Rotate01Icon}
</div> 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 { useTimelineStore } from "@/stores/timeline-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions"; import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts"; import { loadFontAtlas } from "@/lib/fonts/google-fonts";
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer"; import { initializeGpuRenderer, isGpuAvailable } from "@/services/renderer/gpu-renderer";
interface EditorProviderProps { interface EditorProviderProps {
projectId: string; projectId: string;
@ -36,6 +36,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
try { try {
setIsLoading(true); setIsLoading(true);
await initializeGpuRenderer(); await initializeGpuRenderer();
editor.renderer.setDegraded(!isGpuAvailable());
await editor.project.loadProject({ id: projectId }); await editor.project.loadProject({ id: projectId });
if (cancelled) return; if (cancelled) return;

View File

@ -13,24 +13,32 @@ export class PlaybackManager {
private playbackTimer: number | null = null; private playbackTimer: number | null = null;
private playbackStartWallTime = 0; private playbackStartWallTime = 0;
private playbackStartTime = 0; private playbackStartTime = 0;
private timelineScopeBound = false;
constructor(private editor: EditorCore) { constructor(private editor: EditorCore) {}
this.editor.timeline.subscribe(() => {
const maxTime = this.editor.timeline.getTotalDuration(); bindTimelineScope(): void {
if (this.currentTime > maxTime && maxTime > 0) { if (this.timelineScopeBound) {
this.currentTime = maxTime; return;
this.notify(); }
}
}); const reconcile = () => {
this.reconcileTimelineScope();
};
this.editor.timeline.subscribe(reconcile);
this.editor.scenes.subscribe(reconcile);
this.timelineScopeBound = true;
this.reconcileTimelineScope();
} }
play(): void { play(): void {
const maxTime = this.editor.timeline.getTotalDuration(); const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime <= 0) {
return;
}
if (maxTime > 0) { if (this.currentTime >= maxTime) {
if (this.currentTime >= maxTime) { this.seek({ time: 0 });
this.seek({ time: 0 });
}
} }
this.isPlaying = true; this.isPlaying = true;
@ -53,19 +61,13 @@ export class PlaybackManager {
} }
seek({ time }: { time: number }): void { seek({ time }: { time: number }): void {
const maxTime = this.editor.timeline.getTotalDuration(); this.currentTime = this.clampTimeToTimeline(time);
this.currentTime = Math.max(0, Math.min(maxTime, time));
if (this.isPlaying) { if (this.isPlaying) {
this.playbackStartWallTime = performance.now(); this.playbackStartWallTime = performance.now();
this.playbackStartTime = this.currentTime; this.playbackStartTime = this.currentTime;
} }
this.notify(); this.notify();
this.dispatchSeekEvent(this.currentTime);
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: this.currentTime },
}),
);
} }
setVolume({ volume }: { volume: number }): void { setVolume({ volume }: { volume: number }): void {
@ -131,6 +133,29 @@ export class PlaybackManager {
return () => this.listeners.delete(listener); 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 { private notify(): void {
this.listeners.forEach((fn) => { this.listeners.forEach((fn) => {
fn(); fn();
@ -158,31 +183,54 @@ export class PlaybackManager {
if (!this.isPlaying) return; if (!this.isPlaying) return;
const fps = this.editor.project.getActive()?.settings.fps; const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; const elapsedSeconds =
const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); (performance.now() - this.playbackStartWallTime) / 1000;
const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime; 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(); const maxTime = this.editor.timeline.getTotalDuration();
if (maxTime > 0 && newTime >= maxTime) { if (newTime >= maxTime) {
this.pause(); this.pause();
this.currentTime = maxTime; this.currentTime = maxTime;
this.notify(); this.notify();
this.dispatchSeekEvent(maxTime);
window.dispatchEvent( return;
new CustomEvent("playback-seek", {
detail: { time: maxTime },
}),
);
} else {
this.currentTime = newTime;
window.dispatchEvent(
new CustomEvent("playback-update", {
detail: { time: newTime },
}),
);
} }
this.currentTime = newTime;
this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime); 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 { export class RendererManager {
private renderTree: RootNode | null = null; private renderTree: RootNode | null = null;
private _isDegraded = false;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {} 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 { setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree; this.renderTree = renderTree;
this.notify(); this.notify();

View File

@ -5,7 +5,6 @@ import type {
TrackType, TrackType,
TimelineTrack, TimelineTrack,
TimelineElement, TimelineElement,
ClipboardItem,
RetimeConfig, RetimeConfig,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
@ -23,6 +22,11 @@ import type {
AnimationValue, AnimationValue,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import {
getElementLocalTime,
resolveAnimationTarget,
resolveAnimationPathValueAtTime,
} from "@/lib/animation";
import { lastFrameTime } from "opencut-wasm"; import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/lib/commands"; import { BatchCommand } from "@/lib/commands";
import { import {
@ -35,7 +39,6 @@ import {
DuplicateElementsCommand, DuplicateElementsCommand,
UpdateElementsCommand, UpdateElementsCommand,
SplitElementsCommand, SplitElementsCommand,
PasteCommand,
MoveElementCommand, MoveElementCommand,
TracksSnapshotCommand, TracksSnapshotCommand,
UpsertKeyframeCommand, UpsertKeyframeCommand,
@ -244,18 +247,6 @@ export class TimelineManager {
return result; 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({ deleteElements({
elements, elements,
}: { }: {
@ -492,6 +483,45 @@ export class TimelineManager {
return; 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( const commands = keyframes.map(
({ trackId, elementId, propertyPath, keyframeId }) => ({ trackId, elementId, propertyPath, keyframeId }) =>
new RemoveKeyframeCommand({ new RemoveKeyframeCommand({
@ -499,6 +529,8 @@ export class TimelineManager {
elementId, elementId,
propertyPath, propertyPath,
keyframeId, keyframeId,
valueAtPlayhead:
valueAtPlayheadMap.get(`${elementId}:${propertyPath}`) ?? null,
}), }),
); );
const command = const command =

View File

@ -134,15 +134,6 @@ export function usePreviewInteraction({
const current = editingTextRef.current; const current = editingTextRef.current;
if (!current) return; if (!current) return;
editingTextRef.current = null; editingTextRef.current = null;
editor.timeline.previewElements({
updates: [
{
trackId: current.trackId,
elementId: current.elementId,
updates: { opacity: current.originalOpacity },
},
],
});
editor.timeline.commitPreview(); editor.timeline.commitPreview();
setEditingText(null); setEditingText(null);
}, [editor.timeline]); }, [editor.timeline]);
@ -207,15 +198,6 @@ export function usePreviewInteraction({
if (!hit || hit.element.type !== "text") return; if (!hit || hit.element.type !== "text") return;
const textElement = hit.element as TextElement; const textElement = hit.element as TextElement;
editor.timeline.previewElements({
updates: [
{
trackId: hit.trackId,
elementId: hit.elementId,
updates: { opacity: 0 },
},
],
});
setEditingText({ setEditingText({
trackId: hit.trackId, trackId: hit.trackId,
elementId: hit.elementId, elementId: hit.elementId,

View File

@ -2,9 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
label: string; label: string;
value: number; value: number;
}> = [ }> = [
{ label: "Light", value: 10 }, { label: "Light", value: 100 },
{ label: "Medium", value: 50 }, { label: "Medium", value: 200 },
{ label: "Heavy", value: 100 }, { label: "Heavy", value: 500 },
] as const; ] as const;
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;

View File

@ -14,15 +14,18 @@ export interface TextBlockMeasurement {
maxWidth: number; maxWidth: number;
} }
type CanvasContext = export type TextCanvasContext =
| CanvasRenderingContext2D | CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D; | OffscreenCanvasRenderingContext2D;
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
export function setCanvasLetterSpacing({ export function setCanvasLetterSpacing({
ctx, ctx,
letterSpacingPx, letterSpacingPx,
}: { }: {
ctx: CanvasContext; ctx: TextCanvasContext;
letterSpacingPx: number; letterSpacingPx: number;
}): void { }): void {
if ("letterSpacing" in ctx) { if ("letterSpacing" in ctx) {
@ -177,3 +180,47 @@ export function getTextVisualRect({
height: bottom - top, 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 { import {
CORNER_RADIUS_MIN, CORNER_RADIUS_MIN,
FONT_SIZE_SCALE_REFERENCE, FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants"; } from "@/constants/text-constants";
import { resolveNumberAtTime } from "@/lib/animation"; import { resolveNumberAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import type { TextBackground, TextElement } from "@/lib/timeline"; import type { TextBackground, TextElement } from "@/lib/timeline";
import { import {
measureTextBlock, measureTextBlock,
setCanvasLetterSpacing, setCanvasLetterSpacing,
getTextVisualRect, getTextVisualRect,
type TextBlockMeasurement, type TextBlockMeasurement,
} from "./layout"; } from "./layout";
export interface ResolvedTextBackground extends TextBackground { export interface ResolvedTextBackground extends TextBackground {
paddingX: number; paddingX: number;
paddingY: number; paddingY: number;
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
cornerRadius: number; cornerRadius: number;
} }
export interface MeasuredTextElement { export interface MeasuredTextElement {
scaledFontSize: number; scaledFontSize: number;
fontString: string; fontString: string;
letterSpacing: number; letterSpacing: number;
lineHeightPx: number; lineHeightPx: number;
lines: string[]; lines: string[];
lineMetrics: TextMetrics[]; lineMetrics: TextMetrics[];
block: TextBlockMeasurement; block: TextBlockMeasurement;
fontSizeRatio: number; fontSizeRatio: number;
resolvedBackground: ResolvedTextBackground; resolvedBackground: ResolvedTextBackground;
visualRect: { left: number; top: number; width: number; height: number }; visualRect: { left: number; top: number; width: number; height: number };
} }
/** let textMeasurementContext:
* Shared text measurement used by both the renderer and preview bounds. | CanvasRenderingContext2D
* Accepts the canvas context to measure on so callers can reuse an existing | OffscreenCanvasRenderingContext2D
* context (e.g. the renderer's) rather than creating a throwaway canvas. | null = null;
* The context state is preserved via save/restore.
*/ export function getTextMeasurementContext():
export function measureTextElement({ | CanvasRenderingContext2D
element, | OffscreenCanvasRenderingContext2D {
canvasHeight, if (textMeasurementContext) {
localTime, return textMeasurementContext;
ctx, }
}: {
element: TextElement; if (typeof OffscreenCanvas !== "undefined") {
canvasHeight: number; const canvas = new OffscreenCanvas(1, 1);
localTime: number; const context = canvas.getContext("2d");
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; if (context) {
}): MeasuredTextElement { textMeasurementContext = context;
const scaledFontSize = return context;
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, '\\"')}"`; if (typeof document !== "undefined") {
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; const canvas = document.createElement("canvas");
const letterSpacing = element.letterSpacing ?? 0; const context = canvas.getContext("2d");
const lineHeightPx = if (context) {
scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); textMeasurementContext = context;
const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; return context;
const lines = element.content.split("\n"); }
}
ctx.save();
ctx.font = fontString; throw new Error("Failed to create text measurement context");
ctx.textBaseline = "middle"; }
setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
const lineMetrics = lines.map((line) => ctx.measureText(line)); export function measureTextElement({
ctx.restore(); element,
canvasHeight,
const block = measureTextBlock({ localTime,
lineMetrics, ctx,
lineHeightPx, }: {
}); element: TextElement;
canvasHeight: number;
const bg = element.background; localTime: number;
const resolvedBackground: ResolvedTextBackground = { ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
...bg, }): MeasuredTextElement {
paddingX: resolveNumberAtTime({ const scaledFontSize =
baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
animations: element.animations, const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
propertyPath: "background.paddingX", const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
localTime, const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
}), const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
paddingY: resolveNumberAtTime({ const letterSpacing = element.letterSpacing ?? 0;
baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, const lineHeightPx =
animations: element.animations, scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
propertyPath: "background.paddingY", const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
localTime, const lines = element.content.split("\n");
}),
offsetX: resolveNumberAtTime({ ctx.save();
baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, ctx.font = fontString;
animations: element.animations, ctx.textBaseline = "middle";
propertyPath: "background.offsetX", setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
localTime, const lineMetrics = lines.map((line) => ctx.measureText(line));
}), ctx.restore();
offsetY: resolveNumberAtTime({
baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, const block = measureTextBlock({
animations: element.animations, lineMetrics,
propertyPath: "background.offsetY", lineHeightPx,
localTime, });
}),
cornerRadius: resolveNumberAtTime({ const bg = element.background;
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, const resolvedBackground: ResolvedTextBackground = {
animations: element.animations, ...bg,
propertyPath: "background.cornerRadius", paddingX: resolveNumberAtTime({
localTime, baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
}), animations: element.animations,
}; propertyPath: "background.paddingX",
localTime,
const visualRect = getTextVisualRect({ }),
textAlign: element.textAlign, paddingY: resolveNumberAtTime({
block, baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
background: resolvedBackground, animations: element.animations,
fontSizeRatio, propertyPath: "background.paddingY",
}); localTime,
}),
return { offsetX: resolveNumberAtTime({
scaledFontSize, baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
fontString, animations: element.animations,
letterSpacing, propertyPath: "background.offsetX",
lineHeightPx, localTime,
lines, }),
lineMetrics, offsetY: resolveNumberAtTime({
block, baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
fontSizeRatio, animations: element.animations,
resolvedBackground, propertyPath: "background.offsetY",
visualRect, 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 { export class VideoCache {
private sinks = new Map<string, VideoSinkData>(); private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>(); private initPromises = new Map<string, Promise<void>>();
private frameChain = new Map<string, Promise<unknown>>();
async getFrameAt({ async getFrameAt({
mediaId, mediaId,
@ -34,6 +35,21 @@ export class VideoCache {
const sinkData = this.sinks.get(mediaId); const sinkData = this.sinks.get(mediaId);
if (!sinkData) return null; 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) { if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) {
sinkData.currentFrame = sinkData.nextFrame; sinkData.currentFrame = sinkData.nextFrame;
sinkData.nextFrame = null; sinkData.nextFrame = null;

View File

@ -21,7 +21,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@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", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
@ -54,7 +54,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "16.1.3", "next": "16.1.3",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"opencut-wasm": "^0.2.3", "opencut-wasm": "file:../../rust/wasm/pkg",
"pg": "^8.16.2", "pg": "^8.16.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"radix-ui": "^1.4.3", "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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "@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=="],