mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
refactor(studio): code quality — 22 findings, dead code removal, App.tsx split (#144)
## Summary Full code quality review of the studio package, fixing 22 of 25 findings. Removes dead code, extracts modules from App.tsx, fixes accessibility and performance issues. ## Critical fixes (3) - **`aria-valuenow`** on seek bar now updates imperatively via `liveTime.subscribe` — screen readers previously always reported position 0 - **Speed menu** closes on outside click (was permanently stuck open) - **RenderQueue auto-scroll** moved from render phase to `useEffect` (was violating React render purity via `queueMicrotask` during render) ## Dead code removed (-331 lines) | File | Lines | Why dead | |---|---|---| | `PreviewPanel.tsx` | 180 | Replaced by NLELayout + NLEPreview | | `useCodeEditor.ts` | 80 | Exported but never imported | | `formatTick` alias | 2 | Deprecated, unused | | `onClipChange` prop | 5 | Declared, never used | | `trackH` prop | 5 | Declared, never used | | `editRange*` + updaters in store | 60 | Never read or written | ## App.tsx extraction | Extracted to | Lines | What | |---|---|---| | `components/LintModal.tsx` | 130 | Lint results modal + LintFinding type | | `components/MediaPreview.tsx` | 75 | Image/video/audio/font file previewer | | `utils/mediaTypes.ts` | 15 | Shared regex constants (App.tsx and AssetsTab.tsx had diverged copies) | ## Performance fixes - `useMemo` for `compositions`/`assets` derivation from `fileTree` - `useMemo` for `buildTree(files)` in FileTree - Debounced `handleContentChange` PUT (600ms — was firing on every keystroke) - CompositionsTab iframe hover debounced (300ms — was mounting immediately) - `VideoFrameThumbnail` re-extracts frame when `src` prop changes ## Not addressed (3 — low priority) - #6: SystemIcons consolidation (large refactor across many files) - #16-17: Overlay dismiss pattern standardization - #18: Inline SVG → Phosphor replacement (gradual, per-PR) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -19,12 +19,18 @@ pnpm build
|
||||
`pnpm link --global` makes the `hyperframes` binary in your `$PATH` point at your local build. It survives across terminal sessions and auto-picks up new builds without re-linking.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
# If you previously installed hyperframes globally, remove it first —
|
||||
# a global install takes priority over pnpm link and shadows your local build.
|
||||
pnpm remove -g hyperframes 2>/dev/null || npm uninstall -g hyperframes 2>/dev/null
|
||||
|
||||
# Link your local build
|
||||
cd packages/cli
|
||||
pnpm link --global
|
||||
|
||||
# Verify — should print your local version
|
||||
# Verify — should print your local version AND point to the monorepo
|
||||
hyperframes --version
|
||||
which hyperframes
|
||||
# The path should contain your monorepo, NOT pnpm/global/.pnpm/hyperframes@...
|
||||
```
|
||||
|
||||
Now use `hyperframes` normally in any directory:
|
||||
@@ -103,19 +109,21 @@ Common test scenarios:
|
||||
|
||||
The CLI binary is a single bundled file at `packages/cli/dist/cli.js`. If your change is in `@hyperframes/core` or another workspace package, make sure `pnpm build` rebuilt _all_ packages — the CLI bundles its dependencies at build time.
|
||||
|
||||
**`hyperframes` still shows the old version**
|
||||
**`hyperframes` still shows the old version / old UI**
|
||||
|
||||
Check which binary is active:
|
||||
A globally installed `hyperframes` package shadows `pnpm link`. Check which binary is active:
|
||||
|
||||
```bash
|
||||
which hyperframes
|
||||
hyperframes --version
|
||||
# BAD: /Users/you/Library/pnpm/hyperframes → pnpm/global/.pnpm/hyperframes@0.x.x/...
|
||||
# GOOD: /Users/you/Library/pnpm/hyperframes → your-monorepo/packages/cli/dist/cli.js
|
||||
```
|
||||
|
||||
If it points to a global npm installation rather than your link, uninstall the npm version first:
|
||||
If it points to the global store, remove the global install and re-link:
|
||||
|
||||
```bash
|
||||
npm uninstall -g hyperframes
|
||||
pnpm remove -g hyperframes
|
||||
npm uninstall -g hyperframes # in case it was installed via npm
|
||||
cd packages/cli && pnpm link --global
|
||||
```
|
||||
|
||||
|
||||
@@ -183,6 +183,28 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// Serve render file directly from disk (no in-memory map dependency)
|
||||
api.get("/projects/:id/renders/file/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filename = c.req.path.split("/renders/file/")[1];
|
||||
if (!filename) return c.json({ error: "missing filename" }, 400);
|
||||
const rendersDir = adapter.rendersDir(project);
|
||||
const fp = join(rendersDir, filename);
|
||||
if (!existsSync(fp)) return c.json({ error: "not found" }, 404);
|
||||
const isWebm = fp.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
const content = readFileSync(fp);
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(content.length),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// List renders
|
||||
api.get("/projects/:id/renders", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
|
||||
+41
-259
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "./hooks/useMountEffect";
|
||||
import { NLELayout } from "./components/nle/NLELayout";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
@@ -8,245 +8,16 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||
import { CompositionThumbnail, VideoThumbnail } from "./player";
|
||||
import { AudioWaveform } from "./player/components/AudioWaveform";
|
||||
import type { TimelineElement } from "./player";
|
||||
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { LintModal } from "./components/LintModal";
|
||||
import type { LintFinding } from "./components/LintModal";
|
||||
import { MediaPreview } from "./components/MediaPreview";
|
||||
import { isMediaFile } from "./utils/mediaTypes";
|
||||
|
||||
interface EditingFile {
|
||||
path: string;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
interface LintFinding {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}
|
||||
|
||||
// ── Media file detection and preview ──
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||
const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
|
||||
|
||||
function isMediaFile(path: string): boolean {
|
||||
return (
|
||||
IMAGE_EXT.test(path) || VIDEO_EXT.test(path) || AUDIO_EXT.test(path) || FONT_EXT.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
|
||||
const name = filePath.split("/").pop() ?? filePath;
|
||||
|
||||
if (IMAGE_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (VIDEO_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
|
||||
<video
|
||||
src={serveUrl}
|
||||
controls
|
||||
className="max-w-full max-h-[70%] rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (AUDIO_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-3">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<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 className="w-full max-w-[280px]" />
|
||||
<span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fonts and other binary — show info instead of binary dump
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
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>
|
||||
<span className="text-sm text-neutral-400 font-medium">{name}</span>
|
||||
<span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
|
||||
<span className="text-[10px] text-neutral-600">Binary file — preview not available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lint Modal ──
|
||||
|
||||
function LintModal({
|
||||
findings,
|
||||
projectId,
|
||||
onClose,
|
||||
}: {
|
||||
findings: LintFinding[];
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const errors = findings.filter((f) => f.severity === "error");
|
||||
const warnings = findings.filter((f) => f.severity === "warning");
|
||||
const hasIssues = findings.length > 0;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyToAgent = async () => {
|
||||
const lines = findings.map((f) => {
|
||||
let line = `[${f.severity}] ${f.message}`;
|
||||
if (f.file) line += `\n File: ${f.file}`;
|
||||
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
|
||||
return line;
|
||||
});
|
||||
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasIssues ? (
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<WarningIcon size={18} className="text-red-400" weight="fill" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
|
||||
<CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-neutral-200">
|
||||
{hasIssues
|
||||
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
|
||||
: "All checks passed"}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy to agent + findings */}
|
||||
{hasIssues && (
|
||||
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
|
||||
<button
|
||||
onClick={handleCopyToAgent}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
copied ? "bg-green-600 text-white" : "bg-[#3CE6AC] hover:bg-[#3CE6AC]/80 text-white"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{!hasIssues && (
|
||||
<div className="py-8 text-center text-neutral-500 text-sm">
|
||||
No errors or warnings found. Your composition looks good!
|
||||
</div>
|
||||
)}
|
||||
{errors.map((f, i) => (
|
||||
<div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningIcon
|
||||
size={14}
|
||||
className="text-red-400 flex-shrink-0 mt-0.5"
|
||||
weight="fill"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-neutral-200">{f.message}</p>
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{warnings.map((f, i) => (
|
||||
<div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-neutral-300">{f.message}</p>
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main App ──
|
||||
|
||||
export function StudioApp() {
|
||||
@@ -389,6 +160,7 @@ export function StudioApp() {
|
||||
const [linting, setLinting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const projectIdRef = useRef(projectId);
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
@@ -454,20 +226,24 @@ export function StudioApp() {
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const path = editingPathRef.current;
|
||||
if (!pid || !path) return;
|
||||
// Don't update editingFile state — the editor manages its own content.
|
||||
// Only save to disk and refresh the preview.
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
})
|
||||
.then(() => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
||||
if (!path) return;
|
||||
|
||||
// Debounce the server write (600ms)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
})
|
||||
.catch(() => {});
|
||||
.then(() => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const handleLint = useCallback(async () => {
|
||||
@@ -528,21 +304,26 @@ export function StudioApp() {
|
||||
panelDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const compositions = useMemo(
|
||||
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
||||
[fileTree],
|
||||
);
|
||||
const assets = useMemo(
|
||||
() =>
|
||||
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
|
||||
[fileTree],
|
||||
);
|
||||
|
||||
if (resolving || !projectId) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
|
||||
<div className="w-4 h-4 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// At this point projectId is guaranteed non-null (narrowed by the guard above)
|
||||
|
||||
const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/"));
|
||||
const assets = fileTree.filter(
|
||||
(f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-neutral-950">
|
||||
{/* Header bar */}
|
||||
@@ -557,7 +338,7 @@ export function StudioApp() {
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
!leftCollapsed
|
||||
? "bg-neutral-800 border-neutral-700 text-neutral-300"
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
|
||||
@@ -580,7 +361,7 @@ export function StudioApp() {
|
||||
onClick={() => setTimelineVisible((v) => !v)}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
timelineVisible
|
||||
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
||||
@@ -603,7 +384,7 @@ export function StudioApp() {
|
||||
onClick={() => setRightCollapsed((v) => !v)}
|
||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
||||
!rightCollapsed
|
||||
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
||||
}`}
|
||||
>
|
||||
@@ -655,7 +436,7 @@ export function StudioApp() {
|
||||
codeChildren={
|
||||
editingFile ? (
|
||||
isMediaFile(editingFile.path) ? (
|
||||
<MediaPreview projectId={projectId} filePath={editingFile.path} />
|
||||
<MediaPreview projectId={projectId ?? ""} filePath={editingFile.path} />
|
||||
) : (
|
||||
<SourceEditor
|
||||
content={editingFile.content ?? ""}
|
||||
@@ -673,7 +454,7 @@ export function StudioApp() {
|
||||
{/* Left resize handle */}
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
|
||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
||||
onPointerMove={handlePanelResizeMove}
|
||||
@@ -706,7 +487,7 @@ export function StudioApp() {
|
||||
{!rightCollapsed && (
|
||||
<>
|
||||
<div
|
||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
|
||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
||||
onPointerMove={handlePanelResizeMove}
|
||||
@@ -718,6 +499,7 @@ export function StudioApp() {
|
||||
>
|
||||
<RenderQueue
|
||||
jobs={renderQueue.jobs}
|
||||
projectId={projectId}
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
|
||||
@@ -729,7 +511,7 @@ export function StudioApp() {
|
||||
</div>
|
||||
|
||||
{/* Lint modal */}
|
||||
{lintModal !== null && (
|
||||
{lintModal !== null && projectId && (
|
||||
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from "react";
|
||||
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
|
||||
|
||||
export interface LintFinding {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
file?: string;
|
||||
fixHint?: string;
|
||||
}
|
||||
|
||||
export function LintModal({
|
||||
findings,
|
||||
projectId,
|
||||
onClose,
|
||||
}: {
|
||||
findings: LintFinding[];
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const errors = findings.filter((f) => f.severity === "error");
|
||||
const warnings = findings.filter((f) => f.severity === "warning");
|
||||
const hasIssues = findings.length > 0;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyToAgent = async () => {
|
||||
const lines = findings.map((f) => {
|
||||
let line = `[${f.severity}] ${f.message}`;
|
||||
if (f.file) line += `\n File: ${f.file}`;
|
||||
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
|
||||
return line;
|
||||
});
|
||||
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasIssues ? (
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<WarningIcon size={18} className="text-red-400" weight="fill" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-studio-accent/10 flex items-center justify-center">
|
||||
<CheckCircleIcon size={18} className="text-studio-accent" weight="fill" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-neutral-200">
|
||||
{hasIssues
|
||||
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
|
||||
: "All checks passed"}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Copy to agent + findings */}
|
||||
{hasIssues && (
|
||||
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
|
||||
<button
|
||||
onClick={handleCopyToAgent}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
copied
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-studio-accent hover:bg-studio-accent/80 text-white"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{!hasIssues && (
|
||||
<div className="py-8 text-center text-neutral-500 text-sm">
|
||||
No errors or warnings found. Your composition looks good!
|
||||
</div>
|
||||
)}
|
||||
{errors.map((f, i) => (
|
||||
<div key={`e-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningIcon
|
||||
size={14}
|
||||
className="text-red-400 flex-shrink-0 mt-0.5"
|
||||
weight="fill"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-neutral-200">{f.message}</p>
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon
|
||||
size={10}
|
||||
className="text-studio-accent flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p className="text-xs text-studio-accent">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{warnings.map((f, i) => (
|
||||
<div key={`w-${i}`} className="py-3 border-b border-neutral-800/50 last:border-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<WarningIcon size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-neutral-300">{f.message}</p>
|
||||
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
|
||||
{f.fixHint && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<CaretRightIcon
|
||||
size={10}
|
||||
className="text-studio-accent flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p className="text-xs text-studio-accent">{f.fixHint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
|
||||
|
||||
export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
|
||||
const name = filePath.split("/").pop() ?? filePath;
|
||||
|
||||
if (IMAGE_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (VIDEO_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
|
||||
<video
|
||||
src={serveUrl}
|
||||
controls
|
||||
className="max-w-full max-h-[70%] rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (AUDIO_EXT.test(filePath)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-3">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<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 className="w-full max-w-[280px]" />
|
||||
<span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fonts and other binary — show info instead of binary dump
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
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>
|
||||
<span className="text-sm text-neutral-400 font-medium">{name}</span>
|
||||
<span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
|
||||
<span className="text-[10px] text-neutral-600">Binary file — preview not available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { memo, useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
FileHtml,
|
||||
FileCss,
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
FileTs,
|
||||
FileTsx,
|
||||
FileTxt,
|
||||
FileMd,
|
||||
FileSvg,
|
||||
FilePng,
|
||||
FileJpg,
|
||||
FileVideo,
|
||||
FileCode,
|
||||
File,
|
||||
FilmStrip,
|
||||
MusicNote,
|
||||
Waveform,
|
||||
TextAa,
|
||||
Image as PhImage,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
|
||||
@@ -22,27 +27,36 @@ interface FileTreeProps {
|
||||
}
|
||||
|
||||
const SZ = 14;
|
||||
const W = "duotone" as const;
|
||||
|
||||
function FileIcon({ path }: { path: string }) {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const d = { size: SZ, weight: "duotone" as const, className: "flex-shrink-0" };
|
||||
if (ext === "html") return <FileHtml {...d} color="#E44D26" />;
|
||||
if (ext === "css") return <FileCss {...d} color="#264DE4" />;
|
||||
if (ext === "js" || ext === "mjs" || ext === "cjs") return <FileJs {...d} color="#F0DB4F" />;
|
||||
if (ext === "jsx") return <FileJsx {...d} color="#61DAFB" />;
|
||||
if (ext === "ts" || ext === "mts") return <FileTs {...d} color="#3178C6" />;
|
||||
if (ext === "tsx") return <FileTsx {...d} color="#3178C6" />;
|
||||
if (ext === "txt" || ext === "md" || ext === "mdx") return <FileTxt {...d} color="#9CA3AF" />;
|
||||
if (ext === "json" || ext === "svg") return <FileCode {...d} color="#22C55E" />;
|
||||
if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "m4a")
|
||||
return <MusicNote size={SZ} color="#3CE6AC" className="flex-shrink-0" />;
|
||||
const c = "flex-shrink-0";
|
||||
if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
|
||||
if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
|
||||
if (ext === "js" || ext === "mjs" || ext === "cjs")
|
||||
return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
|
||||
if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
|
||||
if (ext === "ts" || ext === "mts")
|
||||
return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
|
||||
if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
|
||||
if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
|
||||
if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
|
||||
if (ext === "md" || ext === "mdx")
|
||||
return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
||||
if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
|
||||
if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
|
||||
if (ext === "jpg" || ext === "jpeg")
|
||||
return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
|
||||
if (ext === "webp" || ext === "gif" || ext === "ico")
|
||||
return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
|
||||
if (ext === "mp4" || ext === "webm" || ext === "mov")
|
||||
return <FilmStrip size={SZ} color="#A855F7" className="flex-shrink-0" />;
|
||||
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp" || ext === "gif")
|
||||
return <PhImage size={SZ} color="#22C55E" className="flex-shrink-0" />;
|
||||
return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
|
||||
return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
|
||||
if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
|
||||
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
|
||||
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
|
||||
return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
|
||||
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
@@ -188,8 +202,8 @@ function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
||||
}
|
||||
|
||||
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
||||
const tree = buildTree(files);
|
||||
const children = sortChildren(tree.children);
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const children = useMemo(() => sortChildren(tree.children), [tree]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -94,7 +94,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={isPickMode ? onDisablePick : onEnablePick}
|
||||
className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
|
||||
className={`mt-3 ${isPickMode ? "bg-studio-accent/20 text-studio-accent border-studio-accent/30" : ""}`}
|
||||
>
|
||||
{isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
|
||||
</Button>
|
||||
@@ -109,7 +109,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-2xs font-mono text-blue-400 truncate">{element.selector}</span>
|
||||
<span className="text-2xs font-mono text-studio-accent truncate">{element.selector}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
@@ -117,7 +117,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
|
||||
size="sm"
|
||||
onClick={isPickMode ? onDisablePick : onEnablePick}
|
||||
className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
|
||||
className={isPickMode ? "text-studio-accent bg-studio-accent/10" : ""}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<X size={11} />}
|
||||
|
||||
@@ -351,7 +351,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
<>
|
||||
{/* Resize divider */}
|
||||
<div
|
||||
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-row-resize transition-colors active:bg-blue-400 z-10"
|
||||
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-row-resize transition-colors active:bg-studio-accent/80 z-10"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
onPointerMove={handleDividerPointerMove}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { memo, useState, useRef } from "react";
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueProps {
|
||||
jobs: RenderJob[];
|
||||
projectId: string;
|
||||
onDelete: (jobId: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onStartRender: (format: "mp4" | "webm") => void;
|
||||
@@ -33,7 +34,7 @@ function FormatExportButton({
|
||||
<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"
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isRendering ? "Rendering..." : "Export"}
|
||||
</button>
|
||||
@@ -43,21 +44,21 @@ function FormatExportButton({
|
||||
|
||||
export const RenderQueue = memo(function RenderQueue({
|
||||
jobs,
|
||||
projectId,
|
||||
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;
|
||||
// Auto-scroll to bottom when new jobs are added.
|
||||
// Runs in an effect to avoid side effects during the render phase.
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [jobs.length]);
|
||||
|
||||
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
||||
|
||||
@@ -111,7 +112,12 @@ export const RenderQueue = memo(function RenderQueue({
|
||||
</div>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
|
||||
<RenderQueueItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
projectId={projectId}
|
||||
onDelete={() => onDelete(job.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
job: RenderJob;
|
||||
projectId: string;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -24,26 +25,30 @@ function formatTimeAgo(timestamp: number): string {
|
||||
|
||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
projectId,
|
||||
onDelete,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
// Direct file URL — serves from disk, survives server restarts
|
||||
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
window.open(`/api/render/${job.id}/view`, "_blank");
|
||||
}, [job.id]);
|
||||
window.open(fileSrc, "_blank");
|
||||
}, [fileSrc]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.href = fileSrc;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
},
|
||||
[job.id, job.filename],
|
||||
[fileSrc, job.filename],
|
||||
);
|
||||
|
||||
const viewSrc = `/api/render/${job.id}/view`;
|
||||
const viewSrc = fileSrc;
|
||||
const isComplete = job.status === "complete";
|
||||
|
||||
return (
|
||||
@@ -85,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
@@ -117,11 +122,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
<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>
|
||||
<span className="text-[9px] font-mono text-studio-accent">{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"
|
||||
className="h-full bg-studio-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -7,11 +8,6 @@ interface AssetsTabProps {
|
||||
onImport?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
|
||||
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;
|
||||
|
||||
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
|
||||
function AssetThumbnail({
|
||||
serveUrl,
|
||||
@@ -104,7 +100,7 @@ function AssetCard({
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
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]"
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
@@ -131,7 +127,7 @@ function AssetCard({
|
||||
<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-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
@@ -168,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-blue-950/20" : ""}`}
|
||||
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-studio-accent/[0.05]" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useRef, useState } from "react";
|
||||
|
||||
interface CompositionsTabProps {
|
||||
projectId: string;
|
||||
@@ -19,6 +19,17 @@ function CompCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleEnter = () => {
|
||||
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
||||
};
|
||||
const handleLeave = () => {
|
||||
if (hoverTimer.current) {
|
||||
clearTimeout(hoverTimer.current);
|
||||
hoverTimer.current = null;
|
||||
}
|
||||
setHovered(false);
|
||||
};
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
@@ -26,11 +37,11 @@ function CompCard({
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onPointerEnter={handleEnter}
|
||||
onPointerLeave={handleLeave}
|
||||
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]"
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -82,7 +82,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
onClick={() => selectTab("code")}
|
||||
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
||||
tab === "code"
|
||||
? "text-neutral-200 border-b-2 border-[#3CE6AC]"
|
||||
? "text-neutral-200 border-b-2 border-studio-accent"
|
||||
: "text-neutral-500 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
onClick={() => selectTab("compositions")}
|
||||
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
||||
tab === "compositions"
|
||||
? "text-neutral-200 border-b-2 border-blue-500"
|
||||
? "text-neutral-200 border-b-2 border-studio-accent"
|
||||
: "text-neutral-500 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
@@ -104,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
onClick={() => selectTab("assets")}
|
||||
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
||||
tab === "assets"
|
||||
? "text-neutral-200 border-b-2 border-blue-500"
|
||||
? "text-neutral-200 border-b-2 border-studio-accent"
|
||||
: "text-neutral-500 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Extracts a representative JPEG frame from a video URL using a hidden
|
||||
@@ -7,12 +7,8 @@ import { useState, useEffect, useRef } from "react";
|
||||
*/
|
||||
export function VideoFrameThumbnail({ src }: { src: string }) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const didExtract = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didExtract.current) return;
|
||||
didExtract.current = true;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export interface OpenFile {
|
||||
path: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export interface UseCodeEditorReturn {
|
||||
openFiles: OpenFile[];
|
||||
activeFilePath: string | null;
|
||||
activeFile: OpenFile | null;
|
||||
openFile: (path: string, content: string) => void;
|
||||
closeFile: (path: string) => void;
|
||||
setActiveFile: (path: string) => void;
|
||||
updateContent: (content: string) => void;
|
||||
markSaved: (path: string) => void;
|
||||
/** External update — updates saved content, shows reload indicator */
|
||||
externalUpdate: (path: string, content: string) => void;
|
||||
}
|
||||
|
||||
export function useCodeEditor(): UseCodeEditorReturn {
|
||||
const [openFiles, setOpenFiles] = useState<OpenFile[]>([]);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
|
||||
const activeFile = openFiles.find((f) => f.path === activeFilePath) ?? null;
|
||||
|
||||
const openFile = useCallback((path: string, content: string) => {
|
||||
setOpenFiles((prev) => {
|
||||
const existing = prev.find((f) => f.path === path);
|
||||
if (existing) return prev;
|
||||
return [...prev, { path, content, savedContent: content, isDirty: false }];
|
||||
});
|
||||
setActiveFilePath(path);
|
||||
}, []);
|
||||
|
||||
const closeFile = useCallback(
|
||||
(path: string) => {
|
||||
setOpenFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
setActiveFilePath((prev) => {
|
||||
if (prev === path) {
|
||||
const remaining = openFiles.filter((f) => f.path !== path);
|
||||
return remaining.length > 0 ? remaining[remaining.length - 1].path : null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[openFiles],
|
||||
);
|
||||
|
||||
const updateContent = useCallback(
|
||||
(content: string) => {
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[activeFilePath],
|
||||
);
|
||||
|
||||
const markSaved = useCallback((path: string) => {
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const externalUpdate = useCallback((path: string, content: string) => {
|
||||
setOpenFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openFiles,
|
||||
activeFilePath,
|
||||
activeFile,
|
||||
openFile,
|
||||
closeFile,
|
||||
setActiveFile: setActiveFilePath,
|
||||
updateContent,
|
||||
markSaved,
|
||||
externalUpdate,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export {
|
||||
Player,
|
||||
PlayerControls,
|
||||
Timeline,
|
||||
PreviewPanel,
|
||||
VideoThumbnail,
|
||||
CompositionThumbnail,
|
||||
useTimelinePlayer,
|
||||
@@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree";
|
||||
export { StudioApp } from "./App";
|
||||
|
||||
// Hooks
|
||||
export { useCodeEditor } from "./hooks/useCodeEditor";
|
||||
export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
|
||||
export { useElementPicker } from "./hooks/useElementPicker";
|
||||
export type { PickedElement } from "./hooks/useElementPicker";
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
/**
|
||||
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
|
||||
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
|
||||
*
|
||||
* Requests multiple thumbnails at different timestamps across the clip duration
|
||||
* and tiles them horizontally — like VideoThumbnail does for video clips.
|
||||
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
|
||||
*
|
||||
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
|
||||
* Takes one screenshot at the midpoint of the clip and covers the full width —
|
||||
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
|
||||
* Puppeteer cost of rendering multiple filmstrip frames.
|
||||
*/
|
||||
|
||||
import { memo, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const MAX_UNIQUE_FRAMES = 6;
|
||||
import { memo } from "react";
|
||||
|
||||
interface CompositionThumbnailProps {
|
||||
previewUrl: string;
|
||||
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
labelColor,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
}: CompositionThumbnailProps) {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
// Walk up to data-clip parent for accurate width
|
||||
let target: HTMLElement = el;
|
||||
let parent = el.parentElement;
|
||||
let depth = 0;
|
||||
while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
|
||||
parent = parent.parentElement;
|
||||
depth++;
|
||||
}
|
||||
if (parent?.hasAttribute("data-clip")) target = parent;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const w = target.clientWidth || target.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
|
||||
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Convert preview URL to thumbnail base URL
|
||||
// Single screenshot at the midpoint of the clip
|
||||
const thumbnailBase = previewUrl
|
||||
.replace("/preview/comp/", "/thumbnail/")
|
||||
.replace(/\/preview$/, "/thumbnail/index.html");
|
||||
|
||||
// Calculate frame layout
|
||||
const aspect = width / height;
|
||||
const frameW = Math.round(CLIP_HEIGHT * aspect);
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
|
||||
|
||||
// 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 pad = Math.min(0.5, duration * 0.05);
|
||||
for (let i = 0; i < uniqueFrames; i++) {
|
||||
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 midTime = seekTime + duration / 2;
|
||||
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
{/* 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-contain"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
<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" }}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
|
||||
@@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
|
||||
<span className="text-[11px] font-medium text-neutral-300">
|
||||
{formatTime(start)} — {formatTime(end)}
|
||||
</span>
|
||||
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
|
||||
{elementsInRange.map((el) => (
|
||||
<div key={el.id} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
|
||||
<span className="text-[10px] font-mono text-studio-accent/80">#{el.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{el.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
}}
|
||||
placeholder="What should change?"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
|
||||
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
|
||||
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
||||
copied
|
||||
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
: "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
|
||||
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
{!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
|
||||
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, memo } from "react";
|
||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
@@ -30,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
const progressThumbRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
const seekBarRef = useRef<HTMLDivElement>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
|
||||
@@ -43,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
||||
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
||||
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
|
||||
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
||||
};
|
||||
const unsub = liveTime.subscribe(updateProgress);
|
||||
updateProgress(usePlayerStore.getState().currentTime);
|
||||
@@ -64,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSpeedMenu) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
speedMenuContainerRef.current &&
|
||||
!speedMenuContainerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowSpeedMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, [showSpeedMenu]);
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const bar = seekBarRef.current;
|
||||
@@ -153,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
{/* Seek bar — teal progress fill */}
|
||||
<div
|
||||
ref={seekBarRef}
|
||||
ref={(el) => {
|
||||
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
@@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
@@ -235,7 +257,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
onClick={onToggleTimeline}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
timelineVisible
|
||||
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { Player } from "./Player";
|
||||
import { PlayerControls } from "./PlayerControls";
|
||||
import { Timeline } from "./Timeline";
|
||||
|
||||
interface RenderStatus {
|
||||
state: "idle" | "rendering" | "complete" | "error";
|
||||
stage?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
onRender?: () => void;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
projectId: string | null;
|
||||
hasProject: boolean;
|
||||
portrait: boolean;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
onIframeLoad: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (t: number) => void;
|
||||
/** Optional render status — pass to show rendering progress/state */
|
||||
renderStatus?: RenderStatus;
|
||||
/** Optional slot for custom content below the timeline */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
projectId,
|
||||
hasProject,
|
||||
portrait,
|
||||
iframeRef,
|
||||
onIframeLoad,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
renderStatus,
|
||||
children,
|
||||
}: PreviewPanelProps) {
|
||||
const renderState = renderStatus?.state ?? "idle";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{hasProject && projectId ? (
|
||||
<>
|
||||
{/* Player — takes all remaining space, constrained for portrait */}
|
||||
<div
|
||||
className="flex items-center justify-center p-2 overflow-hidden"
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
>
|
||||
<Player
|
||||
ref={iframeRef}
|
||||
projectId={projectId}
|
||||
onLoad={onIframeLoad}
|
||||
portrait={portrait}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls — fixed height */}
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
|
||||
<PlayerControls onTogglePlay={onTogglePlay} onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Timeline — capped height, internal scroll */}
|
||||
<div
|
||||
className="bg-neutral-950 flex-shrink-0 overflow-y-auto"
|
||||
style={{ maxHeight: "100px" }}
|
||||
>
|
||||
<Timeline onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Render status — only shown when actively rendering, complete, or error */}
|
||||
{renderStatus &&
|
||||
(renderState === "rendering" ||
|
||||
renderState === "complete" ||
|
||||
renderState === "error") && (
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{renderState === "rendering" && (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
|
||||
style={{ width: `${renderStatus.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">
|
||||
{renderStatus.stage || "Rendering..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "complete" && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>Complete</span>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "error" && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span className="truncate">{renderStatus.error}</span>
|
||||
{renderStatus.onRender && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={renderStatus.onRender}
|
||||
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional custom slot */}
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full min-w-0">
|
||||
<div className="text-center w-full">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Preview will appear here</p>
|
||||
<p className="text-xs text-neutral-700 mt-1">
|
||||
Send a message to generate a video composition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
/** @deprecated Use formatTime from '../lib/time' instead */
|
||||
export const formatTick = formatTime;
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
@@ -170,11 +167,6 @@ interface TimelineProps {
|
||||
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
||||
/** Called when files are dropped onto the empty timeline */
|
||||
onFileDrop?: (files: File[]) => void;
|
||||
/** Called when a clip is moved, resized, or changes track via drag */
|
||||
onClipChange?: (
|
||||
elementId: string,
|
||||
updates: { start?: number; duration?: number; track?: number },
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (e.button !== 0) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
// Shift+click starts range selection
|
||||
// Shift+click starts range selection — even on clips
|
||||
if (e.shiftKey) {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
isRangeSelecting.current = true;
|
||||
setShowPopover(false);
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click on a clip — let the clip handle it
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
isDragging.current = true;
|
||||
setRangeSelection(null);
|
||||
setShowPopover(false);
|
||||
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
|
||||
return (
|
||||
<div
|
||||
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
||||
isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
|
||||
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
|
||||
isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
|
||||
isDragOver
|
||||
? "border-studio-accent/60 bg-studio-accent/[0.06]"
|
||||
: "border-neutral-700/50"
|
||||
}`}
|
||||
>
|
||||
{isDragOver ? (
|
||||
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-400 flex-shrink-0"
|
||||
className="text-studio-accent flex-shrink-0"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="text-[13px] text-blue-400">Drop media files to import</span>
|
||||
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
|
||||
{/* Shift hint */}
|
||||
{shiftHeld && !rangeSelection && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<span className="text-[9px] text-blue-400/60 font-medium">
|
||||
<span className="text-[9px] text-studio-accent/60 font-medium">
|
||||
Drag to select range
|
||||
</span>
|
||||
</div>
|
||||
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
|
||||
key={clipKey}
|
||||
el={el}
|
||||
pps={pps}
|
||||
trackH={TRACK_H}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
pps: number;
|
||||
trackH: number;
|
||||
clipY: number;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
export { Player } from "./components/Player";
|
||||
export { PlayerControls } from "./components/PlayerControls";
|
||||
export { Timeline } from "./components/Timeline";
|
||||
export { PreviewPanel } from "./components/PreviewPanel";
|
||||
export { VideoThumbnail } from "./components/VideoThumbnail";
|
||||
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ interface PlayerState {
|
||||
zoomMode: ZoomMode;
|
||||
/** Pixels per second when in manual zoom mode */
|
||||
pixelsPerSecond: number;
|
||||
/** Edit range selection */
|
||||
editRangeStart: number | null;
|
||||
editRangeEnd: number | null;
|
||||
editMode: boolean;
|
||||
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
@@ -39,11 +35,6 @@ interface PlayerState {
|
||||
setTimelineReady: (ready: boolean) => void;
|
||||
setElements: (elements: TimelineElement[]) => void;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
setEditRange: (start: number | null, end: number | null) => void;
|
||||
setEditMode: (active: boolean) => void;
|
||||
updateElementStart: (elementId: string, newStart: number) => void;
|
||||
updateElementDuration: (elementId: string, newDuration: number) => void;
|
||||
updateElementTrack: (elementId: string, newTrack: number) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
|
||||
@@ -76,9 +67,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
playbackRate: 1,
|
||||
zoomMode: "fit",
|
||||
pixelsPerSecond: 100,
|
||||
editRangeStart: null,
|
||||
editRangeEnd: null,
|
||||
editMode: false,
|
||||
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
||||
@@ -89,26 +77,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
||||
setElements: (elements) => set({ elements }),
|
||||
setSelectedElementId: (id) => set({ selectedElementId: id }),
|
||||
setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
|
||||
setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
|
||||
updateElementStart: (elementId, newStart) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
|
||||
})),
|
||||
updateElementDuration: (elementId, newDuration) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) =>
|
||||
el.id === elementId ? { ...el, duration: newDuration } : el,
|
||||
),
|
||||
})),
|
||||
updateElementTrack: (elementId, newTrack) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
|
||||
})),
|
||||
updateElement: (elementId, updates) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
|
||||
})),
|
||||
// Resets project-specific state when switching compositions.
|
||||
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
|
||||
// because they are user preferences that should survive project switches.
|
||||
reset: () =>
|
||||
set({
|
||||
isPlaying: false,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
||||
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
||||
export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
|
||||
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
|
||||
export function isMediaFile(path: string): boolean {
|
||||
return MEDIA_EXT.test(path);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
border: "#262626",
|
||||
text: "#e5e5e5",
|
||||
muted: "#737373",
|
||||
accent: "#00E3FF",
|
||||
accent: "#3CE6AC",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user