mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Problem Studio still broke down in three concrete authoring flows around timeline assets: - you could import media into Assets, but not drag an already-imported asset from the Assets tab onto the timeline and persist it into source - dragging a file from outside the app onto the timeline only uploaded it into Assets instead of placing it at the dropped time/track - once a clip was on the timeline, there was no reliable keyboard delete flow for removing it safely from source While implementing direct external drops, another real bug showed up: - valid binary uploads like `raycast.mp4` from `Downloads` were being rejected as unsupported media in Studio dev because the Vite API bridge was corrupting multipart request bodies before they reached the upload route ## What this fixes ### Timeline asset placement from inside Studio - asset cards in the Assets tab are draggable - the timeline accepts asset drops even when it already has clips - dropping an asset onto the timeline inserts a new clip into the active composition source at the dropped time / track - asset paths are rewritten relative to the target composition file so drops into sub-compositions resolve correctly - the new clip is persisted immediately and the preview refreshes ### Direct external file drops onto the timeline - dropping a file from outside the app onto the timeline now uploads it and places it onto the dropped track/time in one shot - it no longer stops halfway by only adding the file into Assets - multiple dropped files are placed using the same drop start and successive tracks ### Delete key support - selected timeline clips can now be deleted with `Delete` / `Backspace` - deletion is persisted back to source, not just removed from local state - the delete path now uses a server-side DOM mutation helper with LinkeDOM for structural safety instead of client-side string surgery ### Binary upload fix for media files - the Studio Vite API bridge now forwards non-GET request bodies as raw bytes instead of decoding them as UTF-8 text - that preserves multipart uploads for binary media like MP4s - valid local videos from `Downloads` no longer get rejected as `Unsupported media skipped` just because the dev bridge corrupted the request body - upload validation now probes buffered media through a temp file path that preserves the file extension before saving into the project ## Root cause There were really two separate gaps: ### 1. Asset placement / deletion workflow gaps The timeline and asset systems already existed, but they were disconnected: - `AssetsTab` only supported copy/import flows - `Timeline` only handled raw file import, not positioned placement for existing assets - there was no utility layer for converting a dropped asset into persisted timeline HTML - there was no structurally safe deletion path for arbitrary selected timeline clips ### 2. Binary upload corruption in Studio dev The Studio Vite API bridge rebuilt non-GET request bodies like this: - read each request chunk - call `chunk.toString()` - concatenate into a string - construct the Fetch `Request` from that string body That works for text, but it corrupts multipart binary uploads. By the time the upload route wrote the received file and ran `ffprobe`, otherwise valid MP4s had already been mangled in-flight. ## Behavior - dropping on `index.html` inserts the asset into the root composition - dropping while drilled into a composition inserts into that composition file instead - drop X position maps to `data-start` - drop Y position maps to the current visible track row, with a new bottom track created if the drop lands below existing rows - images default to a short finite duration - audio/video default to their metadata duration when available, with a fallback duration if metadata cannot be read quickly - pressing `Delete` on a selected clip removes that clip from the underlying HTML source and clears selection in Studio - valid uploaded MP4s now survive the Studio dev API bridge intact instead of being rejected during upload validation ## Verification ### Local checks - `bunx oxlint packages/core/src/studio-api/helpers/sourceMutation.ts packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/core/src/studio-api/routes/files.ts packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/sidebar/AssetsTab.tsx packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.config.ts packages/studio/vite.request-body.ts packages/studio/vite.request-body.test.ts` - `bunx oxfmt --check` on the touched files - `bun run --filter @hyperframes/core typecheck` - `bun run --filter @hyperframes/studio typecheck` - `bun test packages/core/src/studio-api/helpers/sourceMutation.test.ts packages/core/src/studio-api/helpers/mediaValidation.test.ts packages/studio/src/player/components/Timeline.test.ts packages/studio/src/utils/timelineAssetDrop.test.ts packages/studio/vite.request-body.test.ts` ### Browser / live verification Verified against a live local Studio fixture: - dragging an existing asset from the Assets tab onto the timeline creates a persisted clip at the dropped position - dropping a file from outside the app directly onto the timeline uploads it and creates a persisted clip at the dropped position - selecting a dropped clip and pressing `Delete` removes it from both the live timeline and the saved source HTML - valid MP4 uploads like `raycast.mp4` now succeed through the live Studio upload route instead of being rejected as unsupported media ## Notes - the local `timeline-trio-verify` and `timeline-overlap-debug` projects used for verification are local-only and are not part of this PR - this PR is about asset placement, upload correctness, and deletion safety; it does not broaden into richer editing workflows beyond placing/removing clips from the timeline
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
import { defineConfig, type Plugin, type ViteDevServer } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import {
|
|
readFileSync,
|
|
readdirSync,
|
|
existsSync,
|
|
writeFileSync,
|
|
lstatSync,
|
|
realpathSync,
|
|
} from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import type {
|
|
StudioApiAdapter,
|
|
ResolvedProject,
|
|
RenderJobState,
|
|
} from "@hyperframes/core/studio-api";
|
|
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
|
import { readNodeRequestBody } from "./vite.request-body.js";
|
|
|
|
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// In-flight thumbnail dedup
|
|
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
|
|
const THUMBNAIL_CACHE_VERSION = "v2";
|
|
|
|
// ── Vite adapter for the shared studio API ───────────────────────────────────
|
|
|
|
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
|
// Lazy-load the bundler via Vite's SSR module loader
|
|
let _bundler: ((dir: string) => Promise<string>) | null = null;
|
|
let _producerModulePromise: Promise<{
|
|
createRenderJob: (config: {
|
|
fps: 24 | 30 | 60;
|
|
quality: "draft" | "standard" | "high";
|
|
format: string;
|
|
}) => unknown;
|
|
executeRenderJob: (
|
|
job: unknown,
|
|
projectDir: string,
|
|
outputPath: string,
|
|
onProgress?: (job: { progress: number; currentStage?: string }) => void,
|
|
) => Promise<void>;
|
|
}> | null = null;
|
|
const getBundler = async () => {
|
|
if (!_bundler) {
|
|
try {
|
|
const mod = await server.ssrLoadModule("@hyperframes/core/compiler");
|
|
_bundler = (dir: string) => mod.bundleToSingleHtml(dir);
|
|
} 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 (!_producerModulePromise) {
|
|
_producerModulePromise = 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 _producerModulePromise();
|
|
};
|
|
|
|
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")),
|
|
)
|
|
.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)) {
|
|
// Try resolving as session ID
|
|
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);
|
|
// Fix empty runtime src from bundler — point to the CDN runtime
|
|
html = html.replace(
|
|
'data-hyperframes-preview-runtime="1" src=""',
|
|
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,
|
|
);
|
|
return html;
|
|
},
|
|
|
|
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 {
|
|
// Help the producer find a browser — it checks PRODUCER_HEADLESS_SHELL_PATH
|
|
// but doesn't search system Chrome paths. Reuse the same discovery as thumbnails.
|
|
if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
const systemChrome = [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/chromium-browser",
|
|
].find((p) => existsSync(p));
|
|
if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome;
|
|
}
|
|
const { createRenderJob, executeRenderJob } = await getProducerModule();
|
|
const job = createRenderJob({
|
|
fps: opts.fps as 24 | 30 | 60,
|
|
quality: opts.quality as "draft" | "standard" | "high",
|
|
format: opts.format,
|
|
});
|
|
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) {
|
|
const selectorKey = opts.selector
|
|
? `_${opts.selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
|
|
: "";
|
|
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}${selectorKey}.jpg`;
|
|
|
|
let bufferPromise = _thumbnailInflight.get(cacheKey);
|
|
if (!bufferPromise) {
|
|
bufferPromise = (async () => {
|
|
const browser = await getSharedBrowser();
|
|
if (!browser) return null;
|
|
const page = await browser.newPage();
|
|
await page.setViewport({
|
|
width: opts.width,
|
|
height: opts.height,
|
|
deviceScaleFactor: 0.5,
|
|
});
|
|
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
|
await page.evaluate(() => {
|
|
document.documentElement.style.background = "#000";
|
|
document.body.style.background = "#000";
|
|
document.body.style.margin = "0";
|
|
document.body.style.overflow = "hidden";
|
|
});
|
|
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: (t?: number) => void }
|
|
>;
|
|
gsap?: { ticker: { tick: () => void } };
|
|
};
|
|
if (w.__timelines) {
|
|
// Seek ALL timelines (compositions may register multiple)
|
|
for (const tl of Object.values(w.__timelines)) {
|
|
if (tl) {
|
|
// pause(t) both seeks AND forces GSAP to render the frame
|
|
tl.pause(t);
|
|
}
|
|
}
|
|
// Force GSAP to flush any pending renders
|
|
if (w.gsap?.ticker) w.gsap.ticker.tick();
|
|
}
|
|
}, opts.seekTime);
|
|
await page.evaluate("document.fonts?.ready");
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
|
if (opts.selector) {
|
|
clip = await page.evaluate((selector: string) => {
|
|
const el = document.querySelector(selector);
|
|
if (!(el instanceof HTMLElement)) return undefined;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width < 4 || rect.height < 4) return undefined;
|
|
const pad = 8;
|
|
const x = Math.max(0, rect.left - pad);
|
|
const y = Math.max(0, rect.top - pad);
|
|
const maxWidth = window.innerWidth - x;
|
|
const maxHeight = window.innerHeight - y;
|
|
return {
|
|
x,
|
|
y,
|
|
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
|
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
|
};
|
|
}, opts.selector);
|
|
}
|
|
const buf = await page.screenshot({
|
|
type: "jpeg",
|
|
quality: 75,
|
|
...(clip ? { clip } : {}),
|
|
});
|
|
await page.close();
|
|
return buf as Buffer;
|
|
})();
|
|
_thumbnailInflight.set(cacheKey, bufferPromise);
|
|
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
|
|
}
|
|
return bufferPromise;
|
|
},
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loadRuntimeSourceForDev(server: ViteDevServer): Promise<string | null> {
|
|
try {
|
|
const mod = await server.ssrLoadModule(
|
|
resolve(__dirname, "../core/src/inline-scripts/hyperframe.ts"),
|
|
);
|
|
if (typeof mod.loadHyperframeRuntimeSource === "function") {
|
|
return mod.loadHyperframeRuntimeSource();
|
|
}
|
|
} catch (err) {
|
|
console.warn("[Studio] Failed to load runtime source from core:", err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
|
|
|
|
async function bridgeHonoResponse(
|
|
honoResponse: Response,
|
|
res: import("node:http").ServerResponse,
|
|
): Promise<void> {
|
|
const headers: Record<string, string> = {};
|
|
honoResponse.headers.forEach((v, k) => {
|
|
headers[k] = v;
|
|
});
|
|
res.writeHead(honoResponse.status, headers);
|
|
|
|
if (!honoResponse.body) {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Stream the response body (important for SSE)
|
|
const reader = honoResponse.body.getReader();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
res.write(value);
|
|
}
|
|
} catch {
|
|
/* client disconnected */
|
|
}
|
|
res.end();
|
|
}
|
|
|
|
// ── Vite plugin ──────────────────────────────────────────────────────────────
|
|
|
|
function devProjectApi(): Plugin {
|
|
const dataDir = resolve(__dirname, "data/projects");
|
|
|
|
return {
|
|
name: "studio-dev-api",
|
|
configureServer(server): void {
|
|
// Load the shared module lazily via SSR (resolves hono + TypeScript)
|
|
let _api: { fetch: (req: Request) => Promise<Response> } | null = null;
|
|
const getApi = async () => {
|
|
if (!_api) {
|
|
const mod = await server.ssrLoadModule("@hyperframes/core/studio-api");
|
|
const adapter = createViteAdapter(dataDir, server);
|
|
_api = mod.createStudioApi(adapter);
|
|
}
|
|
return _api;
|
|
};
|
|
|
|
// In dev, prefer the runtime built from source over a checked-in dist
|
|
// artifact. Otherwise Studio can silently serve a stale runtime bundle
|
|
// after source edits in packages/core, which makes browser behavior lag
|
|
// behind the code under test until someone manually rebuilds core/dist.
|
|
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url !== "/api/runtime.js") return next();
|
|
const serve = async () => {
|
|
let runtimeSource = await loadRuntimeSourceForDev(server);
|
|
if (!runtimeSource && existsSync(runtimePath)) {
|
|
runtimeSource = readFileSync(runtimePath, "utf-8");
|
|
}
|
|
|
|
if (!runtimeSource) {
|
|
res.writeHead(404);
|
|
res.end("runtime not available — build packages/core or load runtime source");
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/javascript",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
res.end(runtimeSource);
|
|
};
|
|
|
|
void serve().catch((err) => {
|
|
console.error("[Studio runtime] Failed to serve runtime", err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500);
|
|
res.end("failed to serve runtime");
|
|
}
|
|
});
|
|
});
|
|
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith("/api/")) return next();
|
|
|
|
try {
|
|
const api = await getApi();
|
|
|
|
// Build a Fetch Request from the Node IncomingMessage
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
// Strip /api prefix — shared module routes are relative
|
|
url.pathname = url.pathname.slice(4);
|
|
|
|
// Read body for non-GET/HEAD
|
|
let body: Buffer | undefined;
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
const bytes = await readNodeRequestBody(req);
|
|
body = bytes.byteLength > 0 ? bytes : undefined;
|
|
}
|
|
|
|
const headers: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
if (value != null) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
}
|
|
|
|
const fetchReq = new Request(url.toString(), {
|
|
method: req.method,
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
const response = await api.fetch(fetchReq);
|
|
await bridgeHonoResponse(response, res);
|
|
} catch (err) {
|
|
console.error("[Studio API] Error:", err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Watch project directories for file changes → HMR
|
|
const realProjectPaths: string[] = [];
|
|
try {
|
|
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
|
|
const full = join(dataDir, entry.name);
|
|
try {
|
|
const real = lstatSync(full).isSymbolicLink() ? realpathSync(full) : full;
|
|
realProjectPaths.push(real);
|
|
server.watcher.add(real);
|
|
} catch {
|
|
/* skip broken symlinks */
|
|
}
|
|
}
|
|
} catch {
|
|
/* dataDir doesn't exist yet */
|
|
}
|
|
|
|
server.watcher.on("change", (filePath: string) => {
|
|
const isProjectFile = realProjectPaths.some((p) => filePath.startsWith(p));
|
|
if (
|
|
isProjectFile &&
|
|
(filePath.endsWith(".html") || filePath.endsWith(".css") || filePath.endsWith(".js"))
|
|
) {
|
|
console.log(`[Studio] File changed: ${filePath}`);
|
|
server.ws.send({ type: "custom", event: "hf:file-change", data: {} });
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), devProjectApi()],
|
|
resolve: {
|
|
alias: {
|
|
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "dist",
|
|
emptyOutDir: true,
|
|
},
|
|
server: {
|
|
port: 5190,
|
|
},
|
|
});
|