mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge origin/main into fix/lint-improvements
Resolve conflict in lint.ts: keep main's lintProject/formatLintFindings refactoring, integrate PR's infoCount tracking, filesScanned in JSON output, info severity display, and try/catch error handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"timeout": 180,
|
||||
"statusMessage": "Running build + lint + typecheck before commit…",
|
||||
"command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = process.env.PWD || process.cwd();\n const steps = [\n ['pnpm build', 'Build'],\n ['pnpm run -w lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -60,16 +60,17 @@ Git hooks (via [lefthook](https://github.com/evilmartians/lefthook)) run automat
|
||||
All packages use **fixed versioning** — every release bumps all packages to the same version.
|
||||
|
||||
```bash
|
||||
bun run set-version 0.2.0
|
||||
git checkout -b release/v0.2.0
|
||||
git add packages/*/package.json
|
||||
git commit -m "chore: release v0.2.0"
|
||||
git push origin release/v0.2.0
|
||||
gh pr create --title "chore: release v0.2.0" --base main
|
||||
# After merge, tag + npm publish + GitHub Release happen automatically
|
||||
bun run set-version 0.2.0 # bumps all packages, commits, and creates git tag
|
||||
git push origin main --tags # triggers the publish workflow
|
||||
```
|
||||
|
||||
You can also publish manually by pushing a tag: `git tag v0.2.0 && git push origin v0.2.0`
|
||||
The `set-version` script automatically creates a `chore: release v<version>` commit and a `v<version>` git tag. Pushing the tag triggers CI to publish all packages to npm and create a GitHub Release.
|
||||
|
||||
If you need to bump versions without committing (e.g., for a release PR), pass `--no-tag`:
|
||||
|
||||
```bash
|
||||
bun run set-version 0.2.0 --no-tag # updates package.json files only
|
||||
```
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Hyperframes
|
||||
|
||||
[](https://www.npmjs.com/package/hyperframes)
|
||||
[](LICENSE)
|
||||
[](https://nodejs.org)
|
||||
|
||||
**Write HTML. Render video. Built for agents.**
|
||||
@@ -29,6 +31,10 @@ npx hyperframes render # render to MP4
|
||||
|
||||
**Requirements:** Node.js >= 22, FFmpeg
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation at **[hyperframes.heygen.com](https://hyperframes.heygen.com)** — start with the [Quickstart](https://hyperframes.heygen.com/quickstart), then explore guides, concepts, API reference, and package docs.
|
||||
|
||||
## How It Works
|
||||
|
||||
Define your video as HTML with data attributes:
|
||||
@@ -102,10 +108,6 @@ npx skills add greensock/gsap-skills
|
||||
|
||||
In Claude Code, invoke with `/hyperframes-compose`, `/hyperframes-captions`, `/gsap-core`, etc.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs at [hyperframes.heygen.com](https://hyperframes.heygen.com) — includes guides, concepts, API reference, and package documentation.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Testing Local CLI Changes
|
||||
description: How to test unreleased CLI changes outside the monorepo using your local build.
|
||||
---
|
||||
|
||||
When you modify the CLI or any package it bundles (core, engine, producer, studio), you need to test those changes against real projects _outside_ the monorepo — the same way an end user would run `hyperframes dev`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Build the monorepo first. Every time you change source files, rebuild before testing.
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Option 1: pnpm link (recommended)
|
||||
|
||||
`pnpm link --global` makes the `hyperframes` binary in your `$PATH` point at your local build. It survives across terminal sessions and auto-picks up new builds without re-linking.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
cd packages/cli
|
||||
pnpm link --global
|
||||
|
||||
# Verify — should print your local version
|
||||
hyperframes --version
|
||||
```
|
||||
|
||||
Now use `hyperframes` normally in any directory:
|
||||
|
||||
```bash
|
||||
cd ~/my-video-project
|
||||
hyperframes dev .
|
||||
```
|
||||
|
||||
**After every `pnpm build`** the linked binary is already up to date — no re-linking needed.
|
||||
|
||||
To restore the published release when you're done:
|
||||
|
||||
```bash
|
||||
pnpm unlink --global hyperframes
|
||||
npm install -g hyperframes@latest
|
||||
```
|
||||
|
||||
## Option 2: node alias (no PATH changes)
|
||||
|
||||
If you don't want to touch your global `$PATH`, add a shell alias or call `node` directly:
|
||||
|
||||
```bash
|
||||
# Temporary alias for your current shell session
|
||||
alias hyperframes="node /path/to/hyperframes-oss/packages/cli/dist/cli.js"
|
||||
|
||||
# Or invoke directly
|
||||
node /path/to/hyperframes-oss/packages/cli/dist/cli.js dev .
|
||||
```
|
||||
|
||||
Replace `/path/to/hyperframes-oss` with your actual monorepo path.
|
||||
|
||||
## Option 3: npm pack (test the exact published artifact)
|
||||
|
||||
Use this when you want to verify what would actually ship in a release, including the bundled studio and templates.
|
||||
|
||||
```bash
|
||||
cd packages/cli
|
||||
npm pack
|
||||
# Creates: hyperframes-<version>.tgz
|
||||
|
||||
# Test it in an isolated directory
|
||||
mkdir /tmp/pack-test && cd /tmp/pack-test
|
||||
npx /path/to/hyperframes-oss/packages/cli/hyperframes-<version>.tgz init my-video
|
||||
cd my-video
|
||||
npx /path/to/hyperframes-oss/packages/cli/hyperframes-<version>.tgz dev .
|
||||
```
|
||||
|
||||
## Testing the fix branches
|
||||
|
||||
When validating a specific bug fix, extract one of the test project archives and run through the scenario:
|
||||
|
||||
```bash
|
||||
# Example: testing audio-after-seek fix
|
||||
unzip golden-lyric-video.zip && cd golden-lyric-video
|
||||
hyperframes dev .
|
||||
# 1. Press Play — confirm audio plays
|
||||
# 2. Drag the timeline scrubber to a different position
|
||||
# 3. Press Play again — audio should resume from the seeked position
|
||||
```
|
||||
|
||||
Common test scenarios:
|
||||
|
||||
| Bug | Project | Steps |
|
||||
|---|---|---|
|
||||
| Audio silent after seek | `golden-lyric-video` | Play → seek → play again, verify audio |
|
||||
| Render stuck at 0% | any | Renders tab → Export → watch progress bar |
|
||||
| Download 404 after restart | any | Complete a render → `Ctrl+C` → restart → Download |
|
||||
| Timeline stops early | `intro-vid` | Play → should reach `0:05`, not stop at `0:03` |
|
||||
| Lottie missing | `hyperframe-build-up-demo` | Play → rocket visible during 0–2 s |
|
||||
| Blank thumbnails | any | Compositions sidebar should show previews |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Changes not reflected after `pnpm build`**
|
||||
|
||||
The CLI binary is a single bundled file at `packages/cli/dist/cli.js`. If your change is in `@hyperframes/core` or another workspace package, make sure `pnpm build` rebuilt _all_ packages — the CLI bundles its dependencies at build time.
|
||||
|
||||
**`hyperframes` still shows the old version**
|
||||
|
||||
Check which binary is active:
|
||||
|
||||
```bash
|
||||
which hyperframes
|
||||
hyperframes --version
|
||||
```
|
||||
|
||||
If it points to a global npm installation rather than your link, uninstall the npm version first:
|
||||
|
||||
```bash
|
||||
npm uninstall -g hyperframes
|
||||
cd packages/cli && pnpm link --global
|
||||
```
|
||||
|
||||
**Port already in use**
|
||||
|
||||
`hyperframes dev` defaults to port 3002 and auto-increments if it's taken. Pass `--port` to use a specific port:
|
||||
|
||||
```bash
|
||||
hyperframes dev . --port 4000
|
||||
```
|
||||
+2
-2
@@ -82,8 +82,8 @@
|
||||
"pages": ["reference/html-schema"]
|
||||
},
|
||||
{
|
||||
"group": "Community",
|
||||
"pages": ["contributing"]
|
||||
"group": "Contributing",
|
||||
"pages": ["contributing", "contributing/testing-local-changes"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,6 +15,7 @@
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy",
|
||||
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
|
||||
@@ -52,7 +53,8 @@
|
||||
"picocolors": "^1.1.1",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createRequire } from "node:module";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
|
||||
/**
|
||||
* Try to start a server on the given port, auto-incrementing up to maxAttempts
|
||||
@@ -71,6 +73,19 @@ export default defineCommand({
|
||||
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
|
||||
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir);
|
||||
|
||||
// Lint before starting — surface issues for the agent to fix.
|
||||
// dev.ts doesn't use resolveProject() because it needs to proceed even without index.html.
|
||||
const indexPath = join(dir, "index.html");
|
||||
if (existsSync(indexPath)) {
|
||||
const project = { dir, name: projectName, indexPath };
|
||||
const lintResult = lintProject(project);
|
||||
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
|
||||
console.log();
|
||||
for (const line of formatLintFindings(lintResult)) console.log(line);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
return runDevMode(dir, projectName);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { lintHyperframeHtml } from "@hyperframes/core/lint";
|
||||
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
||||
import { walkDir } from "@hyperframes/core/studio-api";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import { withMeta } from "../utils/updateCheck.js";
|
||||
|
||||
export default defineCommand({
|
||||
@@ -17,75 +14,36 @@ export default defineCommand({
|
||||
async run({ args }) {
|
||||
try {
|
||||
const project = resolveProject(args.dir);
|
||||
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
|
||||
|
||||
const allFindings: (HyperframeLintFinding & { file: string })[] = [];
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
const html = readFileSync(join(project.dir, file), "utf-8");
|
||||
const result = lintHyperframeHtml(html, { filePath: file });
|
||||
for (const f of result.findings) {
|
||||
allFindings.push({ ...f, file });
|
||||
}
|
||||
totalErrors += result.errorCount;
|
||||
totalWarnings += result.warningCount;
|
||||
totalInfos += result.infoCount;
|
||||
}
|
||||
const lintResult = lintProject(project);
|
||||
|
||||
if (args.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
ok: totalErrors === 0,
|
||||
findings: allFindings,
|
||||
errorCount: totalErrors,
|
||||
warningCount: totalWarnings,
|
||||
infoCount: totalInfos,
|
||||
filesScanned: htmlFiles.length,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(totalErrors > 0 ? 1 : 0);
|
||||
const combined = {
|
||||
ok: lintResult.totalErrors === 0,
|
||||
errorCount: lintResult.totalErrors,
|
||||
warningCount: lintResult.totalWarnings,
|
||||
infoCount: lintResult.totalInfos,
|
||||
findings: lintResult.results.flatMap((r) => r.result.findings),
|
||||
filesScanned: lintResult.results.length,
|
||||
};
|
||||
console.log(JSON.stringify(withMeta(combined), null, 2));
|
||||
process.exit(combined.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`,
|
||||
);
|
||||
const fileCount = lintResult.results.length;
|
||||
const fileLabel =
|
||||
fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`;
|
||||
console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`);
|
||||
console.log();
|
||||
|
||||
if (allFindings.length === 0) {
|
||||
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) {
|
||||
console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const finding of allFindings) {
|
||||
const prefix =
|
||||
finding.severity === "error"
|
||||
? c.error("✗")
|
||||
: finding.severity === "warning"
|
||||
? c.warn("⚠")
|
||||
: c.dim("ℹ");
|
||||
const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
|
||||
console.log(
|
||||
`${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`,
|
||||
);
|
||||
if (finding.fixHint) {
|
||||
console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
|
||||
}
|
||||
}
|
||||
const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true });
|
||||
for (const line of lines) console.log(line);
|
||||
|
||||
const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇");
|
||||
const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`];
|
||||
if (totalInfos > 0) {
|
||||
summaryParts.push(`${totalInfos} info(s)`);
|
||||
}
|
||||
console.log(`\n${summaryIcon} ${summaryParts.join(", ")}`);
|
||||
process.exit(totalErrors > 0 ? 1 : 0);
|
||||
process.exit(lintResult.totalErrors > 0 ? 1 : 0);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (args.json) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { cpus, freemem } from "node:os";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { lintProject, shouldBlockRender } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import { loadProducer } from "../utils/producer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
|
||||
@@ -75,6 +77,16 @@ Examples:
|
||||
description: "Suppress verbose output",
|
||||
default: false,
|
||||
},
|
||||
strict: {
|
||||
type: "boolean",
|
||||
description: "Fail render on lint errors",
|
||||
default: false,
|
||||
},
|
||||
"strict-all": {
|
||||
type: "boolean",
|
||||
description: "Fail render on lint errors AND warnings",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
// ── Resolve project ────────────────────────────────────────────────────
|
||||
@@ -131,6 +143,8 @@ Examples:
|
||||
const useDocker = args.docker ?? false;
|
||||
const useGpu = args.gpu ?? false;
|
||||
const quiet = args.quiet ?? false;
|
||||
const strictAll = args["strict-all"] ?? false;
|
||||
const strictErrors = (args.strict ?? false) || strictAll;
|
||||
|
||||
// ── Print render plan ─────────────────────────────────────────────────
|
||||
const workerCount = workers ?? defaultWorkerCount();
|
||||
@@ -193,6 +207,31 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-render lint ──────────────────────────────────────────────────
|
||||
{
|
||||
const lintResult = lintProject(project);
|
||||
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
|
||||
console.log("");
|
||||
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
|
||||
if (
|
||||
shouldBlockRender(
|
||||
strictErrors,
|
||||
strictAll,
|
||||
lintResult.totalErrors,
|
||||
lintResult.totalWarnings,
|
||||
)
|
||||
) {
|
||||
const mode = strictAll ? "--strict-all" : "--strict";
|
||||
console.log("");
|
||||
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(c.dim(" Continuing render despite lint issues. Use --strict to block."));
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
if (useDocker) {
|
||||
await renderDocker(project.dir, outputPath, {
|
||||
|
||||
@@ -44,6 +44,50 @@ function resolveRuntimePath(): string {
|
||||
return builtPath;
|
||||
}
|
||||
|
||||
// ── Shared thumbnail browser (singleton per process) ────────────────────────
|
||||
// One browser instance is reused across all composition thumbnail requests.
|
||||
// Spawning a new Puppeteer process per request adds 2-5s overhead and causes
|
||||
// contention when the sidebar requests multiple thumbnails simultaneously.
|
||||
|
||||
let _thumbnailBrowser: import("puppeteer-core").Browser | null = null;
|
||||
let _thumbnailBrowserInitializing: Promise<import("puppeteer-core").Browser | null> | null = null;
|
||||
|
||||
async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
||||
if (_thumbnailBrowser?.connected) return _thumbnailBrowser;
|
||||
if (_thumbnailBrowserInitializing) return _thumbnailBrowserInitializing;
|
||||
|
||||
_thumbnailBrowserInitializing = (async () => {
|
||||
try {
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine");
|
||||
|
||||
try {
|
||||
const b = await ensureBrowser();
|
||||
if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
||||
process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath;
|
||||
}
|
||||
} catch {
|
||||
/* continue — acquireBrowser will try its own resolution */
|
||||
}
|
||||
|
||||
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }), {
|
||||
enableBrowserPool: false,
|
||||
});
|
||||
_thumbnailBrowser = acquired.browser;
|
||||
_thumbnailBrowser.on("disconnected", () => {
|
||||
_thumbnailBrowser = null;
|
||||
_thumbnailBrowserInitializing = null;
|
||||
});
|
||||
return _thumbnailBrowser;
|
||||
} catch {
|
||||
_thumbnailBrowserInitializing = null;
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return _thumbnailBrowserInitializing;
|
||||
}
|
||||
|
||||
// ── Server factory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface StudioServerOptions {
|
||||
@@ -152,6 +196,44 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
return state;
|
||||
},
|
||||
|
||||
async generateThumbnail(opts): Promise<Buffer | null> {
|
||||
// Reuse a single browser across all thumbnail requests for this server
|
||||
// instance — avoids paying the ~2s Puppeteer startup cost per composition.
|
||||
// The browser is created lazily and kept alive until the process exits.
|
||||
const browser = await getThumbnailBrowser();
|
||||
if (!browser) return null;
|
||||
let page: import("puppeteer-core").Page | null = null;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
|
||||
// domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie,
|
||||
// fonts) never reach "idle" and cause a 15s timeout per thumbnail.
|
||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
// Wait for the runtime to register timelines (up to 5s, non-fatal).
|
||||
await page
|
||||
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
|
||||
timeout: 5000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await page.evaluate((t: number) => {
|
||||
const win = window as any;
|
||||
if (win.__player?.seek) win.__player.seek(t);
|
||||
else if (win.__timeline?.seek) {
|
||||
win.__timeline.pause();
|
||||
win.__timeline.seek(t);
|
||||
}
|
||||
}, opts.seekTime);
|
||||
// Let the seek render settle.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer;
|
||||
return screenshot;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build the Hono app ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { c } from "../ui/colors.js";
|
||||
import type { ProjectLintResult } from "./lintProject.js";
|
||||
|
||||
export interface LintFormatOptions {
|
||||
/** Show elementId in brackets after the code (default: true) */
|
||||
showElementId?: boolean;
|
||||
/** Show summary line with error/warning counts (default: false) */
|
||||
showSummary?: boolean;
|
||||
/** Group errors before warnings per file (default: false — interleaved) */
|
||||
errorsFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format lint findings for console output. Used by lint, render, and dev commands.
|
||||
*/
|
||||
export function formatLintFindings(
|
||||
{ results, totalErrors, totalWarnings, totalInfos }: ProjectLintResult,
|
||||
options: LintFormatOptions = {},
|
||||
): string[] {
|
||||
const { showElementId = true, showSummary = false, errorsFirst = false } = options;
|
||||
const lines: string[] = [];
|
||||
const multiFile = results.length > 1;
|
||||
|
||||
for (const { file, result } of results) {
|
||||
if (result.findings.length === 0) continue;
|
||||
|
||||
const format = (finding: (typeof result.findings)[0]) => {
|
||||
const prefix =
|
||||
finding.severity === "error"
|
||||
? c.error("✗")
|
||||
: finding.severity === "warning"
|
||||
? c.warn("⚠")
|
||||
: c.dim("ℹ");
|
||||
const fileLabel = multiFile ? c.dim(`[${file}] `) : "";
|
||||
const loc =
|
||||
showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
|
||||
lines.push(` ${prefix} ${fileLabel}${c.bold(finding.code)}${loc}: ${finding.message}`);
|
||||
if (finding.fixHint) lines.push(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
|
||||
};
|
||||
|
||||
if (errorsFirst) {
|
||||
for (const f of result.findings) if (f.severity === "error") format(f);
|
||||
for (const f of result.findings) if (f.severity === "warning") format(f);
|
||||
} else {
|
||||
for (const f of result.findings) format(f);
|
||||
}
|
||||
}
|
||||
|
||||
if (showSummary) {
|
||||
const icon = totalErrors > 0 ? c.error("◇") : c.success("◇");
|
||||
lines.push("");
|
||||
const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`];
|
||||
if (totalInfos > 0) summaryParts.push(`${totalInfos} info(s)`);
|
||||
lines.push(`${icon} ${summaryParts.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { lintProject, shouldBlockRender } from "./lintProject.js";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
|
||||
function tmpProject(name: string): string {
|
||||
const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function validHtml(compId = "main"): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function htmlWithMissingMediaId(): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function htmlWithPreloadNone(): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): ProjectDir {
|
||||
const dir = tmpProject("lint");
|
||||
dirs.push(dir);
|
||||
writeFileSync(join(dir, "index.html"), indexHtml);
|
||||
if (subComps) {
|
||||
const compsDir = join(dir, "compositions");
|
||||
mkdirSync(compsDir, { recursive: true });
|
||||
for (const [name, html] of Object.entries(subComps)) {
|
||||
writeFileSync(join(compsDir, name), html);
|
||||
}
|
||||
}
|
||||
return { dir, name: "test-project", indexPath: join(dir, "index.html") };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
describe("lintProject", () => {
|
||||
it("returns zero errors/warnings for a clean project", () => {
|
||||
const project = makeProject(validHtml());
|
||||
const { totalErrors, totalWarnings, results } = lintProject(project);
|
||||
|
||||
expect(totalErrors).toBe(0);
|
||||
expect(totalWarnings).toBe(0);
|
||||
expect(results).toHaveLength(1);
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.file).toBe("index.html");
|
||||
});
|
||||
|
||||
it("detects errors in index.html", () => {
|
||||
const project = makeProject(htmlWithMissingMediaId());
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const first = results[0];
|
||||
expect(first).toBeDefined();
|
||||
const mediaFinding = first?.result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(mediaFinding).toBeDefined();
|
||||
});
|
||||
|
||||
it("lints sub-compositions in compositions/ directory", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": htmlWithMissingMediaId(),
|
||||
});
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
const second = results[1];
|
||||
expect(second).toBeDefined();
|
||||
expect(second?.file).toBe("compositions/captions.html");
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
const subFindings = second?.result.findings ?? [];
|
||||
expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
|
||||
});
|
||||
|
||||
it("aggregates errors across index.html and sub-compositions", () => {
|
||||
const project = makeProject(htmlWithMissingMediaId(), {
|
||||
"overlay.html": htmlWithMissingMediaId(),
|
||||
});
|
||||
const { totalErrors, results } = lintProject(project);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
// Both files have media_missing_id errors
|
||||
const rootErrors = first?.result.errorCount ?? 0;
|
||||
const subErrors = second?.result.errorCount ?? 0;
|
||||
expect(totalErrors).toBe(rootErrors + subErrors);
|
||||
});
|
||||
|
||||
it("aggregates warnings from sub-compositions", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": htmlWithPreloadNone(),
|
||||
});
|
||||
const { totalWarnings, results } = lintProject(project);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(totalWarnings).toBeGreaterThan(0);
|
||||
const second = results[1];
|
||||
expect(second).toBeDefined();
|
||||
const preloadWarning = second?.result.findings.find((f) => f.code === "media_preload_none");
|
||||
expect(preloadWarning).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles project with no compositions/ directory", () => {
|
||||
const project = makeProject(validHtml());
|
||||
// No compositions/ dir created
|
||||
const { results } = lintProject(project);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores non-HTML files in compositions/", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"captions.html": validHtml("captions"),
|
||||
});
|
||||
// Add a non-HTML file
|
||||
writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html");
|
||||
|
||||
const { results } = lintProject(project);
|
||||
|
||||
expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldBlockRender", () => {
|
||||
it("default: does not block on errors", () => {
|
||||
expect(shouldBlockRender(false, false, 5, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("default: does not block on warnings", () => {
|
||||
expect(shouldBlockRender(false, false, 0, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("--strict: blocks on errors", () => {
|
||||
expect(shouldBlockRender(true, false, 1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("--strict: does not block on warnings only", () => {
|
||||
expect(shouldBlockRender(true, false, 0, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("--strict-all: blocks on errors", () => {
|
||||
expect(shouldBlockRender(true, true, 1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("--strict-all: blocks on warnings", () => {
|
||||
expect(shouldBlockRender(true, true, 0, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("--strict-all: does not block when clean", () => {
|
||||
expect(shouldBlockRender(true, true, 0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("--strict-all alone: blocks on errors", () => {
|
||||
expect(shouldBlockRender(false, true, 1, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("--strict-all alone: blocks on warnings", () => {
|
||||
expect(shouldBlockRender(false, true, 0, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
||||
import type { ProjectDir } from "./project.js";
|
||||
|
||||
export interface ProjectLintResult {
|
||||
results: Array<{ file: string; result: HyperframeLintResult }>;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
totalInfos: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint the root index.html and all sub-compositions in the compositions/ directory.
|
||||
* Returns aggregated results across all files.
|
||||
*/
|
||||
export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
// Lint root composition
|
||||
const rootHtml = readFileSync(project.indexPath, "utf-8");
|
||||
const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath });
|
||||
results.push({ file: "index.html", result: rootResult });
|
||||
totalErrors += rootResult.errorCount;
|
||||
totalWarnings += rootResult.warningCount;
|
||||
totalInfos += rootResult.infoCount;
|
||||
|
||||
// Lint sub-compositions in compositions/ directory
|
||||
const compositionsDir = resolve(project.dir, "compositions");
|
||||
if (existsSync(compositionsDir)) {
|
||||
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
|
||||
for (const file of files) {
|
||||
const filePath = join(compositionsDir, file);
|
||||
const html = readFileSync(filePath, "utf-8");
|
||||
const result = lintHyperframeHtml(html, { filePath });
|
||||
results.push({ file: `compositions/${file}`, result });
|
||||
totalErrors += result.errorCount;
|
||||
totalWarnings += result.warningCount;
|
||||
totalInfos += result.infoCount;
|
||||
}
|
||||
}
|
||||
|
||||
return { results, totalErrors, totalWarnings, totalInfos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a render should be blocked based on lint results and strict mode.
|
||||
* --strict blocks on errors; --strict-all blocks on errors or warnings.
|
||||
*/
|
||||
export function shouldBlockRender(
|
||||
strictErrors: boolean,
|
||||
strictAll: boolean,
|
||||
totalErrors: number,
|
||||
totalWarnings: number,
|
||||
): boolean {
|
||||
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleToSingleHtml } from "./htmlBundler";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("bundleToSingleHtml", () => {
|
||||
it("hoists external CDN scripts from sub-compositions into the bundle", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="rockets-host"
|
||||
data-composition-id="rockets"
|
||||
data-composition-src="compositions/rockets.html"
|
||||
data-start="0" data-duration="2"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/rockets.html": `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const anim = lottie.loadAnimation({ container: document.querySelector("#rocket-container"), path: "rocket.json" });
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// Lottie CDN script from sub-composition must be present in the bundle
|
||||
expect(bundled).toContain(
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js",
|
||||
);
|
||||
|
||||
// Should only appear once (deduped)
|
||||
const occurrences = (bundled.match(/cdnjs\.cloudflare\.com\/ajax\/libs\/lottie-web/g) ?? [])
|
||||
.length;
|
||||
expect(occurrences).toBe(1);
|
||||
|
||||
// GSAP CDN from main doc should still be present
|
||||
expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap");
|
||||
|
||||
// data-composition-src should be stripped (composition was inlined)
|
||||
expect(bundled).not.toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("does not duplicate CDN scripts already present in the main document", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="child-host"
|
||||
data-composition-id="child"
|
||||
data-composition-src="compositions/child.html"
|
||||
data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
|
||||
</body></html>`,
|
||||
"compositions/child.html": `<template id="child-template">
|
||||
<div data-composition-id="child" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
<!-- Same GSAP CDN as parent — should not be duplicated -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["child"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
// GSAP CDN should appear exactly once (deduped)
|
||||
const gsapOccurrences = (
|
||||
bundled.match(/cdn\.jsdelivr\.net\/npm\/gsap@3\.14\.2\/dist\/gsap\.min\.js/g) ?? []
|
||||
).length;
|
||||
expect(gsapOccurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,7 @@ export async function bundleToSingleHtml(
|
||||
// Inline sub-compositions
|
||||
const compStyleChunks: string[] = [];
|
||||
const compScriptChunks: string[] = [];
|
||||
const compExternalScriptSrcs: string[] = [];
|
||||
$("[data-composition-src]").each((_, hostEl) => {
|
||||
const src = $(hostEl).attr("data-composition-src");
|
||||
if (!src || !isRelativeUrl(src)) return;
|
||||
@@ -416,9 +417,18 @@ export async function bundleToSingleHtml(
|
||||
$content(s).remove();
|
||||
});
|
||||
$content("script").each((_, s) => {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
const externalSrc = ($content(s).attr("src") || "").trim();
|
||||
if (externalSrc) {
|
||||
// External CDN/remote script — collect for deduped injection into the document.
|
||||
// Do NOT try to inline the content (external scripts have no innerHTML).
|
||||
if (!compExternalScriptSrcs.includes(externalSrc)) {
|
||||
compExternalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
$content(s).remove();
|
||||
});
|
||||
|
||||
@@ -439,6 +449,14 @@ export async function bundleToSingleHtml(
|
||||
$(hostEl).removeAttr("data-composition-src");
|
||||
});
|
||||
|
||||
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
|
||||
// that aren't already present in the main document.
|
||||
for (const extSrc of compExternalScriptSrcs) {
|
||||
if (!$(`script[src="${extSrc}"]`).length) {
|
||||
$("body").append(`<script src="${extSrc}"></script>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (compStyleChunks.length) $("head").append(`<style>${compStyleChunks.join("\n\n")}</style>`);
|
||||
if (compScriptChunks.length)
|
||||
$("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
|
||||
|
||||
describe("lintHyperframeHtml", () => {
|
||||
const validComposition = `
|
||||
@@ -111,6 +111,42 @@ describe("lintHyperframeHtml", () => {
|
||||
expect(codes.length).toBe(uniqueCodes.length);
|
||||
});
|
||||
|
||||
it("reports info for composition with external CDN script dependency", () => {
|
||||
const html = `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["rockets"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
|
||||
const finding = result.findings.find((f) => f.code === "external_script_dependency");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("info");
|
||||
expect(finding?.message).toContain("cdnjs.cloudflare.com");
|
||||
// info findings do not count as errors — ok should still be true
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report external_script_dependency for inline scripts", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips <template> wrapper before linting composition files", () => {
|
||||
const html = `<template id="my-comp-template">
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
|
||||
@@ -130,4 +166,441 @@ describe("lintHyperframeHtml", () => {
|
||||
);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is assigned without initializing", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("without initializing");
|
||||
});
|
||||
|
||||
it("does not flag timeline assignment when init guard is present", () => {
|
||||
const result = lintHyperframeHtml(validComposition);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when GSAP targets a clip element by id", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#overlay", { opacity: 0, duration: 0.5 }, 4.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.selector).toBe("#overlay");
|
||||
expect(finding?.message).toContain("inner wrapper");
|
||||
});
|
||||
|
||||
it("reports error when GSAP targets a clip element by class", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="card" class="clip my-card" data-start="0" data-duration="5" data-track-index="0">
|
||||
<p>Content</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from(".my-card", { y: 100, duration: 0.3 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".my-card");
|
||||
});
|
||||
|
||||
it("does NOT flag GSAP targeting a child of a clip element", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<h1 class="title">Hello</h1>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".title", { opacity: 1, duration: 0.5 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
|
||||
<h1 class="title">Hello</h1>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#overlay .title", { opacity: 1, duration: 0.5 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip scene-card" data-start="0" data-duration="5" data-track-index="0">
|
||||
<p>Content</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".scene-card");
|
||||
expect(finding?.elementId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for audio with data-start but no id", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("SILENT");
|
||||
});
|
||||
|
||||
it("reports error for video with data-start but no id", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("FROZEN");
|
||||
});
|
||||
|
||||
it("does not flag media elements that have id", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports warning for media with preload=none", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_preload_none");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("reports error for media with id but no src", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio id="a1" data-start="0" data-duration="10"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_src");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lintScriptUrls", () => {
|
||||
it("reports error for script URL returning non-2xx", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://unpkg.com/@hyperframe/player@latest/dist/player.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
const finding = findings.find((f) => f.code === "inaccessible_script_url");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("404");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reports error for unreachable script URL", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://example.invalid/nonexistent.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
const finding = findings.find((f) => f.code === "inaccessible_script_url");
|
||||
expect(finding).toBeDefined();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not flag accessible script URLs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
expect(findings.length).toBe(0);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("skips inline scripts without src", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>console.log("inline")</script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
expect(findings.length).toBe(0);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── gsap_css_transform_conflict ──────────────────────────────────────────
|
||||
|
||||
it("warns when tl.to animates x on an element with CSS translateX", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title" style=""></div>
|
||||
</div>
|
||||
<style>
|
||||
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.selector).toBe("#title");
|
||||
expect(finding?.fixHint).toMatch(/fromTo/);
|
||||
expect(finding?.fixHint).toMatch(/xPercent/);
|
||||
});
|
||||
|
||||
it("warns when tl.to animates scale on an element with CSS scale transform", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="hero"></div>
|
||||
</div>
|
||||
<style>
|
||||
#hero { transform: scale(0.8); opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.selector).toBe("#hero");
|
||||
});
|
||||
|
||||
it("does NOT warn when tl.to targets element without CSS transform", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="card"></div>
|
||||
</div>
|
||||
<style>
|
||||
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(conflict).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="title"></div>
|
||||
</div>
|
||||
<style>
|
||||
#title { position: absolute; left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(conflict).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="hero"></div>
|
||||
</div>
|
||||
<style>
|
||||
#hero { transform: translateX(-50%) scale(0.8); }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template_literal_selector rule", () => {
|
||||
it("reports error when querySelector uses template literal variable", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div class="chart"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const compId = "main";
|
||||
const el = document.querySelector(\`[data-composition-id="\${compId}"] .chart\`);
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "template_literal_selector");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports error for querySelectorAll with template literal variable", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const id = "main";
|
||||
document.querySelectorAll(\`[data-composition-id="\${id}"] .item\`);
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "template_literal_selector");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not report error for hardcoded querySelector strings", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div class="chart"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const el = document.querySelector('[data-composition-id="main"] .chart');
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "template_literal_selector");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,6 +267,24 @@ export function lintHyperframeHtml(
|
||||
});
|
||||
}
|
||||
|
||||
// Build clip element selector map for gsap_animates_clip_element check.
|
||||
// The runtime manages clip visibility — GSAP writing to the same element causes
|
||||
// a runaway style recalculation loop that crashes the browser tab.
|
||||
type ClipInfo = { tag: string; id: string; classes: string };
|
||||
const clipIds = new Map<string, ClipInfo>();
|
||||
const clipClasses = new Map<string, ClipInfo>();
|
||||
for (const tag of tags) {
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const classes = classAttr.split(/\s+/).filter(Boolean);
|
||||
if (!classes.includes("clip")) continue;
|
||||
const id = readAttr(tag.raw, "id");
|
||||
const info: ClipInfo = { tag: tag.name, id: id || "", classes: classAttr };
|
||||
if (id) clipIds.set(`#${id}`, info);
|
||||
for (const cls of classes) {
|
||||
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
||||
}
|
||||
}
|
||||
|
||||
const classUsage = countClassUsage(tags);
|
||||
for (const script of scripts) {
|
||||
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
||||
@@ -310,24 +328,41 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any GSAP selector targets a clip element
|
||||
for (const win of gsapWindows) {
|
||||
const sel = win.targetSelector;
|
||||
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
||||
if (!clipInfo) continue;
|
||||
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
||||
pushFinding({
|
||||
code: "gsap_animates_clip_element",
|
||||
severity: "error",
|
||||
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
|
||||
selector: sel,
|
||||
elementId: clipInfo.id || undefined,
|
||||
fixHint: "Wrap content in a child <div> and target that with GSAP.",
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) {
|
||||
continue;
|
||||
}
|
||||
for (const window of gsapWindows) {
|
||||
if (!isSuspiciousGlobalSelector(window.targetSelector)) {
|
||||
for (const win of gsapWindows) {
|
||||
if (!isSuspiciousGlobalSelector(win.targetSelector)) {
|
||||
continue;
|
||||
}
|
||||
const className = getSingleClassSelector(window.targetSelector);
|
||||
const className = getSingleClassSelector(win.targetSelector);
|
||||
if (className && (classUsage.get(className) || 0) < 2) {
|
||||
continue;
|
||||
}
|
||||
pushFinding({
|
||||
code: "unscoped_gsap_selector",
|
||||
severity: "warning",
|
||||
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
||||
selector: window.targetSelector,
|
||||
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
|
||||
snippet: truncateSnippet(window.raw),
|
||||
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
||||
selector: win.targetSelector,
|
||||
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -379,8 +414,8 @@ export function lintHyperframeHtml(
|
||||
if (!parentClosePattern.test(between)) {
|
||||
pushFinding({
|
||||
code: "video_nested_in_timed_element",
|
||||
severity: "warning",
|
||||
message: `<video> with data-start appears to be nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. This can break media sync.`,
|
||||
severity: "error",
|
||||
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
|
||||
@@ -459,6 +494,46 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// #3.8: Media element checks — missing id, missing src, preload="none"
|
||||
// The runtime discovers media via querySelectorAll("video[data-start]") which
|
||||
// works fine for preview. But the renderer uses querySelectorAll("video[id][src]")
|
||||
// — without id, elements are silently skipped (no audio, frozen video).
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video" && tag.name !== "audio") continue;
|
||||
const hasDataStart = readAttr(tag.raw, "data-start");
|
||||
const hasId = readAttr(tag.raw, "id");
|
||||
const hasSrc = readAttr(tag.raw, "src");
|
||||
if (hasDataStart && !hasId) {
|
||||
pushFinding({
|
||||
code: "media_missing_id",
|
||||
severity: "error",
|
||||
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
|
||||
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasDataStart && hasId && !hasSrc) {
|
||||
pushFinding({
|
||||
code: "media_missing_src",
|
||||
severity: "error",
|
||||
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
|
||||
elementId: hasId,
|
||||
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (readAttr(tag.raw, "preload") === "none") {
|
||||
pushFinding({
|
||||
code: "media_preload_none",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
|
||||
elementId: hasId || undefined,
|
||||
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
|
||||
// Skip: elements with data-composition-id (managed by runtime), elements with
|
||||
// opacity:0 in style (will be animated in by GSAP), and composition host elements.
|
||||
@@ -635,6 +710,32 @@ export function lintHyperframeHtml(
|
||||
}
|
||||
}
|
||||
|
||||
// ── External CDN script dependency check ────────────────────────────────
|
||||
// Compositions that load CDN libraries via <script src="https://..."> work
|
||||
// correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
|
||||
// document) and in runtime mode (loadExternalCompositions re-injects them).
|
||||
// But when a composition is used in a custom pipeline that bypasses both, the
|
||||
// scripts won't be available. Flag this as an info-level finding so developers
|
||||
// know the dependency exists.
|
||||
{
|
||||
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
const seen = new Set<string>();
|
||||
while ((match = externalScriptRe.exec(source)) !== null) {
|
||||
const src = match[1] ?? "";
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
pushFinding({
|
||||
code: "external_script_dependency",
|
||||
severity: "info",
|
||||
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
||||
fixHint:
|
||||
"No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
||||
snippet: truncateSnippet(match[0] ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
||||
const warningCount = findings.filter((finding) => finding.severity === "warning").length;
|
||||
const infoCount = findings.filter((finding) => finding.severity === "info").length;
|
||||
|
||||
@@ -141,16 +141,6 @@ describe("lottie adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("play", () => {
|
||||
it("plays lottie-web animation", () => {
|
||||
const anim = createLottieWebAnim();
|
||||
lottieWindow.__hfLottie = [anim];
|
||||
const adapter = createLottieAdapter();
|
||||
adapter.play!();
|
||||
expect(anim.play).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revert", () => {
|
||||
it("does not throw", () => {
|
||||
const adapter = createLottieAdapter();
|
||||
|
||||
@@ -129,23 +129,6 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter {
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const instances = (window as LottieWindow).__hfLottie;
|
||||
if (!instances || instances.length === 0) return;
|
||||
|
||||
for (const anim of instances) {
|
||||
try {
|
||||
if (isLottieWebAnimation(anim)) {
|
||||
anim.play();
|
||||
} else if (isDotLottiePlayer(anim)) {
|
||||
anim.play();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
revert: () => {
|
||||
// Don't clear __hfLottie — the animation objects are owned by the composition.
|
||||
// Just let them be garbage collected naturally.
|
||||
|
||||
@@ -32,6 +32,21 @@ export function initSandboxRuntimeModular(): void {
|
||||
// keep runtime resilient across reinits
|
||||
}
|
||||
}
|
||||
// Normalize html/body so browser defaults (8px margin, white background) never
|
||||
// bleed into renders as white bars. Runs in both preview and render contexts,
|
||||
// eliminating the preview/render parity gap that existed when only the React
|
||||
// component's normalizePreviewViewport call applied this normalization.
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.margin = "0";
|
||||
document.documentElement.style.padding = "0";
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
}
|
||||
if (document.body) {
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.padding = "0";
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
const registerRuntimeCleanup = (callback: () => void) => {
|
||||
runtimeCleanupCallbacks.push(callback);
|
||||
@@ -707,6 +722,54 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
}
|
||||
}
|
||||
// If the root composition declares an explicit data-duration that meaningfully
|
||||
// exceeds the captured GSAP timeline, extend the timeline in-place by placing
|
||||
// a zero-duration no-op tween at the declared end position. This makes
|
||||
// timeline.duration() report the declared length without creating a composite
|
||||
// (which would double-count the original duration).
|
||||
const rootDeclaredDurAttr = rootCompositionNode?.getAttribute("data-duration");
|
||||
if (rootDeclaredDurAttr) {
|
||||
const rootDeclaredDur = parseFloat(rootDeclaredDurAttr);
|
||||
if (
|
||||
isUsableTimelineDuration(rootDeclaredDur) &&
|
||||
isUsableTimelineDuration(rootDurationSeconds) &&
|
||||
// Only pad when the gap is meaningful (>= 0.5s) to avoid floating-point
|
||||
// false positives on compositions whose GSAP duration is already close
|
||||
// to data-duration.
|
||||
rootDeclaredDur >= rootDurationSeconds + 0.5
|
||||
) {
|
||||
const tlWithTo = rootTimeline as RuntimeTimelineLike & {
|
||||
to?: (target: object, vars: { duration: number }, position: number) => unknown;
|
||||
};
|
||||
if (typeof tlWithTo.to === "function") {
|
||||
try {
|
||||
// Placing a zero-duration tween AT rootDeclaredDur extends
|
||||
// timeline.duration() to exactly rootDeclaredDur.
|
||||
tlWithTo.to({}, { duration: 0 }, rootDeclaredDur);
|
||||
} catch {
|
||||
// keep runtime resilient
|
||||
}
|
||||
}
|
||||
const newDur = getTimelineDurationSeconds(rootTimeline);
|
||||
if (isUsableTimelineDuration(newDur)) {
|
||||
return {
|
||||
timeline: rootTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
selectedDurationSeconds: newDur,
|
||||
mediaDurationFloorSeconds,
|
||||
diagnostics: {
|
||||
code: "root_timeline_padded_to_declared_duration",
|
||||
details: {
|
||||
rootCompositionId,
|
||||
rootDurationSeconds,
|
||||
rootDeclaredDur,
|
||||
newDur,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
timeline: rootTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
@@ -1357,9 +1420,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
||||
}),
|
||||
createWaapiAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
createThreeAdapter(),
|
||||
createLottieAdapter(),
|
||||
createThreeAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
] as RuntimeDeterministicAdapter[];
|
||||
installRuntimeErrorDiagnostics();
|
||||
runAdapters("discover");
|
||||
|
||||
@@ -66,7 +66,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// Static asset serving
|
||||
// Static asset serving (with range request support for audio/video seeking)
|
||||
api.get("/projects/:id/preview/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
@@ -79,9 +79,38 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
}
|
||||
const contentType = getMimeType(subPath);
|
||||
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
|
||||
const content = readFileSync(file, isText ? "utf-8" : undefined);
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": contentType },
|
||||
const buffer: Buffer = isText
|
||||
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
|
||||
: readFileSync(file);
|
||||
const totalSize = buffer.length;
|
||||
|
||||
// Support byte-range requests so browsers can seek audio/video elements.
|
||||
const rangeHeader = c.req.header("Range");
|
||||
if (rangeHeader) {
|
||||
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
||||
if (match) {
|
||||
const start = parseInt(match[1]!, 10);
|
||||
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
|
||||
const safeEnd = Math.min(end, totalSize - 1);
|
||||
const chunkSize = safeEnd - start + 1;
|
||||
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(totalSize),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
quality,
|
||||
jobId,
|
||||
});
|
||||
renderJobs.set(jobId, { ...jobState, createdAt: Date.now() });
|
||||
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
|
||||
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
|
||||
|
||||
// Restart cleanup timer if needed
|
||||
if (!cleanupTimer && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
||||
@@ -125,6 +126,27 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
});
|
||||
});
|
||||
|
||||
// Serve render inline (for in-browser playback — opens in a new tab)
|
||||
api.get("/render/:jobId/view", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
const job = renderJobs.get(jobId);
|
||||
if (!job?.outputPath || !existsSync(job.outputPath)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
const isWebm = job.outputPath.endsWith(".webm");
|
||||
const contentType = isWebm ? "video/webm" : "video/mp4";
|
||||
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
||||
const content = readFileSync(job.outputPath);
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(content.length),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Download render
|
||||
api.get("/render/:jobId/download", (c) => {
|
||||
const { jobId } = c.req.param();
|
||||
@@ -195,6 +217,19 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
// Register on-disk renders that aren't in the current session's job map
|
||||
// so they remain downloadable after a server restart.
|
||||
for (const file of files) {
|
||||
if (!renderJobs.has(file.id)) {
|
||||
renderJobs.set(file.id, {
|
||||
id: file.id,
|
||||
status: file.status,
|
||||
progress: 100,
|
||||
outputPath: join(rendersDir, file.filename),
|
||||
createdAt: file.createdAt,
|
||||
} as RenderJobState & { createdAt: number });
|
||||
}
|
||||
}
|
||||
return c.json({ renders: files });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -146,8 +146,10 @@ export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes
|
||||
export {
|
||||
extractVideoMetadata,
|
||||
extractAudioMetadata,
|
||||
analyzeKeyframeIntervals,
|
||||
type VideoMetadata,
|
||||
type AudioMetadata,
|
||||
type KeyframeAnalysis,
|
||||
} from "./utils/ffprobe.js";
|
||||
|
||||
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
|
||||
@@ -52,7 +52,17 @@ export function parseVideoElements(html: string): VideoElement[] {
|
||||
const videos: VideoElement[] = [];
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
const videoEls = document.querySelectorAll("video[id][src]");
|
||||
// Union: original "video[id][src]" (backward compat) + "video[src][data-start]"
|
||||
// (sub-composition videos that have timing but no explicit id).
|
||||
const videoEls = Array.from(
|
||||
new Set([
|
||||
...Array.from(document.querySelectorAll("video[id][src]")),
|
||||
...Array.from(document.querySelectorAll("video[src][data-start]")),
|
||||
]),
|
||||
);
|
||||
videoEls.forEach((el, i) => {
|
||||
if (!el.id) el.id = `hf-video-${i}`;
|
||||
});
|
||||
for (const el of videoEls) {
|
||||
const id = el.getAttribute("id");
|
||||
const src = el.getAttribute("src");
|
||||
@@ -60,14 +70,27 @@ export function parseVideoElements(html: string): VideoElement[] {
|
||||
|
||||
const startAttr = el.getAttribute("data-start");
|
||||
const endAttr = el.getAttribute("data-end");
|
||||
const durationAttr = el.getAttribute("data-duration");
|
||||
const mediaStartAttr = el.getAttribute("data-media-start");
|
||||
const hasAudioAttr = el.getAttribute("data-has-audio");
|
||||
|
||||
const start = startAttr ? parseFloat(startAttr) : 0;
|
||||
// Derive end from data-end → data-start+data-duration → Infinity (natural duration).
|
||||
// The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
|
||||
let end = 0;
|
||||
if (endAttr) {
|
||||
end = parseFloat(endAttr);
|
||||
} else if (durationAttr) {
|
||||
end = start + parseFloat(durationAttr);
|
||||
} else {
|
||||
end = Infinity; // no explicit bounds — play for the full natural video duration
|
||||
}
|
||||
|
||||
videos.push({
|
||||
id,
|
||||
src,
|
||||
start: startAttr ? parseFloat(startAttr) : 0,
|
||||
end: endAttr ? parseFloat(endAttr) : 0,
|
||||
start,
|
||||
end,
|
||||
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
||||
hasAudio: hasAudioAttr === "true",
|
||||
});
|
||||
@@ -212,8 +235,9 @@ export async function extractAllVideoFrames(
|
||||
|
||||
let videoDuration = video.end - video.start;
|
||||
|
||||
// Fallback: if no data-duration/data-end was specified, probe the actual file
|
||||
if (videoDuration <= 0) {
|
||||
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
|
||||
// probe the actual video file to get its natural duration.
|
||||
if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
const sourceDuration = metadata.durationSeconds - video.mediaStart;
|
||||
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { spawn } from "child_process";
|
||||
|
||||
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
|
||||
function runFfprobe(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffprobe", args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseProbeJson(stdout: string): FFProbeOutput {
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
|
||||
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
|
||||
|
||||
@@ -10,6 +49,8 @@ export interface VideoMetadata {
|
||||
fps: number;
|
||||
videoCodec: string;
|
||||
hasAudio: boolean;
|
||||
/** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
|
||||
isVFR: boolean;
|
||||
}
|
||||
|
||||
export interface AudioMetadata {
|
||||
@@ -54,12 +95,10 @@ function parseFrameRate(frameRateStr: string | undefined): number {
|
||||
|
||||
export async function extractVideoMetadata(filePath: string): Promise<VideoMetadata> {
|
||||
const cached = videoMetadataCache.get(filePath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) return cached;
|
||||
|
||||
const probePromise = new Promise<VideoMetadata>((resolve, reject) => {
|
||||
const args = [
|
||||
const probePromise = (async (): Promise<VideoMetadata> => {
|
||||
const stdout = await runFfprobe([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
@@ -67,64 +106,28 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
]);
|
||||
const output = parseProbeJson(stdout);
|
||||
const videoStream = output.streams.find((s) => s.codec_type === "video");
|
||||
if (!videoStream) throw new Error("[FFmpeg] No video stream found");
|
||||
|
||||
const ffprobe = spawn("ffprobe", args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const rFps = parseFrameRate(videoStream.r_frame_rate);
|
||||
const avgFps = parseFrameRate(videoStream.avg_frame_rate);
|
||||
const fps = avgFps || rFps;
|
||||
// VFR: r_frame_rate (max/nominal) differs from avg_frame_rate (actual average) by >10%
|
||||
const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
|
||||
|
||||
ffprobe.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
ffprobe.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
return {
|
||||
durationSeconds: output.format.duration ? parseFloat(output.format.duration) : 0,
|
||||
width: videoStream.width || 0,
|
||||
height: videoStream.height || 0,
|
||||
fps,
|
||||
videoCodec: videoStream.codec_name || "unknown",
|
||||
hasAudio: output.streams.some((s) => s.codec_type === "audio"),
|
||||
isVFR,
|
||||
};
|
||||
})();
|
||||
|
||||
ffprobe.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const output: FFProbeOutput = JSON.parse(stdout);
|
||||
const videoStream = output.streams.find((s) => s.codec_type === "video");
|
||||
if (!videoStream) {
|
||||
reject(new Error("[FFmpeg] No video stream found"));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAudio = output.streams.some((s) => s.codec_type === "audio");
|
||||
const fps =
|
||||
parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
|
||||
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
|
||||
const metadata: VideoMetadata = {
|
||||
durationSeconds,
|
||||
width: videoStream.width || 0,
|
||||
height: videoStream.height || 0,
|
||||
fps,
|
||||
videoCodec: videoStream.codec_name || "unknown",
|
||||
hasAudio,
|
||||
};
|
||||
resolve(metadata);
|
||||
} catch (parseError: unknown) {
|
||||
reject(
|
||||
new Error(
|
||||
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ffprobe.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
videoMetadataCache.set(filePath, probePromise);
|
||||
probePromise.catch(() => {
|
||||
if (videoMetadataCache.get(filePath) === probePromise) {
|
||||
@@ -136,12 +139,10 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
|
||||
|
||||
export async function extractAudioMetadata(filePath: string): Promise<AudioMetadata> {
|
||||
const cached = audioMetadataCache.get(filePath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) return cached;
|
||||
|
||||
const probePromise = new Promise<AudioMetadata>((resolve, reject) => {
|
||||
const args = [
|
||||
const probePromise = (async (): Promise<AudioMetadata> => {
|
||||
const stdout = await runFfprobe([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
@@ -149,60 +150,22 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
]);
|
||||
const output = parseProbeJson(stdout);
|
||||
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
||||
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
|
||||
|
||||
const ffprobe = spawn("ffprobe", args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
|
||||
ffprobe.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
ffprobe.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
return {
|
||||
durationSeconds,
|
||||
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
|
||||
channels: audioStream.channels || 2,
|
||||
audioCodec: audioStream.codec_name || "unknown",
|
||||
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
ffprobe.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const output: FFProbeOutput = JSON.parse(stdout);
|
||||
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
||||
if (!audioStream) {
|
||||
reject(new Error("[FFmpeg] No audio stream found"));
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
|
||||
const metadata: AudioMetadata = {
|
||||
durationSeconds,
|
||||
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
|
||||
channels: audioStream.channels || 2,
|
||||
audioCodec: audioStream.codec_name || "unknown",
|
||||
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
||||
};
|
||||
resolve(metadata);
|
||||
} catch (parseError: unknown) {
|
||||
reject(
|
||||
new Error(
|
||||
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ffprobe.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
audioMetadataCache.set(filePath, probePromise);
|
||||
probePromise.catch(() => {
|
||||
if (audioMetadataCache.get(filePath) === probePromise) {
|
||||
@@ -211,3 +174,77 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
||||
});
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
export interface KeyframeAnalysis {
|
||||
avgIntervalSeconds: number;
|
||||
maxIntervalSeconds: number;
|
||||
keyframeCount: number;
|
||||
isProblematic: boolean;
|
||||
}
|
||||
|
||||
const keyframeCache = new Map<string, Promise<KeyframeAnalysis>>();
|
||||
|
||||
/**
|
||||
* Check keyframe intervals in a video file. Intervals > 2s cause seeking
|
||||
* issues in the headless renderer and audio/video desync. Videos from
|
||||
* yt-dlp --download-sections or screen recordings often have sparse keyframes.
|
||||
*/
|
||||
export async function analyzeKeyframeIntervals(filePath: string): Promise<KeyframeAnalysis> {
|
||||
const cached = keyframeCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = analyzeKeyframeIntervalsUncached(filePath);
|
||||
keyframeCache.set(filePath, promise);
|
||||
promise.catch(() => {
|
||||
if (keyframeCache.get(filePath) === promise) {
|
||||
keyframeCache.delete(filePath);
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<KeyframeAnalysis> {
|
||||
const stdout = await runFfprobe([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-skip_frame",
|
||||
"nokey",
|
||||
"-show_entries",
|
||||
"frame=pts_time",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
filePath,
|
||||
]);
|
||||
|
||||
const timestamps = stdout
|
||||
.split("\n")
|
||||
.map((line) => parseFloat(line.trim()))
|
||||
.filter((t) => Number.isFinite(t));
|
||||
|
||||
if (timestamps.length < 2) {
|
||||
return {
|
||||
avgIntervalSeconds: 0,
|
||||
maxIntervalSeconds: 0,
|
||||
keyframeCount: timestamps.length,
|
||||
isProblematic: false,
|
||||
};
|
||||
}
|
||||
|
||||
let maxInterval = 0;
|
||||
let totalInterval = 0;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
|
||||
totalInterval += interval;
|
||||
if (interval > maxInterval) maxInterval = interval;
|
||||
}
|
||||
|
||||
const avgInterval = totalInterval / (timestamps.length - 1);
|
||||
return {
|
||||
avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
|
||||
maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
|
||||
keyframeCount: timestamps.length,
|
||||
isProblematic: maxInterval > 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type VideoElement,
|
||||
parseAudioElements,
|
||||
type AudioElement,
|
||||
analyzeKeyframeIntervals,
|
||||
} from "@hyperframes/engine";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
@@ -145,6 +146,12 @@ async function compileHtmlFile(
|
||||
compiledHtml = clampDurations(compiledHtml, clampList);
|
||||
}
|
||||
|
||||
// Strip crossorigin from video elements: the render pipeline replaces them with
|
||||
// injected frame images, so the browser never needs to load the source.
|
||||
// Without this, videos with crossorigin="anonymous" targeting CORS-restricted
|
||||
// origins (e.g. S3 without CORS headers) keep readyState=0, blocking page setup.
|
||||
compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
|
||||
|
||||
return { html: compiledHtml, unresolvedCompositions };
|
||||
}
|
||||
|
||||
@@ -683,8 +690,17 @@ export async function compileForRender(
|
||||
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
|
||||
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
|
||||
|
||||
// Strip preload="none" from media elements — the renderer needs to load all
|
||||
// media upfront for frame capture. Users add this to reduce browser memory in
|
||||
// preview, but it causes the headless renderer to never load the media, leading
|
||||
// to 45s timeout failures.
|
||||
const sanitizedHtml = inlinedHtml.replace(
|
||||
/(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
|
||||
"$1",
|
||||
);
|
||||
|
||||
const html = injectDeterministicFontFaces(
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml)),
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||
);
|
||||
|
||||
// Parse main HTML elements
|
||||
@@ -694,6 +710,52 @@ export async function compileForRender(
|
||||
const videos = dedupeElementsById([...subVideos, ...mainVideos]);
|
||||
const audios = dedupeElementsById([...subAudios, ...mainAudios]);
|
||||
|
||||
// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
|
||||
// ffprobe subprocesses and should not block compilation since they only produce warnings.
|
||||
for (const video of videos) {
|
||||
if (isHttpUrl(video.src)) continue;
|
||||
const videoPath = resolve(projectDir, video.src);
|
||||
const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
|
||||
Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)])
|
||||
.then(([analysis, metadata]) => {
|
||||
if (analysis.isProblematic) {
|
||||
console.warn(
|
||||
`[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). ` +
|
||||
`This causes seek failures and frame freezing. Re-encode with: ${reencode}`,
|
||||
);
|
||||
}
|
||||
if (metadata.isVFR) {
|
||||
console.warn(
|
||||
`[Compiler] WARNING: Video "${video.id}" is variable frame rate (VFR). ` +
|
||||
`Screen recordings and phone videos are often VFR, which causes stuttering and frame skipping in renders. Re-encode with: ${reencode}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Persist auto-assigned IDs back into the HTML so the compiled file served
|
||||
// to Puppeteer has matching element IDs. parseVideoElements uses parseHTML
|
||||
// internally and sets el.id = "hf-video-N" on the JSDOM node, but that does
|
||||
// not mutate the html string. We do one more DOM pass here to write those IDs
|
||||
// into the document and re-serialize — only if there are any id-less videos.
|
||||
const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
|
||||
let htmlWithIds = html;
|
||||
if (autoIdVideos.length > 0) {
|
||||
const { document: idDoc } = parseHTML(html);
|
||||
let changed = false;
|
||||
for (const v of autoIdVideos) {
|
||||
const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
|
||||
if (el) {
|
||||
el.id = v.id;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
|
||||
}
|
||||
}
|
||||
|
||||
// Read dimensions from root composition element using DOM parser
|
||||
const { document } = parseHTML(html);
|
||||
const rootEl = document.querySelector("[data-composition-id]");
|
||||
@@ -711,7 +773,7 @@ export async function compileForRender(
|
||||
: 0;
|
||||
|
||||
return {
|
||||
html,
|
||||
html: htmlWithIds,
|
||||
subCompositions,
|
||||
videos,
|
||||
audios,
|
||||
|
||||
@@ -1114,6 +1114,22 @@ export async function executeRenderJob(
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
// Suggest single-worker retry on parallel capture timeout.
|
||||
// Video-heavy compositions often cause multi-worker timeouts because
|
||||
// Chrome can't seek multiple video elements simultaneously.
|
||||
const isTimeoutError =
|
||||
errorMessage.includes("Waiting failed") ||
|
||||
errorMessage.includes("timeout exceeded") ||
|
||||
errorMessage.includes("Navigation timeout");
|
||||
const wasParallel = job.config.workers !== 1;
|
||||
if (isTimeoutError && wasParallel) {
|
||||
log.warn(
|
||||
`Parallel capture timed out with ${job.config.workers ?? "auto"} workers. ` +
|
||||
`Video-heavy compositions often need sequential capture. Retry with --workers 1`,
|
||||
);
|
||||
}
|
||||
|
||||
job.error = errorMessage;
|
||||
updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73b0bf5379865cec6a606c9d4d79cc7604ad4051e72d68499c60e4cc40c53afe
|
||||
size 115820
|
||||
oid sha256:7a2e53926e44d66a469e74eee88035987e309786eb44fef17de9f34114de0813
|
||||
size 105638
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Sub-composition video in photo area",
|
||||
"description": "Tests that video elements inside sub-compositions (lacking explicit id attributes) render correctly. Regression for: parseVideoElements selector only matched video[id][src], silently dropping id-less videos; end derived from data-end only (not data-duration); compiled HTML did not persist auto-assigned ids.",
|
||||
"tags": ["video", "sub-composition", "regression"],
|
||||
"minPsnr": 25,
|
||||
"maxFrameFailures": 5,
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c08dcb89ed447e5bbff18214576ea7fff9ed6b5129cf12795e97199dff3d8e21
|
||||
size 3830455
|
||||
@@ -0,0 +1,211 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<template id="polaroid-template">
|
||||
<div data-composition-id="polaroid" data-width="1080" data-height="1920" data-duration="13">
|
||||
<!-- Main Container for Floating Animation -->
|
||||
<div class="polaroid-wrapper">
|
||||
<!-- The Polaroid Frame -->
|
||||
<div class="polaroid-frame">
|
||||
<!-- Photo Area -->
|
||||
<div class="photo-area">
|
||||
<video
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/fb7e48ac_f6bf2ab079394d7ebd0491e7008a9242.mp4"
|
||||
data-start="0"
|
||||
data-track-index="0"
|
||||
crossorigin="anonymous"
|
||||
></video>
|
||||
</div>
|
||||
<!-- Bottom Margin for Captions -->
|
||||
<div class="caption-area">
|
||||
<div id="caption-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
|
||||
|
||||
[data-composition-id="polaroid"] {
|
||||
width: 1080px;
|
||||
height: 1920px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .polaroid-wrapper {
|
||||
width: 800px;
|
||||
height: 1000px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0; /* Start hidden for fade-in */
|
||||
transform: translateY(100px); /* Start lower for slide-up */
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .polaroid-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fdfdfd;
|
||||
padding: 40px 40px 160px 40px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
transform: rotate(2.5deg); /* Slight rotation for physical feel */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .photo-area {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
background: #222;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .photo-area video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .caption-area {
|
||||
height: 120px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] #caption-container {
|
||||
font-family: 'Caveat', cursive;
|
||||
font-size: 54px;
|
||||
color: #1a2a4a; /* Dark blue ink style */
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-composition-id="polaroid"] .caption-word {
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const TRANSCRIPT = [{'text': 'Also', 'start': 0.099, 'end': 0.299}, {'text': 'ich', 'start': 0.379, 'end': 0.479}, {'text': 'hab', 'start': 0.519, 'end': 0.659}, {'text': 'dieses', 'start': 0.699, 'end': 0.979}, {'text': 'AI-Tool', 'start': 1.12, 'end': 1.699}, {'text': 'letzte', 'start': 1.74, 'end': 1.979}, {'text': 'Woche', 'start': 2.059, 'end': 2.259}, {'text': 'gefunden', 'start': 2.299, 'end': 2.74}, {'text': 'und', 'start': 2.799, 'end': 2.98}, {'text': 'ehrlich?', 'start': 3.039, 'end': 3.539}, {'text': 'Es', 'start': 4.179, 'end': 4.279}, {'text': 'ist', 'start': 4.339, 'end': 4.519}, {'text': 'krass.', 'start': 4.559, 'end': 4.979}, {'text': 'Früher', 'start': 5.5, 'end': 5.819}, {'text': 'hab', 'start': 5.859, 'end': 5.96}, {'text': 'ich', 'start': 6.019, 'end': 6.119}, {'text': 'stundenlang', 'start': 6.179, 'end': 6.799}, {'text': 'Videos', 'start': 6.899, 'end': 7.259}, {'text': 'bearbeitet,', 'start': 7.339, 'end': 8.039}, {'text': 'jetzt', 'start': 8.399, 'end': 8.599}, {'text': 'dauert', 'start': 8.639, 'end': 8.88}, {'text': 'es', 'start': 8.92, 'end': 9.0}, {'text': 'fünf', 'start': 9.079, 'end': 9.279}, {'text': 'Minuten.', 'start': 9.359, 'end': 9.819}, {'text': 'Wenn', 'start': 10.359, 'end': 10.46}, {'text': 'du', 'start': 10.539, 'end': 10.619}, {'text': 'das', 'start': 10.659, 'end': 10.8}, {'text': 'noch', 'start': 10.84, 'end': 10.96}, {'text': 'nicht', 'start': 11.019, 'end': 11.159}, {'text': 'nutzt,', 'start': 11.239, 'end': 11.519}, {'text': 'bist', 'start': 11.599, 'end': 11.759}, {'text': 'du', 'start': 11.8, 'end': 11.979}, {'text': 'echt', 'start': 12.059, 'end': 12.259}, {'text': 'verrückt.', 'start': 12.3, 'end': 12.779}];
|
||||
const DURATION = 13;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// 1. Entrance Animation
|
||||
tl.to('.polaroid-wrapper', {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 1.2,
|
||||
ease: 'power3.out'
|
||||
}, 0);
|
||||
|
||||
// 2. Floating/Breathing Animation (Deterministic)
|
||||
const floatTl = gsap.timeline();
|
||||
const steps = 4;
|
||||
const stepDuration = DURATION / steps;
|
||||
|
||||
floatTl.to('.polaroid-frame', {
|
||||
y: -15,
|
||||
rotation: 3.5,
|
||||
scale: 1.02,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 0,
|
||||
rotation: 2.5,
|
||||
scale: 1,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 15,
|
||||
rotation: 1.5,
|
||||
scale: 0.98,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
})
|
||||
.to('.polaroid-frame', {
|
||||
y: 0,
|
||||
rotation: 2.5,
|
||||
scale: 1,
|
||||
duration: stepDuration,
|
||||
ease: 'sine.inOut'
|
||||
});
|
||||
|
||||
tl.add(floatTl, 0);
|
||||
|
||||
// 3. Caption Logic
|
||||
const container = document.getElementById('caption-container');
|
||||
|
||||
let currentGroup = [];
|
||||
const groups = [];
|
||||
|
||||
TRANSCRIPT.forEach((word, i) => {
|
||||
currentGroup.push(word);
|
||||
const nextWord = TRANSCRIPT[i+1];
|
||||
const isGap = nextWord && (nextWord.start - word.end > 0.8);
|
||||
if (currentGroup.length >= 5 || isGap || !nextWord) {
|
||||
groups.push([...currentGroup]);
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
|
||||
groups.forEach((group, gIndex) => {
|
||||
const groupDiv = document.createElement('div');
|
||||
groupDiv.style.position = 'absolute';
|
||||
groupDiv.style.width = '100%';
|
||||
groupDiv.style.opacity = 0;
|
||||
groupDiv.style.top = '50%';
|
||||
groupDiv.style.left = '50%';
|
||||
groupDiv.style.transform = 'translate(-50%, -50%)';
|
||||
container.appendChild(groupDiv);
|
||||
|
||||
group.forEach((word, wIndex) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'caption-word';
|
||||
span.style.opacity = 0;
|
||||
span.textContent = word.text;
|
||||
groupDiv.appendChild(span);
|
||||
|
||||
tl.to(span, {
|
||||
opacity: 1,
|
||||
duration: 0.1,
|
||||
ease: 'none'
|
||||
}, word.start);
|
||||
});
|
||||
|
||||
tl.to(groupDiv, {
|
||||
opacity: 1,
|
||||
duration: 0.1
|
||||
}, group[0].start);
|
||||
|
||||
const nextGroup = groups[gIndex + 1];
|
||||
const exitTime = nextGroup ? nextGroup[0].start - 0.1 : DURATION - 0.2;
|
||||
tl.to(groupDiv, {
|
||||
opacity: 0,
|
||||
duration: 0.2
|
||||
}, exitTime);
|
||||
});
|
||||
|
||||
tl.to({}, { duration: 2 }, DURATION);
|
||||
|
||||
window.__timelines["polaroid"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Polaroid Speaker</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
|
||||
body { margin: 0; background: #000; overflow: hidden; font-family: 'Montserrat', sans-serif; }
|
||||
#main-comp { position: relative; width: 1080px; height: 1920px; }
|
||||
|
||||
/* Speaker Background Video */
|
||||
#speaker-video {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(5px) brightness(0.6); /* Reduced blur, slightly brighter */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#polaroid-comp {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-comp" data-composition-id="main-comp" data-width="1080" data-height="1920" data-duration="13">
|
||||
<!-- Background Layer -->
|
||||
<video id="speaker-video"
|
||||
data-start="0"
|
||||
data-track-index="0"
|
||||
src="https://gen-os-static.s3.us-east-2.amazonaws.com/astral_assets/uploaded_assets/fb7e48ac_f6bf2ab079394d7ebd0491e7008a9242.mp4">
|
||||
</video>
|
||||
|
||||
<!-- Polaroid Composition Layer -->
|
||||
<div id="polaroid-comp"
|
||||
data-composition-id="polaroid"
|
||||
data-composition-src="compositions/polaroid.html"
|
||||
data-start="0"
|
||||
data-track-index="1">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// Subtle Ken Burns effect on background
|
||||
tl.fromTo('#speaker-video', { scale: 1.1 }, { scale: 1, duration: 13, ease: 'none' }, 0);
|
||||
window.__timelines["main-comp"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
import type { RenderJob } from "./useRenderQueue";
|
||||
|
||||
interface RenderQueueItemProps {
|
||||
@@ -19,34 +20,84 @@ function formatTimeAgo(timestamp: number): string {
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
}
|
||||
|
||||
/** Static frame extracted once via hidden video + canvas. */
|
||||
|
||||
export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
job,
|
||||
onDelete,
|
||||
}: RenderQueueItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
}, [job.id, job.filename]);
|
||||
const handleOpen = useCallback(() => {
|
||||
window.open(`/api/render/${job.id}/view`, "_blank");
|
||||
}, [job.id]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/render/${job.id}/download`;
|
||||
a.download = job.filename;
|
||||
a.click();
|
||||
},
|
||||
[job.id, job.filename],
|
||||
);
|
||||
|
||||
const viewSrc = `/api/render/${job.id}/view`;
|
||||
const isComplete = job.status === "complete";
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
|
||||
onClick={isComplete ? handleOpen : undefined}
|
||||
className={[
|
||||
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
|
||||
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status indicator */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Thumbnail — static frame; swaps to live video on hover */}
|
||||
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
{isComplete && (
|
||||
<>
|
||||
{/* Live video — visible on hover */}
|
||||
{hovered && (
|
||||
<video
|
||||
src={viewSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
{/* Static frame — visible when not hovering */}
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-150"
|
||||
style={{ opacity: hovered ? 0 : 1 }}
|
||||
>
|
||||
<VideoFrameThumbnail src={viewSrc} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{job.status === "rendering" && (
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "cancelled" && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
||||
</div>
|
||||
)}
|
||||
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
||||
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
@@ -62,7 +113,6 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + percentage */}
|
||||
{job.status === "rendering" && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
@@ -90,7 +140,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{job.status === "complete" && (
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
|
||||
@@ -113,7 +163,10 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
|
||||
@@ -128,6 +128,7 @@ export function useRenderQueue(projectId: string | null) {
|
||||
? "failed"
|
||||
: j.status,
|
||||
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
|
||||
error: data.error ?? j.error,
|
||||
}
|
||||
: j,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
|
||||
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -32,28 +34,13 @@ function AssetThumbnail({
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<>
|
||||
<video
|
||||
src={serveUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
||||
{isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<svg
|
||||
@@ -112,22 +99,33 @@ function ExpandedAssetPreview({
|
||||
isAudio: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
if (isVideo) {
|
||||
return (
|
||||
<ExpandedVideoPreview
|
||||
src={serveUrl}
|
||||
name={name}
|
||||
subtitle={asset}
|
||||
action={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
|
||||
>
|
||||
Copy Path
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
{isImage && (
|
||||
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
|
||||
@@ -146,7 +146,7 @@ function CompCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
|
||||
const card = (
|
||||
@@ -162,7 +162,7 @@ function CompCard({
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ExpandedVideoPreviewProps {
|
||||
src: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
action: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared expanded video preview used by AssetsTab (video assets) and
|
||||
* the Renders panel. Autoplays the video muted+looped inside a full-bleed
|
||||
* card. Caller provides the footer action slot (Copy Path, Open, etc.).
|
||||
*/
|
||||
export function ExpandedVideoPreview({ src, name, subtitle, action }: ExpandedVideoPreviewProps) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
<video
|
||||
src={src}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div className="min-w-0 flex-1 mr-4">
|
||||
<div className="text-sm font-medium text-neutral-200 truncate">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Extracts a representative JPEG frame from a video URL using a hidden
|
||||
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
|
||||
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
|
||||
*/
|
||||
export function VideoFrameThumbnail({ src }: { src: string }) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const didExtract = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didExtract.current) return;
|
||||
didExtract.current = true;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.preload = "metadata";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const cleanup = () => {
|
||||
video.src = "";
|
||||
video.load();
|
||||
};
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
video.currentTime = Math.min(2, video.duration * 0.1 || 2);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
if (!ctx) return;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0);
|
||||
setFrame(canvas.toDataURL("image/jpeg", 0.7));
|
||||
cleanup();
|
||||
});
|
||||
|
||||
video.addEventListener("error", cleanup);
|
||||
video.src = src;
|
||||
video.load();
|
||||
|
||||
return cleanup;
|
||||
}, [src]);
|
||||
|
||||
if (!frame) {
|
||||
return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
|
||||
}
|
||||
|
||||
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
previewUrl,
|
||||
label,
|
||||
labelColor,
|
||||
seekTime = 0.4,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
@@ -112,7 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
border: "none",
|
||||
outline: "1px solid black",
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -749,7 +749,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
{/* Keyboard shortcut hint — always visible */}
|
||||
{!showPopover && !rangeSelection && (
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none">
|
||||
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
|
||||
<kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
|
||||
Shift
|
||||
|
||||
Generated
+6395
File diff suppressed because it is too large
Load Diff
+34
-30
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Set the version across all publishable packages in the monorepo.
|
||||
* Set the version across all publishable packages in the monorepo,
|
||||
* then create a git commit and tag.
|
||||
*
|
||||
* Usage:
|
||||
* bun run set-version 0.1.1
|
||||
* bun run set-version 0.1.1 --tag # also creates a git commit and tag
|
||||
* bun run set-version 0.1.1 # bump, commit, and tag
|
||||
* bun run set-version 0.1.1 --no-tag # bump only (no commit or tag)
|
||||
*
|
||||
* All packages share a single version number (fixed versioning).
|
||||
*/
|
||||
@@ -26,11 +27,11 @@ const ROOT = join(import.meta.dirname, "..");
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const version = args.find((a) => !a.startsWith("--"));
|
||||
const shouldTag = args.includes("--tag");
|
||||
const skipTag = args.includes("--no-tag");
|
||||
|
||||
if (!version) {
|
||||
console.error("Usage: bun run set-version <version> [--tag]");
|
||||
console.error("Example: bun run set-version 0.1.1 --tag");
|
||||
console.error("Usage: bun run set-version <version> [--no-tag]");
|
||||
console.error("Example: bun run set-version 0.1.1");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -51,31 +52,34 @@ function main() {
|
||||
|
||||
console.log(`\nSet ${PACKAGES.length} packages to v${version}`);
|
||||
|
||||
if (shouldTag) {
|
||||
// Verify working tree is clean (aside from the version bumps we just made)
|
||||
const status = execSync("git status --porcelain", {
|
||||
cwd: ROOT,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const unexpected = status
|
||||
.split("\n")
|
||||
.filter((line) => line && !PACKAGES.some((pkg) => line.includes(pkg)));
|
||||
if (unexpected.length > 0) {
|
||||
console.error("\nUnexpected uncommitted changes:");
|
||||
unexpected.forEach((line) => console.error(` ${line}`));
|
||||
console.error("Commit or stash these before tagging.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
execSync(`git add ${PACKAGES.map((p) => join(p, "package.json")).join(" ")}`, {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
execSync(`git commit -m "chore: release v${version}"`, { cwd: ROOT, stdio: "inherit" });
|
||||
execSync(`git tag v${version}`, { cwd: ROOT, stdio: "inherit" });
|
||||
console.log(`\nCreated commit and tag v${version}`);
|
||||
console.log(`Run 'git push origin main --tags' to trigger the publish workflow.`);
|
||||
if (skipTag) {
|
||||
console.log(`\nSkipped commit and tag (--no-tag). Remember to commit and tag manually.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify working tree is clean (aside from the version bumps we just made)
|
||||
const status = execSync("git status --porcelain", {
|
||||
cwd: ROOT,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
const unexpected = status
|
||||
.split("\n")
|
||||
.filter((line) => line && !PACKAGES.some((pkg) => line.includes(pkg)));
|
||||
if (unexpected.length > 0) {
|
||||
console.error("\nUnexpected uncommitted changes:");
|
||||
unexpected.forEach((line) => console.error(` ${line}`));
|
||||
console.error("Commit or stash these before releasing.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
execSync(`git add ${PACKAGES.map((p) => join(p, "package.json")).join(" ")}`, {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit",
|
||||
});
|
||||
execSync(`git commit -m "chore: release v${version}"`, { cwd: ROOT, stdio: "inherit" });
|
||||
execSync(`git tag v${version}`, { cwd: ROOT, stdio: "inherit" });
|
||||
console.log(`\nCreated commit and tag v${version}`);
|
||||
console.log(`Run 'git push origin main --tags' to trigger the publish workflow.`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -5,9 +5,9 @@ Defaults when no `visual-style.md` or animation direction is provided. These rai
|
||||
## Before Writing HTML
|
||||
|
||||
1. **Interpret the prompt.** Generate real content for the topic — don't use the prompt text as body copy. A recipe lists real ingredients. A stats dashboard shows the actual numbers given. A product showcase names real features and specs. A sci-fi HUD has actual crosshairs and readouts, not a heading that says "sci-fi HUD."
|
||||
2. **Pick a palette.** First decide: does this content call for a light or dark canvas? Food, weddings, children, wellness, education, lifestyle, nature, and celebrations → light palette (Warm/Editorial, Clean/Corporate, Nature/Earth, Pastel/Soft). Tech, finance, cinema, nightlife, horror, gaming, and premium → dark palette. Then load the file and pick one palette. Declare your bg, fg, and accent colors before writing any code.
|
||||
2. **Pick a palette.** First decide: does this content call for a light or dark canvas? Then load the file most appropriate for the theme and pick one palette at random from the file. Declare your bg, fg, and accent colors before writing any code.
|
||||
3. **Pick a typeface.** Don't reach for Sora, Space Grotesk, Outfit, Playfair Display, Cormorant Garamond, or Bodoni Moda — they're overused. Explore the full range of Google Fonts. Serif for editorial, mono for technical, display for impact, handwritten for personal.
|
||||
4. **Pick a layout approach.** Don't default to the same structure every time. Options: full-bleed centered hero, left-aligned editorial column, split-frame (content left / visual right or vice versa), scattered/asymmetric positioning, grid-based with uneven cells, stacked vertical sections. Vary this across compositions.
|
||||
4. **Pick a layout approach.** Don't default to the same structure every time.
|
||||
5. **Pick your entrance patterns.** Plan how elements enter — never use the same entrance pattern twice in a composition.
|
||||
|
||||
## Motion
|
||||
|
||||
Reference in New Issue
Block a user