mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* feat(studio): add pasteboard background to preview viewport Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma). * feat(studio): pasteboard background and canvas outline around preview - NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard color surrounding the canvas — distinct from the app chrome (#0a0a0a) - Player wrapper: drop bg-black so the pasteboard shows around the canvas (loading overlays still cover the area with bg-black during load) - Player: set host background to transparent via inline style (overrides :host { background: #000 } in shadow DOM), and inject a style rule into the open shadow root so .hfp-container has overflow:visible and the canvas iframe gets a thin white ring + soft drop-shadow — making the canvas boundary legible against the pasteboard * feat(studio): disable manual positioning JSON by default, add toggle Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting) * feat(studio): enable manual positioning by default (opt-out) * feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle * feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types * fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test * fix(studio): strip GSAP-cached translate from transform on path offset apply * fix(studio): remove Reset edits button from design panel * feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation. * fix(studio): prevent root composition from being selected; correct overlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens). * fix(studio): correct resize overlay for scaled elements; block invisible element selection - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them. * fix(studio): reload preview on external file changes via SSE/HMR Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves. * fix(studio): suppress post-resize click to keep selection on resized element * fix(studio): serve registry blocks without index.html in preview Blocks ship as {id}.html + assets/ with no index.html. The preview route hard-coded index.html so these projects returned 404 and their assets (e.g. korea-map.png, map-nyc-paris.png) were never served. Add resolveProjectMainHtml() that falls back to {id}.html, thread the resolved compositionPath through transformPreviewHtml and injectStudioPreviewAugmentations, and update listProjects() in the vite adapter to surface block directories in the project list. * fix(render): preserve studio drag/resize/rotation offsets in rendered video Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img> during render) included "transform" but not "translate", "rotate", or "scale" — the CSS Transforms Level 2 individual properties used by studio drag/resize/rotation. The proxy was positioned at offsetLeft/ offsetTop without the translate offset. 3. getViewportMatrix (HDR compositor) only read cs.transform, missing individual transform properties entirely. Added composeIndividualTransforms to build the translate × rotate × scale matrix and compose it before the legacy transform matrix. * fix(studio): select elements with pointer-events: none in preview Compositions often set pointer-events: none on scenes, avatar wrappers, and decorative layers. elementsFromPoint() skips these elements entirely, making them unselectable in the Studio. Fix: temporarily inject a * { pointer-events: auto !important } stylesheet during hit-testing, then remove it immediately after. Also adds a pointer_events_none lint rule (info severity, visible with --verbose) so authors know which selectors may affect Studio selection.
250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
// Vite adapter that wires the shared Studio API to the local filesystem and build tools.
|
|
|
|
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
|
|
import { join, relative, resolve, isAbsolute } from "node:path";
|
|
import type { ViteDevServer } from "vite";
|
|
import {
|
|
type ResolvedProject,
|
|
type RenderJobState,
|
|
type StudioApiAdapter,
|
|
} from "@hyperframes/core/studio-api";
|
|
import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature";
|
|
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
|
import { createStudioDevRenderBodyScripts } from "./vite.studioMotion";
|
|
import { generateThumbnail, findSystemChrome } from "./vite.browser";
|
|
|
|
export function isPathWithin(parentDir: string, childPath: string): boolean {
|
|
const childRelativePath = relative(resolve(parentDir), resolve(childPath));
|
|
return (
|
|
childRelativePath === "" ||
|
|
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
|
|
);
|
|
}
|
|
|
|
export function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
|
let _bundler:
|
|
| ((dir: string, options?: { runtime?: "inline" | "placeholder" }) => Promise<string>)
|
|
| null = null;
|
|
let _producerModuleLoader:
|
|
| (() => Promise<{
|
|
createRenderJob: (config: {
|
|
fps: 24 | 30 | 60;
|
|
quality: "draft" | "standard" | "high";
|
|
format: string;
|
|
renderBodyScripts?: string[];
|
|
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
|
|
}) => unknown;
|
|
executeRenderJob: (
|
|
job: unknown,
|
|
projectDir: string,
|
|
outputPath: string,
|
|
onProgress?: (job: { progress: number; currentStage?: string }) => void,
|
|
) => Promise<void>;
|
|
}>)
|
|
| null = null;
|
|
|
|
const projectSignatureCache = new Map<string, string>();
|
|
server.watcher.on("all", (_event, file) => {
|
|
for (const projectDir of projectSignatureCache.keys()) {
|
|
if (isPathWithin(projectDir, file)) projectSignatureCache.delete(projectDir);
|
|
}
|
|
});
|
|
|
|
const getBundler = async () => {
|
|
if (!_bundler) {
|
|
try {
|
|
const mod = await server.ssrLoadModule("@hyperframes/core/compiler");
|
|
_bundler = (dir, options) => mod.bundleToSingleHtml(dir, options);
|
|
} catch (err) {
|
|
console.warn("[Studio] Failed to load compiler, previews will use raw HTML:", err);
|
|
_bundler = null as never;
|
|
}
|
|
}
|
|
return _bundler;
|
|
};
|
|
|
|
const getProducerModule = async () => {
|
|
if (!_producerModuleLoader) {
|
|
_producerModuleLoader = createRetryingModuleLoader(async () => {
|
|
const { built } = ensureProducerDist({
|
|
studioDir: __dirname,
|
|
env: process.env,
|
|
});
|
|
if (built) {
|
|
console.warn(
|
|
"[Studio] @hyperframes/producer dist missing; building producer package for local renders...",
|
|
);
|
|
}
|
|
const producerPkg = "@hyperframes/producer";
|
|
return await import(/* @vite-ignore */ producerPkg);
|
|
});
|
|
}
|
|
return _producerModuleLoader();
|
|
};
|
|
|
|
return {
|
|
listProjects() {
|
|
if (!existsSync(dataDir)) return [];
|
|
const sessionsDir = resolve(dataDir, "../sessions");
|
|
const sessionMap = new Map<string, { sessionId: string; title: string }>();
|
|
if (existsSync(sessionsDir)) {
|
|
for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith(".json"))) {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(join(sessionsDir, file), "utf-8"));
|
|
if (raw.projectId) {
|
|
sessionMap.set(raw.projectId, {
|
|
sessionId: file.replace(".json", ""),
|
|
title: raw.title || "Untitled",
|
|
});
|
|
}
|
|
} catch {
|
|
/* skip corrupt */
|
|
}
|
|
}
|
|
}
|
|
return readdirSync(dataDir, { withFileTypes: true })
|
|
.filter(
|
|
(d) =>
|
|
(d.isDirectory() || d.isSymbolicLink()) &&
|
|
(existsSync(join(dataDir, d.name, "index.html")) ||
|
|
existsSync(join(dataDir, d.name, `${d.name}.html`))),
|
|
)
|
|
.map((d) => {
|
|
const session = sessionMap.get(d.name);
|
|
return {
|
|
id: d.name,
|
|
dir: join(dataDir, d.name),
|
|
title: session?.title ?? d.name,
|
|
sessionId: session?.sessionId,
|
|
} satisfies ResolvedProject;
|
|
})
|
|
.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
|
},
|
|
|
|
resolveProject(id: string) {
|
|
let projectDir = join(dataDir, id);
|
|
if (!existsSync(projectDir)) {
|
|
const sessionsDir = resolve(dataDir, "../sessions");
|
|
const sessionFile = join(sessionsDir, `${id}.json`);
|
|
if (existsSync(sessionFile)) {
|
|
try {
|
|
const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
|
|
if (session.projectId) {
|
|
projectDir = join(dataDir, session.projectId);
|
|
if (existsSync(projectDir)) {
|
|
return { id: session.projectId, dir: projectDir, title: session.title };
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
return { id, dir: projectDir };
|
|
},
|
|
|
|
async bundle(dir: string) {
|
|
const bundler = await getBundler();
|
|
if (!bundler) return null;
|
|
let html = await bundler(dir, { runtime: "placeholder" });
|
|
html = html.replace(
|
|
'data-hyperframes-preview-runtime="1" src=""',
|
|
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,
|
|
);
|
|
return html;
|
|
},
|
|
|
|
async transformPreviewHtml({ html }) {
|
|
const producer = await import("../producer/src/services/deterministicFonts.js");
|
|
return producer.injectDeterministicFontFaces(html);
|
|
},
|
|
|
|
getProjectSignature(projectDir: string): string {
|
|
const cacheKey = resolve(projectDir);
|
|
const cached = projectSignatureCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
const signature = createProjectSignature(cacheKey);
|
|
projectSignatureCache.set(cacheKey, signature);
|
|
return signature;
|
|
},
|
|
|
|
async lint(html: string, opts?: { filePath?: string }) {
|
|
const mod = await server.ssrLoadModule("@hyperframes/core/lint");
|
|
return mod.lintHyperframeHtml(html, opts);
|
|
},
|
|
|
|
runtimeUrl: "/api/runtime.js",
|
|
|
|
rendersDir: () => resolve(dataDir, "../renders"),
|
|
|
|
startRender(opts): RenderJobState {
|
|
const state: RenderJobState = {
|
|
id: opts.jobId,
|
|
status: "rendering",
|
|
progress: 0,
|
|
outputPath: opts.outputPath,
|
|
};
|
|
|
|
const startTime = Date.now();
|
|
(async () => {
|
|
try {
|
|
if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
const systemChrome = findSystemChrome();
|
|
if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome;
|
|
}
|
|
const { createRenderJob, executeRenderJob } = await getProducerModule();
|
|
const renderBodyScripts = createStudioDevRenderBodyScripts(opts.project.dir);
|
|
const job = createRenderJob({
|
|
fps: opts.fps,
|
|
quality: opts.quality as "draft" | "standard" | "high",
|
|
format: opts.format,
|
|
...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}),
|
|
outputResolution: opts.outputResolution,
|
|
});
|
|
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
|
state.progress = j.progress;
|
|
if (j.currentStage) state.stage = j.currentStage;
|
|
};
|
|
await executeRenderJob(job, opts.project.dir, opts.outputPath, onProgress);
|
|
state.status = "complete";
|
|
state.progress = 100;
|
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
|
writeFileSync(
|
|
metaPath,
|
|
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
|
);
|
|
} catch (err) {
|
|
state.status = "failed";
|
|
state.error = err instanceof Error ? err.message : String(err);
|
|
try {
|
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
|
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
})();
|
|
|
|
return state;
|
|
},
|
|
|
|
async generateThumbnail(opts) {
|
|
return generateThumbnail(opts);
|
|
},
|
|
|
|
async resolveSession(sessionId: string) {
|
|
const sessionsDir = resolve(dataDir, "../sessions");
|
|
const sessionFile = join(sessionsDir, `${sessionId}.json`);
|
|
if (!existsSync(sessionFile)) return null;
|
|
try {
|
|
const raw = JSON.parse(readFileSync(sessionFile, "utf-8"));
|
|
if (raw.projectId) return { projectId: raw.projectId, title: raw.title };
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
}
|