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