diff --git a/.gitignore b/.gitignore
index 37c0f754f..141dcc1c7 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 bac954be4..2961df1e3 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 2f3976122..750a3b561 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 a4f0799a6..63fd5db1f 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 (
+      <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>
+  );
+}
+
+// ── Project Card with hover-to-preview ──
+
+function ExpandedPreviewIframe({ src }: { src: string }) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const iframeRef = useRef<HTMLIFrameElement>(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 (
+    <div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
+      <iframe
+        ref={iframeRef}
+        src={src}
+        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})`,
+        }}
+      />
+    </div>
+  );
+}
+
+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 = (
+    <div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800/60 hover:border-[#3CE6AC]/30 hover:shadow-lg hover:shadow-[#3CE6AC]/5 transition-all duration-200 cursor-pointer">
+      <div className="aspect-video bg-neutral-950 relative overflow-hidden flex items-center justify-center">
+        <img
+          src={thumbnailUrl}
+          alt={p.title ?? p.id}
+          loading="lazy"
+          className="max-w-full max-h-full object-contain"
+          onError={(e) => {
+            (e.target as HTMLImageElement).style.display = "none";
+          }}
+        />
+      </div>
+      <div className="px-3.5 py-3">
+        <div className="text-sm font-medium text-neutral-200 truncate">{p.title ?? p.id}</div>
+        <div className="text-[10px] text-neutral-600 font-mono truncate mt-0.5">{p.id}</div>
+      </div>
+    </div>
+  );
+
+  return (
+    <ExpandOnHover
+      expandedContent={(closeExpand) => (
+        <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
+          <div className="flex-1 min-h-0">
+            <ExpandedPreviewIframe src={previewUrl} />
+          </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">{p.title ?? p.id}</div>
+              <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{p.id}</div>
+            </div>
+            <button
+              onClick={(e) => {
+                e.stopPropagation();
+                closeExpand();
+                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>
+      )}
+      onClick={onSelect}
+      expandScale={0.6}
+      delay={400}
+    >
+      {card}
+    </ExpandOnHover>
+  );
+}
+
 // ── Project Picker ──
 
 function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
   const [projects, setProjects] = useState<ProjectEntry[]>([]);
   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 (
     <div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
-      <div className="max-w-lg w-full mx-auto px-4 py-12">
-        <h1 className="text-xl font-semibold text-neutral-200 mb-1">HyperFrames Studio</h1>
-        <p className="text-sm text-neutral-500 mb-6">Select a project to open</p>
+      {/* Header */}
+      <div className="max-w-4xl mx-auto px-6 pt-16 pb-8">
+        <div className="flex items-center gap-3 mb-2">
+          <svg width="32" height="32" viewBox="0 0 512 512" className="flex-shrink-0">
+            <rect width="512" height="512" rx="115" fill="#1A1913" />
+            <g strokeLinecap="round" strokeLinejoin="round">
+              <polyline
+                points="156,176 76,256 156,336"
+                fill="none"
+                stroke="#7B7568"
+                strokeWidth="32"
+              />
+              <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
+              <polygon
+                points="336,176 436,256 336,336"
+                fill="#3CE6AC"
+                stroke="#3CE6AC"
+                strokeWidth="32"
+              />
+            </g>
+          </svg>
+          <h1 className="text-2xl font-bold text-neutral-100 tracking-tight">HyperFrames Studio</h1>
+        </div>
+        <p className="text-sm text-neutral-500 ml-11">Your projects</p>
+      </div>
+
+      {/* Project grid */}
+      <div className="max-w-4xl mx-auto px-6 pb-16">
         {loading ? (
-          <div className="text-sm text-neutral-600">Loading projects...</div>
+          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
+            {[1, 2, 3].map((i) => (
+              <div key={i} className="aspect-video rounded-xl bg-neutral-900 animate-pulse" />
+            ))}
+          </div>
         ) : projects.length === 0 ? (
-          <div className="text-sm text-neutral-600">No projects found.</div>
+          <div className="flex flex-col items-center justify-center py-24 gap-4">
+            <svg width="48" height="48" viewBox="0 0 512 512" className="opacity-20">
+              <rect width="512" height="512" rx="115" fill="#1A1913" />
+              <g strokeLinecap="round" strokeLinejoin="round">
+                <polyline
+                  points="156,176 76,256 156,336"
+                  fill="none"
+                  stroke="#7B7568"
+                  strokeWidth="32"
+                />
+                <line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
+                <polygon
+                  points="336,176 436,256 336,336"
+                  fill="#3CE6AC"
+                  stroke="#3CE6AC"
+                  strokeWidth="32"
+                />
+              </g>
+            </svg>
+            <div className="text-center">
+              <p className="text-sm text-neutral-400 font-medium">No projects yet</p>
+              <p className="text-xs text-neutral-600 mt-1">
+                Run{" "}
+                <code className="px-1.5 py-0.5 rounded bg-neutral-800 text-[#3CE6AC] text-[11px]">
+                  hyperframes init
+                </code>{" "}
+                to create one
+              </p>
+            </div>
+          </div>
         ) : (
-          <div className="flex flex-col gap-1.5">
+          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
             {projects.map((p) => (
-              <button
-                key={p.id}
-                onClick={() => onSelect(p.id)}
-                className="text-left px-4 py-3 rounded-lg bg-neutral-900 border border-neutral-800 hover:border-neutral-600 hover:bg-neutral-800/80 transition-all group"
-              >
-                <div className="text-sm text-neutral-200 truncate">{p.title ?? p.id}</div>
-                <div className="text-[11px] text-neutral-600 font-mono truncate mt-0.5 group-hover:text-neutral-500">
-                  {p.id}
-                </div>
-              </button>
+              <ProjectCard key={p.id} project={p} onSelect={() => onSelect(p.id)} />
             ))}
           </div>
         )}
@@ -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: ()
             <button
               onClick={handleCopyToAgent}
               className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
-                copied ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-500 text-white"
+                copied ? "bg-green-600 text-white" : "bg-[#3CE6AC] hover:bg-[#3CE6AC]/80 text-white"
               }`}
             >
               {copied ? "Copied!" : "Copy to Agent"}
@@ -210,7 +502,7 @@ export function StudioApp() {
   const [projectId, setProjectId] = useState<string | null>(null);
   const [resolving, setResolving] = useState(true);
 
-  useEffect(() => {
+  useMountEffect(() => {
     const hash = window.location.hash;
     const projectMatch = hash.match(/project\/([^/]+)/);
     const sessionMatch = hash.match(/session\/([^/]+)/);
@@ -231,12 +523,28 @@ export function StudioApp() {
     } else {
       setResolving(false);
     }
-  }, []);
+  });
 
   const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
-  const [sidebarOpen, setSidebarOpen] = useState(false);
+  const [rightTab, setRightTab] = useState<"code" | "renders">("code");
+  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
   const [fileTree, setFileTree] = useState<string[]>([]);
   const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
+  const renderQueue = useRenderQueue(projectId);
+
+  // Resizable and collapsible panel widths
+  const [leftWidth, setLeftWidth] = useState(240);
+  const [rightWidth, setRightWidth] = useState(400);
+  const [leftCollapsed, setLeftCollapsed] = useState(false);
+  const [rightCollapsed, setRightCollapsed] = useState(false);
+  const panelDragRef = useRef<{ side: "left" | "right"; startX: number; startW: number } | null>(
+    null,
+  );
+
+  // Derive active preview URL from composition path (for drilled-down thumbnails)
+  const activePreviewUrl = activeCompPath
+    ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
+    : null;
 
   const renderClipContent = useCallback(
     (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
@@ -252,11 +560,27 @@ export function StudioApp() {
         if (resolved) compSrc = resolved;
       }
 
+      // Composition clips — always use the comp's own preview URL for thumbnails.
+      // This renders the composition in isolation so we get clean frames
+      // instead of capturing the master at a time when the comp is fading in.
       if (compSrc) {
-        const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`;
         return (
           <CompositionThumbnail
-            previewUrl={previewUrl}
+            previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
+            label={el.id || el.tag}
+            labelColor={style.label}
+            seekTime={0}
+            duration={el.duration}
+          />
+        );
+      }
+
+      // When drilled into a composition, render all inner elements via
+      // CompositionThumbnail at their start time — most accurate visual.
+      if (activePreviewUrl && el.duration > 0) {
+        return (
+          <CompositionThumbnail
+            previewUrl={activePreviewUrl}
             label={el.id || el.tag}
             labelColor={style.label}
             seekTime={el.start}
@@ -265,6 +589,18 @@ export function StudioApp() {
         );
       }
 
+      // Audio clips — waveform visualization
+      if (el.tag === "audio") {
+        const audioUrl = el.src
+          ? el.src.startsWith("http")
+            ? el.src
+            : `/api/projects/${pid}/preview/${el.src}`
+          : "";
+        return (
+          <AudioWaveform audioUrl={audioUrl} label={el.id || el.tag} labelColor={style.label} />
+        );
+      }
+
       if ((el.tag === "video" || el.tag === "img") && el.src) {
         const mediaSrc = el.src.startsWith("http")
           ? el.src
@@ -279,7 +615,7 @@ export function StudioApp() {
         );
       }
 
-      // HTML scene divs — render from index.html at the scene's time
+      // HTML scene elements — render from the master preview at the scene's time
       if (el.tag === "div" && el.duration > 0) {
         const previewUrl = `/api/projects/${pid}/preview`;
         return (
@@ -295,23 +631,18 @@ export function StudioApp() {
 
       return null;
     },
-    [compIdToSrc],
+    [compIdToSrc, activePreviewUrl],
   );
   const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
   const [linting, setLinting] = useState(false);
   const [refreshKey, setRefreshKey] = useState(0);
-  const [renderState, setRenderState] = useState<"idle" | "rendering" | "complete" | "error">(
-    "idle",
-  );
-  const [renderProgress, setRenderProgress] = useState(0);
-  const [_renderError, setRenderError] = useState<string | null>(null);
   const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const projectIdRef = useRef(projectId);
   const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
 
   // Listen for external file changes (user editing HTML outside the editor).
   // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
-  useEffect(() => {
+  useMountEffect(() => {
     const handler = () => {
       if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
       refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
@@ -324,35 +655,50 @@ export function StudioApp() {
     const es = new EventSource("/api/events");
     es.addEventListener("file-change", handler);
     return () => es.close();
-  }, []);
+  });
   projectIdRef.current = projectId;
 
-  // Load file tree when projectId changes
-  const prevProjectIdRef = useRef<string | null>(null);
-  if (projectId && projectId !== prevProjectIdRef.current) {
-    prevProjectIdRef.current = projectId;
+  // Load file tree when projectId changes.
+  // Note: This is one of the few places where useEffect with deps is acceptable —
+  // it's data fetching tied to a prop change. Ideally this would use a data-fetching
+  // library (useQuery/useSWR) or the parent component would own the fetch.
+  // eslint-disable-next-line no-restricted-syntax
+  useEffect(() => {
+    if (!projectId) return;
+    let cancelled = false;
     fetch(`/api/projects/${projectId}`)
       .then((r) => r.json())
       .then((data: { files?: string[] }) => {
-        if (data.files) setFileTree(data.files);
+        if (!cancelled && data.files) setFileTree(data.files);
       })
       .catch(() => {});
-  }
+    return () => {
+      cancelled = true;
+    };
+  }, [projectId]);
 
   const handleSelectProject = useCallback((id: string) => {
     window.location.hash = `#project/${id}`;
     setProjectId(id);
+    setActiveCompPath(null);
+    setEditingFile(null);
+    setCompIdToSrc(new Map());
+    setFileTree([]);
   }, []);
 
   const handleFileSelect = useCallback((path: string) => {
     const pid = projectIdRef.current;
     if (!pid) return;
+    // Skip fetching binary content for media files — just set the path for preview
+    if (isMediaFile(path)) {
+      setEditingFile({ path, content: null });
+      return;
+    }
     fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
       .then((r) => r.json())
       .then((data: { content?: string }) => {
         if (data.content != null) {
           setEditingFile({ path, content: data.content });
-          setSidebarOpen(true);
         }
       })
       .catch(() => {});
@@ -384,105 +730,16 @@ export function StudioApp() {
     if (!pid) return;
     setLinting(true);
     try {
-      // Fetch all HTML files and lint them client-side using the core linter
-      const res = await fetch(`/api/projects/${pid}`);
+      const res = await fetch(`/api/projects/${pid}/lint`);
       const data = await res.json();
-      const files: string[] = data.files?.filter((f: string) => f.endsWith(".html")) ?? [];
-
-      const findings: LintFinding[] = [];
-      for (const file of files) {
-        const fileRes = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(file)}`);
-        const fileData = await fileRes.json();
-        if (!fileData.content) continue;
-
-        // Basic lint checks (subset of the full linter)
-        const html = fileData.content as string;
-
-        if (file === "index.html") {
-          // Check for root composition
-          if (!html.includes("data-composition-id")) {
-            findings.push({
-              severity: "error",
-              message: "No element with `data-composition-id` found.",
-              file,
-              fixHint: "Add `data-composition-id` to the root composition wrapper.",
-            });
-          }
-          // Check for timeline registration
-          if (!html.includes("__timelines")) {
-            findings.push({
-              severity: "error",
-              message: "Missing `window.__timelines` registration.",
-              file,
-              fixHint: 'Add: window.__timelines["compositionId"] = tl;',
-            });
-          }
-          // Check for TARGET_DURATION
-          if (
-            html.includes("gsap.timeline") &&
-            !html.includes("TARGET_DURATION") &&
-            !html.includes("tl.set({}, {},")
-          ) {
-            findings.push({
-              severity: "warning",
-              message: "No TARGET_DURATION spacer found. Video may be shorter than intended.",
-              file,
-              fixHint:
-                "Add: const TARGET_DURATION = 30; if (tl.duration() < TARGET_DURATION) { tl.set({}, {}, TARGET_DURATION); }",
-            });
-          }
-        }
-
-        // Check for composition hosts missing dimensions
-        const hostRe = /data-composition-src=["']([^"']+)["']/g;
-        let hostMatch;
-        while ((hostMatch = hostRe.exec(html)) !== null) {
-          const surrounding = html.slice(
-            Math.max(0, hostMatch.index - 300),
-            hostMatch.index + hostMatch[0].length + 50,
-          );
-          const hasDataDims =
-            /data-width\s*=/i.test(surrounding) && /data-height\s*=/i.test(surrounding);
-          const hasStyleDims = /style\s*=.*width:\s*\d+px.*height:\s*\d+px/i.test(surrounding);
-          if (!hasDataDims && !hasStyleDims) {
-            findings.push({
-              severity: "warning",
-              message: `Composition host for "${hostMatch[1]}" missing data-width/data-height. May render with zero dimensions.`,
-              file,
-              fixHint:
-                'Add data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px"',
-            });
-          }
-        }
-
-        // Check for repeat: -1
-        if (/repeat\s*:\s*-\s*1/.test(html)) {
-          findings.push({
-            severity: "error",
-            message: "GSAP `repeat: -1` found — infinite loop breaks timeline duration.",
-            file,
-            fixHint: "Use a finite repeat count or CSS animation.",
-          });
-        }
-
-        // Check script syntax
-        const scriptRe = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi;
-        let scriptMatch;
-        while ((scriptMatch = scriptRe.exec(html)) !== null) {
-          const js = scriptMatch[1]?.trim();
-          if (!js) continue;
-          try {
-            new Function(js);
-          } catch (e) {
-            findings.push({
-              severity: "error",
-              message: `Script syntax error: ${e instanceof Error ? e.message : String(e)}`,
-              file,
-            });
-          }
-        }
-      }
-
+      const findings: LintFinding[] = (data.findings ?? []).map(
+        (f: { severity?: string; message?: string; file?: string; fixHint?: string }) => ({
+          severity: f.severity === "error" ? ("error" as const) : ("warning" as const),
+          message: f.message ?? "",
+          file: f.file,
+          fixHint: f.fixHint,
+        }),
+      );
       setLintModal(findings);
     } catch (err) {
       const msg = err instanceof Error ? err.message : String(err);
@@ -492,52 +749,35 @@ export function StudioApp() {
     }
   }, []);
 
-  const handleRender = useCallback(async () => {
-    const pid = projectIdRef.current;
-    if (!pid || renderState === "rendering") return;
-    setRenderState("rendering");
-    setRenderProgress(0);
-    setRenderError(null);
-    try {
-      // Start render via studio backend
-      const res = await fetch(`/api/projects/${pid}/render`, {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({}),
-      });
-      if (!res.ok) throw new Error(`Render failed: ${res.status}`);
-      const { jobId } = await res.json();
-
-      // Subscribe to progress via SSE
-      const eventSource = new EventSource(`/api/render/${jobId}/progress`);
-      eventSource.addEventListener("progress", (event) => {
-        try {
-          const data = JSON.parse(event.data);
-          setRenderProgress(data.progress ?? 0);
-          if (data.status === "complete") {
-            setRenderState("complete");
-            eventSource.close();
-            // Auto-download
-            window.open(`/api/render/${jobId}/download`, "_blank");
-          } else if (data.status === "failed") {
-            setRenderState("error");
-            setRenderError(data.error || "Render failed");
-            eventSource.close();
-          }
-        } catch {
-          /* ignore */
-        }
-      });
-      eventSource.onerror = () => {
-        setRenderState("error");
-        setRenderError("Lost connection to render server");
-        eventSource.close();
+  // Panel resize via pointer events (works for both left sidebar and right panel)
+  const handlePanelResizeStart = useCallback(
+    (side: "left" | "right", e: React.PointerEvent) => {
+      e.preventDefault();
+      (e.target as HTMLElement).setPointerCapture(e.pointerId);
+      panelDragRef.current = {
+        side,
+        startX: e.clientX,
+        startW: side === "left" ? leftWidth : rightWidth,
       };
-    } catch (err) {
-      setRenderState("error");
-      setRenderError(err instanceof Error ? err.message : "Render failed");
-    }
-  }, [renderState]);
+    },
+    [leftWidth, rightWidth],
+  );
+
+  const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
+    const drag = panelDragRef.current;
+    if (!drag) return;
+    const delta = e.clientX - drag.startX;
+    const newW = Math.max(
+      160,
+      Math.min(600, drag.startW + (drag.side === "left" ? delta : -delta)),
+    );
+    if (drag.side === "left") setLeftWidth(newW);
+    else setRightWidth(newW);
+  }, []);
+
+  const handlePanelResizeEnd = useCallback(() => {
+    panelDragRef.current = null;
+  }, []);
 
   if (resolving) {
     return (
@@ -557,138 +797,241 @@ export function StudioApp() {
   );
 
   return (
-    <div className="flex h-screen w-screen bg-neutral-950">
-      {/* Left sidebar: Compositions + Assets */}
-      <LeftSidebar
-        projectId={projectId}
-        compositions={compositions}
-        assets={assets}
-        activeComposition={editingFile?.path ?? null}
-        onSelectComposition={(comp) => {
-          // Open code editor for this composition
-          const controller = new AbortController();
-          setEditingFile({ path: comp, content: null });
-          fetch(`/api/projects/${projectId}/files/${comp}`, { signal: controller.signal })
-            .then((r) => r.json())
-            .then((data) => {
-              // Only update if the path still matches (race condition guard)
-              setEditingFile((prev) =>
-                prev?.path === comp ? { path: comp, content: data.content } : prev,
-              );
-            })
-            .catch((err) => {
-              if (err.name !== "AbortError") console.warn("Failed to load composition:", err);
-            });
-        }}
-      />
-
-      {/* NLE: Preview + Timeline */}
-      <div className="flex-1 relative min-w-0">
-        <NLELayout
-          projectId={projectId}
-          refreshKey={refreshKey}
-          renderClipContent={renderClipContent}
-          onCompIdToSrcChange={setCompIdToSrc}
-          activeCompositionPath={
-            editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
-          }
-          onIframeRef={(iframe) => {
-            previewIframeRef.current = iframe;
-          }}
-        />
-      </div>
-
-      {/* Action buttons — positioned based on sidebar state */}
-      {!sidebarOpen && (
-        <div className="absolute top-3 right-3 z-50 flex items-center gap-1.5">
+    <div className="flex flex-col h-screen w-screen bg-neutral-950">
+      {/* Header bar */}
+      <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
+        {/* Left: back button + project name */}
+        <div className="flex items-center gap-2">
+          <button
+            onClick={() => {
+              window.location.hash = "";
+              setProjectId(null);
+            }}
+            className="flex items-center gap-1.5 px-2 py-1 rounded-md text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
+          >
+            <svg
+              width="14"
+              height="14"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <polyline points="15 18 9 12 15 6" />
+            </svg>
+            <span className="text-xs">Projects</span>
+          </button>
+          <span className="text-[11px] text-neutral-600">/</span>
+          <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
+        </div>
+        {/* Right: toolbar buttons */}
+        <div className="flex items-center gap-1.5">
           <button
-            onClick={() => setSidebarOpen(true)}
-            className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-neutral-200 transition-colors flex items-center justify-center"
-            title="Source editor"
+            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"
+                : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
+            }`}
+            title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
           >
-            <CodeIcon size={16} />
+            <svg
+              width="14"
+              height="14"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <rect x="3" y="3" width="18" height="18" rx="2" />
+              <path d="M9 3v18" />
+            </svg>
           </button>
           <button
             onClick={handleLint}
             disabled={linting}
-            className="h-8 px-3 rounded-lg bg-neutral-900 border border-neutral-800 text-xs font-medium text-neutral-400 hover:text-amber-300 hover:border-amber-800/50 transition-colors disabled:opacity-40"
+            className="h-7 px-2.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
           >
             {linting ? "Linting..." : "Lint"}
           </button>
           <button
-            onClick={handleRender}
-            disabled={renderState === "rendering"}
-            className="h-8 px-3 rounded-lg text-xs font-semibold text-[#09090B] bg-gradient-to-br from-[#3CE6AC] to-[#2BBFA0] hover:brightness-110 active:scale-[0.97] transition-colors disabled:opacity-60 tabular-nums"
+            onClick={() => setRightCollapsed((v) => !v)}
+            className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
+              rightCollapsed
+                ? "bg-neutral-800 border-neutral-700 text-neutral-300"
+                : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
+            }`}
+            title={rightCollapsed ? "Show code panel" : "Hide code panel"}
           >
-            {renderState === "rendering"
-              ? `${Math.round(renderProgress)}%`
-              : renderState === "complete"
-                ? "Done!"
-                : "Export MP4"}
+            <svg
+              width="14"
+              height="14"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              <rect x="3" y="3" width="18" height="18" rx="2" />
+              <path d="M15 3v18" />
+            </svg>
           </button>
         </div>
-      )}
+      </div>
 
-      {/* Source editor sidebar */}
-      {sidebarOpen && (
-        <div className="w-[420px] flex flex-col border-l border-neutral-800 bg-neutral-900">
-          <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 gap-2">
-            <span className="text-xs font-medium text-neutral-500 truncate min-w-0 flex-1">
-              {editingFile?.path ?? "Source"}
-            </span>
-            <div className="flex items-center gap-1.5 flex-shrink-0">
-              <button
-                onClick={handleLint}
-                disabled={linting}
-                className="px-2 py-1 rounded text-[11px] font-medium text-neutral-500 hover:text-amber-300 transition-colors disabled:opacity-40"
-              >
-                {linting ? "..." : "Lint"}
-              </button>
+      {/* Main content: sidebar + preview + right panel */}
+      <div className="flex flex-1 min-h-0">
+        {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
+        {!leftCollapsed && (
+          <LeftSidebar
+            width={leftWidth}
+            projectId={projectId}
+            compositions={compositions}
+            assets={assets}
+            activeComposition={editingFile?.path ?? null}
+            onSelectComposition={(comp) => {
+              // Set active composition for preview drill-down
+              // Don't increment refreshKey — that reloads the master iframe and
+              // overrides the composition navigation. Let activeCompositionPath
+              // handle the preview change via the composition stack.
+              setActiveCompPath(
+                comp === "index.html" || comp.startsWith("compositions/") ? comp : null,
+              );
+              // Load file content for code editor
+              setEditingFile({ path: comp, content: null });
+              fetch(`/api/projects/${projectId}/files/${comp}`)
+                .then((r) => r.json())
+                .then((data) => setEditingFile({ path: comp, content: data.content }))
+                .catch(() => {});
+            }}
+          />
+        )}
+
+        {/* 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"
+            style={{ touchAction: "none" }}
+            onPointerDown={(e) => handlePanelResizeStart("left", e)}
+            onPointerMove={handlePanelResizeMove}
+            onPointerUp={handlePanelResizeEnd}
+          />
+        )}
+
+        {/* Center: Preview */}
+        <div className="flex-1 relative min-w-0">
+          <NLELayout
+            projectId={projectId}
+            refreshKey={refreshKey}
+            activeCompositionPath={activeCompPath}
+            renderClipContent={renderClipContent}
+            onCompIdToSrcChange={setCompIdToSrc}
+            onCompositionChange={(compPath) => {
+              // Sync activeCompPath when user drills down via timeline double-click
+              // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
+              setActiveCompPath(compPath);
+            }}
+            onIframeRef={(iframe) => {
+              previewIframeRef.current = iframe;
+            }}
+          />
+        </div>
+
+        {/* Right resize handle */}
+        {!rightCollapsed && (
+          <div
+            className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-blue-500 cursor-col-resize transition-colors active:bg-blue-400"
+            style={{ touchAction: "none" }}
+            onPointerDown={(e) => handlePanelResizeStart("right", e)}
+            onPointerMove={handlePanelResizeMove}
+            onPointerUp={handlePanelResizeEnd}
+          />
+        )}
+
+        {/* Right panel: Code + Renders tabs (resizable, collapsible) */}
+        {!rightCollapsed && (
+          <div
+            className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
+            style={{ width: rightWidth }}
+          >
+            {/* Tab bar */}
+            <div className="flex items-center border-b border-neutral-800 flex-shrink-0">
               <button
-                onClick={handleRender}
-                disabled={renderState === "rendering"}
-                className="px-2 py-1 rounded text-[11px] font-semibold text-[#3CE6AC] hover:text-[#5EEFC0] transition-colors disabled:opacity-60 tabular-nums"
+                onClick={() => setRightTab("code")}
+                className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
+                  rightTab === "code"
+                    ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
+                    : "text-neutral-500 hover:text-neutral-400"
+                }`}
               >
-                {renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
+                Code
               </button>
               <button
-                onClick={() => setSidebarOpen(false)}
-                className="p-1 rounded text-neutral-600 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
-                title="Close source panel"
+                onClick={() => setRightTab("renders")}
+                className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
+                  rightTab === "renders"
+                    ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
+                    : "text-neutral-500 hover:text-neutral-400"
+                }`}
               >
-                <XIcon size={14} />
+                Renders{renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
               </button>
             </div>
-          </div>
-
-          {fileTree.length > 0 && (
-            <div className="border-b border-neutral-800 max-h-40 overflow-y-auto">
-              <FileTree
-                files={fileTree}
-                activeFile={editingFile?.path ?? null}
-                onSelectFile={handleFileSelect}
-              />
-            </div>
-          )}
 
-          <div className="flex-1 overflow-hidden">
-            {editingFile ? (
-              <SourceEditor
-                content={editingFile.content ?? ""}
-                filePath={editingFile.path}
-                onChange={handleContentChange}
-              />
-            ) : (
-              <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
-                Select a file to edit
+            {/* Tab content */}
+            {rightTab === "code" ? (
+              <div className="flex flex-1 min-h-0">
+                {/* File tree sidebar */}
+                {fileTree.length > 0 && (
+                  <div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
+                    <FileTree
+                      files={fileTree}
+                      activeFile={editingFile?.path ?? null}
+                      onSelectFile={handleFileSelect}
+                    />
+                  </div>
+                )}
+                {/* Code editor or media preview */}
+                <div className="flex-1 overflow-hidden min-w-0">
+                  {editingFile ? (
+                    isMediaFile(editingFile.path) ? (
+                      <MediaPreview projectId={projectId} filePath={editingFile.path} />
+                    ) : (
+                      <SourceEditor
+                        content={editingFile.content ?? ""}
+                        filePath={editingFile.path}
+                        onChange={handleContentChange}
+                      />
+                    )
+                  ) : (
+                    <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
+                      Select a file to edit
+                    </div>
+                  )}
+                </div>
               </div>
+            ) : (
+              <RenderQueue
+                jobs={renderQueue.jobs}
+                onDelete={renderQueue.deleteRender}
+                onClearCompleted={renderQueue.clearCompleted}
+                onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
+                isRendering={renderQueue.isRendering}
+              />
             )}
           </div>
-        </div>
-      )}
+        )}
+      </div>
 
       {/* Lint modal */}
-      {lintModal !== null && <LintModal findings={lintModal} onClose={() => setLintModal(null)} />}
+      {lintModal !== null && (
+        <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
+      )}
     </div>
   );
 }
diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx
index 31d3f8aa9..b416c68c5 100644
--- a/packages/studio/src/components/editor/FileTree.tsx
+++ b/packages/studio/src/components/editor/FileTree.tsx
@@ -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>
   );
diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx
index 48752bf40..aacca0017 100644
--- a/packages/studio/src/components/nle/NLELayout.tsx
+++ b/packages/studio/src/components/nle/NLELayout.tsx
@@ -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) => {
diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx
new file mode 100644
index 000000000..522fbf662
--- /dev/null
+++ b/packages/studio/src/components/renders/RenderQueue.tsx
@@ -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>
+  );
+});
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx
new file mode 100644
index 000000000..467c85de0
--- /dev/null
+++ b/packages/studio/src/components/renders/RenderQueueItem.tsx
@@ -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>
+  );
+});
diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts
new file mode 100644
index 000000000..42a583db3
--- /dev/null
+++ b/packages/studio/src/components/renders/useRenderQueue.ts
@@ -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"),
+  };
+}
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx
index 8bd4a3edb..7584b284c 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.tsx
+++ b/packages/studio/src/components/sidebar/AssetsTab.tsx
@@ -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)) {
+function AssetThumbnail({
+  serveUrl,
+  name,
+  isImage,
+  isVideo,
+  isAudio,
+}: {
+  serveUrl: string;
+  name: string;
+  isImage: boolean;
+  isVideo: boolean;
+  isAudio: boolean;
+}) {
+  return (
+    <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"
+      }`}
+    >
+      <AssetThumbnail
+        serveUrl={serveUrl}
+        name={name}
+        isImage={isImage}
+        isVideo={isVideo}
+        isAudio={isAudio}
+      />
+      <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 (
-      <svg
-        width="16"
-        height="16"
-        viewBox="0 0 24 24"
-        fill="none"
-        stroke="currentColor"
-        strokeWidth="1.5"
-        className="text-green-400"
+      <button
+        type="button"
+        onClick={() => onCopy(asset)}
+        title="Click to copy path"
+        className="w-full"
       >
-        <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>
+        {card}
+      </button>
     );
   }
+
   return (
-    <svg
-      width="16"
-      height="16"
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="1.5"
-      className="text-neutral-500"
+    <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}
     >
-      <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>
+      {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>
diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx
index 79e792b78..6e46001b8 100644
--- a/packages/studio/src/components/sidebar/CompositionsTab.tsx
+++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx
@@ -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>
   );
 });
diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx
index 03f715fa4..9aedd1891 100644
--- a/packages/studio/src/components/sidebar/LeftSidebar.tsx
+++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx
@@ -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">
diff --git a/packages/studio/src/components/ui/ExpandOnHover.tsx b/packages/studio/src/components/ui/ExpandOnHover.tsx
new file mode 100644
index 000000000..6d4583cdc
--- /dev/null
+++ b/packages/studio/src/components/ui/ExpandOnHover.tsx
@@ -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>
+    </>
+  );
+}
diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx
new file mode 100644
index 000000000..d2b3a2fd8
--- /dev/null
+++ b/packages/studio/src/player/components/AudioWaveform.tsx
@@ -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>
+  );
+});
diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx
index 8a81c8922..ae9722a4c 100644
--- a/packages/studio/src/player/components/CompositionThumbnail.tsx
+++ b/packages/studio/src/player/components/CompositionThumbnail.tsx
@@ -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
diff --git a/packages/studio/src/components/timeline/EditModal.tsx b/packages/studio/src/player/components/EditModal.tsx
similarity index 94%
rename from packages/studio/src/components/timeline/EditModal.tsx
rename to packages/studio/src/player/components/EditModal.tsx
index b5523a26b..234f12e73 100644
--- a/packages/studio/src/components/timeline/EditModal.tsx
+++ b/packages/studio/src/player/components/EditModal.tsx
@@ -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
diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx
index 618dda722..c13306cf8 100644
--- a/packages/studio/src/player/components/Player.tsx
+++ b/packages/studio/src/player/components/Player.tsx
@@ -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;
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index 0f10cf508..82c43decb 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -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) => {
diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts
index aa81613f2..3e9d6170a 100644
--- a/packages/studio/src/player/components/Timeline.test.ts
+++ b/packages/studio/src/player/components/Timeline.test.ts
@@ -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");
   });
 });
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index 1ecc604f3..63404d139 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -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>
diff --git a/packages/studio/src/player/components/VideoThumbnail.tsx b/packages/studio/src/player/components/VideoThumbnail.tsx
index 5cc325a65..83a134e4e 100644
--- a/packages/studio/src/player/components/VideoThumbnail.tsx
+++ b/packages/studio/src/player/components/VideoThumbnail.tsx
@@ -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;
diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts
index c8bc4f94e..44280b15d 100644
--- a/packages/studio/src/player/hooks/useTimelinePlayer.ts
+++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts
@@ -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);
diff --git a/packages/studio/src/player/lib/useMountEffect.ts b/packages/studio/src/player/lib/useMountEffect.ts
deleted file mode 100644
index 75bb7f23b..000000000
--- a/packages/studio/src/player/lib/useMountEffect.ts
+++ /dev/null
@@ -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, []);
-}
diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts
index 2e9908685..2a4a5834b 100644
--- a/packages/studio/vite.config.ts
+++ b/packages/studio/vite.config.ts
@@ -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");
-
-          // Extract content from <template> if present
-          const templateMatch = rawComp.match(/<template>([\s\S]*)<\/template>/i);
-          let 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>`
-                  : "")
-              );
-            },
-          );
-
-          // Build a standalone HTML page with GSAP + runtime
+          const rawComp = readFileSync(compFile, "utf-8");
+
+          // Extract content from <template> wrapper (compositions are always templates)
+          const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
+          const content = templateMatch ? templateMatch[1] : rawComp;
+
+          // 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] : "";
+          }
+
+          // 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",
