mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
feat(studio): render queue, layout restructure, home page, hover preview (#95)
## Summary - Add render queue panel with progress tracking, download, and delete actions - Restructure App layout: home page with project picker, session-based routing - Add ExpandOnHover component for preview-on-hover interactions (uses motion/react) - CompositionsTab now supports hover preview with expanded iframe view - Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout) - Add favicon and update studio package deps ## Test plan - [x] Render queue shows progress, completes, and allows download - [x] Home page lists projects and navigates to session view - [x] ExpandOnHover shows expanded preview on mouse hover with spring animation - [x] `vite build` exits cleanly (no hanging process from setInterval) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
+1
-5
@@ -48,9 +48,5 @@ packages/producer/src/services/fontData.generated.ts
|
||||
# Test artifacts
|
||||
my-video/
|
||||
packages/studio/data/
|
||||
|
||||
# QA artifacts
|
||||
qa-*.webm
|
||||
scorecard.png
|
||||
.worktrees/
|
||||
.desloppify/
|
||||
.worktrees/
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"codemirror": "^6.0.1",
|
||||
"motion": "^12.38.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -926,6 +927,8 @@
|
||||
|
||||
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
@@ -1110,6 +1113,12 @@
|
||||
|
||||
"mlly": ["[email protected]", "", { "dependencies": { "acorn": "8.16.0", "pathe": "2.0.3", "pkg-types": "1.3.1", "ufo": "1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
|
||||
|
||||
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
|
||||
|
||||
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||
|
||||
"motion-utils": ["[email protected]", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "1.3.0", "object-assign": "4.1.1", "thenify-all": "1.6.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"codemirror": "^6.0.1"
|
||||
"codemirror": "^6.0.1",
|
||||
"motion": "^12.38.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
+643
-300
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
||||
import { memo } from "react";
|
||||
import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import {
|
||||
FileCode,
|
||||
Image,
|
||||
Film,
|
||||
Music,
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "../../icons/SystemIcons";
|
||||
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
@@ -13,12 +21,23 @@ const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
|
||||
js: { icon: FileCode, color: "#F59E0B" },
|
||||
ts: { icon: FileCode, color: "#3B82F6" },
|
||||
json: { icon: File, color: "#22C55E" },
|
||||
md: { icon: File, color: "#737373" },
|
||||
png: { icon: Image, color: "#22C55E" },
|
||||
jpg: { icon: Image, color: "#22C55E" },
|
||||
jpeg: { icon: Image, color: "#22C55E" },
|
||||
webp: { icon: Image, color: "#22C55E" },
|
||||
gif: { icon: Image, color: "#22C55E" },
|
||||
svg: { icon: Image, color: "#F97316" },
|
||||
mp4: { icon: Film, color: "#A855F7" },
|
||||
webm: { icon: Film, color: "#A855F7" },
|
||||
mov: { icon: Film, color: "#A855F7" },
|
||||
mp3: { icon: Music, color: "#F59E0B" },
|
||||
wav: { icon: Music, color: "#F59E0B" },
|
||||
ogg: { icon: Music, color: "#F59E0B" },
|
||||
m4a: { icon: Music, color: "#F59E0B" },
|
||||
woff: { icon: File, color: "#525252" },
|
||||
woff2: { icon: File, color: "#525252" },
|
||||
ttf: { icon: File, color: "#525252" },
|
||||
};
|
||||
|
||||
function getFileIcon(path: string) {
|
||||
@@ -26,13 +45,152 @@ function getFileIcon(path: string) {
|
||||
return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
|
||||
}
|
||||
|
||||
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
// index.html first, then alphabetical
|
||||
if (a === "index.html") return -1;
|
||||
if (b === "index.html") return 1;
|
||||
return a.localeCompare(b);
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
children: Map<string, TreeNode>;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
function buildTree(files: string[]): TreeNode {
|
||||
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
||||
for (const file of files) {
|
||||
const parts = file.split("/");
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join("/");
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, {
|
||||
name: part,
|
||||
fullPath,
|
||||
children: new Map(),
|
||||
isFile: isLast,
|
||||
});
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
if (isLast) current.isFile = true;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
||||
return Array.from(children.values()).sort((a, b) => {
|
||||
// index.html always first
|
||||
if (a.name === "index.html") return -1;
|
||||
if (b.name === "index.html") return 1;
|
||||
// Directories before files
|
||||
if (!a.isFile && b.isFile) return -1;
|
||||
if (a.isFile && !b.isFile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function TreeFolder({
|
||||
node,
|
||||
depth,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
defaultOpen,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
||||
const children = sortChildren(node.children);
|
||||
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors"
|
||||
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||
>
|
||||
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
||||
<span className="truncate font-medium">{node.name}</span>
|
||||
</button>
|
||||
{isOpen &&
|
||||
children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
) : child.children.size > 0 ? (
|
||||
<TreeFolder
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
defaultOpen={isActiveInSubtree(child, activeFile)}
|
||||
/>
|
||||
) : (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeFile({
|
||||
node,
|
||||
depth,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
}) {
|
||||
const { icon: Icon, color } = getFileIcon(node.name);
|
||||
const isActive = node.fullPath === activeFile;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelectFile(node.fullPath)}
|
||||
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
||||
isActive
|
||||
? "bg-neutral-800/60 text-neutral-200"
|
||||
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
|
||||
>
|
||||
<Icon size={12} style={{ color }} className="flex-shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
||||
if (!activeFile) return false;
|
||||
if (node.fullPath === activeFile) return true;
|
||||
for (const child of node.children.values()) {
|
||||
if (isActiveInSubtree(child, activeFile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
||||
const tree = buildTree(files);
|
||||
const children = sortChildren(tree.children);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
@@ -40,30 +198,26 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
||||
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{sorted.map((path) => {
|
||||
const { icon: Icon, color } = getFileIcon(path);
|
||||
const isActive = path === activeFile;
|
||||
const name = path.split("/").pop() ?? path;
|
||||
const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") + "/" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => onSelectFile(path)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1 min-h-7 text-left transition-all duration-press text-xs ${
|
||||
isActive
|
||||
? "bg-neutral-800/60 text-neutral-200"
|
||||
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300 active:scale-[0.98]"
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} style={{ color }} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{dir && <span className="text-neutral-600">{dir}</span>}
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={0}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
) : (
|
||||
<TreeFolder
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={0}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
defaultOpen={isActiveInSubtree(child, activeFile)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
@@ -54,15 +54,16 @@ export const NLELayout = memo(function NLELayout({
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
saveSeekPosition,
|
||||
resetPlayer,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
// Reset timeline state when the project changes to prevent stale data from a
|
||||
// previous project leaking into the new one.
|
||||
const prevProjectIdRef = useRef<string | null>(null);
|
||||
const prevProjectIdRef = useRef(projectId);
|
||||
if (prevProjectIdRef.current !== projectId) {
|
||||
prevProjectIdRef.current = projectId;
|
||||
resetPlayer();
|
||||
// Only reset Zustand state during render (safe — pure state update).
|
||||
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
|
||||
usePlayerStore.getState().reset();
|
||||
}
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
@@ -100,6 +101,49 @@ export const NLELayout = memo(function NLELayout({
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
||||
// The runtime strips data-composition-src from the DOM after loading, so elements
|
||||
// arrive without it. This bridges the gap using the map built from raw HTML.
|
||||
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
|
||||
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
|
||||
const compIdToSrcRef = useRef(compIdToSrc);
|
||||
compIdToSrcRef.current = compIdToSrc;
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (compIdToSrc.size === 0) return;
|
||||
const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
|
||||
const map = compIdToSrcRef.current;
|
||||
if (map.size === 0) return null;
|
||||
let patched = false;
|
||||
const updated = elements.map((el) => {
|
||||
if (el.compositionSrc) return el;
|
||||
// Try exact match, then strip common suffixes (-host, -comp, -layer)
|
||||
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
|
||||
if (src) {
|
||||
patched = true;
|
||||
return { ...el, compositionSrc: src };
|
||||
}
|
||||
return el;
|
||||
});
|
||||
return patched ? updated : null;
|
||||
};
|
||||
// Patch current elements immediately
|
||||
const patched = patchElements(usePlayerStore.getState().elements);
|
||||
if (patched) usePlayerStore.getState().setElements(patched);
|
||||
// Subscribe for future element updates — use a flag to prevent re-entrant patching
|
||||
let patching = false;
|
||||
return usePlayerStore.subscribe((state, prev) => {
|
||||
if (patching) return;
|
||||
if (state.elements === prev.elements || state.elements.length === 0) return;
|
||||
// Skip if all elements already have compositionSrc
|
||||
if (state.elements.every((el) => el.compositionSrc)) return;
|
||||
patching = true;
|
||||
const result = patchElements(state.elements);
|
||||
if (result) state.setElements(result);
|
||||
patching = false;
|
||||
});
|
||||
}, [compIdToSrc]);
|
||||
|
||||
// Composition drill-down stack
|
||||
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
@@ -126,11 +170,18 @@ export const NLELayout = memo(function NLELayout({
|
||||
const currentLevel = compositionStack[compositionStack.length - 1];
|
||||
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
||||
|
||||
// Save master seek position before drilling down so we can restore it on back-navigation.
|
||||
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
||||
const masterSeekRef = useRef(0);
|
||||
|
||||
// Drill-down: push a sub-composition onto the stack
|
||||
const iframeRef_ = iframeRef; // stable ref for the callback
|
||||
const handleDrillDown = useCallback(
|
||||
(element: TimelineElement) => {
|
||||
if (!element.compositionSrc) return;
|
||||
// Save current master playback position for back-navigation
|
||||
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
||||
saveSeekPosition();
|
||||
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
||||
// Extract the element's composition ID from its timeline ID
|
||||
const compId = element.id;
|
||||
@@ -185,33 +236,39 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
// Navigate back to a specific breadcrumb level
|
||||
const handleNavigateComposition = useCallback((index: number) => {
|
||||
// When going back to master (index 0), restore the saved master position
|
||||
if (index === 0 && masterSeekRef.current > 0) {
|
||||
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
||||
}
|
||||
saveSeekPosition();
|
||||
usePlayerStore.getState().setElements([]);
|
||||
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Navigate to a composition when activeCompositionPath changes
|
||||
const prevActiveCompRef = useRef<string | null>(null);
|
||||
if (activeCompositionPath && activeCompositionPath !== prevActiveCompRef.current) {
|
||||
prevActiveCompRef.current = activeCompositionPath;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
// Navigate to a composition when activeCompositionPath changes.
|
||||
// Uses useEffect to ensure state updates happen after render commit,
|
||||
// avoiding render-time mutations that React can swallow during batching.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (activeCompositionPath === "index.html") {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
||||
} else if (activeCompositionPath.startsWith("compositions/")) {
|
||||
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
|
||||
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
||||
usePlayerStore.getState().setElements([]);
|
||||
updateCompositionStack((prev) => {
|
||||
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
|
||||
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
||||
return [
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
{ id: activeCompositionPath, label, previewUrl },
|
||||
];
|
||||
});
|
||||
} else if (!activeCompositionPath) {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
}
|
||||
} else if (!activeCompositionPath && prevActiveCompRef.current) {
|
||||
prevActiveCompRef.current = null;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
}
|
||||
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
||||
|
||||
// Resize divider handlers
|
||||
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { memo, useState, useRef } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
onDelete: (jobId: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onStartRender: (format: "mp4" | "webm") => void;
|
||||
isRendering: boolean;
|
||||
}
|
||||
|
||||
function FormatExportButton({
|
||||
onStartRender,
|
||||
isRendering,
|
||||
}: {
|
||||
onStartRender: (format: "mp4" | "webm") => void;
|
||||
isRendering: boolean;
|
||||
}) {
|
||||
const [format, setFormat] = useState<"mp4" | "webm">("mp4");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as "mp4" | "webm")}
|
||||
disabled={isRendering}
|
||||
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="mp4">MP4</option>
|
||||
<option value="webm">WebM</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onStartRender(format)}
|
||||
disabled={isRendering}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isRendering ? "Rendering..." : "Export"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RenderQueue = memo(function RenderQueue({
|
||||
jobs,
|
||||
onDelete,
|
||||
onClearCompleted,
|
||||
onStartRender,
|
||||
isRendering,
|
||||
}: RenderQueueProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevCount = useRef(jobs.length);
|
||||
|
||||
// Auto-scroll to bottom when new jobs are added (adjust during render)
|
||||
if (jobs.length > prevCount.current && listRef.current) {
|
||||
queueMicrotask(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
prevCount.current = jobs.length;
|
||||
|
||||
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
|
||||
<span className="text-[11px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||
Renders ({jobs.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job list */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-700"
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="20"
|
||||
height="20"
|
||||
rx="2.18"
|
||||
ry="2.18"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
|
||||
</div>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
job: RenderJob;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
if (diff < 60000) return "just now";
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
onDelete,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
}, [job.id, job.filename]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator */}
|
||||
<div className="flex-shrink-0">
|
||||
{job.status === "rendering" && (
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
)}
|
||||
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
||||
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate">
|
||||
{job.filename}
|
||||
</span>
|
||||
{job.durationMs && (
|
||||
<span className="text-[9px] text-neutral-600 flex-shrink-0">
|
||||
{formatDuration(job.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + percentage */}
|
||||
{job.status === "rendering" && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
|
||||
<span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.status !== "rendering" && (
|
||||
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{job.status === "complete" && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
export interface RenderJob {
|
||||
id: string;
|
||||
status: "rendering" | "complete" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
stage?: string;
|
||||
filename: string;
|
||||
createdAt: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export function useRenderQueue(projectId: string | null) {
|
||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const activeJobRef = useRef<string | null>(null);
|
||||
|
||||
// Load completed renders from the server
|
||||
const loadRenders = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/renders`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.renders)) {
|
||||
setJobs((prev) => {
|
||||
const existing = new Set(prev.map((j) => j.id));
|
||||
const fromServer: RenderJob[] = data.renders
|
||||
.filter((r: { id: string }) => !existing.has(r.id))
|
||||
.map((r: { id: string; filename: string; createdAt: number; size: number }) => ({
|
||||
id: r.id,
|
||||
status: "complete" as const,
|
||||
progress: 100,
|
||||
filename: r.filename,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
return [...prev, ...fromServer];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRenders();
|
||||
}, [loadRenders]);
|
||||
|
||||
// Start a render and track progress via SSE
|
||||
const startRender = useCallback(
|
||||
async (fps = 30, quality = "standard", format: "mp4" | "webm" = "mp4") => {
|
||||
if (!projectId) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
const res = await fetch(`/api/projects/${projectId}/render`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fps, quality, format }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const { jobId } = await res.json();
|
||||
|
||||
const ext = format === "webm" ? ".webm" : ".mp4";
|
||||
const job: RenderJob = {
|
||||
id: jobId,
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
filename: `${jobId}${ext}`,
|
||||
createdAt: startTime,
|
||||
};
|
||||
setJobs((prev) => [...prev, job]);
|
||||
activeJobRef.current = jobId;
|
||||
|
||||
// Track progress via SSE
|
||||
const es = new EventSource(`/api/render/${jobId}/progress`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener("progress", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId
|
||||
? {
|
||||
...j,
|
||||
progress: data.progress ?? j.progress,
|
||||
stage: data.stage ?? data.message ?? j.stage,
|
||||
status:
|
||||
data.status === "complete"
|
||||
? "complete"
|
||||
: data.status === "failed"
|
||||
? "failed"
|
||||
: j.status,
|
||||
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
||||
}
|
||||
: j,
|
||||
),
|
||||
);
|
||||
if (data.status === "complete" || data.status === "failed") {
|
||||
es.close();
|
||||
activeJobRef.current = null;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setJobs((prev) =>
|
||||
prev.map((j) =>
|
||||
j.id === jobId && j.status === "rendering" ? { ...j, status: "failed" } : j,
|
||||
),
|
||||
);
|
||||
activeJobRef.current = null;
|
||||
};
|
||||
|
||||
return jobId;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const deleteRender = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
}, []);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
|
||||
}, []);
|
||||
|
||||
// Clean up EventSource on unmount or projectId change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
startRender,
|
||||
deleteRender,
|
||||
clearCompleted,
|
||||
isRendering: jobs.some((j) => j.status === "rendering"),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -11,81 +12,239 @@ const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
|
||||
|
||||
function AssetIcon({ ext }: { ext: string }) {
|
||||
if (VIDEO_EXT.test(ext)) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-blue-400"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (AUDIO_EXT.test(ext)) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-purple-400"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (IMAGE_EXT.test(ext)) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-green-400"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function AssetThumbnail({
|
||||
serveUrl,
|
||||
name,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
}: {
|
||||
serveUrl: string;
|
||||
name: string;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
isAudio: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-500"
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
{isImage && (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<>
|
||||
<video
|
||||
src={serveUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-purple-400"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{!isImage && !isVideo && !isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedAssetPreview({
|
||||
serveUrl,
|
||||
name,
|
||||
asset,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
onCopy,
|
||||
}: {
|
||||
serveUrl: string;
|
||||
name: string;
|
||||
asset: string;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
isAudio: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
{isImage && (
|
||||
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-purple-400"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
<audio src={serveUrl} controls autoPlay className="w-64" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
||||
>
|
||||
Copy Path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
projectId,
|
||||
asset,
|
||||
onCopy,
|
||||
isCopied,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: string;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
}) {
|
||||
const name = asset.split("/").pop() ?? asset;
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
||||
const isImage = IMAGE_EXT.test(asset);
|
||||
const isVideo = VIDEO_EXT.test(asset);
|
||||
const isAudio = AUDIO_EXT.test(asset);
|
||||
const hasExpandablePreview = isImage || isVideo || isAudio;
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isCopied
|
||||
? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={isImage}
|
||||
isVideo={isVideo}
|
||||
isAudio={isAudio}
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-[#3CE6AC]">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hasExpandablePreview) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(asset)}
|
||||
title="Click to copy path"
|
||||
className="w-full"
|
||||
>
|
||||
{card}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandOnHover
|
||||
expandedContent={(closeExpand) => (
|
||||
<ExpandedAssetPreview
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
asset={asset}
|
||||
isImage={isImage}
|
||||
isVideo={isVideo}
|
||||
isAudio={isAudio}
|
||||
onCopy={() => {
|
||||
closeExpand();
|
||||
onCopy(asset);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onClick={() => onCopy(asset)}
|
||||
expandScale={0.45}
|
||||
delay={500}
|
||||
>
|
||||
{card}
|
||||
</ExpandOnHover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,42 +344,15 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
||||
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
||||
</div>
|
||||
) : (
|
||||
mediaAssets.map((asset) => {
|
||||
const name = asset.split("/").pop() ?? asset;
|
||||
const ext = "." + (name.split(".").pop() ?? "");
|
||||
const isImage = IMAGE_EXT.test(asset);
|
||||
const isCopied = copiedPath === asset;
|
||||
const serveUrl = `/api/projects/${projectId}/serve/${asset}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={asset}
|
||||
type="button"
|
||||
onClick={() => handleCopyPath(asset)}
|
||||
title="Click to copy path"
|
||||
className="w-full text-left px-3 py-2 flex items-center gap-2.5 hover:bg-neutral-800/40 transition-colors"
|
||||
>
|
||||
{isImage ? (
|
||||
<div className="w-8 h-8 rounded overflow-hidden bg-neutral-900 flex-shrink-0">
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded bg-neutral-900 flex items-center justify-center flex-shrink-0">
|
||||
<AssetIcon ext={ext} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] text-neutral-300 truncate block">{name}</span>
|
||||
{isCopied && <span className="text-[9px] text-green-400">Copied!</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
mediaAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset}
|
||||
projectId={projectId}
|
||||
asset={asset}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === asset}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
|
||||
interface CompositionsTabProps {
|
||||
projectId: string;
|
||||
@@ -7,14 +8,201 @@ interface CompositionsTabProps {
|
||||
onSelect: (comp: string) => void;
|
||||
}
|
||||
|
||||
function ExpandedCompPreview({
|
||||
previewUrl,
|
||||
name,
|
||||
comp,
|
||||
onSelect,
|
||||
}: {
|
||||
previewUrl: string;
|
||||
name: string;
|
||||
comp: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [dims, setDims] = useState({ w: 1920, h: 1080 });
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const updateScale = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
|
||||
setScale(s);
|
||||
}, [dims]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
updateScale();
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(updateScale);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateScale]);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
// Detect dimensions from composition
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc) {
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
|
||||
const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
|
||||
if (w > 0 && h > 0) setDims({ w, h });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
try {
|
||||
const win = iframe.contentWindow as Window & {
|
||||
__player?: { play: () => void; seek: (t: number) => void };
|
||||
__timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
|
||||
};
|
||||
if (win?.__player) {
|
||||
win.__player.seek(0.5);
|
||||
win.__player.play();
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
if (win?.__timelines) {
|
||||
const keys = Object.keys(win.__timelines);
|
||||
const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
|
||||
if (tl) {
|
||||
tl.seek(0.5);
|
||||
tl.play();
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
if (++attempts > 15) clearInterval(interval);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const offsetX = containerRef.current
|
||||
? (containerRef.current.clientWidth - dims.w * scale) / 2
|
||||
: 0;
|
||||
const offsetY = containerRef.current
|
||||
? (containerRef.current.clientHeight - dims.h * scale) / 2
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
onLoad={handleLoad}
|
||||
className="absolute border-none"
|
||||
style={{
|
||||
left: Math.max(0, offsetX),
|
||||
top: Math.max(0, offsetY),
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
transformOrigin: "0 0",
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompCard({
|
||||
projectId,
|
||||
comp,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
projectId: string;
|
||||
comp: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandOnHover
|
||||
expandedContent={(closeExpand) => (
|
||||
<ExpandedCompPreview
|
||||
previewUrl={previewUrl}
|
||||
name={name}
|
||||
comp={comp}
|
||||
onSelect={() => {
|
||||
closeExpand();
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onClick={onSelect}
|
||||
expandScale={0.5}
|
||||
delay={500}
|
||||
>
|
||||
{card}
|
||||
</ExpandOnHover>
|
||||
);
|
||||
}
|
||||
|
||||
export const CompositionsTab = memo(function CompositionsTab({
|
||||
projectId,
|
||||
compositions,
|
||||
activeComposition,
|
||||
onSelect,
|
||||
}: CompositionsTabProps) {
|
||||
const [hoveredComp, setHoveredComp] = useState<string | null>(null);
|
||||
|
||||
if (compositions.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
@@ -25,53 +213,15 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{compositions.map((comp) => {
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const isActive = activeComposition === comp;
|
||||
const isHovered = hoveredComp === comp;
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={comp}
|
||||
type="button"
|
||||
onClick={() => onSelect(comp)}
|
||||
onPointerEnter={() => setHoveredComp(comp)}
|
||||
onPointerLeave={() => setHoveredComp(null)}
|
||||
className={`w-full text-left px-3 py-2 flex items-center gap-3 transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-500/10 border-l-2 border-blue-500"
|
||||
: isHovered
|
||||
? "bg-neutral-800/50"
|
||||
: ""
|
||||
} ${!isActive ? "border-l-2 border-transparent" : ""}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-16 h-9 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "0";
|
||||
}}
|
||||
/>
|
||||
{/* Fallback: show name initial when thumbnail fails */}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-neutral-600 font-mono pointer-events-none">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{/* Name */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{compositions.map((comp) => (
|
||||
<CompCard
|
||||
key={comp}
|
||||
projectId={projectId}
|
||||
comp={comp}
|
||||
isActive={activeComposition === comp}
|
||||
onSelect={() => onSelect(comp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useState, useCallback, useEffect } from "react";
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
|
||||
@@ -12,6 +13,7 @@ function getPersistedTab(): SidebarTab {
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
width?: number;
|
||||
projectId: string;
|
||||
compositions: string[];
|
||||
assets: string[];
|
||||
@@ -21,6 +23,7 @@ interface LeftSidebarProps {
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
width = 240,
|
||||
projectId,
|
||||
compositions,
|
||||
assets,
|
||||
@@ -36,7 +39,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (e.key === "1") {
|
||||
@@ -50,12 +53,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectTab]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
||||
style={{ width: 240 }}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface ExpandOnHoverProps {
|
||||
children: ReactNode;
|
||||
expandedContent?: ReactNode | ((close: () => void) => ReactNode);
|
||||
expandScale?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ExpandOnHover({
|
||||
children,
|
||||
expandedContent,
|
||||
expandScale = 0.75,
|
||||
delay = 300,
|
||||
className = "",
|
||||
onClick,
|
||||
}: ExpandOnHoverProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [origin, setOrigin] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedRef = useRef<HTMLDivElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsExpanded(false);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setOrigin({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
const handleCardEnter = useCallback(() => {
|
||||
if (isExpanded) return;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(open, delay);
|
||||
}, [delay, open, isExpanded]);
|
||||
|
||||
const handleCardLeave = useCallback(() => {
|
||||
if (isExpanded) return;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// When expanded: track mouse position. If mouse stays outside the expanded
|
||||
// card for 600ms continuously, close. Any re-entry resets the timer.
|
||||
// Note: useEffect with [isExpanded] is acceptable — subscribes to window mousemove
|
||||
// only while expanded, with cleanup on collapse. Can't be a mount effect.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
|
||||
const CLOSE_DELAY = 600; // ms mouse must be outside to close
|
||||
const START_DELAY = 400; // ms before we start checking (let animation settle)
|
||||
let tracking = false;
|
||||
|
||||
const startTracking = setTimeout(() => {
|
||||
tracking = true;
|
||||
}, START_DELAY);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!tracking) return;
|
||||
const el = expandedRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Add generous padding so edge movements don't trigger close
|
||||
const pad = 20;
|
||||
const inside =
|
||||
e.clientX >= rect.left - pad &&
|
||||
e.clientX <= rect.right + pad &&
|
||||
e.clientY >= rect.top - pad &&
|
||||
e.clientY <= rect.bottom + pad;
|
||||
|
||||
if (inside) {
|
||||
// Mouse is inside — cancel any pending close
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Mouse is outside — start close countdown if not already started
|
||||
if (!closeTimerRef.current) {
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setIsExpanded(false);
|
||||
}, CLOSE_DELAY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
clearTimeout(startTracking);
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
const vw = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||
const vh = typeof window !== "undefined" ? window.innerHeight : 900;
|
||||
const targetW = vw * expandScale;
|
||||
const targetH = vh * expandScale;
|
||||
const targetX = (vw - targetW) / 2;
|
||||
const targetY = (vh - targetH) / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
onMouseEnter={handleCardEnter}
|
||||
onMouseLeave={handleCardLeave}
|
||||
onClick={onClick}
|
||||
style={{ opacity: isExpanded ? 0 : 1, transition: "opacity 100ms ease-out" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
||||
onClick={close}
|
||||
/>
|
||||
{/* Expanded card */}
|
||||
<motion.div
|
||||
ref={expandedRef}
|
||||
initial={{
|
||||
left: origin.x,
|
||||
top: origin.y,
|
||||
width: origin.w,
|
||||
height: origin.h,
|
||||
}}
|
||||
animate={{
|
||||
left: targetX,
|
||||
top: targetY,
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
}}
|
||||
exit={{
|
||||
left: origin.x,
|
||||
top: origin.y,
|
||||
width: origin.w,
|
||||
height: origin.h,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 28,
|
||||
mass: 0.8,
|
||||
}}
|
||||
className="fixed z-50 overflow-hidden rounded-[16px] shadow-dialog"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{typeof expandedContent === "function"
|
||||
? expandedContent(close)
|
||||
: (expandedContent ?? children)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
}
|
||||
|
||||
const BAR_W = 2;
|
||||
const GAP = 1;
|
||||
const STEP = BAR_W + GAP;
|
||||
|
||||
/** Downsample PCM channel data into peak amplitudes (0–1). */
|
||||
function extractPeaks(channelData: Float32Array, barCount: number): number[] {
|
||||
const peaks: number[] = [];
|
||||
const samplesPerBar = Math.floor(channelData.length / barCount);
|
||||
if (samplesPerBar === 0) return Array(barCount).fill(0);
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
let max = 0;
|
||||
const start = i * samplesPerBar;
|
||||
const end = Math.min(start + samplesPerBar, channelData.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channelData[j] ?? 0);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(max);
|
||||
}
|
||||
const maxPeak = Math.max(...peaks, 0.001);
|
||||
return peaks.map((p) => p / maxPeak);
|
||||
}
|
||||
|
||||
/** Deterministic fake waveform as fallback (matches demo app). */
|
||||
function fakePeaks(url: string, count: number): number[] {
|
||||
let seed = 0;
|
||||
for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0;
|
||||
seed = Math.abs(seed) || 42;
|
||||
const rand = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return (seed & 0x7fffffff) / 2147483647;
|
||||
};
|
||||
const peaks: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count;
|
||||
const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1);
|
||||
peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand()))));
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
||||
const peaksCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Audio waveform rendered from real PCM data via Web Audio API.
|
||||
* Falls back to a deterministic fake pattern if decoding fails.
|
||||
* Bars grow from bottom to top, rendered as CSS divs for zoom resilience.
|
||||
*/
|
||||
export const AudioWaveform = memo(function AudioWaveform({
|
||||
audioUrl,
|
||||
label,
|
||||
labelColor,
|
||||
}: AudioWaveformProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const barsRef = useRef<HTMLDivElement | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
|
||||
|
||||
// Fetch + decode audio once
|
||||
useEffect(() => {
|
||||
if (peaks || !audioUrl) return;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
fetch(audioUrl, { signal: ctrl.signal })
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((buf) => {
|
||||
const ctx = new AudioContext();
|
||||
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
||||
})
|
||||
.then((decoded) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const channel = decoded.getChannelData(0);
|
||||
// Extract enough peaks for wide clips (up to 4000 bars)
|
||||
const p = extractPeaks(channel, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
})
|
||||
.catch(() => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Fallback to fake waveform
|
||||
const p = fakePeaks(audioUrl, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
});
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [audioUrl, peaks]);
|
||||
|
||||
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
||||
const draw = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const barsEl = barsRef.current;
|
||||
if (!container || !barsEl || !peaks) return;
|
||||
|
||||
const w = container.clientWidth || 400;
|
||||
const barCount = Math.min(Math.floor(w / STEP), peaks.length);
|
||||
|
||||
let html = "";
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
// Map bar index to peak index (resample)
|
||||
const peakIdx = Math.floor((i / barCount) * peaks.length);
|
||||
const amp = peaks[peakIdx] ?? 0;
|
||||
const pct = Math.max(3, Math.round(amp * 100));
|
||||
const opacity = (0.45 + amp * 0.4).toFixed(2);
|
||||
html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
|
||||
}
|
||||
barsEl.innerHTML = html;
|
||||
}, [peaks]);
|
||||
|
||||
// Observe container size and redraw
|
||||
const setContainerRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
containerRef.current = el;
|
||||
if (!el) return;
|
||||
draw();
|
||||
roRef.current = new ResizeObserver(() => draw());
|
||||
roRef.current.observe(el);
|
||||
},
|
||||
[draw],
|
||||
);
|
||||
|
||||
// Redraw when peaks arrive
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
||||
<div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
|
||||
{/* Shimmer while decoding */}
|
||||
{!peaks && (
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 animate-pulse"
|
||||
style={{
|
||||
top: 16,
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -5,11 +5,11 @@
|
||||
* and tiles them horizontally — like VideoThumbnail does for video clips.
|
||||
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
|
||||
*
|
||||
* Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count
|
||||
* when the clip width changes (zoom).
|
||||
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
|
||||
*/
|
||||
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { memo, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const MAX_UNIQUE_FRAMES = 6;
|
||||
@@ -33,18 +33,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
}: CompositionThumbnailProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [loadedFrames, setLoadedFrames] = useState<Set<number>>(new Set());
|
||||
const ioRef = useRef<IntersectionObserver | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setRef = useCallback((el: HTMLDivElement | null) => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
// Walk up to data-clip parent for accurate width (max 5 levels to avoid overshoot)
|
||||
// Walk up to data-clip parent for accurate width
|
||||
let target: HTMLElement = el;
|
||||
let parent = el.parentElement;
|
||||
let depth = 0;
|
||||
@@ -59,32 +55,13 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
|
||||
ioRef.current = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
ioRef.current?.disconnect();
|
||||
requestAnimationFrame(() => {
|
||||
const w = target.clientWidth || target.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rootMargin: "300px" },
|
||||
);
|
||||
ioRef.current.observe(el);
|
||||
|
||||
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Convert preview URL to thumbnail base URL
|
||||
const thumbnailBase = previewUrl
|
||||
@@ -97,63 +74,51 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
|
||||
|
||||
// Generate timestamps spread across the clip duration.
|
||||
// Start at 30% into the scene to skip entrance animations (opacity:0 → 1).
|
||||
// End at 90% to avoid catching exit animations.
|
||||
// Each frame tile represents a real position in the clip.
|
||||
// Offset slightly (0.5s) into each segment to avoid landing on transition
|
||||
// points where content is invisible due to fade-in/fade-out animations.
|
||||
const timestamps: number[] = [];
|
||||
const startOffset = duration * 0.3;
|
||||
const endOffset = duration * 0.9;
|
||||
const range = endOffset - startOffset;
|
||||
const pad = Math.min(0.5, duration * 0.05);
|
||||
for (let i = 0; i < uniqueFrames; i++) {
|
||||
const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1);
|
||||
timestamps.push(seekTime + startOffset + frac * range);
|
||||
const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
|
||||
const raw = seekTime + frac * duration;
|
||||
// Clamp to [pad, duration - pad] to stay inside visible content
|
||||
timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
|
||||
}
|
||||
|
||||
const hasAnyFrame = loadedFrames.size > 0;
|
||||
|
||||
return (
|
||||
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
{/* Film strip */}
|
||||
{visible && (
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => {
|
||||
const uniqueIdx = i % uniqueFrames;
|
||||
const t = timestamps[uniqueIdx];
|
||||
const url = `${thumbnailBase}?t=${t.toFixed(2)}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
opacity: loadedFrames.has(uniqueIdx) ? 1 : 0,
|
||||
transition: "opacity 200ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shimmer while loading */}
|
||||
{(!visible || !hasAnyFrame) && (
|
||||
<div
|
||||
className="absolute inset-0 animate-pulse"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Film strip — each tile maps to its real timeline position */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => {
|
||||
// Map this tile's visual position to a timestamp
|
||||
const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
|
||||
const t = seekTime + tileFrac * duration;
|
||||
// Use the nearest cached unique frame
|
||||
const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
|
||||
const cachedT = timestamps[uniqueIdx];
|
||||
const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
|
||||
+10
-9
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { formatTime } from "../../player/lib/time";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
interface EditPopoverProps {
|
||||
rangeStart: number;
|
||||
@@ -27,19 +28,19 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
||||
});
|
||||
}, [elements, start, end]);
|
||||
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, []);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
@@ -47,7 +48,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
||||
};
|
||||
setTimeout(() => window.addEventListener("mousedown", handleClick), 100);
|
||||
return () => window.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
});
|
||||
|
||||
const buildClipboardText = useCallback(() => {
|
||||
const elementLines = elementsInRange
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const NATIVE_W = 1920;
|
||||
const NATIVE_H = 1080;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||
import { useRef, useState, useCallback, memo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
|
||||
@@ -30,7 +31,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const updateProgress = (t: number) => {
|
||||
currentTimeRef.current = t;
|
||||
const dur = durationRef.current;
|
||||
@@ -57,7 +58,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
unsub();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateTicks, formatTick } from "./Timeline";
|
||||
import { generateTicks } from "./Timeline";
|
||||
import { formatTime } from "../lib/time";
|
||||
|
||||
describe("generateTicks", () => {
|
||||
it("returns empty arrays for duration <= 0", () => {
|
||||
@@ -70,40 +71,40 @@ describe("generateTicks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTick", () => {
|
||||
describe("formatTime", () => {
|
||||
it("formats 0 seconds as 0:00", () => {
|
||||
expect(formatTick(0)).toBe("0:00");
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("formats seconds below a minute", () => {
|
||||
expect(formatTick(5)).toBe("0:05");
|
||||
expect(formatTick(30)).toBe("0:30");
|
||||
expect(formatTick(59)).toBe("0:59");
|
||||
expect(formatTime(5)).toBe("0:05");
|
||||
expect(formatTime(30)).toBe("0:30");
|
||||
expect(formatTime(59)).toBe("0:59");
|
||||
});
|
||||
|
||||
it("formats exactly one minute", () => {
|
||||
expect(formatTick(60)).toBe("1:00");
|
||||
expect(formatTime(60)).toBe("1:00");
|
||||
});
|
||||
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(formatTick(90)).toBe("1:30");
|
||||
expect(formatTick(125)).toBe("2:05");
|
||||
expect(formatTime(90)).toBe("1:30");
|
||||
expect(formatTime(125)).toBe("2:05");
|
||||
});
|
||||
|
||||
it("floors fractional seconds", () => {
|
||||
expect(formatTick(5.7)).toBe("0:05");
|
||||
expect(formatTick(59.9)).toBe("0:59");
|
||||
expect(formatTick(90.5)).toBe("1:30");
|
||||
expect(formatTime(5.7)).toBe("0:05");
|
||||
expect(formatTime(59.9)).toBe("0:59");
|
||||
expect(formatTime(90.5)).toBe("1:30");
|
||||
});
|
||||
|
||||
it("handles large values", () => {
|
||||
expect(formatTick(600)).toBe("10:00");
|
||||
expect(formatTick(3661)).toBe("61:01");
|
||||
expect(formatTime(600)).toBe("10:00");
|
||||
expect(formatTime(3661)).toBe("61:01");
|
||||
});
|
||||
|
||||
it("zero-pads seconds to two digits", () => {
|
||||
expect(formatTick(1)).toBe("0:01");
|
||||
expect(formatTick(9)).toBe("0:09");
|
||||
expect(formatTick(61)).toBe("1:01");
|
||||
expect(formatTime(1)).toBe("0:01");
|
||||
expect(formatTime(9)).toBe("0:09");
|
||||
expect(formatTime(61)).toBe("1:01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode, useEffect } from "react";
|
||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { EditPopover } from "../../components/timeline/EditModal";
|
||||
import { EditPopover } from "./EditModal";
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
const GUTTER = 32;
|
||||
@@ -151,11 +152,8 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
export function formatTick(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
/** @deprecated Use formatTime from '../lib/time' instead */
|
||||
export const formatTick = formatTime;
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
@@ -200,17 +198,19 @@ export const Timeline = memo(function Timeline({
|
||||
const isDragging = useRef(false);
|
||||
// Range selection (Shift+drag)
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
||||
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
||||
const blur = () => setShiftHeld(false);
|
||||
window.addEventListener("keydown", down);
|
||||
window.addEventListener("keyup", up);
|
||||
window.addEventListener("blur", () => setShiftHeld(false));
|
||||
window.addEventListener("blur", blur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down);
|
||||
window.removeEventListener("keyup", up);
|
||||
window.removeEventListener("blur", blur);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
const [rangeSelection, setRangeSelection] = useState<{
|
||||
@@ -242,12 +242,9 @@ export const Timeline = memo(function Timeline({
|
||||
}, []);
|
||||
|
||||
// Clean up ResizeObserver on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Effective duration: max of store duration and the furthest element end.
|
||||
// processTimelineMessage updates elements but not duration, so elements can
|
||||
@@ -593,7 +590,7 @@ export const Timeline = memo(function Timeline({
|
||||
style={{ left: t * pps }}
|
||||
>
|
||||
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
|
||||
{formatTick(t)}
|
||||
{formatTime(t)}
|
||||
</span>
|
||||
<div className="w-px h-[5px] bg-neutral-600/60" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface VideoThumbnailProps {
|
||||
videoSrc: string;
|
||||
@@ -55,15 +56,15 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
useMountEffect(() => () => {
|
||||
ioRef.current?.disconnect();
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Extract frames progressively — each frame appears as soon as it's ready
|
||||
// Extract frames progressively — each frame appears as soon as it's ready.
|
||||
// Note: useEffect with deps is acceptable — syncs with external video element API,
|
||||
// requires cleanup (cancel extraction, revoke URLs) when inputs change.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!visible || extractingRef.current) return;
|
||||
extractingRef.current = true;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
||||
import { useMountEffect } from "../lib/useMountEffect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface PlayerAPI {
|
||||
interface PlaybackAdapter {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
@@ -41,32 +41,12 @@ interface ClipManifest {
|
||||
}
|
||||
|
||||
type IframeWindow = Window & {
|
||||
__player?: PlayerAPI;
|
||||
__player?: PlaybackAdapter;
|
||||
__timeline?: TimelineLike;
|
||||
__timelines?: Record<string, TimelineLike>;
|
||||
__clipManifest?: ClipManifest;
|
||||
};
|
||||
|
||||
interface PlaybackAdapter {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
}
|
||||
|
||||
function wrapPlayer(p: PlayerAPI): PlaybackAdapter {
|
||||
return {
|
||||
play: () => p.play(),
|
||||
pause: () => p.pause(),
|
||||
seek: (t) => p.seek(t),
|
||||
getTime: () => p.getTime(),
|
||||
getDuration: () => p.getDuration(),
|
||||
isPlaying: () => p.isPlaying(),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
||||
return {
|
||||
play: () => tl.play(),
|
||||
@@ -224,7 +204,7 @@ export function useTimelinePlayer() {
|
||||
if (!win) return null;
|
||||
|
||||
if (win.__player && typeof win.__player.play === "function") {
|
||||
return wrapPlayer(win.__player);
|
||||
return win.__player;
|
||||
}
|
||||
|
||||
if (win.__timeline) return wrapTimeline(win.__timeline);
|
||||
@@ -366,7 +346,10 @@ export function useTimelinePlayer() {
|
||||
const iframeDoc = iframeRef.current?.contentDocument;
|
||||
hostEl =
|
||||
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? null;
|
||||
resolvedSrc = hostEl?.getAttribute("data-composition-file") ?? null;
|
||||
resolvedSrc =
|
||||
hostEl?.getAttribute("data-composition-src") ??
|
||||
hostEl?.getAttribute("data-composition-file") ??
|
||||
null;
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
@@ -392,7 +375,7 @@ export function useTimelinePlayer() {
|
||||
// Clamp non-finite or absurdly large durations — the runtime can emit
|
||||
// Infinity when it detects a loop-inflated GSAP timeline without an
|
||||
// explicit data-duration on the root composition.
|
||||
const newDuration = Number.isFinite(rawDuration) ? rawDuration : 0;
|
||||
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
||||
if (currentElements.length > els.length && newDuration <= currentDuration) {
|
||||
return; // skip transient downgrade
|
||||
}
|
||||
@@ -509,8 +492,29 @@ export function useTimelinePlayer() {
|
||||
missing.push(entry);
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
setElements([...currentEls, ...missing]);
|
||||
// Patch existing elements that are missing compositionSrc
|
||||
let patched = false;
|
||||
const updatedEls = currentEls.map((existing) => {
|
||||
if (existing.compositionSrc) return existing;
|
||||
// Find the matching DOM host by element id or composition id
|
||||
const host =
|
||||
doc.getElementById(existing.id) ??
|
||||
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
||||
if (!host) return existing;
|
||||
const compSrc =
|
||||
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
||||
if (compSrc) {
|
||||
patched = true;
|
||||
return { ...existing, compositionSrc: compSrc };
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
if (missing.length > 0 || patched) {
|
||||
// Dedup: ensure no missing element duplicates an existing one
|
||||
const finalIds = new Set(updatedEls.map((e) => e.id));
|
||||
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
||||
setElements([...updatedEls, ...dedupedMissing]);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -539,7 +543,9 @@ export function useTimelinePlayer() {
|
||||
|
||||
adapter.seek(startTime);
|
||||
const adapterDur = adapter.getDuration();
|
||||
if (Number.isFinite(adapterDur) && adapterDur > 0) setDuration(adapterDur);
|
||||
// Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
|
||||
if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
|
||||
setDuration(adapterDur);
|
||||
setCurrentTime(startTime);
|
||||
if (!isRefreshingRef.current) {
|
||||
setTimelineReady(true);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Run an effect exactly once on mount (and optional cleanup on unmount).
|
||||
* This is the ONLY sanctioned way to call useEffect in this package.
|
||||
*/
|
||||
export function useMountEffect(effect: () => void | (() => void)) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(effect, []);
|
||||
}
|
||||
+228
-105
@@ -47,6 +47,9 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
||||
return _browserLaunchPromise;
|
||||
}
|
||||
|
||||
// In-flight thumbnail dedup — prevents parallel Puppeteer pages for the same URL
|
||||
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
|
||||
|
||||
// Render job store with TTL cleanup (fixes globalThis memory leak)
|
||||
const renderJobs = new Map<
|
||||
string,
|
||||
@@ -109,7 +112,26 @@ function devProjectApi(): Plugin {
|
||||
res.end(JSON.stringify({ error: "Project not found" }));
|
||||
return;
|
||||
}
|
||||
const jobId = `${pid}-${Date.now()}`;
|
||||
// Parse request body for format option
|
||||
let bodyStr = "";
|
||||
for await (const chunk of req) bodyStr += chunk;
|
||||
let reqBody: { format?: string; fps?: number; quality?: string } = {};
|
||||
try {
|
||||
reqBody = bodyStr ? JSON.parse(bodyStr) : {};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const format = reqBody.format === "webm" ? "webm" : "mp4";
|
||||
const fps = [24, 30, 60].includes(reqBody.fps ?? 0) ? reqBody.fps! : 30;
|
||||
const quality = ["draft", "standard", "high"].includes(reqBody.quality ?? "")
|
||||
? reqBody.quality!
|
||||
: "standard";
|
||||
|
||||
// Human-readable render name: project-name_2026-03-27_14-30-45
|
||||
const now = new Date();
|
||||
const datePart = now.toISOString().slice(0, 10);
|
||||
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
||||
const jobId = `${pid}_${datePart}_${timePart}`;
|
||||
const outputDir = resolve(dataDir, "../renders");
|
||||
if (!existsSync(outputDir)) {
|
||||
const { mkdirSync: mk } = await import("fs");
|
||||
@@ -124,7 +146,13 @@ function devProjectApi(): Plugin {
|
||||
fetch(`${PRODUCER_URL}/render/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectDir: pDir, outputPath, fps: 30, quality: "standard" }),
|
||||
body: JSON.stringify({
|
||||
projectDir: pDir,
|
||||
outputPath,
|
||||
fps,
|
||||
quality,
|
||||
format,
|
||||
}),
|
||||
})
|
||||
.then(async (resp) => {
|
||||
if (!resp.ok || !resp.body) {
|
||||
@@ -151,6 +179,9 @@ function devProjectApi(): Plugin {
|
||||
const evt = JSON.parse(data);
|
||||
if (evt.type === "progress") {
|
||||
_jobState.progress = evt.progress;
|
||||
if (evt.stage || evt.message) {
|
||||
(_jobState as Record<string, unknown>).stage = evt.stage || evt.message;
|
||||
}
|
||||
}
|
||||
if (evt.type === "complete") {
|
||||
_jobState.status = "complete";
|
||||
@@ -193,7 +224,7 @@ function devProjectApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
res.write(
|
||||
`event: progress\ndata: ${JSON.stringify({ status: state.status, progress: state.progress })}\n\n`,
|
||||
`event: progress\ndata: ${JSON.stringify({ status: state.status, progress: state.progress, stage: (state as Record<string, unknown>).stage })}\n\n`,
|
||||
);
|
||||
if (state.status === "complete" || state.status === "failed") {
|
||||
clearInterval(interval);
|
||||
@@ -225,16 +256,65 @@ function devProjectApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
const fileStat = statSync(jobState.outputPath);
|
||||
const isWebm = jobState.outputPath.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
const ext = isWebm ? ".webm" : ".mp4";
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(fileStat.size),
|
||||
"Content-Disposition": `attachment; filename="${jobId}.mp4"`,
|
||||
"Content-Disposition": `attachment; filename="${jobId}${ext}"`,
|
||||
});
|
||||
const stream = createReadStream(jobState.outputPath);
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/projects/:id/renders — list render outputs for a project
|
||||
const rendersMatch =
|
||||
req.method === "GET" && req.url?.match(/^\/api\/projects\/([^/]+)\/renders/);
|
||||
if (rendersMatch) {
|
||||
const rendersDir = resolve(dataDir, "../renders");
|
||||
if (!existsSync(rendersDir)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ renders: [] }));
|
||||
return;
|
||||
}
|
||||
const files = readdirSync(rendersDir)
|
||||
.filter((f: string) => f.endsWith(".mp4") || f.endsWith(".webm"))
|
||||
.map((f: string) => {
|
||||
const fp = join(rendersDir, f);
|
||||
const stat = statSync(fp);
|
||||
return {
|
||||
id: f.replace(/\.(mp4|webm)$/, ""),
|
||||
filename: f,
|
||||
size: stat.size,
|
||||
createdAt: stat.mtimeMs,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a: { createdAt: number }, b: { createdAt: number }) => b.createdAt - a.createdAt,
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ renders: files }));
|
||||
return;
|
||||
}
|
||||
|
||||
// DELETE /api/render/:jobId — delete a render output
|
||||
const deleteRenderMatch =
|
||||
req.method === "DELETE" && req.url?.match(/^\/api\/render\/([^/]+)$/);
|
||||
if (deleteRenderMatch) {
|
||||
const jobId = deleteRenderMatch[1];
|
||||
const rendersDir = resolve(dataDir, "../renders");
|
||||
for (const ext of [".mp4", ".webm"]) {
|
||||
const fp = join(rendersDir, `${jobId}${ext}`);
|
||||
if (existsSync(fp)) unlinkSync(fp);
|
||||
}
|
||||
renderJobs.delete(jobId);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ deleted: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/projects — list all projects with session metadata
|
||||
if (req.method === "GET" && (req.url === "/api/projects" || req.url === "/api/projects/")) {
|
||||
// Build session → project mapping for titles
|
||||
@@ -323,6 +403,57 @@ function devProjectApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/projects/:id/lint — run the core linter on project HTML files
|
||||
if (req.method === "GET" && rest === "/lint") {
|
||||
try {
|
||||
const lintMod = await server.ssrLoadModule("@hyperframes/core/lint");
|
||||
const lintFn = lintMod.lintHyperframeHtml as (
|
||||
html: string,
|
||||
opts?: { filePath?: string },
|
||||
) => {
|
||||
findings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}>;
|
||||
};
|
||||
const htmlFiles: string[] = [];
|
||||
const IGNORE = new Set([".thumbnails", "node_modules", ".git"]);
|
||||
function walkLint(d: string, prefix: string) {
|
||||
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
||||
if (IGNORE.has(entry.name)) continue;
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) walkLint(join(d, entry.name), rel);
|
||||
else if (entry.name.endsWith(".html")) htmlFiles.push(rel);
|
||||
}
|
||||
}
|
||||
walkLint(projectDir, "");
|
||||
const allFindings: Array<{
|
||||
severity: string;
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}> = [];
|
||||
for (const file of htmlFiles) {
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
const result = lintFn(content, { filePath: file });
|
||||
if (result?.findings) {
|
||||
for (const f of result.findings) {
|
||||
allFindings.push({ ...f, file });
|
||||
}
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ findings: allFindings }));
|
||||
} catch (err) {
|
||||
console.warn("[Studio] Lint failed:", err);
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Lint failed" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/projects/:id
|
||||
if (req.method === "GET" && !rest) {
|
||||
const files: string[] = [];
|
||||
@@ -400,63 +531,46 @@ function devProjectApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
let rawComp = readFileSync(compFile, "utf-8");
|
||||
const rawComp = readFileSync(compFile, "utf-8");
|
||||
|
||||
// Extract content from <template> if present
|
||||
const templateMatch = rawComp.match(/<template>([\s\S]*)<\/template>/i);
|
||||
let content = templateMatch ? templateMatch[1] : rawComp;
|
||||
// Extract content from <template> wrapper (compositions are always templates)
|
||||
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
const content = templateMatch ? templateMatch[1] : rawComp;
|
||||
|
||||
// Inline nested data-composition-src references (keep the attr for drill-down navigation)
|
||||
content = content.replace(
|
||||
/(<[^>]*?)(data-composition-src=["']([^"']+)["'])([^>]*>)/g,
|
||||
(_match, before, srcAttr, src, after) => {
|
||||
const nestedFile = join(projectDir, src);
|
||||
if (!existsSync(nestedFile)) return before + srcAttr + after;
|
||||
const nestedRaw = readFileSync(nestedFile, "utf-8");
|
||||
const nestedTemplate = nestedRaw.match(/<template>([\s\S]*)<\/template>/i);
|
||||
const nestedContent = nestedTemplate ? nestedTemplate[1] : nestedRaw;
|
||||
// Extract styles, scripts, and body from nested content
|
||||
const styles: string[] = [];
|
||||
const scripts: string[] = [];
|
||||
let body = nestedContent
|
||||
.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
|
||||
styles.push(css);
|
||||
return "";
|
||||
})
|
||||
.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, (_, js) => {
|
||||
scripts.push(js);
|
||||
return "";
|
||||
});
|
||||
// Find the inner root with data-composition-id and use its children
|
||||
const innerRootMatch = body.match(
|
||||
/<([a-z][a-z0-9]*)\b[^>]*data-composition-id[^>]*>([\s\S]*)<\/\1>/i,
|
||||
);
|
||||
const innerHTML = innerRootMatch ? innerRootMatch[2] : body;
|
||||
// Keep data-composition-src on the host for drill-down URL resolution
|
||||
return (
|
||||
before +
|
||||
srcAttr +
|
||||
after.replace(/>$/, ">") +
|
||||
innerHTML +
|
||||
(styles.length ? `<style>${styles.join("\n")}</style>` : "") +
|
||||
(scripts.length
|
||||
? `<script>${scripts.map((s) => `(function(){try{${s}}catch(e){}})();`).join("\n")}</script>`
|
||||
: "")
|
||||
);
|
||||
},
|
||||
);
|
||||
// Use the project's index.html <head> to preserve all dependencies
|
||||
// (GSAP, fonts, Lottie, reset styles, runtime) instead of building from scratch
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
let headContent = "";
|
||||
if (existsSync(indexPath)) {
|
||||
const indexHtml = readFileSync(indexPath, "utf-8");
|
||||
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
||||
headContent = headMatch ? headMatch[1] : "";
|
||||
}
|
||||
|
||||
// Build a standalone HTML page with GSAP + runtime
|
||||
// Inject <base> for relative asset resolution
|
||||
const baseHref = `/api/projects/${projectId}/preview/`;
|
||||
if (!headContent.includes("<base")) {
|
||||
headContent = `<base href="${baseHref}">\n${headContent}`;
|
||||
}
|
||||
|
||||
// Ensure runtime is present
|
||||
const runtimeUrl =
|
||||
(process.env.HYPERFRAME_RUNTIME_URL || "").trim() ||
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
if (
|
||||
!headContent.includes("hyperframe.runtime") &&
|
||||
!headContent.includes("hyperframes-preview-runtime")
|
||||
) {
|
||||
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
|
||||
}
|
||||
|
||||
const standalone = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<script>window.__timelines=window.__timelines||{};</script>
|
||||
${content}
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -498,62 +612,71 @@ ${content}
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getSharedBrowser();
|
||||
if (!browser) {
|
||||
res.writeHead(501, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Chrome not found for thumbnails" }));
|
||||
return;
|
||||
}
|
||||
// Detect composition dimensions from the HTML file
|
||||
let compW = vpWidth || 1920;
|
||||
let compH = vpHeight || 1080;
|
||||
if (!vpWidth) {
|
||||
const htmlFile = join(projectDir, compPath);
|
||||
if (existsSync(htmlFile)) {
|
||||
const html = readFileSync(htmlFile, "utf-8");
|
||||
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
||||
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
||||
if (wMatch) compW = parseInt(wMatch[1]);
|
||||
if (hMatch) compH = parseInt(hMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: compW, height: compH, deviceScaleFactor: 0.5 });
|
||||
await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
|
||||
// Wait for GSAP + seek
|
||||
await page
|
||||
.waitForFunction(
|
||||
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page.evaluate((t: number) => {
|
||||
const w = window as Window & {
|
||||
__timelines?: Record<string, { seek: (t: number) => void; pause: () => void }>;
|
||||
};
|
||||
if (w.__timelines) {
|
||||
const tl = Object.values(w.__timelines)[0];
|
||||
if (tl) {
|
||||
tl.seek(t);
|
||||
tl.pause();
|
||||
// Dedup in-flight requests — multiple frames from the same comp+time
|
||||
// share a single Puppeteer page instead of spawning parallel ones.
|
||||
let bufferPromise = _thumbnailInflight.get(cacheKey);
|
||||
if (!bufferPromise) {
|
||||
bufferPromise = (async () => {
|
||||
const browser = await getSharedBrowser();
|
||||
if (!browser) throw new Error("Chrome not found");
|
||||
let compW = vpWidth || 1920;
|
||||
let compH = vpHeight || 1080;
|
||||
if (!vpWidth) {
|
||||
const htmlFile = join(projectDir, compPath);
|
||||
if (existsSync(htmlFile)) {
|
||||
const html = readFileSync(htmlFile, "utf-8");
|
||||
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
||||
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
||||
if (wMatch) compW = parseInt(wMatch[1]);
|
||||
if (hMatch) compH = parseInt(hMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, seekTime);
|
||||
await page.evaluate("document.fonts?.ready");
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const buffer = await page.screenshot({ type: "jpeg", quality: 75 });
|
||||
await page.close();
|
||||
|
||||
// Cache
|
||||
if (!existsSync(cacheDir)) {
|
||||
const { mkdirSync } = await import("fs");
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: compW, height: compH, deviceScaleFactor: 0.5 });
|
||||
await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
// Dark background prevents white edges bleeding through in thumbnails
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.background = "#000";
|
||||
document.body.style.background = "#000";
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.overflow = "hidden";
|
||||
});
|
||||
await page
|
||||
.waitForFunction(
|
||||
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page.evaluate((t: number) => {
|
||||
const w = window as Window & {
|
||||
__timelines?: Record<
|
||||
string,
|
||||
{ seek: (t: number) => void; pause: (t?: number) => void }
|
||||
>;
|
||||
gsap?: { ticker: { tick: () => void } };
|
||||
};
|
||||
if (w.__timelines) {
|
||||
for (const tl of Object.values(w.__timelines)) {
|
||||
if (tl) tl.pause(t);
|
||||
}
|
||||
if (w.gsap?.ticker) w.gsap.ticker.tick();
|
||||
}
|
||||
}, seekTime);
|
||||
await page.evaluate("document.fonts?.ready");
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const buf = await page.screenshot({ type: "jpeg", quality: 75 });
|
||||
await page.close();
|
||||
if (!existsSync(cacheDir)) {
|
||||
const { mkdirSync } = await import("fs");
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(cachePath, buf);
|
||||
return buf as Buffer;
|
||||
})();
|
||||
_thumbnailInflight.set(cacheKey, bufferPromise);
|
||||
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
|
||||
}
|
||||
writeFileSync(cachePath, buffer);
|
||||
|
||||
const buffer = await bufferPromise;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/jpeg",
|
||||
"Cache-Control": "public, max-age=60",
|
||||
|
||||
Reference in New Issue
Block a user