diff --git a/.gitignore b/.gitignore
index 37c0f754..141dcc1c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/bun.lock b/bun.lock
index bac954be..2961df1e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+ "framer-motion": ["framer-motion@12.38.0", "", { "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": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1110,6 +1113,12 @@
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "8.16.0", "pathe": "2.0.3", "pkg-types": "1.3.1", "ufo": "1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
+ "motion": ["motion@12.38.0", "", { "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": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
+
+ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "1.3.0", "object-assign": "4.1.1", "thenify-all": "1.6.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
diff --git a/packages/studio/package.json b/packages/studio/package.json
index 2f397612..750a3b56 100644
--- a/packages/studio/package.json
+++ b/packages/studio/package.json
@@ -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",
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index a4f0799a..63fd5db1 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -1,18 +1,15 @@
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
+import { useMountEffect } from "./hooks/useMountEffect";
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { FileTree } from "./components/editor/FileTree";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
-import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
-import { VideoThumbnail } from "./player/components/VideoThumbnail";
-import type { TimelineElement } from "./player/store/playerStore";
-import {
- XIcon,
- CodeIcon,
- WarningIcon,
- CheckCircleIcon,
- CaretRightIcon,
-} from "@phosphor-icons/react";
+import { RenderQueue } from "./components/renders/RenderQueue";
+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";
interface EditingFile {
path: string;
@@ -32,13 +29,251 @@ interface LintFinding {
fixHint?: string;
}
+import { ExpandOnHover } from "./components/ui/ExpandOnHover";
+
+// ── 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 (
+
+

+
{filePath}
+
+ );
+ }
+
+ if (VIDEO_EXT.test(filePath)) {
+ return (
+
+
+ {filePath}
+
+ );
+ }
+
+ if (AUDIO_EXT.test(filePath)) {
+ return (
+
+
+
+
{filePath}
+
+ );
+ }
+
+ // Fonts and other binary — show info instead of binary dump
+ return (
+
+
+
{name}
+
{filePath}
+
Binary file — preview not available
+
+ );
+}
+
+// ── Project Card with hover-to-preview ──
+
+function ExpandedPreviewIframe({ src }: { src: string }) {
+ const containerRef = useRef(null);
+ const iframeRef = useRef(null);
+ const [dims, setDims] = useState({ w: 1920, h: 1080 });
+ const [scale, setScale] = useState(1);
+
+ // Recalculate scale when container resizes or dims change.
+ // Note: useEffect with [dims] dep — syncs with ResizeObserver (external system).
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const update = () => {
+ const cw = el.clientWidth;
+ const ch = el.clientHeight;
+ // Fit the composition inside the container (contain, not cover)
+ const s = Math.min(cw / dims.w, ch / dims.h);
+ setScale(s);
+ };
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [dims]);
+
+ // After iframe loads: detect composition dimensions, seek, and play
+ const handleLoad = useCallback(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return;
+ let attempts = 0;
+ const interval = setInterval(() => {
+ try {
+ const doc = iframe.contentDocument;
+ if (doc) {
+ const comp = doc.querySelector("[data-composition-id]") as HTMLElement | null;
+ if (comp) {
+ const w = parseInt(comp.getAttribute("data-width") ?? "0", 10);
+ const h = parseInt(comp.getAttribute("data-height") ?? "0", 10);
+ if (w > 0 && h > 0) setDims({ w, h });
+ }
+ }
+ const win = iframe.contentWindow as Window & {
+ __player?: { seek: (t: number) => void; play: () => void };
+ };
+ if (win?.__player) {
+ win.__player.seek(2);
+ win.__player.play();
+ clearInterval(interval);
+ }
+ } catch {
+ /* cross-origin */
+ }
+ if (++attempts > 25) clearInterval(interval);
+ }, 200);
+ }, []);
+
+ // Center the scaled iframe
+ 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 (
+
+
+
+ );
+}
+
+function ProjectCard({ project: p, onSelect }: { project: ProjectEntry; onSelect: () => void }) {
+ const thumbnailUrl = `/api/projects/${p.id}/thumbnail/index.html?t=0.5`;
+ const previewUrl = `/api/projects/${p.id}/preview`;
+
+ const card = (
+
+
+

{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+
+
+
{p.title ?? p.id}
+
{p.id}
+
+
+ );
+
+ return (
+ (
+
+
+
+
+
+
+
{p.title ?? p.id}
+
{p.id}
+
+
+
+
+ )}
+ onClick={onSelect}
+ expandScale={0.6}
+ delay={400}
+ >
+ {card}
+
+ );
+}
+
// ── Project Picker ──
function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
- useEffect(() => {
+ useMountEffect(() => {
fetch("/api/projects")
.then((r) => r.json())
.then((data: { projects?: ProjectEntry[] }) => {
@@ -46,30 +281,79 @@ function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
setLoading(false);
})
.catch(() => setLoading(false));
- }, []);
+ });
return (
-
-
HyperFrames Studio
-
Select a project to open
+ {/* Header */}
+
+
+
+
HyperFrames Studio
+
+
Your projects
+
+
+ {/* Project grid */}
+
{loading ? (
-
Loading projects...
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
) : projects.length === 0 ? (
-
No projects found.
+
+
+
+
No projects yet
+
+ Run{" "}
+
+ hyperframes init
+ {" "}
+ to create one
+
+
+
) : (
-
+
{projects.map((p) => (
-
+
onSelect(p.id)} />
))}
)}
@@ -80,7 +364,15 @@ function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
// ── Lint Modal ──
-function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () => void }) {
+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;
@@ -93,7 +385,7 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
return line;
});
- const text = `Fix these HyperFrames lint issues:\n\n${lines.join("\n\n")}`;
+ 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);
@@ -147,7 +439,7 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()