fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
This commit is contained in:
parent
cad88ee4d1
commit
7d4e166678
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
10
bun.lock
10
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue