Files
wehub-resource-sync 85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

389 lines
14 KiB
TypeScript

// Vite adapter that wires the shared Studio API to the local filesystem and build tools.
import {
readFileSync,
readdirSync,
existsSync,
writeFileSync,
realpathSync,
mkdirSync,
copyFileSync,
unlinkSync,
} from "node:fs";
import { join, relative, resolve, isAbsolute, dirname } from "node:path";
import type { ViteDevServer } from "vite";
import {
type ResolvedProject,
type RenderJobState,
type StudioApiAdapter,
type BackgroundRemovalRender,
createBackgroundRemovalJob,
createProjectSignature,
} from "@hyperframes/studio-server";
import type { RegistryItem } from "@hyperframes/core/registry";
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"; inlineColorGradingLuts?: boolean },
) => 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";
variables?: Record<string, unknown>;
}) => 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 {
// fallow-ignore-next-line complexity
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 ?? ""));
},
// fallow-ignore-next-line complexity
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: realpathSync(projectDir),
title: session.title,
};
}
}
} catch {
/* ignore */
}
}
return null;
}
return { id, dir: realpathSync(projectDir) };
},
async bundle(dir: string) {
const bundler = await getBundler();
if (!bundler) return null;
let html = await bundler(dir, { runtime: "placeholder", inlineColorGradingLuts: false });
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;
// Project dirs are symlinked from anywhere on disk (often outside the
// studio package), so Vite's default watch roots don't cover them.
// Without this, the signature cache never invalidates for external
// projects and the preview ETag serves stale 304s after edits.
server.watcher.add(cacheKey);
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 await mod.lintHyperframeHtml(html, opts);
},
runtimeUrl: "/api/runtime.js",
rendersDir: () => resolve(dataDir, "../renders"),
startRender(opts): RenderJobState {
const abortController = new AbortController();
const state: RenderJobState = {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
cancel: () => abortController.abort(),
};
const startTime = Date.now();
const removeCancelledOutput = () => {
// User-initiated cancel: not a failure. Remove any output so the
// cancelled job doesn't resurrect in the render history.
state.status = "cancelled";
for (const fp of [
opts.outputPath,
opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json"),
]) {
try {
if (existsSync(fp)) unlinkSync(fp);
} catch {
/* ignore */
}
}
};
// fallow-ignore-next-line complexity
(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,
...(opts.composition ? { entryFile: opts.composition } : {}),
...(opts.variables ? { variables: opts.variables } : {}),
});
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,
abortController.signal,
);
if (abortController.signal.aborted) {
// Cancel landed just as the render finished: honor the cancel the
// route already reported instead of resurrecting a completed job.
removeCancelledOutput();
return;
}
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) {
if (abortController.signal.aborted) {
removeCancelledOutput();
return;
}
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;
},
startBackgroundRemoval(opts) {
return createBackgroundRemovalJob(opts, async (renderOpts) => {
const mod = await server.ssrLoadModule(
resolve(__dirname, "../cli/src/background-removal/pipeline.ts"),
);
const render = mod.render as BackgroundRemovalRender;
return render(renderOpts);
});
},
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;
},
// fallow-ignore-next-line complexity
async listRegistryCatalog(): Promise<RegistryItem[]> {
const registryRoot = resolve(__dirname, "../../registry");
const items: RegistryItem[] = [];
for (const subdir of ["blocks", "components"]) {
const dir = join(registryRoot, subdir);
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(dir, entry.name, "registry-item.json");
if (!existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
if (manifest.type === "hyperframes:block" || manifest.type === "hyperframes:component")
items.push(manifest);
} catch {
/* skip malformed manifests */
}
}
}
return items;
},
// fallow-ignore-next-line complexity
async installRegistryBlock(opts: {
project: ResolvedProject;
blockName: string;
}): Promise<{ written: string[]; block: RegistryItem }> {
const registryRoot = resolve(__dirname, "../../registry");
let itemDir = join(registryRoot, "blocks", opts.blockName);
if (!existsSync(join(itemDir, "registry-item.json"))) {
itemDir = join(registryRoot, "components", opts.blockName);
}
const manifestPath = join(itemDir, "registry-item.json");
if (!existsSync(manifestPath)) {
throw new Error(`Item "${opts.blockName}" not found in registry`);
}
const block = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
const written: string[] = [];
for (const file of block.files) {
const sourcePath = join(itemDir, file.path);
const targetPath = resolve(opts.project.dir, file.target);
if (!isPathWithin(opts.project.dir, targetPath)) {
throw new Error(`Target path escapes project directory: ${file.target}`);
}
mkdirSync(dirname(targetPath), { recursive: true });
if (file.type === "hyperframes:composition") {
let content = readFileSync(sourcePath, "utf-8");
content = `<!-- hyperframes-registry-item: ${block.name} -->\n${content}`;
writeFileSync(targetPath, content, "utf-8");
} else {
copyFileSync(sourcePath, targetPath);
}
written.push(file.target);
}
return { written, block };
},
};
}