mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -17,6 +17,11 @@ 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();
|
||||
@@ -26,6 +31,7 @@ export default function Editor() {
|
||||
<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 />
|
||||
@@ -38,6 +44,29 @@ export default function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -65,19 +65,6 @@ export function PreviewInteractionOverlay() {
|
||||
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
|
||||
@@ -97,7 +84,6 @@ export function PreviewInteractionOverlay() {
|
||||
onPointerCancel={handlePointerUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{editingText ? (
|
||||
<TextEditOverlay
|
||||
|
||||
@@ -9,67 +9,9 @@ import {
|
||||
} 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;
|
||||
}
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
export function TextEditOverlay({
|
||||
trackId,
|
||||
@@ -122,47 +64,43 @@ export function TextEditOverlay({
|
||||
|
||||
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: element.transform.position.x,
|
||||
positionY: element.transform.position.y,
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
});
|
||||
|
||||
const { x: displayScaleX } = viewport.getDisplayScale();
|
||||
|
||||
const displayFontSize =
|
||||
element.fontSize *
|
||||
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
|
||||
displayScaleX;
|
||||
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 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 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 displayPaddingX = shouldShowBackground
|
||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
|
||||
fontSizeRatio *
|
||||
displayScaleX
|
||||
const canvasPaddingX = shouldShowBackground
|
||||
? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio
|
||||
: 0;
|
||||
const displayPaddingY = shouldShowBackground
|
||||
? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
|
||||
fontSizeRatio *
|
||||
displayScaleX
|
||||
const canvasPaddingY = shouldShowBackground
|
||||
? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio
|
||||
: 0;
|
||||
|
||||
return (
|
||||
@@ -170,8 +108,8 @@ export function TextEditOverlay({
|
||||
className="absolute"
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY - cssVisualCenterOffset,
|
||||
transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
|
||||
top: posY,
|
||||
transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
@@ -185,19 +123,20 @@ export function TextEditOverlay({
|
||||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: displayFontSize,
|
||||
fontSize: scaledFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${displayLetterSpacing}px`,
|
||||
letterSpacing: `${canvasLetterSpacing}px`,
|
||||
lineHeight,
|
||||
color: element.color,
|
||||
color: "transparent",
|
||||
caretColor: element.color,
|
||||
backgroundColor: shouldShowBackground ? bg.color : "transparent",
|
||||
minHeight: lineHeightPx,
|
||||
textDecoration: element.textDecoration ?? "none",
|
||||
padding: shouldShowBackground
|
||||
? `${displayPaddingY}px ${displayPaddingX}px`
|
||||
? `${canvasPaddingY}px ${canvasPaddingX}px`
|
||||
: 0,
|
||||
minWidth: 1,
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
getCornerPosition,
|
||||
getEdgeHandlePosition,
|
||||
getRotationHandlePosition,
|
||||
ROTATION_HANDLE_OFFSET,
|
||||
type Corner,
|
||||
type Edge,
|
||||
} from "@/lib/preview/element-bounds";
|
||||
@@ -67,11 +67,16 @@ export function TransformHandles({
|
||||
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 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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,39 @@ export interface MeasuredTextElement {
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user