From d78a68f066abe1a37ef71fb56272ba882423f1ea Mon Sep 17 00:00:00 2001 From: ishan pandey Date: Thu, 30 Apr 2026 14:28:18 +0530 Subject: [PATCH] feat: detect stale index.html and surface a one-click regenerate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembled index.html drifts in two ways: (a) source files (script, manifests, images, voiceovers) get edited without a re-assemble, and (b) new core features / bug-fixes ship that the rendered HTML doesn't include (this is what caused the my-first-video letter-dropout regression — the HTML was assembled before PR #20). Today the user has no signal that this has happened and silently ships an outdated render. This change makes drift visible and one-click recoverable. Backend - assemble.ts now stamps every produced HTML with two meta tags: hyperframes:assembled-at (ISO timestamp) and hyperframes:core-version (the @hyperframes/core package version that produced the file). - coreVersion.ts walks up to the nearest @hyperframes/core/package.json so the stamp works in source mode (tsx CLI), built mode (tsc → dist), and the studio dev server. Falls back to "0.0.0-dev". - assembleStaleness.ts is the pure decision layer: collects source-files-newer + core-version-changed + no-stamp + no-html signals and returns a typed AssemblyStatus with a human-readable message. No writes, no LLM, ~handful of stat() calls + 16KB head read of the HTML. - New routes: GET /projects/:id/script/assembly-status (poll) and POST /projects/:id/script/assemble (re-assemble without audio re-synth, ~2s on a 25-scene project — mirrors the CLI verb). Frontend - StaleAssemblyBanner mounts in the topbar next to the cost badge. Polls every 30s, hides when up-to-date, shows an amber chip + Regenerate button when stale. Tooltip carries the full reason (which files changed, what core version drifted from). Click → POST → preview iframe reloads via a hf:assembly-regenerated CustomEvent (App.tsx bumps refreshKey). Tests - 18 new tests across pure helpers (extractMetaContent, readStamp, computeAssemblyStatus including missing HTML, no-stamp legacy files, tracked-directory walks, dotfile filtering, multi-reason aggregation, tracked-file cap, custom htmlPath). - Full suite stays green: 765 core (was 745) + 281 studio. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/script/assemble.ts | 18 ++ .../core/src/script/assembleStaleness.test.ts | 245 ++++++++++++++ packages/core/src/script/assembleStaleness.ts | 300 ++++++++++++++++++ packages/core/src/script/coreVersion.test.ts | 20 ++ packages/core/src/script/coreVersion.ts | 70 ++++ packages/core/src/script/index.ts | 5 +- packages/core/src/studio-api/routes/script.ts | 92 ++++++ packages/studio/src/App.tsx | 14 +- .../src/components/StaleAssemblyBanner.tsx | 166 ++++++++++ 9 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/script/assembleStaleness.test.ts create mode 100644 packages/core/src/script/assembleStaleness.ts create mode 100644 packages/core/src/script/coreVersion.test.ts create mode 100644 packages/core/src/script/coreVersion.ts create mode 100644 packages/studio/src/components/StaleAssemblyBanner.tsx diff --git a/packages/core/src/script/assemble.ts b/packages/core/src/script/assemble.ts index 42bccb752..cafd23f58 100644 --- a/packages/core/src/script/assemble.ts +++ b/packages/core/src/script/assemble.ts @@ -10,6 +10,14 @@ import type { ImageEntry, ImageManifest } from "../images/index.js"; import type { VisualDirectionPlan } from "./visualDirector.js"; import { readSfxManifest, resolveSfxStartForScene, type SfxEntry } from "./sfx/manifest.js"; import { readMusicManifest, resolveMusicSpan, type SceneSpan } from "./music/manifest.js"; +import { getCoreVersion } from "./coreVersion.js"; + +/** + * Stamp constants. Studio-side staleness detection looks for these exact + * names — keep them in lockstep with `assembleStaleness.ts`. + */ +export const ASSEMBLED_AT_META = "hyperframes:assembled-at"; +export const CORE_VERSION_META = "hyperframes:core-version"; export interface AssembleOptions { projectDir: string; @@ -259,11 +267,21 @@ export function assembleMaster(planned: PlannedScript, opts: AssembleOptions): A // We do not write our own master timeline JS — the runtime + studio player // drive playback, scrubbing, and audio sync. Per-scene timelines are // registered by each template's inline diff --git a/packages/core/src/script/assembleStaleness.test.ts b/packages/core/src/script/assembleStaleness.test.ts new file mode 100644 index 000000000..9e8cb7857 --- /dev/null +++ b/packages/core/src/script/assembleStaleness.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js"; +import { computeAssemblyStatus, extractMetaContent, readStamp } from "./assembleStaleness.js"; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "hf-staleness-")); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function writeHtml(version: string, assembledAt: string, extraHead = ""): void { + const html = ` + + + + +${extraHead} +`; + writeFileSync(join(tmp, "index.html"), html); +} + +function setMtime(absPath: string, msSinceEpoch: number): void { + const seconds = msSinceEpoch / 1000; + utimesSync(absPath, seconds, seconds); +} + +describe("extractMetaContent", () => { + it("extracts the content of a named meta", () => { + expect(extractMetaContent('', "x")).toBe("hello"); + }); + + it("returns null when the meta is absent", () => { + expect(extractMetaContent('', "x")).toBeNull(); + }); + + it("tolerates single-quoted attributes", () => { + expect(extractMetaContent("", "x")).toBe("hi"); + }); + + it("is case-insensitive on the tag/attr names", () => { + expect(extractMetaContent('', "x")).toBe("ok"); + }); + + it("never matches when the name has special regex chars but is escaped on lookup", () => { + // Confirms we don't treat the name as a regex pattern. + expect(extractMetaContent('', "a*b")).toBeNull(); + expect(extractMetaContent('', "a.b")).toBe("x"); + }); +}); + +describe("readStamp", () => { + it("returns nulls when the file does not exist", () => { + expect(readStamp(join(tmp, "missing.html"))).toEqual({ + assembledAt: null, + coreVersion: null, + }); + }); + + it("reads both stamps from a freshly assembled file", () => { + writeHtml("1.2.3", "2026-01-01T00:00:00.000Z"); + expect(readStamp(join(tmp, "index.html"))).toEqual({ + assembledAt: "2026-01-01T00:00:00.000Z", + coreVersion: "1.2.3", + }); + }); + + it("returns nulls when stamps are missing (older HTML)", () => { + writeFileSync(join(tmp, "index.html"), ""); + expect(readStamp(join(tmp, "index.html"))).toEqual({ + assembledAt: null, + coreVersion: null, + }); + }); +}); + +describe("computeAssemblyStatus", () => { + it("flags missing index.html as stale with reason no-html", () => { + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.exists).toBe(false); + expect(status.stale).toBe(true); + expect(status.reasons).toEqual(["no-html"]); + }); + + it("returns stale=false when stamps match and no source file is newer", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.exists).toBe(true); + expect(status.stale).toBe(false); + expect(status.reasons).toEqual([]); + expect(status.coreVersionChanged).toBe(false); + }); + + it("flags core-version-changed when stamp != current", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.1.0", + }); + expect(status.coreVersion).toBe("1.0.0"); + expect(status.currentCoreVersion).toBe("1.1.0"); + expect(status.coreVersionChanged).toBe(true); + expect(status.reasons).toContain("core-version-changed"); + expect(status.stale).toBe(true); + }); + + it("flags no-stamp when an older HTML lacks meta tags", () => { + writeFileSync(join(tmp, "index.html"), ""); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.coreVersion).toBeNull(); + expect(status.coreVersionChanged).toBe(false); // no stamp → no comparison + expect(status.reasons).toContain("no-stamp"); + expect(status.stale).toBe(true); + }); + + it("flags source-files-newer when script.json is touched after assembly", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const htmlPath = join(tmp, "index.html"); + const scriptPath = join(tmp, "script.json"); + writeFileSync(scriptPath, "{}"); + setMtime(htmlPath, Date.now() - 60_000); + setMtime(scriptPath, Date.now()); // newer + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.reasons).toContain("source-files-newer"); + expect(status.sourceFilesNewer).toContain("script.json"); + expect(status.stale).toBe(true); + }); + + it("flags newer files inside tracked directories (e.g. assets/images)", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const htmlPath = join(tmp, "index.html"); + mkdirSync(join(tmp, "assets", "images"), { recursive: true }); + const imgPath = join(tmp, "assets", "images", "hero.webp"); + writeFileSync(imgPath, "fake bytes"); + setMtime(htmlPath, Date.now() - 60_000); + setMtime(imgPath, Date.now()); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.reasons).toContain("source-files-newer"); + // The relative path can use forward slashes on POSIX or backslashes on + // Windows; we only assert membership in a normalized form. + expect(status.sourceFilesNewer.length).toBeGreaterThan(0); + expect( + status.sourceFilesNewer.some((p) => + p.replace(/\\/g, "/").endsWith("assets/images/hero.webp"), + ), + ).toBe(true); + }); + + it("ignores dotfiles inside tracked directories", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const htmlPath = join(tmp, "index.html"); + mkdirSync(join(tmp, "voiceovers"), { recursive: true }); + writeFileSync(join(tmp, "voiceovers", ".DS_Store"), "garbage"); + setMtime(htmlPath, Date.now() - 60_000); + setMtime(join(tmp, "voiceovers", ".DS_Store"), Date.now()); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.sourceFilesNewer).toEqual([]); + expect(status.stale).toBe(false); + }); + + it("collects multiple reasons when both core-version and source-files apply", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const htmlPath = join(tmp, "index.html"); + const scriptPath = join(tmp, "script.json"); + writeFileSync(scriptPath, "{}"); + setMtime(htmlPath, Date.now() - 60_000); + setMtime(scriptPath, Date.now()); + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.5.0", + }); + expect(status.reasons).toContain("core-version-changed"); + expect(status.reasons).toContain("source-files-newer"); + expect(status.message).toContain("core 1.0.0 → 1.5.0"); + expect(status.message).toContain("script.json"); + }); + + it("caps reported source files to a small list", () => { + writeHtml("1.0.0", "2026-01-01T00:00:00.000Z"); + const htmlPath = join(tmp, "index.html"); + setMtime(htmlPath, Date.now() - 60_000); + // Touch many tracked files. + const filesToTouch = [ + "script.json", + "script.generated.json", + "visual-direction.json", + "DESIGN.md", + "DESIGN-ART.md", + "RESEARCH.md", + ]; + for (const f of filesToTouch) { + const p = join(tmp, f); + writeFileSync(p, "x"); + setMtime(p, Date.now()); + } + const status = computeAssemblyStatus({ + projectDir: tmp, + currentCoreVersion: "1.0.0", + }); + expect(status.sourceFilesNewer.length).toBeLessThanOrEqual(5); + }); + + it("respects custom htmlPath when project keeps a non-default name", () => { + const html = ` + + + +`; + mkdirSync(join(tmp, "build"), { recursive: true }); + writeFileSync(join(tmp, "build", "render.html"), html); + const status = computeAssemblyStatus({ + projectDir: tmp, + htmlPath: "build/render.html", + currentCoreVersion: "1.0.0", + }); + expect(status.exists).toBe(true); + expect(status.htmlPath).toBe("build/render.html"); + expect(status.coreVersion).toBe("1.0.0"); + expect(status.stale).toBe(false); + }); +}); diff --git a/packages/core/src/script/assembleStaleness.ts b/packages/core/src/script/assembleStaleness.ts new file mode 100644 index 000000000..c632472d8 --- /dev/null +++ b/packages/core/src/script/assembleStaleness.ts @@ -0,0 +1,300 @@ +/** + * Assembly-staleness detection — answers the question "is the project's + * `index.html` out of date?" The studio shows a "Regenerate" CTA when this + * returns stale, with a tooltip explaining why. + * + * Two signals collapse into one stale flag: + * + * 1. **Source-files-newer-than-html** — at least one tracked input file + * has an mtime later than `index.html`. This catches the common case: + * user edited script.json, added an image, generated music, etc., and + * forgot to re-assemble. + * 2. **Core-version-changed** — `index.html`'s embedded core-version + * stamp doesn't match the running `@hyperframes/core` version. This + * catches feature/bug-fix drift: e.g. PR #20 fixed letter-dropouts + * in hook-bigtext, but a project assembled before that merge still + * ships the broken version. + * + * Both signals are collected (not collapsed) so the UI can explain + * precisely what's stale, and the user can decide whether the drift + * matters for their workflow. + * + * The module is pure — file-system reads only, no writes, no LLM calls. + * Failure modes (missing index.html, missing stamp, unparseable file) + * collapse to a graceful "no stamp / unknown" rather than throwing. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js"; +import { getCoreVersion } from "./coreVersion.js"; + +/** + * Project-relative paths whose mtime we compare against `index.html`. + * Order matters only for the human-readable message — the FIRST file in + * this list that's newer wins the "primary culprit" slot. Putting + * `script.json` first means the most common case (user edited the + * script) is shown clearly. + */ +const TRACKED_FILES_RELATIVE: ReadonlyArray = [ + "script.json", + "script.generated.json", + "visual-direction.json", + "assets/images.json", + "assets/sfx/sfx.manifest.json", + "assets/music/music.manifest.json", + "DESIGN.md", + "DESIGN-ART.md", + "design-art.md", + "RESEARCH.md", + "research.md", +]; + +/** + * Directories scanned recursively for newer files. Adding a new image, + * generating SFX, or regenerating voiceovers should mark the HTML stale + * even if the manifest itself wasn't touched (e.g. deduped or replaced + * in place). + */ +const TRACKED_DIRECTORIES_RELATIVE: ReadonlyArray = [ + "voiceovers", + "assets/sfx", + "assets/music", + "assets/images", +]; + +/** Maximum number of "newer source files" we report. The UI shows them + * inline — five is more than enough to communicate the issue. */ +const MAX_NEWER_FILES_REPORTED = 5; + +export type StaleReason = "no-html" | "no-stamp" | "core-version-changed" | "source-files-newer"; + +export interface AssemblyStatus { + /** Project-relative path of the assembled HTML (default: index.html). */ + htmlPath: string; + /** True iff the HTML file exists at htmlPath. */ + exists: boolean; + /** ISO timestamp from the `hyperframes:assembled-at` meta. null when + * the HTML is missing or doesn't carry the stamp. */ + assembledAt: string | null; + /** Core version recorded in the HTML's stamp. null when missing. */ + coreVersion: string | null; + /** Core version of the currently-running `@hyperframes/core`. */ + currentCoreVersion: string; + /** Project-relative paths of files whose mtime exceeds the HTML's. */ + sourceFilesNewer: ReadonlyArray; + /** True when coreVersion is set AND differs from currentCoreVersion. */ + coreVersionChanged: boolean; + /** Top-level summary: should the UI show the Regenerate CTA? */ + stale: boolean; + /** Machine-readable list of reasons (UI maps these to copy). */ + reasons: ReadonlyArray; + /** Human-readable summary, suitable for a tooltip. */ + message: string; +} + +export interface AssemblyStatusOptions { + projectDir: string; + /** Default: "index.html". */ + htmlPath?: string; + /** + * Test seam — overrides the running core version detection. Production + * callers leave this undefined. + */ + currentCoreVersion?: string; +} + +/** + * Compute the staleness status for a project's assembled HTML. Pure with + * respect to the project (no writes). Designed to run on every studio + * refresh and on every Storyline tab mount — no I/O beyond a handful of + * stat() calls and one readFileSync() of the HTML head. + */ +export function computeAssemblyStatus(opts: AssemblyStatusOptions): AssemblyStatus { + const htmlPath = opts.htmlPath ?? "index.html"; + const absHtml = join(opts.projectDir, htmlPath); + const currentCoreVersion = opts.currentCoreVersion ?? getCoreVersion(); + + if (!existsSync(absHtml)) { + return { + htmlPath, + exists: false, + assembledAt: null, + coreVersion: null, + currentCoreVersion, + sourceFilesNewer: [], + coreVersionChanged: false, + stale: true, + reasons: ["no-html"], + message: `No ${htmlPath} found. Generate or assemble the project to produce one.`, + }; + } + + const stamp = readStamp(absHtml); + const htmlMtimeMs = safeStatMs(absHtml); + const sourceFilesNewer = htmlMtimeMs + ? listSourceFilesNewerThan(opts.projectDir, htmlMtimeMs).slice(0, MAX_NEWER_FILES_REPORTED) + : []; + + const coreVersionChanged = Boolean(stamp.coreVersion && stamp.coreVersion !== currentCoreVersion); + const reasons: StaleReason[] = []; + if (!stamp.coreVersion && !stamp.assembledAt) reasons.push("no-stamp"); + if (coreVersionChanged) reasons.push("core-version-changed"); + if (sourceFilesNewer.length > 0) reasons.push("source-files-newer"); + + const stale = reasons.length > 0; + return { + htmlPath, + exists: true, + assembledAt: stamp.assembledAt, + coreVersion: stamp.coreVersion, + currentCoreVersion, + sourceFilesNewer, + coreVersionChanged, + stale, + reasons, + message: buildMessage({ + reasons, + stamp, + currentCoreVersion, + sourceFilesNewer, + }), + }; +} + +interface ParsedStamp { + assembledAt: string | null; + coreVersion: string | null; +} + +/** + * Parse the `` and core-version + * meta tags out of the HTML's head. Reads at most the first 16KB so the + * scan stays cheap even on multi-MB assembled files. Exported for tests. + */ +export function readStamp(absHtmlPath: string): ParsedStamp { + try { + // The head fits comfortably in 16KB even for projects with 30+ scenes. + // We only need the meta tags so don't bother streaming the whole file. + const head = readFileSync(absHtmlPath, "utf-8").slice(0, 16_384); + return { + assembledAt: extractMetaContent(head, ASSEMBLED_AT_META), + coreVersion: extractMetaContent(head, CORE_VERSION_META), + }; + } catch { + return { assembledAt: null, coreVersion: null }; + } +} + +/** + * Extract `` value from an HTML head. Tolerates + * single/double-quoted attrs and whitespace variation. Returns null when + * the meta is absent. + */ +export function extractMetaContent(html: string, metaName: string): string | null { + // Build the regex from a fixed metaName — never user-controlled — so we + // don't risk regex injection. + const escaped = metaName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(``, "i"); + const m = re.exec(html); + return m && m[1] ? m[1] : null; +} + +function safeStatMs(absPath: string): number | null { + try { + return statSync(absPath).mtimeMs; + } catch { + return null; + } +} + +/** + * Walk the curated tracked-files / tracked-directories list and return + * project-relative paths whose mtime is later than the threshold. Pure; + * doesn't follow symlinks or descend into non-default directories. + */ +function listSourceFilesNewerThan(projectDir: string, htmlMtimeMs: number): string[] { + const out: string[] = []; + + for (const rel of TRACKED_FILES_RELATIVE) { + const abs = join(projectDir, rel); + const m = safeStatMs(abs); + if (m !== null && m > htmlMtimeMs) { + out.push(rel); + } + } + + for (const dirRel of TRACKED_DIRECTORIES_RELATIVE) { + const newest = newestFileInDirectory(projectDir, dirRel, htmlMtimeMs); + if (newest) out.push(newest); + } + + // Stable order so tests can assert without sorting. + return out; +} + +/** + * Return the project-relative path of the newest file under `dirRel` + * whose mtime exceeds the threshold, or null if the directory is missing + * / empty / older than the threshold. We only need ONE representative + * file per directory — listing every regenerated voiceover is noise in + * the UI. + * + * One level deep is sufficient for current layout (voiceovers/, sfx/, + * music/, images/ — all flat). Recursing further isn't worth the cost. + */ +function newestFileInDirectory( + projectDir: string, + dirRel: string, + thresholdMs: number, +): string | null { + const absDir = join(projectDir, dirRel); + if (!existsSync(absDir)) return null; + let bestMs = thresholdMs; + let bestPath: string | null = null; + let entries: string[] = []; + try { + // These directories have at most ~50 entries — sync read is fine. + entries = readdirSync(absDir); + } catch { + return null; + } + for (const name of entries) { + if (name.startsWith(".")) continue; + const abs = join(absDir, name); + const m = safeStatMs(abs); + if (m !== null && m > bestMs) { + bestMs = m; + bestPath = relative(projectDir, abs); + } + } + return bestPath; +} + +/** Pretty human-readable message for the studio tooltip. */ +function buildMessage(args: { + reasons: StaleReason[]; + stamp: ParsedStamp; + currentCoreVersion: string; + sourceFilesNewer: ReadonlyArray; +}): string { + if (args.reasons.length === 0) return "Up to date."; + const parts: string[] = []; + if (args.reasons.includes("core-version-changed") && args.stamp.coreVersion) { + parts.push( + `core ${args.stamp.coreVersion} → ${args.currentCoreVersion}; new features and bug-fixes have shipped since this index.html was assembled.`, + ); + } + if (args.reasons.includes("no-stamp")) { + parts.push( + `index.html was assembled by an older Hyperframes version that didn't stamp itself; we can't tell which features it includes.`, + ); + } + if (args.reasons.includes("source-files-newer") && args.sourceFilesNewer.length > 0) { + const list = args.sourceFilesNewer.slice(0, 3).join(", "); + const suffix = + args.sourceFilesNewer.length > 3 ? ` and ${args.sourceFilesNewer.length - 3} more` : ""; + parts.push(`source files newer than index.html: ${list}${suffix}.`); + } + return parts.join(" "); +} diff --git a/packages/core/src/script/coreVersion.test.ts b/packages/core/src/script/coreVersion.test.ts new file mode 100644 index 000000000..00606df14 --- /dev/null +++ b/packages/core/src/script/coreVersion.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { getCoreVersion } from "./coreVersion.js"; + +describe("getCoreVersion", () => { + it("returns the core package version (or the dev fallback)", () => { + const v = getCoreVersion(); + expect(typeof v).toBe("string"); + // Either a real semver-ish version (e.g. 0.4.27) or the explicit + // fallback. The exact value drifts across releases, so we assert + // shape only. + expect(v.length).toBeGreaterThan(0); + expect(/^\d+\.\d+\.\d+/.test(v) || v === "0.0.0-dev").toBe(true); + }); + + it("is memoized — repeated calls return the same value", () => { + const a = getCoreVersion(); + const b = getCoreVersion(); + expect(a).toBe(b); + }); +}); diff --git a/packages/core/src/script/coreVersion.ts b/packages/core/src/script/coreVersion.ts new file mode 100644 index 000000000..3fd510546 --- /dev/null +++ b/packages/core/src/script/coreVersion.ts @@ -0,0 +1,70 @@ +/** + * Resolve the running `@hyperframes/core` package version. Used by + * `assemble.ts` to stamp the assembled HTML so the studio can detect + * version drift, and by the staleness route to compare against the + * stamp. + * + * Reads from `packages/core/package.json` at module-load time. We avoid + * the simpler `import pkg from "../../package.json" assert { type: "json" }` + * because Node's import-attribute support varies across the bun + tsx + * + node combinations the CLI runs under, and the studio dev server + * bundles this file with vite which has its own preferences. + * + * The fallback string is `0.0.0-dev` — emitted when the package.json read + * fails for any reason (test fixtures, source-mode where the file path + * doesn't resolve cleanly). Any non-default value indicates a real + * shipped package. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const FALLBACK = "0.0.0-dev"; + +let cached: string | null = null; + +export function getCoreVersion(): string { + if (cached !== null) return cached; + cached = resolveCoreVersion(); + return cached; +} + +function resolveCoreVersion(): string { + // Walk upward from this module's directory until we hit the nearest + // package.json. Source layout: packages/core/src/script/coreVersion.ts + // → look at packages/core/package.json. After tsc build the file lives + // at packages/core/dist/script/coreVersion.js, so the same walk works. + try { + const here = dirname(fileURLToPath(import.meta.url)); + let dir = here; + for (let i = 0; i < 6; i++) { + try { + const candidate = join(dir, "package.json"); + const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { + name?: string; + version?: string; + }; + if (pkg.name === "@hyperframes/core" && typeof pkg.version === "string") { + return pkg.version; + } + } catch { + /* not here, walk up */ + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + } catch { + /* fallthrough */ + } + return FALLBACK; +} + +/** + * Test seam — lets unit tests inject a deterministic version without + * monkey-patching the cache. NOT exported via the package index. + */ +export function __setCoreVersionForTesting(version: string | null): void { + cached = version; +} diff --git a/packages/core/src/script/index.ts b/packages/core/src/script/index.ts index a96a37af5..a3946586c 100644 --- a/packages/core/src/script/index.ts +++ b/packages/core/src/script/index.ts @@ -38,8 +38,11 @@ export type { VisualDirectionEntry, VisualDirectionPlan, } from "./visualDirector.js"; -export { assembleMaster } from "./assemble.js"; +export { assembleMaster, ASSEMBLED_AT_META, CORE_VERSION_META } from "./assemble.js"; export type { AssembleOptions, AssembleResult } from "./assemble.js"; +export { computeAssemblyStatus, readStamp, extractMetaContent } from "./assembleStaleness.js"; +export type { AssemblyStatus, AssemblyStatusOptions, StaleReason } from "./assembleStaleness.js"; +export { getCoreVersion } from "./coreVersion.js"; export { BUILTIN_TEMPLATES, getTemplate, DEFAULT_TOKENS } from "./templates/index.js"; export type { Template, TemplateRenderContext, DesignTokens } from "./templates/index.js"; export { RETENTION_PLAYBOOK } from "./playbook.js"; diff --git a/packages/core/src/studio-api/routes/script.ts b/packages/core/src/studio-api/routes/script.ts index 5641aae12..edef09472 100644 --- a/packages/core/src/studio-api/routes/script.ts +++ b/packages/core/src/studio-api/routes/script.ts @@ -28,6 +28,7 @@ import { type ScriptFidelity, type VisualDirectionPlan, } from "../../script/index.js"; +import { computeAssemblyStatus } from "../../script/assembleStaleness.js"; import { readManifest as readImagesManifest } from "../../images/index.js"; import { validateAgainstSchema } from "../../script/themes/validateProps.js"; import { CostLogger, loggerSink } from "../../telemetry/cost.js"; @@ -758,6 +759,97 @@ export function registerScriptRoutes(api: Hono, adapter: StudioApiAdapter): void return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); } }); + + // Staleness check: is the project's index.html out of date relative to + // the running @hyperframes/core or any source file? Studio polls this + // on Storyline tab mount and after every mutation that could affect + // the rendered HTML. Cheap — handful of stat() calls plus a 16KB read + // of the head. No LLM or write involved. + api.get("/projects/:id/script/assembly-status", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + const htmlPath = c.req.query("path") ?? "index.html"; + if (!isSafePath(project.dir, join(project.dir, htmlPath))) { + return c.json({ error: "forbidden html path" }, 403); + } + const status = computeAssemblyStatus({ projectDir: project.dir, htmlPath }); + return c.json(status); + }); + + // Re-assemble index.html from the existing script.generated.json without + // re-synthesizing audio. Mirrors the CLI `hyperframes script assemble` + // verb so the studio "Regenerate" CTA can fix staleness in one click + // (~2s on a 25-scene project — no API calls, just templating). + api.post("/projects/:id/script/assemble", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + + let body: { outFile?: string } = {}; + try { + // Body is optional — the studio sends `{}` for the default path. + body = (await c.req.json().catch(() => ({}))) as { outFile?: string }; + } catch { + /* ignore — empty body is fine */ + } + const outFile = body.outFile ?? "index.html"; + const absOut = join(project.dir, outFile); + if (!isSafePath(project.dir, absOut)) { + return c.json({ error: "forbidden outFile path" }, 403); + } + + const plannedPath = join(project.dir, PLANNED_FILE); + if (!existsSync(plannedPath)) { + return c.json({ error: "no script.generated.json — run script generate first" }, 400); + } + let planned: import("../../script/types.js").PlannedScript; + try { + planned = JSON.parse(readFileSync(plannedPath, "utf-8")); + } catch (err) { + return c.json( + { + error: `script.generated.json is unreadable: ${err instanceof Error ? err.message : String(err)}`, + }, + 500, + ); + } + + const ops = new OpsLogger(project.dir); + const start = Date.now(); + try { + const briefForAssemble = loadDesignBrief(project.dir); + const tokens = resolveProjectTokens(project.dir, briefForAssemble); + const templates = resolveTemplateRegistry(project.dir, briefForAssemble); + const directionPath = join(project.dir, DIRECTION_FILE); + let directionPlan: VisualDirectionPlan | undefined; + if (existsSync(directionPath)) { + try { + directionPlan = JSON.parse(readFileSync(directionPath, "utf-8")) as VisualDirectionPlan; + } catch (err) { + console.warn("[script] visual-direction.json is malformed; ignoring", err); + } + } + const imagesManifest = readImagesManifest(project.dir); + const result = assembleMaster(planned, { + projectDir: project.dir, + outFile, + tokens, + templates, + ...(directionPlan ? { directionPlan } : {}), + imagesManifest, + }); + const status = computeAssemblyStatus({ projectDir: project.dir, htmlPath: outFile }); + opsFireAndForget(ops, { + op: "script.assemble", + message: `${planned.scenes.length} scenes → ${outFile} (re-emit only)`, + wallMs: Date.now() - start, + meta: { sceneCount: planned.scenes.length, outFile }, + }); + return c.json({ ok: true, result, status }); + } catch (err) { + void ops.logError("script.assemble", err); + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); + } + }); } function writeJson(path: string, data: unknown): void { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e132d6383..37fdee529 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -6,6 +6,7 @@ import { SourceEditor } from "./components/editor/SourceEditor"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; import { ProjectSwitcher } from "./components/ProjectSwitcher"; import { CostBadge } from "./components/CostBadge"; +import { StaleAssemblyBanner } from "./components/StaleAssemblyBanner"; import { RenderQueue } from "./components/renders/RenderQueue"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player"; @@ -575,6 +576,16 @@ export function StudioApp() { es.addEventListener("file-change", handler); return () => es.close(); }); + + // Listen for "user clicked Regenerate in the staleness banner" events. + // Bumping refreshKey forces the preview iframe to remount with the + // freshly assembled HTML — keeps the user's mental model "click → see + // updated preview" intact. + useMountEffect(() => { + const handler = () => setRefreshKey((k) => k + 1); + window.addEventListener("hf:assembly-regenerated", handler as EventListener); + return () => window.removeEventListener("hf:assembly-regenerated", handler as EventListener); + }); projectIdRef.current = projectId; // Load file tree when projectId changes. @@ -1388,10 +1399,11 @@ export function StudioApp() { > {/* Header bar */}
- {/* Left: project switcher + cost badge */} + {/* Left: project switcher + cost badge + staleness chip */}
+
{/* Right: toolbar buttons */}
diff --git a/packages/studio/src/components/StaleAssemblyBanner.tsx b/packages/studio/src/components/StaleAssemblyBanner.tsx new file mode 100644 index 000000000..48e6bf918 --- /dev/null +++ b/packages/studio/src/components/StaleAssemblyBanner.tsx @@ -0,0 +1,166 @@ +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +/** + * Mirrors `AssemblyStatus` from packages/core/src/script/assembleStaleness.ts. + * Kept as a local type to avoid the studio package taking a runtime import on + * core internals. + */ +type StaleReason = "no-html" | "no-stamp" | "core-version-changed" | "source-files-newer"; + +interface AssemblyStatus { + htmlPath: string; + exists: boolean; + assembledAt: string | null; + coreVersion: string | null; + currentCoreVersion: string; + sourceFilesNewer: ReadonlyArray; + coreVersionChanged: boolean; + stale: boolean; + reasons: ReadonlyArray; + message: string; +} + +interface StaleAssemblyBannerProps { + projectId: string; + /** Bumped externally to force a re-poll (e.g. after a script mutation). */ + refreshKey?: number; +} + +const POLL_INTERVAL_MS = 30_000; + +/** + * Lightweight chip for the studio topbar that surfaces "your index.html is + * stale, click to re-assemble" without nagging. + * + * Behavior: + * - Polls /script/assembly-status every 30s + on mount + when refreshKey + * changes. Cheap call, no LLM. + * - When `stale === true`, renders an amber chip with the reason. Click to + * POST /script/assemble; chip flips to "Regenerating…" then back to + * "Up to date" on success (or shows the error if it fails). + * - Renders nothing when status === up-to-date — keeps the topbar clean + * for the 99% case. + * + * The reason copy is deliberately compact: full detail lives in the + * tooltip so the topbar stays scannable. + */ +export const StaleAssemblyBanner = memo(function StaleAssemblyBanner({ + projectId, + refreshKey, +}: StaleAssemblyBannerProps) { + const [status, setStatus] = useState(null); + const [regenerating, setRegenerating] = useState(false); + const [error, setError] = useState(null); + const acRef = useRef(null); + + const fetchStatus = useCallback(async () => { + acRef.current?.abort(); + const ac = new AbortController(); + acRef.current = ac; + try { + const res = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/script/assembly-status`, + { signal: ac.signal }, + ); + if (ac.signal.aborted) return; + if (!res.ok) { + // 404 (no project) or 500 — silently swallow; banner just hides. + setStatus(null); + return; + } + const data = (await res.json()) as AssemblyStatus; + if (ac.signal.aborted) return; + setStatus(data); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + // Network error — hide rather than scream. + setStatus(null); + } + }, [projectId]); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + void fetchStatus(); + const id = window.setInterval(fetchStatus, POLL_INTERVAL_MS); + return () => { + window.clearInterval(id); + acRef.current?.abort(); + }; + }, [fetchStatus, refreshKey]); + + const onRegenerate = useCallback(async () => { + setRegenerating(true); + setError(null); + try { + const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/script/assemble`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = (await res.json().catch(() => ({}))) as { + status?: AssemblyStatus; + error?: string; + }; + if (!res.ok) { + setError(data.error ?? `HTTP ${res.status}`); + return; + } + if (data.status) setStatus(data.status); + else await fetchStatus(); + // Force the preview iframe to reload — the assembled HTML changed. + // Listeners can subscribe to the same event from elsewhere. + try { + window.dispatchEvent(new CustomEvent("hf:assembly-regenerated")); + } catch { + /* ignore */ + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRegenerating(false); + } + }, [projectId, fetchStatus]); + + if (!status || !status.stale) return null; + + // Map reasons to a 1-word leading label. The full sentence lives in + // status.message and is shown via the title attribute. + const headline = pickHeadline(status); + + return ( +
+ + ⚠ + + + {headline} + + +
+ ); +}); + +function pickHeadline(status: AssemblyStatus): string { + if (status.reasons.includes("no-html")) return "No index.html"; + if (status.reasons.includes("core-version-changed")) { + return `Core ${status.coreVersion} → ${status.currentCoreVersion}`; + } + if (status.reasons.includes("source-files-newer")) { + const n = status.sourceFilesNewer.length; + if (n === 0) return "Stale"; + return `${n} source file${n === 1 ? "" : "s"} newer than index.html`; + } + if (status.reasons.includes("no-stamp")) return "Older index.html (no stamp)"; + return "Stale"; +}