Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"motion": "^12.23.26",
"next": "16.0.7",
"next-themes": "^0.4.6",
"openvideo": "^0.2.11",
"openvideo": "^0.2.13",
"opfs-tools": "^0.7.2",
"pg": "^8.20.0",
"pixi.js": "^8.14.3",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion src/components/editor/canvas-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useRef, useMemo, useState } from "react";
import { Studio, fontManager, registerCustomTransition, registerCustomEffect } from "openvideo";
import { useTheme } from "next-themes";
import { useStudioStore } from "@/stores/studio-store";
Expand All @@ -7,6 +7,7 @@ import { editorFont } from "./constants";
import { CUSTOM_TRANSITIONS } from "./transition-custom";
import { CUSTOM_EFFECTS } from "./effect-custom";
import { SelectionFloatingMenu } from "./selection-floating-menu";
import { TextEditorOverlay } from "./text-editor-overlay";
import { useClipActions } from "./options-floating-menu";
import {
ContextMenu,
Expand Down Expand Up @@ -53,6 +54,7 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) {
handleToggleLock,
handleDelete,
} = useClipActions();
const [editingClip, setEditingClip] = useState<any | null>(null);

const bgColor = useMemo(() => {
const currentTheme = theme === "system" ? resolvedTheme : theme;
Expand Down Expand Up @@ -179,16 +181,23 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) {

const handleClear = () => {
setSelectedClips([]);
setEditingClip(null);
};

const handleDblClick = ({ clip }: { clip: any }) => {
setEditingClip(clip);
};

studio.on("selection:created", handleSelection);
studio.on("selection:updated", handleSelection);
studio.on("selection:cleared", handleClear);
studio.on("clip:dblclick", handleDblClick);

return () => {
studio.off("selection:created", handleSelection);
studio.off("selection:updated", handleSelection);
studio.off("selection:cleared", handleClear);
studio.off("clip:dblclick", handleDblClick);
};
}, [setSelectedClips]);

Expand Down Expand Up @@ -235,6 +244,9 @@ export function CanvasPanel({ onReady }: CanvasPanelProps) {
tabIndex={0}
/>
<SelectionFloatingMenu />
{editingClip && (
<TextEditorOverlay clip={editingClip} onClose={() => setEditingClip(null)} />
)}
</div>
</div>
</ContextMenuTrigger>
Expand Down
4 changes: 2 additions & 2 deletions src/components/editor/media-panel/panel/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function PanelText() {
if (!studio) return;

try {
const textClip = new Text(preset ? preset.description : "Add Text pro", {
const textClip = new Text(preset ? preset.description : "Add Text", {
fontSize: preset?.style.fontSize || 124,
fontFamily: preset?.style.fontFamily || "Arial",
align: "center",
Expand All @@ -113,7 +113,7 @@ export default function PanelText() {
stroke: (preset?.style as any)?.stroke || undefined,
dropShadow: (preset?.style as any)?.dropShadow || undefined,
wordWrap: true,
wordWrapWidth: 800,
wordWrapWidth: 600,
fontUrl: (preset?.style as any)?.fontUrl,
});
textClip.name = preset ? preset.name : "Text";
Expand Down
55 changes: 53 additions & 2 deletions src/components/editor/media-panel/panel/transition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ type CustomPreset = {
userId: string;
};

// ─── Helpers ──────────────────────────────────────────────────────────────────

/**
* Finds the first junction between two adjacent "media" clips (Video or Image)
* across all Video tracks, sorted by start time.
*/
const findFirstMediaClipUnion = (studio: any) => {
const tracks = studio.getTracks().filter((t: any) => t.type === "Video" || t.type === "Image");
const unions: { fromId: string; toId: string; start: number; duration: number }[] = [];

for (const track of tracks) {
const clips = track.clipIds
.map((id: string) => studio.getClipById(id))
.filter((c: any) => c && (c.type === "Video" || c.type === "Image"))
.sort((a: any, b: any) => a.display.from - b.display.from);

for (let i = 0; i < clips.length - 1; i++) {
const current = clips[i];
const next = clips[i + 1];

// Check if they are adjacent (allowing for a small 100ms tolerance for sub-frame gaps)
const gap = Math.abs(next.display.from - current.display.to);
if (gap < 100_000) {
const duration = Math.min(current.duration, next.duration) * 0.25;
unions.push({
fromId: current.id,
toId: next.id,
start: current.display.to,
duration,
});
}
}
}

if (unions.length === 0) return null;

// Return the one that starts earliest
return unions.sort((a, b) => a.start - b.start)[0];
};

// ─── Shared card for built-in transitions ─────────────────────────────────────

type TransitionCardProps = {
Expand Down Expand Up @@ -210,7 +250,12 @@ const TransitionDefault = () => {
previewDynamic={effect.previewDynamic}
onClick={() => {
if (!studio) return;
studio.addTransition(effect.key, TRANSITION_DURATION_DEFAULT);
const union = findFirstMediaClipUnion(studio);
if (union) {
studio.addTransition(effect.key, union.duration, union.fromId, union.toId);
} else {
studio.addTransition(effect.key, TRANSITION_DURATION_DEFAULT);
}
}}
/>
))}
Expand Down Expand Up @@ -256,7 +301,13 @@ const TransitionCustom = () => {
label: preset.data.label || preset.name,
fragment: preset.data.fragment,
} as any);
studio.addTransition(key, TRANSITION_DURATION_DEFAULT);

const union = findFirstMediaClipUnion(studio);
if (union) {
studio.addTransition(key, union.duration, union.fromId, union.toId);
} else {
studio.addTransition(key, TRANSITION_DURATION_DEFAULT);
}
};

if (isLoading) {
Expand Down
198 changes: 198 additions & 0 deletions src/components/editor/text-editor-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"use client";

import React, { useState, useEffect, useRef } from "react";
import { useStudioStore } from "@/stores/studio-store";
import { Textarea } from "@/components/ui/textarea";

interface TextEditorOverlayProps {
clip: any;
onClose: () => void;
}

/**
* TextEditorOverlay - An inline text editor that overlays the canvas.
* Appears when a text clip is double-clicked.
*/
export function TextEditorOverlay({ clip, onClose }: TextEditorOverlayProps) {
const { studio } = useStudioStore();
const [text, setText] = useState(clip.text || "");
const [bounds, setBounds] = useState({ x: 0, y: 0, width: 0, height: 0, rotation: 0 });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const textRef = useRef(text); // Track text for saving on unmount

// Sync textRef with state
useEffect(() => {
textRef.current = text;
}, [text]);

useEffect(() => {
if (!studio || !studio.pixiApp) return;

const updatePosition = () => {
const transformer = studio.activeTransformer;
if (!transformer) {
onClose();
return;
}

// getBounds() returns coordinates relative to the canvas stage
const pixiBounds = transformer.getBounds();

setBounds({
x: pixiBounds.x,
y: pixiBounds.y,
width: pixiBounds.width,
height: pixiBounds.height,
rotation: transformer.rotation,
});
};

updatePosition();

// Sync position if the clip is moved/resized while editing
studio.on("transforming", updatePosition);
studio.on("selection:cleared", onClose);

return () => {
studio.off("transforming", updatePosition);
studio.off("selection:cleared", onClose);
};
}, [studio, onClose]);

// Auto-resize height as you type
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, [text, bounds.width]);

// Focus and select text on mount
useEffect(() => {
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, 50);
}, []);

const handleSave = () => {
const currentText = textRef.current;
if (studio && currentText !== clip.text) {
studio.updateClip(clip.id, { text: currentText } as any);
studio.updateFrame(studio.currentTime);
}
onClose();
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
handleSave();
}
};

// Visibility toggle: hide the original clip and transformer while editing
useEffect(() => {
const originalOpacity = clip.opacity ?? 1;
clip.opacity = 0;

// Hide the selection transformer (blue box/handles) to avoid clutter
const transformer = studio?.activeTransformer;
const originalTransformerVisible = transformer?.visible ?? true;
if (transformer) {
transformer.visible = false;
}

if (studio) {
studio.updateFrame(studio.currentTime);
}

return () => {
// Auto-save on unmount if dirty
if (studio && textRef.current !== clip.text) {
studio.updateClip(clip.id, { text: textRef.current } as any);
}
clip.opacity = originalOpacity;

// Restore transformer visibility
if (transformer) {
transformer.visible = originalTransformerVisible;
}

if (studio) {
studio.updateFrame(studio.currentTime);
}
};
}, [clip, studio]);

// Calculate font size scaling
// Use width ratio for better stability than height
const style = clip.style || {};
const logicalFontSize = style.fontSize || 40;

// Safe scale calculation to avoid "exploding" text
const logicalWidth = clip.width || 1;
const currentScale =
bounds.width > 0 && logicalWidth > 1
? bounds.width / logicalWidth
: studio?.artboard?.scale?.x || 1;

const scaledFontSize = logicalFontSize * currentScale;

return (
<div
style={{
position: "absolute",
left: bounds.x,
top: bounds.y + 38, // Move slightly up from original position
width: bounds.width,
height: "auto",
minHeight: `${scaledFontSize}px`,
transform: `rotate(${bounds.rotation}rad)`,
zIndex: 50,
pointerEvents: "auto",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "stretch",
border: "2px solid #00aaff", // Cyan bounding box
boxSizing: "border-box",
overflow: "visible",
}}
onClick={(e) => e.stopPropagation()}
>
<textarea
ref={textareaRef}
value={text}
onChange={(e) => {
const newText = e.target.value;
setText(newText);
// Update engine in real-time
if (studio) {
studio.updateClip(clip.id, { text: newText } as any);
studio.updateFrame(studio.currentTime);
}
}}
onBlur={handleSave}
onKeyDown={handleKeyDown}
spellCheck={false}
className="w-full p-0 m-0 resize-none bg-transparent border-none outline-none overflow-hidden whitespace-pre-wrap wrap-break-word block"
style={{
color: style.fill || "white",
fontFamily: style.fontFamily || "sans-serif",
fontSize: `${scaledFontSize}px`,
fontWeight: style.fontWeight || "normal",
lineHeight: style.lineHeight || "1.0", // Tighter line height for editing
textAlign: (clip as any).textAlign || "center",
padding: "5px 0", // Small vertical padding to match baseline better
// Text shadow to match subtle Pixi rendering if possible
textShadow: style.dropShadow ? "1px 1px 2px rgba(0,0,0,0.3)" : "none",
}}
/>
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/editor/timeline/timeline/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ class Timeline extends EventEmitter<TimelineCanvasEvents> {
width: clientWidth,
height: clientHeight,
selection: true,
selectionColor: "rgba(10, 189, 227, 0.2)",
selectionBorderColor: "#0abde3",
selectionLineWidth: 2,
renderOnAddRemove: false, // Performance optimization
preserveObjectStacking: true,
});
Expand Down