mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
committed by
Miguel Ángel
parent
0769678f46
commit
fa4b1e4671
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
data/projects/
|
||||
+110
-13
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user