diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index 90e022d07..1acb2aad0 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -94,7 +94,7 @@ export const PropertyPanel = memo(function PropertyPanel({
variant="secondary"
size="sm"
onClick={isPickMode ? onDisablePick : onEnablePick}
- className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
+ className={`mt-3 ${isPickMode ? "bg-studio-accent/20 text-studio-accent border-studio-accent/30" : ""}`}
>
{isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
@@ -109,7 +109,7 @@ export const PropertyPanel = memo(function PropertyPanel({
{/* Header */}
- {element.selector}
+ {element.selector}
}
diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx
index b29d267a8..9e47a9e77 100644
--- a/packages/studio/src/components/nle/NLELayout.tsx
+++ b/packages/studio/src/components/nle/NLELayout.tsx
@@ -351,7 +351,7 @@ export const NLELayout = memo(function NLELayout({
<>
{/* Resize divider */}
void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm") => void;
@@ -33,7 +34,7 @@ function FormatExportButton({
onStartRender(format)}
disabled={isRendering}
- className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
+ className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
{isRendering ? "Rendering..." : "Export"}
@@ -43,21 +44,21 @@ function FormatExportButton({
export const RenderQueue = memo(function RenderQueue({
jobs,
+ projectId,
onDelete,
onClearCompleted,
onStartRender,
isRendering,
}: RenderQueueProps) {
const listRef = useRef(null);
- const prevCount = useRef(jobs.length);
- // Auto-scroll to bottom when new jobs are added (adjust during render)
- if (jobs.length > prevCount.current && listRef.current) {
- queueMicrotask(() => {
- listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
- });
- }
- prevCount.current = jobs.length;
+ // Auto-scroll to bottom when new jobs are added.
+ // Runs in an effect to avoid side effects during the render phase.
+ useEffect(() => {
+ if (listRef.current) {
+ listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
+ }
+ }, [jobs.length]);
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
@@ -111,7 +112,12 @@ export const RenderQueue = memo(function RenderQueue({
) : (
jobs.map((job) => (
-
onDelete(job.id)} />
+ onDelete(job.id)}
+ />
))
)}
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx
index b3b9e4166..09a78b8a1 100644
--- a/packages/studio/src/components/renders/RenderQueueItem.tsx
+++ b/packages/studio/src/components/renders/RenderQueueItem.tsx
@@ -4,6 +4,7 @@ import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
job: RenderJob;
+ projectId: string;
onDelete: () => void;
}
@@ -24,26 +25,30 @@ function formatTimeAgo(timestamp: number): string {
export const RenderQueueItem = memo(function RenderQueueItem({
job,
+ projectId,
onDelete,
}: RenderQueueItemProps) {
const [hovered, setHovered] = useState(false);
+ // Direct file URL — serves from disk, survives server restarts
+ const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
+
const handleOpen = useCallback(() => {
- window.open(`/api/render/${job.id}/view`, "_blank");
- }, [job.id]);
+ window.open(fileSrc, "_blank");
+ }, [fileSrc]);
const handleDownload = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const a = document.createElement("a");
- a.href = `/api/render/${job.id}/download`;
+ a.href = fileSrc;
a.download = job.filename;
a.click();
},
- [job.id, job.filename],
+ [fileSrc, job.filename],
);
- const viewSrc = `/api/render/${job.id}/view`;
+ const viewSrc = fileSrc;
const isComplete = job.status === "complete";
return (
@@ -85,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status === "rendering" && (
)}
{job.status === "failed" && (
@@ -117,11 +122,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{job.stage || "Rendering"}
- {job.progress}%
+ {job.progress}%
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx
index 9c2169303..9207a9a3c 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.tsx
+++ b/packages/studio/src/components/sidebar/AssetsTab.tsx
@@ -1,5 +1,6 @@
import { memo, useState, useCallback, useRef } from "react";
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
+import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
interface AssetsTabProps {
projectId: string;
@@ -7,11 +8,6 @@ interface AssetsTabProps {
onImport?: (files: FileList) => void;
}
-const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
-const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
-const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
-const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
-
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
function AssetThumbnail({
serveUrl,
@@ -104,7 +100,7 @@ function AssetCard({
onPointerLeave={() => setHovered(false)}
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
isCopied
- ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
: "border-l-2 border-transparent hover:bg-neutral-800/50"
}`}
>
@@ -131,7 +127,7 @@ function AssetCard({
{name}
{isCopied ? (
-
Copied!
+
Copied!
) : (
{asset}
)}
@@ -168,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
return (
{
e.preventDefault();
setDragOver(true);
diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx
index 6a2d66bbb..e1f7400e0 100644
--- a/packages/studio/src/components/sidebar/CompositionsTab.tsx
+++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx
@@ -1,4 +1,4 @@
-import { memo, useState } from "react";
+import { memo, useRef, useState } from "react";
interface CompositionsTabProps {
projectId: string;
@@ -19,6 +19,17 @@ function CompCard({
onSelect: () => void;
}) {
const [hovered, setHovered] = useState(false);
+ const hoverTimer = useRef
| null>(null);
+ const handleEnter = () => {
+ hoverTimer.current = setTimeout(() => setHovered(true), 300);
+ };
+ const handleLeave = () => {
+ if (hoverTimer.current) {
+ clearTimeout(hoverTimer.current);
+ hoverTimer.current = null;
+ }
+ setHovered(false);
+ };
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
@@ -26,11 +37,11 @@ function CompCard({
return (
setHovered(true)}
- onPointerLeave={() => setHovered(false)}
+ onPointerEnter={handleEnter}
+ onPointerLeave={handleLeave}
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
isActive
- ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
: "border-l-2 border-transparent hover:bg-neutral-800/50"
}`}
>
diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx
index 2632161fc..a4665d185 100644
--- a/packages/studio/src/components/sidebar/LeftSidebar.tsx
+++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx
@@ -82,7 +82,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onClick={() => selectTab("code")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "code"
- ? "text-neutral-200 border-b-2 border-[#3CE6AC]"
+ ? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
@@ -93,7 +93,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onClick={() => selectTab("compositions")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "compositions"
- ? "text-neutral-200 border-b-2 border-blue-500"
+ ? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
@@ -104,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onClick={() => selectTab("assets")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "assets"
- ? "text-neutral-200 border-b-2 border-blue-500"
+ ? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
diff --git a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
index ff45cf3b4..f1c44e33a 100644
--- a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
+++ b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
/**
* Extracts a representative JPEG frame from a video URL using a hidden
@@ -7,12 +7,8 @@ import { useState, useEffect, useRef } from "react";
*/
export function VideoFrameThumbnail({ src }: { src: string }) {
const [frame, setFrame] = useState
(null);
- const didExtract = useRef(false);
useEffect(() => {
- if (didExtract.current) return;
- didExtract.current = true;
-
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
diff --git a/packages/studio/src/hooks/useCodeEditor.ts b/packages/studio/src/hooks/useCodeEditor.ts
deleted file mode 100644
index 467343db7..000000000
--- a/packages/studio/src/hooks/useCodeEditor.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useState, useCallback } from "react";
-
-export interface OpenFile {
- path: string;
- content: string;
- savedContent: string;
- isDirty: boolean;
-}
-
-export interface UseCodeEditorReturn {
- openFiles: OpenFile[];
- activeFilePath: string | null;
- activeFile: OpenFile | null;
- openFile: (path: string, content: string) => void;
- closeFile: (path: string) => void;
- setActiveFile: (path: string) => void;
- updateContent: (content: string) => void;
- markSaved: (path: string) => void;
- /** External update — updates saved content, shows reload indicator */
- externalUpdate: (path: string, content: string) => void;
-}
-
-export function useCodeEditor(): UseCodeEditorReturn {
- const [openFiles, setOpenFiles] = useState([]);
- const [activeFilePath, setActiveFilePath] = useState(null);
-
- const activeFile = openFiles.find((f) => f.path === activeFilePath) ?? null;
-
- const openFile = useCallback((path: string, content: string) => {
- setOpenFiles((prev) => {
- const existing = prev.find((f) => f.path === path);
- if (existing) return prev;
- return [...prev, { path, content, savedContent: content, isDirty: false }];
- });
- setActiveFilePath(path);
- }, []);
-
- const closeFile = useCallback(
- (path: string) => {
- setOpenFiles((prev) => prev.filter((f) => f.path !== path));
- setActiveFilePath((prev) => {
- if (prev === path) {
- const remaining = openFiles.filter((f) => f.path !== path);
- return remaining.length > 0 ? remaining[remaining.length - 1].path : null;
- }
- return prev;
- });
- },
- [openFiles],
- );
-
- const updateContent = useCallback(
- (content: string) => {
- setOpenFiles((prev) =>
- prev.map((f) =>
- f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f,
- ),
- );
- },
- [activeFilePath],
- );
-
- const markSaved = useCallback((path: string) => {
- setOpenFiles((prev) =>
- prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)),
- );
- }, []);
-
- const externalUpdate = useCallback((path: string, content: string) => {
- setOpenFiles((prev) =>
- prev.map((f) =>
- f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f,
- ),
- );
- }, []);
-
- return {
- openFiles,
- activeFilePath,
- activeFile,
- openFile,
- closeFile,
- setActiveFile: setActiveFilePath,
- updateContent,
- markSaved,
- externalUpdate,
- };
-}
diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts
index 9c7b9be44..d788f6bba 100644
--- a/packages/studio/src/index.ts
+++ b/packages/studio/src/index.ts
@@ -9,7 +9,6 @@ export {
Player,
PlayerControls,
Timeline,
- PreviewPanel,
VideoThumbnail,
CompositionThumbnail,
useTimelinePlayer,
@@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree";
export { StudioApp } from "./App";
// Hooks
-export { useCodeEditor } from "./hooks/useCodeEditor";
-export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
export { useElementPicker } from "./hooks/useElementPicker";
export type { PickedElement } from "./hooks/useElementPicker";
diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx
index 66709df21..e558b1b90 100644
--- a/packages/studio/src/player/components/CompositionThumbnail.tsx
+++ b/packages/studio/src/player/components/CompositionThumbnail.tsx
@@ -1,18 +1,12 @@
/**
- * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
+ * CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
*
- * Requests multiple thumbnails at different timestamps across the clip duration
- * and tiles them horizontally — like VideoThumbnail does for video clips.
- * Each frame is a separate from /api/projects/:id/thumbnail/:path?t=X.
- *
- * Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
+ * Takes one screenshot at the midpoint of the clip and covers the full width —
+ * same approach as After Effects for precomps. This avoids the 1-2s per-frame
+ * Puppeteer cost of rendering multiple filmstrip frames.
*/
-import { memo, useRef, useState, useCallback } from "react";
-import { useMountEffect } from "../../hooks/useMountEffect";
-
-const CLIP_HEIGHT = 66;
-const MAX_UNIQUE_FRAMES = 6;
+import { memo } from "react";
interface CompositionThumbnailProps {
previewUrl: string;
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
labelColor,
seekTime = 2,
duration = 5,
- width = 1920,
- height = 1080,
}: CompositionThumbnailProps) {
- const [containerWidth, setContainerWidth] = useState(0);
- const roRef = useRef(null);
-
- const setRef = useCallback((el: HTMLDivElement | null) => {
- roRef.current?.disconnect();
- if (!el) return;
-
- // Walk up to data-clip parent for accurate width
- let target: HTMLElement = el;
- let parent = el.parentElement;
- let depth = 0;
- while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
- parent = parent.parentElement;
- depth++;
- }
- if (parent?.hasAttribute("data-clip")) target = parent;
-
- requestAnimationFrame(() => {
- const w = target.clientWidth || target.getBoundingClientRect().width;
- if (w > 0) setContainerWidth(w);
- });
-
- roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
- roRef.current.observe(target);
- }, []);
-
- useMountEffect(() => () => {
- roRef.current?.disconnect();
- });
-
- // Convert preview URL to thumbnail base URL
+ // Single screenshot at the midpoint of the clip
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
-
- // Calculate frame layout
- const aspect = width / height;
- const frameW = Math.round(CLIP_HEIGHT * aspect);
- const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
- const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
-
- // Each frame tile represents a real position in the clip.
- // Offset slightly (0.5s) into each segment to avoid landing on transition
- // points where content is invisible due to fade-in/fade-out animations.
- const timestamps: number[] = [];
- const pad = Math.min(0.5, duration * 0.05);
- for (let i = 0; i < uniqueFrames; i++) {
- const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
- const raw = seekTime + frac * duration;
- // Clamp to [pad, duration - pad] to stay inside visible content
- timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
- }
+ const midTime = seekTime + duration / 2;
+ const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
return (
-
- {/* Film strip — each tile maps to its real timeline position */}
-
- {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 (
-
-
{
- (e.target as HTMLImageElement).style.opacity = "1";
- }}
- className="absolute inset-0 w-full h-full object-contain"
- style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
- />
-
- );
- })}
-
+
+
{
+ (e.target as HTMLImageElement).style.opacity = "1";
+ }}
+ className="absolute inset-0 w-full h-full object-cover"
+ style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
+ />
{/* Label */}
-
+
{formatTime(start)} — {formatTime(end)}
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
{elementsInRange.map((el) => (
- #{el.id}
+ #{el.id}
{el.tag}
))}
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
}}
placeholder="What should change?"
rows={2}
- className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
+ className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
/>
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
copied
? "bg-green-500/20 text-green-400 border border-green-500/30"
- : "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
+ : "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
- {!copied &&
Cmd+Enter }
+ {!copied &&
Cmd+Enter }
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index 86a8a971d..684b9de76 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState, useCallback, memo } from "react";
+import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -30,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
const progressThumbRef = useRef
(null);
const timeDisplayRef = useRef(null);
const seekBarRef = useRef(null);
+ const sliderRef = useRef(null);
+ const speedMenuContainerRef = useRef(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
@@ -43,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
+ if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
};
const unsub = liveTime.subscribe(updateProgress);
updateProgress(usePlayerStore.getState().currentTime);
@@ -64,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
};
});
+ useEffect(() => {
+ if (!showSpeedMenu) return;
+ const handleMouseDown = (e: MouseEvent) => {
+ if (
+ speedMenuContainerRef.current &&
+ !speedMenuContainerRef.current.contains(e.target as Node)
+ ) {
+ setShowSpeedMenu(false);
+ }
+ };
+ document.addEventListener("mousedown", handleMouseDown);
+ return () => {
+ document.removeEventListener("mousedown", handleMouseDown);
+ };
+ }, [showSpeedMenu]);
+
const seekFromClientX = useCallback(
(clientX: number) => {
const bar = seekBarRef.current;
@@ -153,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
{/* Seek bar — teal progress fill */}
{
+ (seekBarRef as React.MutableRefObject).current = el;
+ (sliderRef as React.MutableRefObject).current = el;
+ }}
role="slider"
tabIndex={0}
aria-label="Seek"
@@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
{/* Speed control */}
-
+
setShowSpeedMenu((v) => !v)}
@@ -235,7 +257,7 @@ export const PlayerControls = memo(function PlayerControls({
onClick={onToggleTimeline}
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
timelineVisible
- ? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={timelineVisible ? "Hide timeline" : "Show timeline"}
diff --git a/packages/studio/src/player/components/PreviewPanel.tsx b/packages/studio/src/player/components/PreviewPanel.tsx
deleted file mode 100644
index b560b0c6b..000000000
--- a/packages/studio/src/player/components/PreviewPanel.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import type { ReactNode, Ref } from "react";
-import { Player } from "./Player";
-import { PlayerControls } from "./PlayerControls";
-import { Timeline } from "./Timeline";
-
-interface RenderStatus {
- state: "idle" | "rendering" | "complete" | "error";
- stage?: string;
- progress?: number;
- error?: string;
- onRender?: () => void;
-}
-
-interface PreviewPanelProps {
- projectId: string | null;
- hasProject: boolean;
- portrait: boolean;
- iframeRef: Ref;
- onIframeLoad: () => void;
- onTogglePlay: () => void;
- onSeek: (t: number) => void;
- /** Optional render status — pass to show rendering progress/state */
- renderStatus?: RenderStatus;
- /** Optional slot for custom content below the timeline */
- children?: ReactNode;
-}
-
-export function PreviewPanel({
- projectId,
- hasProject,
- portrait,
- iframeRef,
- onIframeLoad,
- onTogglePlay,
- onSeek,
- renderStatus,
- children,
-}: PreviewPanelProps) {
- const renderState = renderStatus?.state ?? "idle";
-
- return (
-
- {hasProject && projectId ? (
- <>
- {/* Player — takes all remaining space, constrained for portrait */}
-
-
- {/* Controls — fixed height */}
-
-
- {/* Timeline — capped height, internal scroll */}
-
-
-
-
- {/* Render status — only shown when actively rendering, complete, or error */}
- {renderStatus &&
- (renderState === "rendering" ||
- renderState === "complete" ||
- renderState === "error") && (
-
- {renderState === "rendering" && (
-
-
-
-
- {renderStatus.stage || "Rendering..."}
-
-
-
- )}
- {renderState === "complete" && (
-
- )}
- {renderState === "error" && (
-
-
-
-
-
-
- {renderStatus.error}
- {renderStatus.onRender && (
-
- Retry
-
- )}
-
- )}
-
- )}
-
- {/* Optional custom slot */}
- {children}
- >
- ) : (
-
-
-
-
Preview will appear here
-
- Send a message to generate a video composition
-
-
-
- )}
-
- );
-}
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index 0ac424ce9..64efd0b6f 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
return { major, minor };
}
-/** @deprecated Use formatTime from '../lib/time' instead */
-export const formatTick = formatTime;
-
/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
@@ -170,11 +167,6 @@ interface TimelineProps {
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
/** Called when files are dropped onto the empty timeline */
onFileDrop?: (files: File[]) => void;
- /** Called when a clip is moved, resized, or changes track via drag */
- onClipChange?: (
- elementId: string,
- updates: { start?: number; duration?: number; track?: number },
- ) => void;
}
export const Timeline = memo(function Timeline({
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (e.button !== 0) return;
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
- // Shift+click starts range selection
+ // Shift+click starts range selection — even on clips
if (e.shiftKey) {
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isRangeSelecting.current = true;
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
return;
}
+ // Normal click on a clip — let the clip handle it
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
+
isDragging.current = true;
setRangeSelection(null);
setShowPopover(false);
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
return (
{
e.preventDefault();
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
{isDragOver ? (
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
- className="text-blue-400 flex-shrink-0"
+ className="text-studio-accent flex-shrink-0"
>
-
Drop media files to import
+
Drop media files to import
>
) : (
<>
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
{/* Shift hint */}
{shiftHeld && !rangeSelection && (
-
+
Drag to select range
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
key={clipKey}
el={el}
pps={pps}
- trackH={TRACK_H}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={isHovered}
diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx
index b33c52e10..ba8deb741 100644
--- a/packages/studio/src/player/components/TimelineClip.tsx
+++ b/packages/studio/src/player/components/TimelineClip.tsx
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
interface TimelineClipProps {
el: TimelineElement;
pps: number;
- trackH: number;
clipY: number;
isSelected: boolean;
isHovered: boolean;
diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts
index 1c09cc5e4..cea14c0ca 100644
--- a/packages/studio/src/player/index.ts
+++ b/packages/studio/src/player/index.ts
@@ -2,7 +2,6 @@
export { Player } from "./components/Player";
export { PlayerControls } from "./components/PlayerControls";
export { Timeline } from "./components/Timeline";
-export { PreviewPanel } from "./components/PreviewPanel";
export { VideoThumbnail } from "./components/VideoThumbnail";
export { CompositionThumbnail } from "./components/CompositionThumbnail";
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 89eb44df2..8b5be919d 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -27,10 +27,6 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Pixels per second when in manual zoom mode */
pixelsPerSecond: number;
- /** Edit range selection */
- editRangeStart: number | null;
- editRangeEnd: number | null;
- editMode: boolean;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
@@ -39,11 +35,6 @@ interface PlayerState {
setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null) => void;
- setEditRange: (start: number | null, end: number | null) => void;
- setEditMode: (active: boolean) => void;
- updateElementStart: (elementId: string, newStart: number) => void;
- updateElementDuration: (elementId: string, newDuration: number) => void;
- updateElementTrack: (elementId: string, newTrack: number) => void;
updateElement: (
elementId: string,
updates: Partial
>,
@@ -76,9 +67,6 @@ export const usePlayerStore = create((set) => ({
playbackRate: 1,
zoomMode: "fit",
pixelsPerSecond: 100,
- editRangeStart: null,
- editRangeEnd: null,
- editMode: false,
setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => set({ playbackRate: rate }),
@@ -89,26 +77,13 @@ export const usePlayerStore = create((set) => ({
setTimelineReady: (ready) => set({ timelineReady: ready }),
setElements: (elements) => set({ elements }),
setSelectedElementId: (id) => set({ selectedElementId: id }),
- setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
- setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
- updateElementStart: (elementId, newStart) =>
- set((state) => ({
- elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
- })),
- updateElementDuration: (elementId, newDuration) =>
- set((state) => ({
- elements: state.elements.map((el) =>
- el.id === elementId ? { ...el, duration: newDuration } : el,
- ),
- })),
- updateElementTrack: (elementId, newTrack) =>
- set((state) => ({
- elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
- })),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
})),
+ // Resets project-specific state when switching compositions.
+ // playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
+ // because they are user preferences that should survive project switches.
reset: () =>
set({
isPlaying: false,
diff --git a/packages/studio/src/utils/mediaTypes.ts b/packages/studio/src/utils/mediaTypes.ts
new file mode 100644
index 000000000..ebc41540d
--- /dev/null
+++ b/packages/studio/src/utils/mediaTypes.ts
@@ -0,0 +1,9 @@
+export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
+export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
+export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
+export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
+export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
+
+export function isMediaFile(path: string): boolean {
+ return MEDIA_EXT.test(path);
+}
diff --git a/packages/studio/tailwind.config.js b/packages/studio/tailwind.config.js
index baff0b04f..ecdc87de4 100644
--- a/packages/studio/tailwind.config.js
+++ b/packages/studio/tailwind.config.js
@@ -15,7 +15,7 @@ export default {
border: "#262626",
text: "#e5e5e5",
muted: "#737373",
- accent: "#00E3FF",
+ accent: "#3CE6AC",
},
},
},