mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
## Summary Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture. The manifest lives at: ```text .hyperframes/studio-manual-edits.json ``` It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset. ## Architecture - **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values. - **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file. - **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base. - **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script. - **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines. - **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly. ## User Impact Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state. ## Main Files - `packages/studio/src/components/editor/manualEdits.ts` - `packages/studio/src/components/editor/DomEditOverlay.tsx` - `packages/studio/src/components/editor/PropertyPanel.tsx` - `packages/studio/src/App.tsx` - `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts` - `packages/studio/vite.config.ts` - `packages/cli/src/server/studioServer.ts` - `packages/core/src/compiler/htmlBundler.ts` - `packages/producer/src/services/htmlCompiler.ts` - `packages/core/src/studio-api/routes/thumbnail.ts` - `packages/producer/src/services/fileServer.ts` - `packages/producer/src/services/renderOrchestrator.ts` ## Test Plan ```bash volta run --node 22.20.0 bun run build volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck volta run --node 22.20.0 bunx oxlint <changed files> volta run --node 22.20.0 bunx oxfmt --check <changed files> git diff --check ```
248 lines
6.4 KiB
TypeScript
248 lines
6.4 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
import { homedir, platform } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { Hono } from "hono";
|
|
|
|
const FONT_EXT_RE = /\.(otf|ttf|ttc|woff2?)$/i;
|
|
const MAX_FONT_RESULTS = 2000;
|
|
const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
|
|
const GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3000;
|
|
let cachedFonts: string[] | null = null;
|
|
let cachedGoogleFonts: string[] | null = null;
|
|
|
|
const STYLE_SUFFIXES = new Set([
|
|
"black",
|
|
"bold",
|
|
"book",
|
|
"condensed",
|
|
"demi",
|
|
"demibold",
|
|
"display",
|
|
"extra",
|
|
"extrabold",
|
|
"hairline",
|
|
"heavy",
|
|
"italic",
|
|
"light",
|
|
"medium",
|
|
"normal",
|
|
"regular",
|
|
"roman",
|
|
"semibold",
|
|
"thin",
|
|
"ultra",
|
|
"ultralight",
|
|
]);
|
|
|
|
const GOOGLE_FONT_FALLBACKS = [
|
|
"Inter",
|
|
"Roboto",
|
|
"Open Sans",
|
|
"Montserrat",
|
|
"Poppins",
|
|
"Lato",
|
|
"Oswald",
|
|
"Raleway",
|
|
"Nunito",
|
|
"Playfair Display",
|
|
"Merriweather",
|
|
"Source Sans 3",
|
|
"Source Serif 4",
|
|
"Source Code Pro",
|
|
"DM Sans",
|
|
"Space Grotesk",
|
|
"Space Mono",
|
|
"Bebas Neue",
|
|
"Outfit",
|
|
"JetBrains Mono",
|
|
];
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function fontDirectories(): string[] {
|
|
const home = homedir();
|
|
if (platform() === "darwin") {
|
|
return [
|
|
join(home, "Library", "Fonts"),
|
|
"/Library/Fonts",
|
|
"/System/Library/Fonts",
|
|
"/System/Library/Fonts/Supplemental",
|
|
];
|
|
}
|
|
if (platform() === "win32") {
|
|
return [join(process.env.WINDIR || "C:\\Windows", "Fonts")];
|
|
}
|
|
return [
|
|
join(home, ".fonts"),
|
|
join(home, ".local", "share", "fonts"),
|
|
"/usr/local/share/fonts",
|
|
"/usr/share/fonts",
|
|
];
|
|
}
|
|
|
|
function toFamilyName(fileName: string): string | null {
|
|
const withoutExt = fileName.replace(FONT_EXT_RE, "");
|
|
if (!withoutExt || withoutExt.startsWith(".")) return null;
|
|
|
|
const spaced = withoutExt
|
|
.replace(/[_-]+/g, " ")
|
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const words = spaced.split(" ").filter(Boolean);
|
|
while (words.length > 1 && STYLE_SUFFIXES.has((words.at(-1) ?? "").toLowerCase())) {
|
|
words.pop();
|
|
}
|
|
|
|
const family = words.join(" ").trim();
|
|
return family.length >= 2 ? family : null;
|
|
}
|
|
|
|
function collectMacSystemProfilerFonts(): string[] {
|
|
if (platform() !== "darwin") return [];
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
|
|
encoding: "utf8",
|
|
maxBuffer: 12 * 1024 * 1024,
|
|
timeout: 5000,
|
|
});
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
if (!isRecord(parsed) || !Array.isArray(parsed.SPFontsDataType)) return [];
|
|
const fonts: string[] = [];
|
|
|
|
for (const fontEntry of parsed.SPFontsDataType) {
|
|
if (!isRecord(fontEntry)) continue;
|
|
const typefaces = fontEntry.typefaces;
|
|
if (!Array.isArray(typefaces)) continue;
|
|
|
|
for (const typeface of typefaces) {
|
|
if (!isRecord(typeface)) continue;
|
|
const family = typeface.family;
|
|
const fullName = typeface.fullname;
|
|
const name = typeface._name;
|
|
if (typeof family === "string" && family.trim()) {
|
|
fonts.push(family.trim());
|
|
} else if (typeof fullName === "string" && fullName.trim()) {
|
|
fonts.push(fullName.trim());
|
|
} else if (typeof name === "string" && name.trim()) {
|
|
fonts.push(name.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
return fonts;
|
|
}
|
|
|
|
function collectFontsFromDir(dir: string, depth = 0): string[] {
|
|
if (!existsSync(dir) || depth > 2) return [];
|
|
const fonts: string[] = [];
|
|
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
fonts.push(...collectFontsFromDir(fullPath, depth + 1));
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !FONT_EXT_RE.test(entry.name)) continue;
|
|
try {
|
|
if (!statSync(fullPath).isFile()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const family = toFamilyName(entry.name);
|
|
if (family) fonts.push(family);
|
|
}
|
|
|
|
return fonts;
|
|
}
|
|
|
|
function listInstalledFontFamilies(): string[] {
|
|
if (cachedFonts) return cachedFonts;
|
|
const families = new Set<string>();
|
|
|
|
for (const family of collectMacSystemProfilerFonts()) {
|
|
families.add(family);
|
|
if (families.size >= MAX_FONT_RESULTS) break;
|
|
}
|
|
|
|
for (const dir of fontDirectories()) {
|
|
for (const family of collectFontsFromDir(dir)) {
|
|
families.add(family);
|
|
if (families.size >= MAX_FONT_RESULTS) break;
|
|
}
|
|
if (families.size >= MAX_FONT_RESULTS) break;
|
|
}
|
|
|
|
cachedFonts = Array.from(families).sort((a, b) => a.localeCompare(b));
|
|
return cachedFonts;
|
|
}
|
|
|
|
function parseGoogleFontMetadata(value: unknown): string[] {
|
|
if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
|
|
const families: string[] = [];
|
|
for (const entry of value.familyMetadataList) {
|
|
if (!isRecord(entry) || typeof entry.family !== "string") continue;
|
|
families.push(entry.family);
|
|
}
|
|
return families;
|
|
}
|
|
|
|
function stripGoogleJsonGuard(raw: string): string {
|
|
const prefix = ")]}'";
|
|
if (!raw.startsWith(prefix)) return raw;
|
|
|
|
let index = prefix.length;
|
|
while (
|
|
index < raw.length &&
|
|
(raw[index] === " " ||
|
|
raw[index] === "\n" ||
|
|
raw[index] === "\r" ||
|
|
raw[index] === "\t" ||
|
|
raw[index] === "\f")
|
|
) {
|
|
index += 1;
|
|
}
|
|
|
|
return raw.slice(index);
|
|
}
|
|
|
|
async function listGoogleFontFamilies(): Promise<string[]> {
|
|
if (cachedGoogleFonts) return cachedGoogleFonts;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), GOOGLE_FONTS_FETCH_TIMEOUT_MS);
|
|
|
|
try {
|
|
const response = await fetch(GOOGLE_FONTS_METADATA_URL, { signal: controller.signal });
|
|
if (!response.ok) {
|
|
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
|
|
return cachedGoogleFonts;
|
|
}
|
|
const raw = await response.text();
|
|
const jsonText = stripGoogleJsonGuard(raw);
|
|
const families = parseGoogleFontMetadata(JSON.parse(jsonText));
|
|
cachedGoogleFonts = families.length > 0 ? families : GOOGLE_FONT_FALLBACKS;
|
|
} catch {
|
|
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
return cachedGoogleFonts;
|
|
}
|
|
|
|
export function registerFontRoutes(api: Hono): void {
|
|
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
|
|
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
|
|
}
|