Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/studio/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
node_modules/
data/projects/
123 changes: 110 additions & 13 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { FileTree } from "./components/editor/FileTree";
import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
import { VideoThumbnail } from "./player/components/VideoThumbnail";
import type { TimelineElement } from "./player/store/playerStore";
import {
XIcon,
CodeIcon,
Expand Down Expand Up @@ -80,6 +83,24 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
const errors = findings.filter((f) => f.severity === "error");
const warnings = findings.filter((f) => f.severity === "warning");
const hasIssues = findings.length > 0;
const [copied, setCopied] = useState(false);

const handleCopyToAgent = async () => {
const lines = findings.map((f) => {
let line = `[${f.severity}] ${f.message}`;
if (f.file) line += `\n File: ${f.file}`;
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
return line;
});
const text = `Fix these HyperFrames lint issues:\n\n${lines.join("\n\n")}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};

return (
<div
Expand All @@ -98,8 +119,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
<WarningIcon size={18} className="text-red-400" weight="fill" />
</div>
) : (
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center">
<CheckCircleIcon size={18} className="text-green-400" weight="fill" />
<div className="w-8 h-8 rounded-full bg-[#3CE6AC]/10 flex items-center justify-center">
<CheckCircleIcon size={18} className="text-[#3CE6AC]" weight="fill" />
</div>
)}
<div>
Expand All @@ -119,7 +140,19 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
</button>
</div>

{/* Findings */}
{/* Copy to agent + findings */}
{hasIssues && (
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
<button
onClick={handleCopyToAgent}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
copied ? "bg-green-600 text-white" : "bg-blue-600 hover:bg-blue-500 text-white"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-3">
{!hasIssues && (
<div className="py-8 text-center text-neutral-500 text-sm">
Expand All @@ -139,8 +172,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
{f.fixHint && (
<div className="flex items-start gap-1 mt-1.5">
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-400">{f.fixHint}</p>
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
</div>
)}
</div>
Expand All @@ -156,8 +189,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file && <p className="text-xs text-neutral-600 font-mono mt-0.5">{f.file}</p>}
{f.fixHint && (
<div className="flex items-start gap-1 mt-1.5">
<CaretRightIcon size={10} className="text-blue-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-400">{f.fixHint}</p>
<CaretRightIcon size={10} className="text-[#3CE6AC] flex-shrink-0 mt-0.5" />
<p className="text-xs text-[#3CE6AC]">{f.fixHint}</p>
</div>
)}
</div>
Expand Down Expand Up @@ -202,6 +235,67 @@ export function StudioApp() {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fileTree, setFileTree] = useState<string[]>([]);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());

const renderClipContent = useCallback(
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
const pid = projectIdRef.current;
if (!pid) return null;

// Resolve composition source path using the compIdToSrc map
let compSrc = el.compositionSrc;
if (compSrc && compIdToSrc.size > 0) {
const resolved =
compIdToSrc.get(el.id) ||
compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, ""));
if (resolved) compSrc = resolved;
}

if (compSrc) {
const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`;
return (
<CompositionThumbnail
previewUrl={previewUrl}
label={el.id || el.tag}
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
/>
);
}

if ((el.tag === "video" || el.tag === "img") && el.src) {
const mediaSrc = el.src.startsWith("http")
? el.src
: `/api/projects/${pid}/preview/${el.src}`;
return (
<VideoThumbnail
videoSrc={mediaSrc}
label={el.id || el.tag}
labelColor={style.label}
duration={el.duration}
/>
);
}

// HTML scene divs — render from index.html at the scene's time
if (el.tag === "div" && el.duration > 0) {
const previewUrl = `/api/projects/${pid}/preview`;
return (
<CompositionThumbnail
previewUrl={previewUrl}
label={el.id || el.tag}
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
/>
);
}

return null;
},
[compIdToSrc],
);
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
const [linting, setLinting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
Expand All @@ -212,6 +306,7 @@ export function StudioApp() {
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.
Expand Down Expand Up @@ -462,9 +557,11 @@ export function StudioApp() {
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={
editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
}
renderClipContent={renderClipContent}
onCompIdToSrcChange={setCompIdToSrc}
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
}}
/>
</div>

Expand All @@ -488,7 +585,7 @@ export function StudioApp() {
<button
onClick={handleRender}
disabled={renderState === "rendering"}
className="h-8 px-3 rounded-lg bg-blue-600 border border-blue-500 text-xs font-semibold text-white hover:bg-blue-500 transition-colors disabled:opacity-60 tabular-nums"
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"
>
{renderState === "rendering"
? `${Math.round(renderProgress)}%`
Expand Down Expand Up @@ -517,7 +614,7 @@ export function StudioApp() {
<button
onClick={handleRender}
disabled={renderState === "rendering"}
className="px-2 py-1 rounded text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition-colors disabled:opacity-60 tabular-nums"
className="px-2 py-1 rounded text-[11px] font-semibold text-[#3CE6AC] hover:text-[#5EEFC0] transition-colors disabled:opacity-60 tabular-nums"
>
{renderState === "rendering" ? `${Math.round(renderProgress)}%` : "Export MP4"}
</button>
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/editor/SourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
case "js":
case "typescript":
case "ts":
return javascript({ typescript: language === "typescript" || language === "ts" });
return javascript({
typescript: language === "typescript" || language === "ts",
});
default:
return html();
}
Expand Down
69 changes: 57 additions & 12 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import { NLEPreview } from "./NLEPreview";
Expand All @@ -9,14 +10,25 @@ interface NLELayoutProps {
portrait?: boolean;
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
previewOverlay?: ReactNode;
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
timelineToolbar?: ReactNode;
/** Slot rendered below the timeline tracks */
timelineFooter?: ReactNode;
/** Increment to force the preview to reload (e.g., after file writes) */
refreshKey?: number;
/** Navigate to a specific composition path (e.g., "compositions/intro.html") */
activeCompositionPath?: string | null;
/** Callback to expose the iframe ref (for element picker, etc.) */
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
/** Callback when the viewed composition changes (drill-down/back) */
onCompositionChange?: (compositionPath: string | null) => void;
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
}

const MIN_TIMELINE_H = 100;
Expand All @@ -27,19 +39,32 @@ export const NLELayout = memo(function NLELayout({
projectId,
portrait,
previewOverlay,
timelineToolbar,
timelineFooter,
refreshKey,
activeCompositionPath,
onIframeRef,
onCompositionChange,
renderClipContent,
onCompIdToSrcChange,
}: NLELayoutProps) {
const {
iframeRef,
togglePlay,
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);
if (prevProjectIdRef.current !== projectId) {
prevProjectIdRef.current = projectId;
resetPlayer();
}

// Preserve seek position when refreshKey changes (iframe will remount via key prop).
const prevRefreshKeyRef = useRef(refreshKey);
if (refreshKey !== prevRefreshKeyRef.current) {
Expand All @@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({

// Composition ID → actual file path mapping, built from the raw index.html
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
useEffect(() => {
useMountEffect(() => {
fetch(`/api/projects/${projectId}/files/index.html`)
.then((r) => r.json())
.then((data: { content?: string }) => {
Expand All @@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({
if (id && src) map.set(id, src);
}
setCompIdToSrc(map);
onCompIdToSrcChange?.(map);
})
.catch(() => {});
}, [projectId]);
});

// Composition drill-down stack
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
]);

// Wrap setCompositionStack to auto-notify parent on composition change
const onCompositionChangeRef = useRef(onCompositionChange);
onCompositionChangeRef.current = onCompositionChange;
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
setCompositionStack((prev) => {
const next = typeof action === "function" ? action(prev) : action;
const id = next[next.length - 1]?.id;
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
return next;
});
}, []);

// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const isDragging = useRef(false);
Expand Down Expand Up @@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({
usePlayerStore.getState().setElements([]);

// Toggle: if already viewing this composition, go back to parent (like Premiere)
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
const currentId = prev[prev.length - 1].id;
if (currentId === resolvedPath && prev.length > 1) {
return prev.slice(0, -1);
Expand All @@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectId, compIdToSrc],
);

// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback((index: number) => {
usePlayerStore.getState().setElements([]);
setCompositionStack((prev) => prev.slice(0, index + 1));
updateCompositionStack((prev) => prev.slice(0, index + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Navigate to a composition when activeCompositionPath changes
Expand All @@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({
prevActiveCompRef.current = activeCompositionPath;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
if (activeCompositionPath === "index.html") {
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
setCompositionStack((prev) => {
updateCompositionStack((prev) => {
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
Expand Down Expand Up @@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape" && compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[compositionStack.length],
);

Expand Down Expand Up @@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (compositionStack.length > 1) {
setCompositionStack((prev) => prev.slice(0, -1));
updateCompositionStack((prev) => prev.slice(0, -1));
}
}}
>
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
{timelineToolbar}
<Timeline
onSeek={seek}
onDrillDown={handleDrillDown}
renderClipContent={renderClipContent}
/>
{timelineFooter}
</div>
</div>
Expand Down
Loading
Loading