refactor(studio): update layout, build config, and clean up exports

- NLELayout: add toolbar slot, composition breadcrumb, improved resizing
- App: update toolbar wiring, remove split/delete, add edit modal
- Remove AgentActivityTrack (unused)
- Update vite config with improved dev server and build settings
- Update package.json dependencies
- Simplify htmlEditor.ts (keep parseStyleString, mergeStyleIntoTag, findElementBlock)
- Update exports in index.ts
This commit is contained in:
Miguel Ángel
2026-03-28 19:40:23 +00:00
committed by Miguel Ángel
parent 0769678f46
commit fa4b1e4671
12 changed files with 674 additions and 187 deletions
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
data/projects/
+110 -13
View File
@@ -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,
@@ -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
@@ -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>
@@ -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">
@@ -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>
@@ -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>
@@ -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);
@@ -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.
@@ -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>
@@ -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)}%`
@@ -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>
@@ -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();
}
@@ -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";
@@ -9,7 +10,9 @@ 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;
@@ -17,6 +20,15 @@ interface NLELayoutProps {
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;
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
projectId,
portrait,
previewOverlay,
timelineToolbar,
timelineFooter,
refreshKey,
activeCompositionPath,
onIframeRef,
onCompositionChange,
renderClipContent,
onCompIdToSrcChange,
}: NLELayoutProps) {
const {
iframeRef,
@@ -38,8 +54,17 @@ 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);
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) {
@@ -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 }) => {
@@ -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);
@@ -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);
@@ -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
@@ -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` },
@@ -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],
);
@@ -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>
+1 -1
View File
@@ -16,7 +16,7 @@ export interface UseCodeEditorReturn {
setActiveFile: (path: string) => void;
updateContent: (content: string) => void;
markSaved: (path: string) => void;
/** External update from agent — updates saved content, shows reload indicator */
/** External update — updates saved content, shows reload indicator */
externalUpdate: (path: string, content: string) => void;
}
@@ -156,7 +156,11 @@ export function useElementPicker(
(
elementId: string,
selector: string,
op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
op: {
type: "inline-style" | "attribute" | "text-content";
property: string;
value: string;
},
) => {
const opts = optionsRef.current;
if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
+10 -1
View File
@@ -10,12 +10,14 @@ export {
PlayerControls,
Timeline,
PreviewPanel,
VideoThumbnail,
CompositionThumbnail,
useTimelinePlayer,
usePlayerStore,
liveTime,
formatTime,
} from "./player";
export type { TimelineElement, ZoomMode } from "./player";
export type { TimelineElement } from "./player";
// Editor
export { SourceEditor } from "./components/editor/SourceEditor";
@@ -27,4 +29,11 @@ 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";
// Utilities
export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
export type { PatchOperation } from "./utils/sourcePatcher";
export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
@@ -1,93 +0,0 @@
import { memo } from "react";
const TRACK_H = 20;
const GUTTER = 32;
export interface AgentActivity {
agentId: string;
name: string;
color: string;
/** Active work periods mapped to VIDEO time (not wall clock) */
periods: Array<{ start: number; end: number }>;
/** Element creation events at specific video times */
events: Array<{ time: number; type: "create" | "modify" }>;
}
interface AgentActivityTrackProps {
agents: AgentActivity[];
duration: number;
}
export const AgentActivityTrack = memo(function AgentActivityTrack({
agents,
duration,
}: AgentActivityTrackProps) {
if (agents.length === 0 || duration <= 0) return null;
return (
<div className="border-t border-neutral-800/30">
{/* Section header */}
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="4" />
</svg>
Agent Activity
</div>
{agents.map((agent) => (
<div key={agent.agentId} className="relative flex" style={{ height: TRACK_H }}>
{/* Gutter: agent name */}
<div
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
title={agent.name}
>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: agent.color }} />
</div>
{/* Lane */}
<div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
{/* Active work periods */}
{agent.periods.map((period, i) => {
const leftPct = (period.start / duration) * 100;
const widthPct = ((period.end - period.start) / duration) * 100;
return (
<div
key={`period-${i}`}
className="absolute top-1 bottom-1 rounded-sm"
style={{
left: `${leftPct}%`,
width: `${Math.max(widthPct, 0.5)}%`,
backgroundColor: `${agent.color}30`,
border: `1px solid ${agent.color}20`,
}}
/>
);
})}
{/* Events: diamonds for create, circles for modify */}
{agent.events.map((event, i) => {
const leftPct = (event.time / duration) * 100;
return (
<div
key={`event-${i}`}
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{event.type === "create" ? (
<div className="w-2 h-2 rotate-45" style={{ backgroundColor: agent.color }} />
) : (
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: agent.color }}
/>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
});
@@ -54,7 +54,7 @@ export const TimelineClip = memo(function TimelineClip({
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected
? "2px solid rgba(255,255,255,0.9)"
? `2px solid rgba(255,255,255,0.9)`
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
@@ -63,7 +63,6 @@ export const TimelineClip = memo(function TimelineClip({
: "none",
transition: "border-color 120ms, box-shadow 120ms",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
cursor: "pointer",
}}
title={
isComposition
+164
View File
@@ -0,0 +1,164 @@
/**
* HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
*/
/**
* Parse a CSS inline style string into a key-value map.
* e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
* { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
*/
export function parseStyleString(style: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of style.split(";")) {
const colonIdx = decl.indexOf(":");
if (colonIdx < 0) continue;
const key = decl.slice(0, colonIdx).trim();
const value = decl.slice(colonIdx + 1).trim();
if (key && value) result[key] = value;
}
return result;
}
/**
* Merge `newStyles` into an opening tag string's `style` attribute.
* - New values win over existing ones.
* - If no `style` attribute is present, one is added before the closing `>`.
*/
export function mergeStyleIntoTag(tag: string, newStyles: string): string {
if (!newStyles.trim()) return tag;
const incoming = parseStyleString(newStyles);
// Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
const match = tag.match(styleAttrRe);
if (match) {
const quote = match[1];
const existing = parseStyleString(match[2]);
const merged = { ...existing, ...incoming };
const serialized = Object.entries(merged)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
}
// No style attribute — insert one before the closing `>`
const serialized = Object.entries(incoming)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
// Handle self-closing tags (`/>`) and regular closing (`>`)
return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
}
/**
* Find the full element block (opening tag through closing tag) in the source.
* Uses quote-aware scanning to handle attributes containing >.
* Uses depth counting to handle nested same-name tags.
*/
export function findElementBlock(
html: string,
elementId: string,
): {
start: number;
end: number;
openTag: string;
tagName: string;
indent: string;
innerContent: string;
isSelfClosing: boolean;
} | null {
let idIdx = html.indexOf(`id="${elementId}"`);
if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
if (idIdx < 0) return null;
// Walk backward to find < and capture indent
let tagStart = idIdx;
while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
let indentStart = tagStart;
while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
const indent = html.slice(indentStart, tagStart);
// Walk forward from id to find the closing > of the opening tag
let tagEnd = idIdx;
let inQuote: string | null = null;
while (tagEnd < html.length) {
const ch = html[tagEnd];
if (inQuote) {
if (ch === inQuote) inQuote = null;
} else {
if (ch === '"' || ch === "'") inQuote = ch;
if (ch === ">") {
tagEnd++;
break;
}
}
tagEnd++;
}
const openTag = html.slice(tagStart, tagEnd);
const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
if (!tagNameMatch) return null;
const tagName = tagNameMatch[1];
const isSelfClosing =
openTag.trimEnd().endsWith("/>") ||
["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
if (isSelfClosing) {
return {
start: tagStart,
end: tagStart + openTag.length,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent: "",
isSelfClosing: true,
};
}
// Find matching closing tag using depth counting
const closeTag = `</${tagName.toLowerCase()}>`;
const openPattern = `<${tagName.toLowerCase()}`;
let depth = 0;
let pos = tagStart;
const lower = html.toLowerCase();
while (pos < html.length) {
if (lower.startsWith("<!--", pos)) {
const commentEnd = lower.indexOf("-->", pos + 4);
pos = commentEnd < 0 ? html.length : commentEnd + 3;
continue;
}
if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
depth++;
pos += openPattern.length;
continue;
}
if (lower.startsWith(closeTag, pos)) {
depth--;
if (depth === 0) {
const end = pos + closeTag.length;
const innerContent = html.slice(tagStart + openTag.length, pos);
return {
start: tagStart,
end,
openTag,
tagName,
indent: /^[\t ]*$/.test(indent) ? indent : "",
innerContent,
isSelfClosing: false,
};
}
pos += closeTag.length;
continue;
}
pos++;
}
return null;
}
+8 -2
View File
@@ -13,8 +13,14 @@
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["vite/client"]
"types": ["vite/client"],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["dist", "node_modules", "src/dev-server.ts"]
"exclude": ["dist", "node_modules", "src/**/*.test.ts"]
}
+312 -61
View File
@@ -8,6 +8,7 @@ import {
writeFileSync,
lstatSync,
realpathSync,
createReadStream,
} from "node:fs";
import { join, resolve, sep } from "node:path";
@@ -20,6 +21,53 @@ function isSafePath(base: string, resolved: string): boolean {
// Lazy-load the bundler via Vite's SSR module loader (resolves .ts imports correctly)
let _bundler: ((dir: string) => Promise<string>) | null = null;
// Shared Puppeteer browser instance — lazy-init, reused across thumbnail requests
let _browser: import("puppeteer-core").Browser | null = null;
let _browserLaunchPromise: Promise<import("puppeteer-core").Browser> | null = null;
async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | null> {
if (_browser?.connected) return _browser;
if (_browserLaunchPromise) return _browserLaunchPromise;
_browserLaunchPromise = (async () => {
const puppeteer = await import("puppeteer-core");
const executablePath = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
].find((p) => existsSync(p));
if (!executablePath) return null;
_browser = await puppeteer.default.launch({
headless: true,
executablePath,
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
_browserLaunchPromise = null;
return _browser;
})();
return _browserLaunchPromise;
}
// Render job store with TTL cleanup (fixes globalThis memory leak)
const renderJobs = new Map<
string,
{ id: string; status: string; progress: number; outputPath: string }
>();
// Only run cleanup interval in dev mode — setInterval keeps the process
// alive and prevents `vite build` from exiting, causing CI timeouts.
if (process.env.NODE_ENV !== "production" && !process.argv.includes("build")) {
setInterval(() => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if (
(job.status === "complete" || job.status === "failed") &&
now - parseInt(key.split("-").pop() || "0") > 300_000
) {
renderJobs.delete(key);
}
}
}, 60_000);
}
/** Minimal project API for standalone dev mode */
function devProjectApi(): Plugin {
const dataDir = resolve(__dirname, "data/projects");
@@ -44,26 +92,146 @@ function devProjectApi(): Plugin {
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/api/")) return next();
// Render endpoints — not yet wired up in standalone studio
if (
req.url.startsWith("/api/render/") ||
(req.method === "POST" && req.url.match(/\/api\/projects\/[^/]+\/render/))
) {
res.writeHead(501, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Render not available in standalone studio mode" }));
// ── Render endpoints ──────────────────────────────────────────
const PRODUCER_URL = (process.env.PRODUCER_SERVER_URL || "http://127.0.0.1:9847").replace(
/\/+$/,
"",
);
// POST /api/projects/:id/render — start a render job via producer
const renderMatch =
req.method === "POST" && req.url.match(/\/api\/projects\/([^/]+)\/render/);
if (renderMatch) {
const pid = renderMatch[1];
const pDir = join(dataDir, pid);
if (!existsSync(pDir)) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Project not found" }));
return;
}
const jobId = `${pid}-${Date.now()}`;
const outputDir = resolve(dataDir, "../renders");
if (!existsSync(outputDir)) {
const { mkdirSync: mk } = await import("fs");
mk(outputDir, { recursive: true });
}
const outputPath = join(outputDir, `${jobId}.mp4`);
// Store job state — referenced by the SSE progress endpoint and the fetch callback below
const _jobState = { id: jobId, status: "rendering", progress: 0, outputPath };
renderJobs.set(jobId, _jobState);
// Start render in background
fetch(`${PRODUCER_URL}/render/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectDir: pDir, outputPath, fps: 30, quality: "standard" }),
})
.then(async (resp) => {
if (!resp.ok || !resp.body) {
_jobState.status = "failed";
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() || "";
for (const block of blocks) {
const data = block
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).trim())
.join("");
if (!data) continue;
try {
const evt = JSON.parse(data);
if (evt.type === "progress") {
_jobState.progress = evt.progress;
}
if (evt.type === "complete") {
_jobState.status = "complete";
_jobState.outputPath = evt.outputPath || outputPath;
}
if (evt.type === "error") {
_jobState.status = "failed";
}
} catch {}
}
}
if (_jobState.status === "rendering") _jobState.status = "complete";
})
.catch(() => {
_jobState.status = "failed";
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ jobId, status: "rendering" }));
return;
}
// GET /api/runtime.js — serve the HyperFrames runtime
if (req.method === "GET" && req.url === "/api/runtime.js") {
const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js");
if (existsSync(cliRuntime)) {
res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-store" });
res.end(readFileSync(cliRuntime, "utf-8"));
} else {
res.writeHead(404);
res.end("runtime not built");
// GET /api/render/:jobId/progress — SSE progress stream
if (
req.method === "GET" &&
req.url.startsWith("/api/render/") &&
req.url.endsWith("/progress")
) {
const jobId = req.url.replace("/api/render/", "").replace("/progress", "");
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const interval = setInterval(() => {
const state = renderJobs.get(jobId) as { status: string; progress: number } | undefined;
if (!state) {
clearInterval(interval);
res.end();
return;
}
res.write(
`event: progress\ndata: ${JSON.stringify({ status: state.status, progress: state.progress })}\n\n`,
);
if (state.status === "complete" || state.status === "failed") {
clearInterval(interval);
setTimeout(() => res.end(), 100);
}
}, 500);
req.on("close", () => clearInterval(interval));
return;
}
// GET /api/render/:jobId/download — serve the rendered MP4
if (
req.method === "GET" &&
req.url.startsWith("/api/render/") &&
req.url.endsWith("/download")
) {
const jobId = req.url.replace("/api/render/", "").replace("/download", "");
const jobState = renderJobs.get(jobId) as
| { outputPath?: string; status: string }
| undefined;
if (
!jobState ||
jobState.status !== "complete" ||
!jobState.outputPath ||
!existsSync(jobState.outputPath)
) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Render not ready or not found" }));
return;
}
const fileStat = statSync(jobState.outputPath);
res.writeHead(200, {
"Content-Type": "video/mp4",
"Content-Length": String(fileStat.size),
"Content-Disposition": `attachment; filename="${jobId}.mp4"`,
});
const stream = createReadStream(jobState.outputPath);
stream.pipe(res);
return;
}
@@ -158,8 +326,10 @@ function devProjectApi(): Plugin {
// GET /api/projects/:id
if (req.method === "GET" && !rest) {
const files: string[] = [];
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
function walk(d: string, prefix: string) {
for (const entry of readdirSync(d, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) walk(join(d, entry.name), rel);
else files.push(rel);
@@ -178,21 +348,25 @@ function devProjectApi(): Plugin {
let bundled = bundler
? await bundler(projectDir)
: readFileSync(join(projectDir, "index.html"), "utf-8");
// Inject <base> so relative asset paths resolve through /preview/ route
const baseTag = `<base href="/api/projects/${projectId}/preview/">`;
if (bundled.includes("<head>")) {
bundled = bundled.replace("<head>", `<head>${baseTag}`);
} else {
bundled = baseTag + bundled;
// Inject runtime if not already present
const runtimeUrl =
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
if (!bundled.includes("hyperframe.runtime")) {
const runtimeTag = `<script src="${runtimeUrl}"></script>`;
if (bundled.includes("</body>")) {
bundled = bundled.replace("</body>", `${runtimeTag}\n</body>`);
} else {
bundled += `\n${runtimeTag}`;
}
}
// Inject runtime if available and not already set
const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js");
if (existsSync(cliRuntime) && bundled.includes('src=""')) {
bundled = bundled.replace(
'data-hyperframes-preview-runtime="1" src=""',
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
);
// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${projectId}/preview/`;
if (!bundled.includes("<base")) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
@@ -273,15 +447,9 @@ function devProjectApi(): Plugin {
);
// Build a standalone HTML page with GSAP + runtime
// Resolve runtime: env var → built CLI dist → empty (no runtime)
let runtimeUrl = (process.env.HYPERFRAME_RUNTIME_URL || "").trim();
if (!runtimeUrl) {
// In dev: serve the built runtime from the CLI dist
const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js");
if (existsSync(cliRuntime)) {
runtimeUrl = `/api/runtime.js`;
}
}
const runtimeUrl =
(process.env.HYPERFRAME_RUNTIME_URL || "").trim() ||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
const standalone = `<!DOCTYPE html>
<html>
<head>
@@ -300,6 +468,105 @@ ${content}
return;
}
// GET /api/projects/:id/thumbnail/* — generate JPEG thumbnail via Puppeteer
if (req.method === "GET" && rest.startsWith("/thumbnail/")) {
const compPath = decodeURIComponent(rest.replace("/thumbnail/", "").split("?")[0]);
const url = new URL(req.url!, `http://${req.headers.host}`);
const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
// Determine the preview URL for this composition
const previewUrl =
compPath === "index.html"
? `http://${req.headers.host}/api/projects/${projectId}/preview`
: `http://${req.headers.host}/api/projects/${projectId}/preview/comp/${compPath}`;
// Cache path
const cacheDir = join(projectDir, ".thumbnails");
const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`;
const cachePath = join(cacheDir, cacheKey);
// Return cached thumbnail if available
if (existsSync(cachePath)) {
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=60",
});
res.end(readFileSync(cachePath));
return;
}
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();
}
}
}, 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 });
}
writeFileSync(cachePath, buffer);
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=60",
});
res.end(buffer);
} catch (err) {
console.warn("[Studio] Thumbnail generation failed:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Thumbnail generation failed" }));
}
return;
}
// GET /api/projects/:id/preview/* — serve static assets (images, audio, etc.)
if (req.method === "GET" && rest.startsWith("/preview/")) {
const subPath = decodeURIComponent(rest.replace("/preview/", "").split("?")[0]);
@@ -310,29 +577,13 @@ ${content}
return;
}
const isText = /\.(html|css|js|json|svg|txt)$/i.test(subPath);
const mimeTypes: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
};
const ext = "." + subPath.split(".").pop()?.toLowerCase();
const contentType = mimeTypes[ext] ?? "application/octet-stream";
const contentType = subPath.endsWith(".html")
? "text/html"
: subPath.endsWith(".js")
? "text/javascript"
: subPath.endsWith(".css")
? "text/css"
: "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
res.end(readFileSync(file, isText ? "utf-8" : undefined));
return;