Document HDR plugin operations

This commit is contained in:
Test User
2026-09-15 00:04:00 +08:00
commit fe4428a5e9
24 changed files with 5596 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.DS_Store
node_modules/
# Local Cloudflare/Wrangler state. Do not commit account/session metadata.
.wrangler/
# Generated Pages upload bundle. Regenerate it from source before deployment.
public/
# Temporary media source downloads created by generator scripts.
media/.*-source.*
+389
View File
@@ -0,0 +1,389 @@
# HDR Web Plugin Agent Manual
## Scope
This file applies to the entire repository.
Use Chinese when talking with the project owner unless they ask for another language. Keep implementation work simple, direct, and narrowly scoped. Reuse existing files, functions, scripts, media conventions, and tests before adding new structure.
Follow higher-priority system and developer instructions first. When replacing an implementation, remove the old path in the same change so the repository does not keep parallel implementations.
## Product Summary
HDR Web Plugin is a static Cloudflare Pages site plus a drop-in JavaScript file that makes ordinary pages present a black-stage HDR media layer, optional HDR text assets, and a small runtime UI.
Current public site:
- Production Pages project: `hdr-web-plugin-demo`
- Production URL: `https://hdr-web-plugin-demo.pages.dev/`
- Main plugin URL: `https://hdr-web-plugin-demo.pages.dev/dist/hdr-web-plugin.js?v=0.6.2`
- Real starfield demo URL: `https://hdr-web-plugin-demo.pages.dev/demos/fireworks.html?v=0.6.9`
Important version facts:
- `package.json` version: `0.6.2`
- `dist/hdr-web-plugin.js` banner version: `0.6.2`
- Current CSS cache query version used by pages: `0.6.9`
- Current starfield asset: `media/hubble-westerlund-2-hdr.png`
The route `demos/fireworks.html` is kept for URL compatibility with earlier demos, but its current product meaning is a real astronomy starfield HDR image demo. Do not reintroduce the old WebGL fireworks path unless the owner explicitly asks for a separate new demo. If a replacement is made, delete stale scripts, markup references, and generated copies.
## Repository Map
- `src/hdr-enhance.js`: canonical plugin source.
- `dist/hdr-web-plugin.js`: generated plugin bundle with the version banner. Do not edit this file by hand; run `npm run build`.
- `index.html`: one-page landing page for installation, API notes, assets, and demo links.
- `demos/*.html`: standalone demos that use the same plugin and site chrome.
- `styles/demo.css`: shared landing/demo CSS. Bump query strings in HTML when changing this file.
- `media/`: committed HDR media assets and generated visual assets.
- `scripts/build-dist.mjs`: copies `src/hdr-enhance.js` into `dist/hdr-web-plugin.js` with a banner.
- `scripts/generate-hdr-media.mjs`: creates HDR video demo media.
- `scripts/generate-hdr-image.mjs`: creates landing prism HDR image.
- `scripts/generate-hdr-wordmark.mjs`: creates HDR PNG text/wordmark assets.
- `scripts/generate-real-starfield-image.mjs`: downloads the ESA/Hubble source image and writes the HDR PNG starfield.
- `test/*.mjs`: Node test suite covering plugin behavior, landing page structure, HDR metadata, and demo constraints.
- `_headers`: Cloudflare Pages static header rules. This file must be copied into the upload/build output.
- `public/`: generated Cloudflare Pages upload bundle. It is intentionally ignored by Git; regenerate it before deployment.
## Engineering Rules
- Prefer the smallest working change that solves the current request.
- Do not add abstractions for hypothetical future needs.
- Add an abstraction only when it removes current duplication or matches an existing local pattern.
- Do not keep old and new implementations side by side after a replacement.
- Never revert user work unless the user explicitly asks.
- Use `rg` or `rg --files` for searching.
- Use `apply_patch` for manual source edits.
- Avoid broad refactors while changing visual design, media, or deployment docs.
- Update tests when changing behavior, generated media expectations, file structure, or production operations.
- Update this `AGENTS.md` when production URLs, deployment commands, cache strategy, HDR asset formats, or ownership rules change.
## Local Development
Install and build:
```bash
npm run build
npm test
```
Run a local static server:
```bash
python3 -m http.server 4173
```
Open locally:
- Landing page: `http://localhost:4173/`
- Starfield demo: `http://localhost:4173/demos/fireworks.html?v=0.6.9`
The test script runs `npm run build` in `pretest`, so `dist/hdr-web-plugin.js` should stay aligned with `src/hdr-enhance.js` after every test run.
## HDR Implementation Notes
Real HDR on macOS depends on the full chain:
- HDR-capable Apple display or external HDR display.
- macOS display setting with HDR enabled where applicable.
- Browser path that supports HDR media rendering.
- Black page background so bright HDR media has visible contrast.
- Media encoded with HDR metadata, usually BT.2020 primaries and SMPTE ST 2084/PQ transfer.
- Avoid CSS filters, opacity stacks, or canvas readbacks on true HDR assets unless verified. These can flatten or tone-map the output.
For high-brightness text, prefer HDR PNG assets produced by `scripts/generate-hdr-wordmark.mjs` instead of normal DOM text. Ordinary CSS text is normally SDR-composited by browsers and will not visibly exceed SDR white in the same way as an HDR image/video layer.
Current HDR PNG checks expected by tests:
- `pix_fmt=rgba64be`
- `color_transfer=smpte2084`
- `color_primaries=bt2020`
- PNG bit depth: `16`
- PNG color type: `6` (RGBA)
Use `ffprobe` to inspect a media asset:
```bash
ffprobe -v error -select_streams v:0 \
-show_entries stream=pix_fmt,color_transfer,color_primaries \
-of default=nw=1 media/hubble-westerlund-2-hdr.png
```
## Starfield Asset Policy
The real starfield demo uses the NASA/ESA Hubble Westerlund 2 image through the ESA/Hubble archive source URL embedded in `scripts/generate-real-starfield-image.mjs`:
```text
https://esahubble.org/media/archives/images/large/heic1509a.jpg
```
The generator creates `media/hubble-westerlund-2-hdr.png` at `2560x1440`. Keep that size unless there is a clear reason to change it; it gives an immersive full-screen result while keeping the file size manageable for Pages.
The visible credit must remain available on the demo page:
```text
Westerlund 2 · NASA, ESA, the Hubble Heritage Team
```
Do not replace the real starfield with abstract particles, shader bloom, simulated fireworks, gradients, or decorative blobs when the user asks for a true starfield.
## Generated Bundle Sync
Cloudflare Pages deploys from a static directory. In this repository, use `public/` as the local upload bundle.
Regenerate `public/` from source after tests pass:
```bash
rm -rf public
mkdir -p public
cp -R index.html _headers dist styles demos media public/
```
Do not edit files inside `public/` directly. If `public/` contains a stale file after a feature replacement, regenerate it from source so old paths disappear.
## Cache Strategy
This project uses long cache headers for versioned static assets and no-cache headers for HTML.
Current `_headers` intent:
- `/dist/*`: immutable long cache.
- `/styles/*`: immutable long cache.
- `/media/*`: immutable long cache.
- `/*.html` and `/`: revalidate.
Because `dist/`, `styles/`, and `media/` are immutable in production, update HTML query strings when changing CSS or plugin JS. For media changes, either use a new filename or update every URL/query string that points at the changed asset.
Cloudflare Pages also has deploy-level caching behavior, so never assume a changed asset path will refresh immediately if the URL did not change.
Official references:
- Cloudflare Pages headers: `https://developers.cloudflare.com/pages/configuration/headers/`
- Cloudflare Pages serving and cache behavior: `https://developers.cloudflare.com/pages/configuration/serving-pages/`
## Production Deployment Manual
Use this checklist for every production deployment.
1. Inspect repository state.
```bash
git status --short --branch
git remote -v
```
If unexpected modified files exist, inspect them and preserve user work. Do not reset the worktree.
2. Run the full test suite.
```bash
npm test
```
3. Verify important HDR media metadata when media changed.
```bash
ffprobe -v error -select_streams v:0 \
-show_entries stream=pix_fmt,color_transfer,color_primaries \
-of default=nw=1 media/hubble-westerlund-2-hdr.png
ffprobe -v error -select_streams v:0 \
-show_entries stream=pix_fmt,color_transfer,color_primaries \
-of default=nw=1 media/hdr-wordmark.png
```
Expected output includes `rgba64be`, `smpte2084`, and `bt2020`.
4. Regenerate the Pages upload bundle.
```bash
rm -rf public
mkdir -p public
cp -R index.html _headers dist styles demos media public/
```
5. Deploy with Wrangler.
Use the latest locally verified Wrangler if available. This command shape is known to work for this project:
```bash
npx --yes wrangler@4 pages deploy public \
--project-name=hdr-web-plugin-demo \
--branch=main \
--commit-message="Deploy HDR Web Plugin" \
--commit-dirty=true
```
If the repository has no commit yet, Wrangler can print a Git warning about `HEAD`. If the command still returns a successful Pages deployment URL, treat the Git warning as non-fatal and continue verification.
6. Verify production URLs.
```bash
curl -I https://hdr-web-plugin-demo.pages.dev/
curl -I https://hdr-web-plugin-demo.pages.dev/styles/demo.css?v=0.6.9
curl -I https://hdr-web-plugin-demo.pages.dev/dist/hdr-web-plugin.js?v=0.6.2
curl -I https://hdr-web-plugin-demo.pages.dev/media/hubble-westerlund-2-hdr.png
```
Open these URLs in a browser after deployment:
- `https://hdr-web-plugin-demo.pages.dev/`
- `https://hdr-web-plugin-demo.pages.dev/demos/fireworks.html?v=0.6.9`
Check that:
- The landing page is one coherent page, not split into mismatched subpages.
- Navigation styling is consistent.
- Backgrounds intended for HDR contrast are true black.
- The starfield demo is a real full-screen astronomy image.
- No old `fireworks-webgl.js` script is loaded.
- The console has no JavaScript errors.
7. Record the outcome.
If working in Git, commit the source changes after verification. If a Gitea remote is configured, push the branch.
## Rollback Manual
For urgent production rollback:
1. Prefer the Cloudflare Pages dashboard rollback for the project `hdr-web-plugin-demo` when an immediately previous deployment is known good.
2. If using CLI, inspect available deployments first:
```bash
npx --yes wrangler@4 pages deployment list --project-name=hdr-web-plugin-demo
```
3. Do not guess an unsupported rollback command. If the CLI rollback path is unclear, use the dashboard.
4. For source-controlled rollback, use `git revert <bad-commit>`, run `npm test`, regenerate `public/`, deploy the reverted source, and verify production.
Never use `git reset --hard` or force-push as a production rollback unless the owner explicitly asks for that exact operation.
## Incident Runbooks
### Stale CSS, JS, Or Media
Symptoms:
- Production looks like the previous version.
- Browser loads an old CSS/plugin/media file.
- Local preview is correct but Pages is stale.
Actions:
1. Confirm the HTML references the intended versioned URL.
2. Bump the CSS or plugin query string, or use a new media filename.
3. Run `npm test`.
4. Regenerate `public/`.
5. Deploy again.
6. Use `curl -I` and browser DevTools Network to confirm the new URL is served.
### HDR Is Not Visibly Strong
Symptoms:
- User reports that text or media still looks SDR.
- Bright image layer looks stronger than text.
Actions:
1. Confirm the page background is pure black where HDR should be judged.
2. Confirm macOS and the target display have HDR enabled.
3. Confirm browser support with `matchMedia('(dynamic-range: high)')`.
4. Verify media metadata with `ffprobe`.
5. Avoid normal DOM text for peak HDR claims; use generated HDR PNG text assets.
6. Check CSS for `filter`, opacity, blend modes, canvas rendering, or parent effects that can flatten HDR.
7. If using a new text asset, regenerate it through `scripts/generate-hdr-wordmark.mjs` or add a focused generator script and test its metadata.
### Page Is Blank Or Broken
Actions:
1. Open browser DevTools Console and Network.
2. Check that all referenced assets exist in source and in regenerated `public/`.
3. Run `rg` for missing filenames and stale references.
4. Run `npm test`.
5. Verify that HTML paths work from both local root and Pages root.
### Performance Regression
Actions:
1. Prefer static HDR image/video layers over custom per-frame WebGL for showcase backgrounds.
2. Remove unnecessary continuous animations.
3. Keep demo pages full-screen but visually simple.
4. Avoid large blur filters, huge box shadows, and many animated particles.
5. Test on the target Mac browser before deployment.
### Wrangler Or Cloudflare Auth Problem
Actions:
```bash
npx --yes wrangler@4 whoami
npx --yes wrangler@4 login
```
If login is required, ask the owner to complete the browser auth flow. Do not commit `.wrangler/`.
## Gitea Alignment Manual
This repository must be kept commit-ready. Before committing:
```bash
git status --short --branch
npm test
```
Recommended source files to commit:
- `AGENTS.md`
- `.gitignore`
- `README.md`
- `_headers`
- `package.json`
- `index.html`
- `src/`
- `dist/`
- `styles/`
- `demos/`
- `scripts/`
- `media/`
- `test/`
Do not commit:
- `.wrangler/`
- `public/`
- `node_modules/`
- temporary media source downloads under `media/.*-source.*`
If `git remote -v` is empty, a Gitea push cannot be completed from this machine until the remote URL is configured.
Configure a Gitea remote only with the exact owner-provided URL:
```bash
git remote add origin <gitea-repository-url>
git push -u origin main
```
If a remote already exists:
```bash
git fetch origin
git status --short --branch
git push origin main
```
Do not force-push to Gitea unless the owner explicitly requests it.
## Pre-Final Agent Checklist
Before telling the user the work is done:
1. Run `npm test`.
2. Confirm `git status --short --branch`.
3. Confirm old replaced paths are gone with `rg --files`.
4. If production changed, verify the deployed Pages URL.
5. If Git was requested, commit locally and push only when a remote is configured.
6. Report any missing external state plainly, especially missing Gitea remote or Cloudflare auth.
+71
View File
@@ -0,0 +1,71 @@
# HDR Web Plugin
Drop-in JavaScript plugin that gives normal web pages a pure-black HDR media
stage. It detects `dynamic-range`, `color-gamut`, and CSS wide-gamut support,
then injects an HDR PNG or hardware-decoded HDR10 video layer when the output
path reports HDR capability. SDR-only environments keep a low-power fallback.
## Run
```bash
npm run build
npm run media:hdr
npm test
npm start
```
Open `http://localhost:4173`.
## Inject Into A Page
```html
<script src="/dist/hdr-web-plugin.js?v=0.6.2" defer></script>
```
The script auto-runs in the browser. You can tune it without writing custom JS:
```html
<script
src="/dist/hdr-web-plugin.js?v=0.6.2"
defer
data-hdr-media="true"
data-hdr-media-type="image"
data-hdr-image-src="/media/hdr-landing-prism.png"
data-hdr-text="true"
data-hdr-text-boost="2.8"
data-hdr-blackout="true"
data-hdr-badge="true"
></script>
```
For video-backed motion:
```html
<script
src="/dist/hdr-web-plugin.js?v=0.6.2"
defer
data-hdr-media="true"
data-hdr-media-type="video"
data-hdr-video-src="/media/hdr-galaxy-fireworks.mp4"
data-hdr-webm-src="/media/hdr-galaxy-fireworks.webm"
data-hdr-effects="false"
data-hdr-overlay="false"
data-hdr-scanlines="false"
data-hdr-blackout="true"
data-hdr-badge="true"
></script>
```
The plugin also exposes:
```js
window.HDREnhancer.enhance({ intensity: 1.2, text: true, textBoost: 2.8 });
window.HDREnhancer.disable();
window.HDREnhancer.toggle();
```
Set `data-hdr-auto="false"` when you want to load the plugin but start it
manually.
Real HDR output still depends on the physical display, OS HDR setting, browser
support, and source media. Unsupported environments get an SDR neon fallback.
+14
View File
@@ -0,0 +1,14 @@
/dist/*
Cache-Control: public, max-age=31536000, immutable
/styles/*
Cache-Control: public, max-age=31536000, immutable
/media/*
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, must-revalidate
/
Cache-Control: public, max-age=0, must-revalidate
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HDR Real Starfield Demo</title>
<link rel="stylesheet" href="../styles/demo.css?v=0.6.9">
<script src="../dist/hdr-web-plugin.js?v=0.6.2" defer data-hdr-intensity="1.08" data-hdr-media="true" data-hdr-media-type="image" data-hdr-image-src="../media/hubble-westerlund-2-hdr.png" data-hdr-text="false" data-hdr-effects="false" data-hdr-overlay="false" data-hdr-scanlines="false" data-hdr-blackout="true" data-hdr-badge="false"></script>
</head>
<body class="starfield-page immersive-starfield-page">
<main class="starfield-immersive" aria-label="Immersive real Hubble starfield">
<img class="starfield-asset" src="../media/hubble-westerlund-2-hdr.png" alt="" width="2560" height="1440" decoding="async" fetchpriority="high" aria-hidden="true">
<div class="starfield-vignette" aria-hidden="true"></div>
<p class="starfield-credit">Westerlund 2 · NASA, ESA, the Hubble Heritage Team</p>
<nav class="immersive-controls" aria-label="Starfield controls">
<a href="../index.html">Home</a>
<button type="button" data-toggle-hdr>HDR</button>
</nav>
</main>
<script>
document.querySelector('[data-toggle-hdr]').addEventListener('click', () => {
window.HDREnhancer.toggle();
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hello HDR World</title>
<link rel="stylesheet" href="../styles/demo.css?v=0.6.9">
<script src="../dist/hdr-web-plugin.js?v=0.6.2" defer data-hdr-intensity="1.2" data-hdr-media="true" data-hdr-text="true" data-hdr-text-boost="2.5" data-hdr-effects="false" data-hdr-overlay="false" data-hdr-scanlines="false" data-hdr-blackout="true"></script>
</head>
<body>
<div class="site-shell">
<header class="topbar">
<a class="brand" href="../index.html">
<span class="brand-mark"></span>
HDR Web Injector
</a>
<nav class="nav-links" aria-label="Demo navigation">
<a href="../index.html">Home</a>
<a href="./fireworks.html">Stars</a>
<a href="./neon-dashboard.html">Dashboard</a>
<button class="control-button" type="button" data-toggle-hdr>Toggle HDR</button>
</nav>
</header>
<main class="hero">
<section class="hero-copy">
<p class="eyebrow">Cosmic hello page</p>
<h1 class="hdr-title" data-hdr-text="true">Hello HDR Galaxy</h1>
<p>
This is the simplest target page: text, links, and a pure black background. The injected
script adds an HDR10 video layer for the galaxy, meteors, and fireworks while keeping
SDR decoration out of the highlights.
</p>
<div class="hero-actions">
<a class="primary-action" href="#comparison">View comparison</a>
<button class="secondary-action control-button" type="button" data-toggle-hdr-secondary>Switch effect</button>
</div>
</section>
<section class="demo-stage">
<div class="stage-grid"></div>
<div class="neon-ring"></div>
<div class="neon-ring"></div>
<div class="neon-word">Hello<br>HDR</div>
</section>
</main>
<section id="comparison" class="split-demo" aria-label="Before and after comparison">
<article class="sample-page plain">
<h2>Before</h2>
<p>A normal SDR page is readable, but the light range stays flat.</p>
<div class="meter">
<span style="width: 42%"></span>
<span style="width: 62%"></span>
<span style="width: 34%"></span>
</div>
</article>
<article class="sample-page enhanced">
<h2>After</h2>
<p>Injected CSS holds the page at black while HDR video carries the luminous layer.</p>
<div class="meter">
<span style="width: 88%"></span>
<span style="width: 96%"></span>
<span style="width: 74%"></span>
</div>
</article>
</section>
</div>
<script>
const toggle = () => window.HDREnhancer.toggle();
document.querySelector('[data-toggle-hdr]').addEventListener('click', toggle);
document.querySelector('[data-toggle-hdr-secondary]').addEventListener('click', toggle);
</script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon HDR Dashboard</title>
<link rel="stylesheet" href="../styles/demo.css?v=0.6.9">
<script src="../dist/hdr-web-plugin.js?v=0.6.2" defer data-hdr-intensity="1.25" data-hdr-media="true" data-hdr-text="true" data-hdr-text-boost="2.5" data-hdr-effects="false" data-hdr-overlay="false" data-hdr-scanlines="false" data-hdr-blackout="true"></script>
</head>
<body>
<div class="site-shell">
<header class="topbar">
<a class="brand" href="../index.html">
<span class="brand-mark"></span>
HDR Web Injector
</a>
<nav class="nav-links" aria-label="Demo navigation">
<a href="../index.html">Home</a>
<a href="./fireworks.html">Stars</a>
<a href="./hello-world.html">Hello</a>
<button class="control-button" type="button" data-toggle-hdr>Toggle HDR</button>
</nav>
</header>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Dashboard target</p>
<h1 class="hdr-title" data-hdr-text="true">Signal Supernova</h1>
<p>
A heavier page with panels, buttons, charts, and media-like blocks. The plugin
keeps the page pure black and lets a hardware-decoded HDR video layer carry the
galaxy and firework highlights.
</p>
</div>
<div class="demo-stage">
<div class="stage-grid"></div>
<div class="neon-ring"></div>
<div class="neon-ring"></div>
<div class="neon-word">98.7<br>Lux</div>
</div>
</section>
<section class="panel-grid" aria-label="Live metrics">
<article class="info-panel">
<h2>Peak Glow</h2>
<p style="font-size: 44px; color: var(--cyan); font-weight: 900; line-height: 1;">146%</p>
<p>Dynamic-range query active when the output pipeline reports HDR capability.</p>
</article>
<article class="info-panel">
<h2>Wide Gamut</h2>
<p style="font-size: 44px; color: var(--magenta); font-weight: 900; line-height: 1;">P3</p>
<p>Color variables upgrade from SRGB fallbacks to display-p3 or Rec.2020 colors.</p>
</article>
<article class="info-panel">
<h2>Motion Layer</h2>
<p style="font-size: 44px; color: var(--lime); font-weight: 900; line-height: 1;">Live</p>
<p>HDR motion is video-backed so the JavaScript layer stays low power.</p>
</article>
</section>
<section class="code-box" aria-label="Runtime controls">
<pre><code>window.HDREnhancer.enhance({ media: true, effects: false });
window.HDREnhancer.disable();
window.HDREnhancer.toggle();</code></pre>
</section>
</main>
</div>
<script>
document.querySelector('[data-toggle-hdr]').addEventListener('click', () => {
window.HDREnhancer.toggle();
});
</script>
</body>
</html>
+1112
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="HDR Web Plugin is a drop-in JavaScript file that adds a pure-black HDR media stage to any website.">
<title>HDR Web Plugin</title>
<link rel="stylesheet" href="./styles/demo.css?v=0.6.9">
<script src="./dist/hdr-web-plugin.js?v=0.6.2" defer data-hdr-media="true" data-hdr-media-type="image" data-hdr-image-src="./media/hdr-landing-prism.png" data-hdr-text="true" data-hdr-text-boost="2.8" data-hdr-effects="false" data-hdr-overlay="false" data-hdr-scanlines="false" data-hdr-blackout="true"></script>
</head>
<body>
<header class="topbar product-nav">
<a class="brand" href="#overview" aria-label="HDR Web Plugin">
<span class="brand-mark"></span>
HDR Web Plugin
</a>
<nav class="nav-links" aria-label="Site navigation">
<a href="#overview">Overview</a>
<a href="#install">Install</a>
<a href="#assets">Assets</a>
<a href="#api">API</a>
<a href="#specs">Specs</a>
<button class="control-button" type="button" data-toggle-hdr>HDR</button>
</nav>
</header>
<main class="product-page">
<section id="overview" class="product-hero" aria-label="HDR Web Plugin">
<div class="product-hero-copy">
<p class="product-kicker">One-page HDR media for any website</p>
<h1 class="product-wordmark-title" aria-label="HDR Web Plugin">
<span class="sr-only">HDR Web Plugin</span>
<img class="hdr-wordmark" src="./media/hdr-wordmark.png" alt="" width="2200" height="500" decoding="async" fetchpriority="high" aria-hidden="true">
</h1>
<p>
Add one JavaScript file, keep one page, and place HDR media behind your existing UI.
The plugin handles the black stage, HDR image or video layer, text boost, and runtime toggle.
</p>
<div class="hero-actions">
<a class="primary-action" href="#install">Get the script</a>
<a class="secondary-action" href="#assets">Use your HDR asset</a>
</div>
</div>
<a class="scroll-cue" href="#install" aria-label="Scroll to installation">Everything below, same page</a>
</section>
<section id="install" class="product-section unified-section" aria-label="Install HDR Web Plugin">
<div class="section-heading centered-heading">
<p class="eyebrow">Single page</p>
<h2>Install, customize, and control it in one flow.</h2>
<p>
The public page now works like one product surface. Every navigation item stays on this page,
and the same install snippet powers the live HDR preview.
</p>
</div>
<div class="single-page-grid">
<article class="landing-panel wide install-panel">
<div class="panel-heading">
<p class="eyebrow">Install</p>
<h3>One tag. No rebuild.</h3>
<p>Paste the hosted file into any page. The plugin detects HDR-capable output and injects the HDR layer behind your content.</p>
</div>
<div class="setup-flow compact">
<article class="setup-step">
<span>01</span>
<h3>Load</h3>
<p>Use the hosted script while testing.</p>
</article>
<article class="setup-step">
<span>02</span>
<h3>Choose</h3>
<p>Point it at an HDR PNG or HDR video.</p>
</article>
<article class="setup-step">
<span>03</span>
<h3>Control</h3>
<p>Toggle or swap sources after load.</p>
</article>
</div>
<section class="code-box install-box" aria-label="Install snippet">
<button class="copy-button" type="button" data-copy-snippet>Copy</button>
<pre><code>&lt;script
src="https://hdr-web-plugin-demo.pages.dev/dist/hdr-web-plugin.js?v=0.6.2"
defer
data-hdr-media="true"
data-hdr-media-type="image"
data-hdr-image-src="https://hdr-web-plugin-demo.pages.dev/media/hdr-landing-prism.png"
data-hdr-text="true"
data-hdr-text-boost="2.8"
data-hdr-blackout="true"&gt;&lt;/script&gt;</code></pre>
</section>
</article>
<article id="assets" class="landing-panel asset-panel">
<div class="panel-heading">
<p class="eyebrow">Assets</p>
<h3>Swap the source, keep the stage.</h3>
<p>Your page remains a normal page; the HDR image or video is the luminous layer behind it.</p>
</div>
<div class="asset-options compact">
<article class="asset-option">
<h3>HDR PNG</h3>
<p>Best for polished landing pages and product hero art.</p>
<pre><code>data-hdr-media-type="image"
data-hdr-image-src="/media/your-hdr.png"</code></pre>
</article>
<article class="asset-option">
<h3>HDR Video</h3>
<p>Best for motion. HEVC for Apple browsers, WebM fallback elsewhere.</p>
<pre><code>data-hdr-media-type="video"
data-hdr-video-src="/media/your-hdr.mp4"
data-hdr-webm-src="/media/your-hdr.webm"</code></pre>
</article>
</div>
</article>
<article class="landing-panel demo-card">
<div class="panel-heading">
<p class="eyebrow">Demo</p>
<h3>Real starfield.</h3>
<p>Open a focused page that uses a real Hubble star cluster as the HDR media layer.</p>
</div>
<a class="demo-link" href="./demos/fireworks.html">
Launch real starfield
<span aria-hidden="true">View demo</span>
</a>
</article>
<article id="api" class="landing-panel api-panel">
<div class="panel-heading">
<p class="eyebrow">Runtime</p>
<h3>Designed to disappear into your stack.</h3>
<p>No framework, no canvas workload by default, and a reversible media stage.</p>
</div>
<section class="code-box" aria-label="Runtime API snippet">
<pre><code>window.HDREnhancer.enhance({
media: true,
mediaType: "image",
imageSrc: "/media/your-hdr.png",
text: true,
textBoost: 2.8,
blackout: true
});
window.HDREnhancer.disable();
window.HDREnhancer.toggle();</code></pre>
</section>
</article>
<article id="specs" class="landing-panel wide specs-panel">
<div class="panel-heading">
<p class="eyebrow">Specs</p>
<h3>One consistent surface for the whole plugin.</h3>
</div>
<div class="spec-grid compact">
<article class="spec-item">
<span>Base</span>
<strong>Pure black</strong>
<p>Forces the foundation to black so HDR highlights have contrast.</p>
</article>
<article class="spec-item">
<span>Media</span>
<strong>PNG / HEVC / WebM</strong>
<p>Supports swappable HDR image and video sources.</p>
</article>
<article class="spec-item">
<span>Detection</span>
<strong>HDR-aware</strong>
<p>Checks dynamic range, color gamut, and dynamic-range-limit support.</p>
</article>
<article class="spec-item">
<span>Power</span>
<strong>Low JS load</strong>
<p>Uses browser media decoding instead of heavy JavaScript animation.</p>
</article>
</div>
</article>
</div>
</section>
</main>
<footer class="demo-footer product-footer">
Real HDR output depends on the display, OS settings, browser support, and the HDR metadata inside your media.
</footer>
<script>
document.querySelector('[data-toggle-hdr]').addEventListener('click', () => {
window.HDREnhancer.toggle();
});
document.querySelector('[data-copy-snippet]').addEventListener('click', async (event) => {
const text = '<script src="https://hdr-web-plugin-demo.pages.dev/dist/hdr-web-plugin.js?v=0.6.2" defer data-hdr-media="true" data-hdr-media-type="image" data-hdr-image-src="https://hdr-web-plugin-demo.pages.dev/media/hdr-landing-prism.png" data-hdr-text="true" data-hdr-text-boost="2.8" data-hdr-blackout="true"><\\/script>';
await navigator.clipboard.writeText(text);
event.currentTarget.textContent = 'Copied';
window.setTimeout(() => {
event.currentTarget.textContent = 'Copy';
}, 1400);
});
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

+23
View File
@@ -0,0 +1,23 @@
{
"name": "hdr-web-plugin",
"version": "0.6.2",
"private": true,
"description": "Drop-in JavaScript plugin that adds a pure-black HDR media stage to web pages.",
"main": "dist/hdr-web-plugin.js",
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "node scripts/build-dist.mjs",
"media:hdr": "node scripts/generate-hdr-media.mjs && node scripts/generate-hdr-image.mjs && node scripts/generate-hdr-wordmark.mjs && node scripts/generate-real-starfield-image.mjs",
"media:starfield": "node scripts/generate-real-starfield-image.mjs",
"pretest": "npm run build",
"test": "node --test",
"start": "python3 -m http.server 4173"
},
"engines": {
"node": ">=20"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const sourcePath = resolve(root, 'src/hdr-enhance.js');
const outputPath = resolve(root, 'dist/hdr-web-plugin.js');
const source = await readFile(sourcePath, 'utf8');
const banner = [
'/*!',
' * HDR Web Plugin v0.6.2',
' * Drop-in pure-black HDR media stage for web pages.',
' */',
''
].join('\n');
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${banner}${source}`, 'utf8');
+174
View File
@@ -0,0 +1,174 @@
import { mkdir, rm } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
const root = resolve(import.meta.dirname, '..');
const outputPath = resolve(root, 'media/hdr-landing-prism.png');
const intermediatePath = resolve(root, 'media/.hdr-landing-prism-source.mp4');
const width = 1440;
const height = 810;
const frame = Buffer.alloc(width * height * 6);
function mulberry(seed) {
return () => {
seed += 0x6d2b79f5;
let value = seed;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
const random = mulberry(0x0a0d06);
const colors = {
cyan: [0.08, 0.98, 1.0],
blue: [0.22, 0.42, 1.0],
magenta: [1.0, 0.12, 0.92],
amber: [1.0, 0.78, 0.18],
lime: [0.72, 1.0, 0.1],
white: [1.0, 1.0, 1.0]
};
function mix(a, b, t) {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t
];
}
function addPoint(x, y, radius, color, strength) {
const minX = Math.max(0, Math.floor(x - radius));
const maxX = Math.min(width - 1, Math.ceil(x + radius));
const minY = Math.max(0, Math.floor(y - radius));
const maxY = Math.min(height - 1, Math.ceil(y + radius));
const r2 = radius * radius;
for (let py = minY; py <= maxY; py += 1) {
const dy = py - y;
for (let px = minX; px <= maxX; px += 1) {
const dx = px - x;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const falloff = 1 - d2 / r2;
const bloom = falloff * falloff * strength;
const offset = (py * width + px) * 6;
for (let channel = 0; channel < 3; channel += 1) {
const current = frame.readUInt16LE(offset + channel * 2);
const value = Math.min(65535, current + Math.round(65535 * color[channel] * bloom));
frame.writeUInt16LE(value, offset + channel * 2);
}
}
}
}
function addLine(x1, y1, x2, y2, radius, color, strength, steps = 220) {
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const x = x1 + (x2 - x1) * t;
const y = y1 + (y2 - y1) * t;
const taper = Math.sin(Math.PI * t);
addPoint(x, y, radius * (0.28 + taper * 0.72), color, strength * (0.18 + taper * 0.82));
}
}
function addCurve(offsetY, radius, colorA, colorB, strength) {
const steps = 420;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const x = width * (0.08 + t * 0.86);
const arc = Math.sin(Math.PI * t);
const ripple = Math.sin(t * Math.PI * 5.2 + offsetY * 0.018) * 0.035;
const y = height * (0.70 - arc * 0.34 + ripple) + offsetY;
const color = t < 0.5
? mix(colorA, colors.white, t * 0.34)
: mix(colors.white, colorB, (t - 0.5) * 0.68);
addPoint(x, y, radius * (0.22 + arc * 0.78), color, strength * (0.15 + arc * 0.85));
}
}
frame.fill(0);
for (let i = 0; i < 360; i += 1) {
const x = random() * width;
const y = random() * height;
const radius = 0.7 + random() * 1.8;
const color = [colors.cyan, colors.blue, colors.magenta, colors.amber, colors.white][Math.floor(random() * 5)];
const strength = 0.05 + random() * 0.16;
addPoint(x, y, radius * 2.2, color, strength);
}
for (let i = 0; i < 140; i += 1) {
const t = i / 139;
const x = width * (0.18 + t * 0.68);
const y = height * (0.51 + Math.sin(t * Math.PI * 2.5) * 0.09 + (random() - 0.5) * 0.05);
const color = mix(colors.blue, colors.magenta, t);
addPoint(x, y, 4 + random() * 9, color, 0.08 + random() * 0.18);
}
addCurve(-68, 34, colors.cyan, colors.magenta, 0.26);
addCurve(-22, 26, colors.blue, colors.amber, 0.20);
addCurve(38, 18, colors.lime, colors.cyan, 0.13);
addPoint(width * 0.56, height * 0.43, 108, colors.white, 0.42);
addPoint(width * 0.56, height * 0.43, 220, colors.cyan, 0.12);
addPoint(width * 0.56, height * 0.43, 320, colors.magenta, 0.07);
addLine(width * 0.16, height * 0.76, width * 0.78, height * 0.24, 9, colors.cyan, 0.22, 180);
addLine(width * 0.30, height * 0.84, width * 0.88, height * 0.33, 5, colors.amber, 0.14, 160);
for (let i = 0; i < 9; i += 1) {
const x = width * (0.12 + random() * 0.72);
const y = height * (0.20 + random() * 0.52);
const color = [colors.cyan, colors.magenta, colors.amber][Math.floor(random() * 3)];
addPoint(x, y, 18 + random() * 38, color, 0.10 + random() * 0.14);
}
await mkdir(dirname(outputPath), { recursive: true });
const encode = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo',
'-pix_fmt', 'rgb48le',
'-s:v', `${width}x${height}`,
'-r', '1',
'-i', 'pipe:0',
'-an',
'-vf', 'format=yuv420p10le',
'-c:v', 'libx265',
'-profile:v', 'main10',
'-preset', 'medium',
'-crf', '8',
'-tag:v', 'hvc1',
'-movflags', '+faststart',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
'-colorspace', 'bt2020nc',
'-x265-params',
'hdr10=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1400,260',
intermediatePath
], { stdio: ['pipe', 'inherit', 'inherit'] });
encode.stdin.end(frame);
const [encodeCode] = await once(encode, 'close');
if (encodeCode !== 0) throw new Error(`ffmpeg intermediate exited with code ${encodeCode}`);
const extract = spawn('ffmpeg', [
'-y',
'-i', intermediatePath,
'-frames:v', '1',
'-update', '1',
'-c:v', 'png',
'-pred', 'mixed',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
outputPath
], { stdio: ['ignore', 'inherit', 'inherit'] });
const [extractCode] = await once(extract, 'close');
await rm(intermediatePath, { force: true });
if (extractCode !== 0) throw new Error(`ffmpeg png exited with code ${extractCode}`);
console.log(`Wrote ${outputPath}`);
+363
View File
@@ -0,0 +1,363 @@
import { mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
const root = resolve(import.meta.dirname, '..');
const outputPath = resolve(root, 'media/hdr-galaxy-fireworks.mp4');
const webmOutputPath = resolve(root, 'media/hdr-galaxy-fireworks.webm');
const width = 960;
const height = 540;
const fps = 24;
const duration = 6;
const frames = fps * duration;
const channels = 3;
const bytesPerChannel = 2;
const frameBytes = width * height * channels * bytesPerChannel;
const palette = [
[0.08, 0.98, 1.0],
[1.0, 0.12, 0.92],
[0.72, 1.0, 0.10],
[1.0, 0.82, 0.22],
[0.22, 0.42, 1.0],
[1.0, 1.0, 1.0]
];
function mulberry(seed) {
return () => {
seed += 0x6d2b79f5;
let value = seed;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
const random = mulberry(0x485452);
function lerp(a, b, t) {
return a + (b - a) * t;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function easeOutCubic(t) {
return 1 - (1 - t) ** 3;
}
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}
function smoothstep(edge0, edge1, value) {
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function colorMix(a, b, amount) {
return [
lerp(a[0], b[0], amount),
lerp(a[1], b[1], amount),
lerp(a[2], b[2], amount)
];
}
function makeParticles(count, type, baseColor, accentColor) {
return Array.from({ length: count }, (_, index) => {
const ringAngle = (Math.PI * 2 * index) / count;
const jitter = (random() - 0.5) * (type === 'willow' ? 0.34 : 0.18);
const angle = ringAngle + jitter;
const verticalBias = type === 'willow' ? 0.22 + random() * 0.48 : random() * 0.24 - 0.12;
const speed = type === 'willow' ? 92 + random() * 116 : 118 + random() * 174;
return {
angle,
verticalBias,
speed,
radiusBias: type === 'willow' ? 0.58 + random() * 0.36 : 0.82 + random() * 0.24,
size: type === 'willow' ? 1.0 + random() * 1.7 : 0.9 + random() * 2.2,
color: random() > 0.72 ? colorMix(baseColor, accentColor, random() * 0.58) : baseColor,
peak: type === 'willow' ? 0.38 + random() * 0.36 : 0.54 + random() * 0.42,
tail: type === 'willow' ? 0.72 + random() * 0.28 : random() * 0.2,
crackle: random() > 0.78
};
});
}
const shells = [
{
start: 0.12,
launch: [width * 0.18, height + 32],
apex: [width * 0.31, height * 0.25],
type: 'chrysanthemum',
color: palette[0],
accent: palette[5],
particles: makeParticles(168, 'chrysanthemum', palette[0], palette[5])
},
{
start: 0.88,
launch: [width * 0.78, height + 28],
apex: [width * 0.67, height * 0.34],
type: 'willow',
color: palette[3],
accent: palette[1],
particles: makeParticles(132, 'willow', palette[3], palette[1])
},
{
start: 1.74,
launch: [width * 0.48, height + 34],
apex: [width * 0.5, height * 0.18],
type: 'chrysanthemum',
color: palette[1],
accent: palette[0],
particles: makeParticles(190, 'chrysanthemum', palette[1], palette[0])
},
{
start: 2.76,
launch: [width * 0.28, height + 26],
apex: [width * 0.2, height * 0.38],
type: 'willow',
color: palette[2],
accent: palette[3],
particles: makeParticles(124, 'willow', palette[2], palette[3])
},
{
start: 3.56,
launch: [width * 0.86, height + 38],
apex: [width * 0.82, height * 0.22],
type: 'chrysanthemum',
color: palette[4],
accent: palette[5],
particles: makeParticles(158, 'chrysanthemum', palette[4], palette[5])
},
{
start: 4.35,
launch: [width * 0.43, height + 36],
apex: [width * 0.43, height * 0.31],
type: 'willow',
color: palette[5],
accent: palette[0],
particles: makeParticles(142, 'willow', palette[5], palette[0])
},
{
start: 5.08,
launch: [width * 0.58, height + 30],
apex: [width * 0.61, height * 0.2],
type: 'chrysanthemum',
color: palette[3],
accent: palette[1],
particles: makeParticles(210, 'chrysanthemum', palette[3], palette[1])
}
];
const stars = Array.from({ length: 54 }, () => ({
x: random() * width,
y: height * (0.06 + random() * 0.52),
r: 0.32 + random() * 0.72,
phase: random() * Math.PI * 2,
peak: 0.025 + random() * 0.08
}));
function addPoint(frame, x, y, radius, color, strength, softness = 2.4) {
const minX = Math.max(0, Math.floor(x - radius));
const maxX = Math.min(width - 1, Math.ceil(x + radius));
const minY = Math.max(0, Math.floor(y - radius));
const maxY = Math.min(height - 1, Math.ceil(y + radius));
const radiusSquared = radius * radius;
for (let py = minY; py <= maxY; py += 1) {
const dy = py - y;
for (let px = minX; px <= maxX; px += 1) {
const dx = px - x;
const d2 = dx * dx + dy * dy;
if (d2 > radiusSquared) continue;
const falloff = Math.max(0, 1 - d2 / radiusSquared);
const bloom = falloff ** softness * strength;
const offset = (py * width + px) * 6;
for (let channel = 0; channel < 3; channel += 1) {
const current = frame.readUInt16LE(offset + channel * 2);
const value = Math.min(65535, current + Math.round(65535 * color[channel] * bloom));
frame.writeUInt16LE(value, offset + channel * 2);
}
}
}
}
function addLine(frame, x1, y1, x2, y2, radius, color, strength, samples = 42) {
for (let i = 0; i < samples; i += 1) {
const t = i / Math.max(1, samples - 1);
const x = lerp(x1, x2, t);
const y = lerp(y1, y2, t);
addPoint(frame, x, y, radius, color, strength * (1 - t * 0.72), 2.0);
}
}
function pathPoint(shell, progress) {
const eased = easeOutQuad(progress);
const curve = Math.sin(progress * Math.PI) * height * 0.1;
return {
x: lerp(shell.launch[0], shell.apex[0], eased) + Math.sin(progress * Math.PI * 1.4) * 16,
y: lerp(shell.launch[1], shell.apex[1], eased) - curve
};
}
function drawLaunch(frame, shell, t) {
const launchDuration = 0.78;
const age = t - shell.start;
if (age < 0 || age > launchDuration) return;
const progress = clamp(age / launchDuration, 0, 1);
const head = pathPoint(shell, progress);
const previous = pathPoint(shell, Math.max(0, progress - 0.06));
const trailColor = colorMix(shell.color, [1, 1, 1], 0.32);
for (let i = 0; i < 9; i += 1) {
const tailProgress = Math.max(0, progress - i * 0.035);
const point = pathPoint(shell, tailProgress);
addPoint(frame, point.x, point.y, 1.7 + i * 0.18, trailColor, (0.52 - i * 0.045) * smoothstep(0, 0.08, progress), 1.8);
}
addLine(frame, previous.x, previous.y, head.x, head.y, 1.4, shell.color, 0.36, 18);
addPoint(frame, head.x, head.y, 4.2, [1, 1, 1], 0.72, 2.1);
}
function drawBurst(frame, shell, t) {
const launchDuration = 0.78;
const burstAge = t - shell.start - launchDuration;
const burstDuration = shell.type === 'willow' ? 1.72 : 1.32;
if (burstAge < 0 || burstAge > burstDuration) return;
const progress = clamp(burstAge / burstDuration, 0, 1);
const expansion = easeOutCubic(progress);
const fade = 1 - smoothstep(0.62, 1, progress);
const centerStrength = Math.max(0, 1 - progress * 3.4);
const center = shell.apex;
addPoint(frame, center[0], center[1], 5.5 + progress * 3.5, [1, 1, 1], 0.95 * centerStrength, 2.5);
addPoint(frame, center[0], center[1], 14 + progress * 9, shell.color, 0.18 * centerStrength, 2.8);
for (const particle of shell.particles) {
const sideDistance = particle.speed * particle.radiusBias * expansion;
const drop = (shell.type === 'willow' ? 142 : 68) * burstAge * burstAge;
const x = center[0] + Math.cos(particle.angle) * sideDistance;
const y = center[1] + Math.sin(particle.angle) * sideDistance * (0.84 + particle.verticalBias) + drop;
const strength = particle.peak * fade;
const radius = particle.size * (shell.type === 'willow' ? 2.2 + progress * 1.2 : 2.4 + progress * 0.7);
if (shell.type === 'willow' && progress > 0.16) {
const tailAge = Math.max(0, burstAge - 0.12);
const tailExpansion = easeOutCubic(clamp(tailAge / burstDuration, 0, 1));
const tailDistance = particle.speed * particle.radiusBias * tailExpansion * 0.86;
const tailDrop = 142 * tailAge * tailAge;
const tx = center[0] + Math.cos(particle.angle) * tailDistance;
const ty = center[1] + Math.sin(particle.angle) * tailDistance * (0.84 + particle.verticalBias) + tailDrop;
addLine(frame, tx, ty, x, y, 0.72, particle.color, strength * particle.tail * 0.34, 10);
}
addPoint(frame, x, y, radius, particle.color, strength, shell.type === 'willow' ? 2.0 : 2.6);
if (particle.crackle && progress > 0.54 && progress < 0.86) {
const cracklePhase = Math.sin((progress * 24 + particle.angle) * Math.PI);
if (cracklePhase > 0.28) {
addPoint(frame, x + Math.cos(particle.angle * 3.1) * 8, y + Math.sin(particle.angle * 2.3) * 8, 1.8, [1, 1, 1], strength * 0.68, 1.8);
}
}
}
}
function drawSkyline(frame) {
const y = height * 0.91;
addLine(frame, 0, y, width, y, 0.7, [0.02, 0.06, 0.08], 0.035, 160);
for (let i = 0; i < 7; i += 1) {
const x = width * (0.08 + i * 0.13);
addLine(frame, x, y, x + width * 0.04, y - height * (0.012 + (i % 3) * 0.008), 0.65, [0.02, 0.06, 0.08], 0.026, 16);
}
}
function renderFrame(index) {
const frame = Buffer.allocUnsafe(frameBytes);
frame.fill(0);
const t = index / fps;
for (const star of stars) {
const pulse = 0.62 + Math.sin(t * 2.2 + star.phase) * 0.28;
addPoint(frame, star.x, star.y, star.r, [0.76, 0.94, 1], star.peak * pulse, 2.4);
}
drawSkyline(frame);
for (const shell of shells) {
drawLaunch(frame, shell, t);
drawBurst(frame, shell, t);
}
return frame;
}
await mkdir(dirname(outputPath), { recursive: true });
const args = [
'-y',
'-f', 'rawvideo',
'-pix_fmt', 'rgb48le',
'-s:v', `${width}x${height}`,
'-r', String(fps),
'-i', 'pipe:0',
'-an',
'-vf', 'format=yuv420p10le',
'-c:v', 'libx265',
'-profile:v', 'main10',
'-preset', 'medium',
'-crf', '10',
'-tag:v', 'hvc1',
'-movflags', '+faststart',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
'-colorspace', 'bt2020nc',
'-x265-params',
'hdr10=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1400,220',
outputPath
];
const ffmpeg = spawn('ffmpeg', args, { stdio: ['pipe', 'inherit', 'inherit'] });
for (let index = 0; index < frames; index += 1) {
const frame = renderFrame(index);
if (!ffmpeg.stdin.write(frame)) {
await once(ffmpeg.stdin, 'drain');
}
}
ffmpeg.stdin.end();
const [code] = await once(ffmpeg, 'close');
if (code !== 0) {
throw new Error(`ffmpeg exited with code ${code}`);
}
console.log(`Wrote ${outputPath}`);
const webmArgs = [
'-y',
'-i', outputPath,
'-an',
'-c:v', 'libvpx-vp9',
'-pix_fmt', 'yuv420p10le',
'-row-mt', '1',
'-b:v', '0',
'-crf', '16',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
'-colorspace', 'bt2020nc',
webmOutputPath
];
const webm = spawn('ffmpeg', webmArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
const [webmCode] = await once(webm, 'close');
if (webmCode !== 0) {
throw new Error(`ffmpeg webm exited with code ${webmCode}`);
}
console.log(`Wrote ${webmOutputPath}`);
+138
View File
@@ -0,0 +1,138 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const outputPath = resolve(root, 'media/hdr-wordmark.png');
const width = 2200;
const height = 500;
function run(command, args, options = {}) {
const child = spawn(command, args, options);
return once(child, 'close').then(([code]) => {
if (code !== 0) throw new Error(`${command} exited with code ${code}`);
});
}
function capture(command, args) {
return new Promise((resolveBuffer, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'] });
const chunks = [];
child.stdout.on('data', (chunk) => chunks.push(chunk));
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`${command} exited with code ${code}`));
return;
}
resolveBuffer(Buffer.concat(chunks));
});
});
}
const tempDir = await mkdtemp(join(tmpdir(), 'hdr-wordmark-'));
const svgPath = join(tempDir, 'wordmark.svg');
const maskPath = join(tempDir, 'wordmark-mask.png');
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<filter id="blur-strong" x="-20%" y="-70%" width="140%" height="240%">
<feGaussianBlur stdDeviation="38"/>
</filter>
<filter id="blur-soft" x="-20%" y="-70%" width="140%" height="240%">
<feGaussianBlur stdDeviation="16"/>
</filter>
</defs>
<g font-family="Arial Black, Helvetica, Arial, sans-serif" font-size="220" font-weight="900" letter-spacing="-9" text-anchor="middle">
<text x="1100" y="326" fill="#00faff" stroke="#00faff" stroke-width="48" opacity="0.96" filter="url(#blur-strong)">HDR Web Plugin</text>
<text x="1114" y="336" fill="#ff31ef" stroke="#ff31ef" stroke-width="30" opacity="0.74" filter="url(#blur-strong)">HDR Web Plugin</text>
<text x="1086" y="316" fill="#2f76ff" stroke="#2f76ff" stroke-width="22" opacity="0.62" filter="url(#blur-soft)">HDR Web Plugin</text>
<text x="1100" y="326" fill="#ffffff" stroke="#ffffff" stroke-width="18" opacity="0.72" filter="url(#blur-soft)">HDR Web Plugin</text>
<text x="1094" y="322" fill="#eaffff" stroke="#00faff" stroke-width="12" opacity="1" filter="url(#blur-soft)">HDR Web Plugin</text>
<text x="1100" y="326" fill="#ffffff" stroke="#ffffff" stroke-width="7">HDR Web Plugin</text>
</g>
</svg>`;
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(svgPath, svg, 'utf8');
await run('/usr/bin/sips', [
'-s',
'format',
'png',
svgPath,
'--out',
maskPath
], { stdio: ['ignore', 'inherit', 'inherit'] });
const rgba = await capture('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-i',
maskPath,
'-f',
'rawvideo',
'-pix_fmt',
'rgba',
'pipe:1'
]);
const frame = Buffer.alloc(width * height * 8);
for (let index = 0; index < width * height; index += 1) {
const src = index * 4;
const dst = index * 8;
const alpha = rgba[src + 3] / 255;
const red = alpha === 0 ? 0 : rgba[src] / 255;
const green = alpha === 0 ? 0 : rgba[src + 1] / 255;
const blue = alpha === 0 ? 0 : rgba[src + 2] / 255;
const channels = [red, green, blue];
for (let channel = 0; channel < 3; channel += 1) {
const lifted = Math.pow(channels[channel], 0.42);
const value = Math.min(65535, Math.round(lifted * 65535));
frame.writeUInt16LE(value, dst + channel * 2);
}
const alphaValue = Math.min(65535, Math.round(Math.pow(alpha, 0.42) * 65535));
frame.writeUInt16LE(alphaValue, dst + 6);
}
const encode = spawn('ffmpeg', [
'-y',
'-f',
'rawvideo',
'-pix_fmt',
'rgba64le',
'-s:v',
`${width}x${height}`,
'-i',
'pipe:0',
'-vf',
'setparams=range=full:color_primaries=bt2020:color_trc=smpte2084:colorspace=gbr',
'-frames:v',
'1',
'-update',
'1',
'-c:v',
'png',
'-pix_fmt',
'rgba64be',
'-pred',
'mixed',
'-color_primaries',
'bt2020',
'-color_trc',
'smpte2084',
outputPath
], { stdio: ['pipe', 'inherit', 'inherit'] });
encode.stdin.end(frame);
const [encodeCode] = await once(encode, 'close');
await rm(tempDir, { recursive: true, force: true });
if (encodeCode !== 0) throw new Error(`ffmpeg png exited with code ${encodeCode}`);
console.log(`Wrote ${outputPath}`);
+82
View File
@@ -0,0 +1,82 @@
import { mkdir, rm } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
const root = resolve(import.meta.dirname, '..');
const sourceUrl = 'https://esahubble.org/media/archives/images/large/heic1509a.jpg';
const sourcePath = resolve(root, 'media/.hubble-westerlund-2-source.jpg');
const outputPath = resolve(root, 'media/hubble-westerlund-2-hdr.png');
async function run(command, args, options = {}) {
const child = spawn(command, args, { stdio: 'inherit', ...options });
const [code] = await once(child, 'close');
if (code !== 0) throw new Error(`${command} exited with code ${code}`);
}
function capture(command, args) {
return new Promise((resolveBuffer, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'] });
const chunks = [];
child.stdout.on('data', (chunk) => chunks.push(chunk));
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`${command} exited with code ${code}`));
return;
}
resolveBuffer(Buffer.concat(chunks));
});
});
}
await mkdir(dirname(outputPath), { recursive: true });
await run('curl', [
'-L',
'--fail',
'--silent',
'--show-error',
sourceUrl,
'-o',
sourcePath
]);
const frame = await capture('ffmpeg', [
'-hide_banner',
'-loglevel',
'error',
'-i', sourcePath,
'-vf',
'scale=2560:1440:force_original_aspect_ratio=increase,crop=2560:1440,eq=contrast=1.06:saturation=1.12:brightness=-0.02',
'-f', 'rawvideo',
'-pix_fmt', 'rgba64le',
'pipe:1'
]);
const encode = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo',
'-pix_fmt', 'rgba64le',
'-s:v', '2560x1440',
'-i', 'pipe:0',
'-vf',
'setparams=range=full:color_primaries=bt2020:color_trc=smpte2084:colorspace=gbr',
'-frames:v', '1',
'-update', '1',
'-c:v', 'png',
'-pix_fmt', 'rgba64be',
'-pred', 'mixed',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
outputPath
], { stdio: ['pipe', 'inherit', 'inherit'] });
encode.stdin.end(frame);
const [encodeCode] = await once(encode, 'close');
if (encodeCode !== 0) throw new Error(`ffmpeg png exited with code ${encodeCode}`);
await rm(sourcePath, { force: true });
console.log(`Wrote ${outputPath}`);
console.log('Source: NASA/ESA Hubble Westerlund 2, heic1509a');
+1108
View File
File diff suppressed because it is too large Load Diff
+1191
View File
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import test from 'node:test';
const require = createRequire(import.meta.url);
const HDREnhancer = require('../src/hdr-enhance.js');
function createMatchMedia(matches) {
return (query) => ({
media: query,
matches: Boolean(matches[query]),
addEventListener() {},
removeEventListener() {}
});
}
function createFakeDocument() {
const nodesById = new Map();
const attach = (node, parentList) => {
parentList.push(node);
if (node.id) nodesById.set(node.id, node);
return node;
};
const documentElement = {
dataset: {},
style: {
values: {},
setProperty(name, value) {
this.values[name] = value;
},
removeProperty(name) {
delete this.values[name];
}
}
};
const body = {
dataset: {},
children: [],
appendChild(node) {
return attach(node, this.children);
},
removeAttribute(name) {
if (name === 'data-hdr-enhanced') delete this.dataset.hdrEnhanced;
if (name === 'data-hdr-mode') delete this.dataset.hdrMode;
if (name === 'data-hdr-media') delete this.dataset.hdrMedia;
if (name === 'data-hdr-text') delete this.dataset.hdrText;
}
};
const head = {
children: [],
appendChild(node) {
return attach(node, this.children);
}
};
return {
body,
documentElement,
head,
createElement(tagName) {
return {
tagName: tagName.toUpperCase(),
id: '',
className: '',
textContent: '',
innerHTML: '',
dataset: {},
style: {},
children: [],
attributes: {},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
appendChild(child) {
return attach(child, this.children);
},
remove() {
nodesById.delete(this.id);
head.children = head.children.filter((node) => node !== this);
body.children = body.children.filter((node) => node !== this);
}
};
},
getElementById(id) {
return nodesById.get(id) || null;
},
querySelectorAll() {
return [];
}
};
}
test('detectCapabilities prefers the widest available gamut and HDR dynamic range', () => {
const capabilities = HDREnhancer.detectCapabilities({
matchMedia: createMatchMedia({
'(dynamic-range: high)': true,
'(color-gamut: rec2020)': true,
'(color-gamut: p3)': true,
'(prefers-reduced-motion: reduce)': false
}),
CSS: {
supports(property, value) {
return (
property === 'color' && value === 'color(display-p3 1 0 0)'
) || (
property === 'dynamic-range-limit' && value === 'no-limit'
);
}
}
});
assert.equal(capabilities.dynamicRange, 'high');
assert.equal(capabilities.colorGamut, 'rec2020');
assert.equal(capabilities.supportsDisplayP3, true);
assert.equal(capabilities.supportsDynamicRangeLimit, true);
assert.equal(capabilities.prefersReducedMotion, false);
});
test('createStyleText includes HDR, P3, pure-black, no-limit, and reduced-motion rules', () => {
const css = HDREnhancer.createStyleText({
intensity: 1.2,
animated: true,
text: true,
textBoost: 2.4
}, {
dynamicRange: 'high',
colorGamut: 'p3',
supportsDisplayP3: true,
supportsHdrColor: true,
prefersReducedMotion: false
});
assert.match(css, /@media \(dynamic-range: high\)/);
assert.match(css, /@media \(color-gamut: p3\)/);
assert.match(css, /dynamic-range-limit: no-limit/);
assert.match(css, /background: #000000 !important/);
assert.match(css, /body\[data-hdr-enhanced="true"\]::before/);
assert.match(css, /color\(display-p3/);
assert.match(css, /--hdr-fallback-cyan/);
assert.match(css, /--hdr-text-boost: 2\.40/);
assert.match(css, /data-hdr-text/);
assert.match(css, /--hdr-texture-url/);
assert.match(css, /background-clip: text/);
assert.match(css, /-webkit-text-fill-color: transparent/);
assert.match(css, /hdr-color/);
assert.match(css, /:where\(body\[data-hdr-enhanced="true"\] > :not/);
assert.match(css, /prefers-reduced-motion: reduce/);
});
test('createMediaConfig defaults to HDR video on HDR-capable outputs only', () => {
const hdr = HDREnhancer.createMediaConfig({
assetBase: 'https://example.com/',
media: true
}, {
dynamicRange: 'high'
});
const sdr = HDREnhancer.createMediaConfig({
assetBase: 'https://example.com/',
media: true
}, {
dynamicRange: 'standard'
});
const disabled = HDREnhancer.createMediaConfig({
assetBase: 'https://example.com/',
media: false
}, {
dynamicRange: 'high'
});
assert.equal(hdr.enabled, true);
assert.equal(hdr.type, 'video');
assert.equal(hdr.src, 'https://example.com/media/hdr-galaxy-fireworks.mp4');
assert.deepEqual(hdr.sources, [
{
src: 'https://example.com/media/hdr-galaxy-fireworks.mp4',
type: 'video/mp4; codecs="hvc1"'
},
{
src: 'https://example.com/media/hdr-galaxy-fireworks.webm',
type: 'video/webm; codecs="vp09.02.10.10.01.09.16.09.01"'
}
]);
assert.equal(sdr.enabled, false);
assert.equal(disabled.enabled, false);
});
test('createMediaConfig can inject a swappable HDR image stage', () => {
const hdr = HDREnhancer.createMediaConfig({
assetBase: 'https://example.com/',
media: true,
mediaType: 'image',
imageSrc: '/assets/custom-hdr.png'
}, {
dynamicRange: 'high'
});
assert.equal(hdr.enabled, true);
assert.equal(hdr.type, 'image');
assert.equal(hdr.src, 'https://example.com/assets/custom-hdr.png');
assert.equal(hdr.sources.length, 1);
assert.equal(hdr.sources[0].type, 'image/png');
});
test('enhance injects pure-black HDR media once, then disable removes it', () => {
const document = createFakeDocument();
const window = {
document,
matchMedia: createMatchMedia({
'(dynamic-range: high)': true,
'(color-gamut: p3)': true
}),
CSS: {
supports() {
return true;
}
}
};
const first = HDREnhancer.enhance({ window, intensity: 1.1 });
const second = HDREnhancer.enhance({ window, intensity: 1.1 });
assert.equal(first.active, true);
assert.equal(second.active, true);
assert.equal(second.reused, true);
assert.equal(document.head.children.filter((node) => node.id === 'hdr-enhancer-style').length, 1);
assert.equal(document.body.children.filter((node) => node.id === 'hdr-enhancer-overlay').length, 0);
assert.equal(document.body.children.filter((node) => node.id === 'hdr-enhancer-video').length, 1);
assert.equal(document.body.dataset.hdrEnhanced, 'true');
assert.equal(document.body.dataset.hdrMode, 'p3');
assert.equal(document.body.dataset.hdrMedia, 'true');
const disabled = HDREnhancer.disable({ window });
assert.equal(disabled.active, false);
assert.equal(document.getElementById('hdr-enhancer-style'), null);
assert.equal(document.getElementById('hdr-enhancer-overlay'), null);
assert.equal(document.getElementById('hdr-enhancer-video'), null);
assert.equal(document.body.dataset.hdrEnhanced, undefined);
assert.equal(document.body.dataset.hdrMedia, undefined);
});
test('readScriptOptions maps script data attributes into plugin options', () => {
const options = HDREnhancer.readScriptOptions({
dataset: {
hdrIntensity: '1.35',
hdrAnimated: 'false',
hdrOverlay: 'true',
hdrScanlines: 'false',
hdrBadge: 'false',
hdrEffects: 'true',
hdrEffect: 'cosmic-fireworks',
hdrQuality: 'high',
hdrFps: '48',
hdrMaxDpr: '1.5',
hdrMotion: 'ambient',
hdrMedia: 'true',
hdrBlackout: 'true',
hdrVideoSrc: '/media/custom-hdr.mp4',
hdrWebmSrc: '/media/custom-hdr.webm',
hdrImageSrc: '/media/custom-hdr.png',
hdrMediaType: 'image',
hdrText: 'true',
hdrTextBoost: '2.65',
hdrTextSrc: '/media/custom-text-hdr.png'
},
src: 'https://cdn.example.com/dist/hdr-web-plugin.js'
});
assert.deepEqual(options, {
intensity: 1.35,
animated: false,
overlay: true,
scanlines: false,
badge: false,
effects: true,
effect: 'cosmic-fireworks',
quality: 'high',
fps: 48,
maxDpr: 1.5,
motion: 'ambient',
media: true,
blackout: true,
videoSrc: '/media/custom-hdr.mp4',
webmSrc: '/media/custom-hdr.webm',
imageSrc: '/media/custom-hdr.png',
mediaType: 'image',
text: true,
textBoost: 2.65,
textSrc: '/media/custom-text-hdr.png',
assetBase: 'https://cdn.example.com/'
});
});
test('plugin exposes stable metadata for script tag consumers', () => {
assert.equal(HDREnhancer.pluginName, 'HDR Web Plugin');
assert.match(HDREnhancer.version, /^\d+\.\d+\.\d+$/);
assert.equal(typeof HDREnhancer.install, 'function');
});
test('dist plugin entry exports the same public API', () => {
const DistHDREnhancer = require('../dist/hdr-web-plugin.js');
assert.equal(DistHDREnhancer.pluginName, HDREnhancer.pluginName);
assert.equal(DistHDREnhancer.version, HDREnhancer.version);
assert.equal(typeof DistHDREnhancer.enhance, 'function');
assert.equal(typeof DistHDREnhancer.disable, 'function');
});
test('createEffectConfig enables cosmic fireworks with reduced-motion fallback', () => {
const active = HDREnhancer.createEffectConfig({
intensity: 1.5,
effect: 'cosmic-fireworks',
effects: true,
motion: 'ambient'
}, {
prefersReducedMotion: false,
colorGamut: 'p3'
});
const reduced = HDREnhancer.createEffectConfig({
intensity: 1.5,
effect: 'cosmic-fireworks',
effects: true
}, {
prefersReducedMotion: true,
colorGamut: 'p3'
});
assert.equal(active.enabled, true);
assert.equal(active.effect, 'cosmic-fireworks');
assert.equal(active.fireworkBursts, true);
assert.equal(active.galaxy, true);
assert.equal(active.motionScale, 1);
assert.equal(reduced.motionScale, 0.18);
});
test('createEffectConfig defaults to a static low-power budget', () => {
const config = HDREnhancer.createEffectConfig({
intensity: 1.55,
effect: 'cosmic-fireworks',
effects: true
}, {
prefersReducedMotion: false,
colorGamut: 'p3'
});
assert.equal(config.quality, 'low');
assert.equal(config.motion, 'static');
assert.equal(config.shouldAnimate, false);
assert.equal(config.targetFps <= 1, true);
assert.equal(config.dprCap, 1);
assert.equal(config.starCount <= 50, true);
assert.equal(config.galaxyCount <= 58, true);
assert.equal(config.burstParticleCount, 0);
assert.equal(config.maxSparks, 0);
});
test('createEffectConfig requires explicit ambient motion for continuous animation', () => {
const config = HDREnhancer.createEffectConfig({
intensity: 1.55,
effect: 'cosmic-fireworks',
effects: true,
quality: 'balanced',
motion: 'ambient'
}, {
prefersReducedMotion: false,
colorGamut: 'p3'
});
assert.equal(config.quality, 'balanced');
assert.equal(config.motion, 'ambient');
assert.equal(config.shouldAnimate, true);
assert.equal(config.targetFps <= 24, true);
assert.equal(config.dprCap <= 1.15, true);
assert.equal(config.maxSparks <= 90, true);
});
+146
View File
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import test from 'node:test';
test('landing page teaches users how to install and swap HDR assets', async () => {
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const productSectionCount = [...html.matchAll(/class="[^"]*product-section/g)].length;
assert.match(html, /<title>HDR Web Plugin/);
assert.match(html, /styles\/demo\.css\?v=/);
assert.match(html, /HDR Web Plugin/);
assert.match(html, /data-hdr-image-src/);
assert.match(html, /data-hdr-video-src/);
assert.match(html, /data-copy-snippet/);
assert.match(html, /https:\/\/hdr-web-plugin-demo\.pages\.dev\/dist\/hdr-web-plugin\.js\?v=/);
assert.match(html, /data-hdr-media-type="image"/);
assert.match(html, /dist\/hdr-web-plugin\.js\?v=0\.6\.2/);
assert.match(html, /data-hdr-text="true"/);
assert.match(html, /data-hdr-text-boost="2\.8"/);
assert.match(html, /class="product-wordmark-title"/);
assert.match(html, /class="sr-only">HDR Web Plugin/);
assert.match(html, /class="hdr-wordmark"/);
assert.match(html, /src="\.\/media\/hdr-wordmark\.png"/);
assert.doesNotMatch(html, /<h1 class="hdr-title" data-hdr-text="true">HDR Web Plugin<\/h1>/);
assert.match(html, /id="overview"/);
assert.match(html, /href="#overview"/);
assert.match(html, /href="#install"/);
assert.match(html, /href="#assets"/);
assert.match(html, /href="#api"/);
assert.match(html, /href="#specs"/);
assert.match(html, /href="\.\/demos\/fireworks.html"/);
assert.match(html, /Real starfield/);
assert.match(html, /class="product-hero"/);
assert.match(html, /class="[^"]*unified-section/);
assert.match(html, /class="single-page-grid"/);
assert.match(html, /class="[^"]*landing-panel/);
assert.match(html, /class="[^"]*demo-card/);
assert.match(html, /class="[^"]*setup-flow compact/);
assert.match(html, /class="[^"]*asset-options compact/);
assert.match(html, /class="[^"]*spec-grid/);
assert.equal(productSectionCount, 1);
});
test('landing page wordmark is a 16-bit HDR PNG asset', async () => {
const png = await readFile(new URL('../media/hdr-wordmark.png', import.meta.url));
const css = await readFile(new URL('../styles/demo.css', import.meta.url), 'utf8');
const signature = png.subarray(0, 8).toString('hex');
const chunkType = png.subarray(12, 16).toString('ascii');
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
const bitDepth = png.readUInt8(24);
const colorType = png.readUInt8(25);
assert.equal(signature, '89504e470d0a1a0a');
assert.equal(chunkType, 'IHDR');
assert.equal(width >= 1600, true);
assert.equal(height >= 320, true);
assert.equal(bitDepth, 16);
assert.equal(colorType, 6);
assert.doesNotMatch(css, /\.hdr-wordmark\s*{[^}]*background:\s*#000000/i);
assert.match(css, /\.hdr-wordmark\s*{[^}]*background:\s*transparent/i);
assert.match(css, /\.hdr-wordmark\s*{[^}]*filter:\s*none/i);
const probe = spawnSync('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=pix_fmt,color_transfer,color_primaries',
'-of',
'default=nw=1',
new URL('../media/hdr-wordmark.png', import.meta.url)
], { encoding: 'utf8' });
assert.equal(probe.status, 0);
assert.match(probe.stdout, /pix_fmt=rgba64be/);
assert.match(probe.stdout, /color_transfer=smpte2084/);
assert.match(probe.stdout, /color_primaries=bt2020/);
});
test('real starfield demo uses a true astronomy HDR image', async () => {
const html = await readFile(new URL('../demos/fireworks.html', import.meta.url), 'utf8');
const css = await readFile(new URL('../styles/demo.css', import.meta.url), 'utf8');
const demoFiles = await readdir(new URL('../demos/', import.meta.url));
assert.match(html, /<title>HDR Real Starfield Demo<\/title>/);
assert.match(html, /styles\/demo\.css\?v=0\.6\.9/);
assert.match(html, /data-hdr-media-type="image"/);
assert.match(html, /data-hdr-image-src="..\/media\/hubble-westerlund-2-hdr\.png"/);
assert.doesNotMatch(html, /data-hdr-video-src/);
assert.doesNotMatch(html, /data-hdr-webm-src/);
assert.match(html, /data-hdr-effects="false"/);
assert.match(html, /data-hdr-badge="false"/);
assert.match(html, /class="[^"]*starfield-page/);
assert.match(html, /class="starfield-immersive"/);
assert.match(html, /class="starfield-asset"/);
assert.match(html, /src="..\/media\/hubble-westerlund-2-hdr\.png"/);
assert.match(html, /NASA, ESA, the Hubble Heritage Team/);
assert.match(html, /href="..\/index\.html"/);
assert.doesNotMatch(html, /class="demo-layout"/);
assert.doesNotMatch(html, /class="fireworks-copy"/);
assert.doesNotMatch(html, /class="fireworks-stage"/);
assert.doesNotMatch(html, /class="demo-notes"/);
assert.doesNotMatch(html, /Shader bloom/);
assert.doesNotMatch(html, /fireworks-webgl\.js/);
assert.doesNotMatch(html, /class="[^"]*firework-bloom/);
assert.equal(demoFiles.includes('fireworks-webgl.js'), false);
assert.match(css, /\.starfield-asset\s*{[^}]*object-fit:\s*cover;/s);
assert.match(css, /dynamic-range-limit:\s*no-limit;/);
});
test('real starfield image is a 16-bit HDR PNG asset', async () => {
const png = await readFile(new URL('../media/hubble-westerlund-2-hdr.png', import.meta.url));
const signature = png.subarray(0, 8).toString('hex');
const chunkType = png.subarray(12, 16).toString('ascii');
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
const bitDepth = png.readUInt8(24);
const colorType = png.readUInt8(25);
assert.equal(signature, '89504e470d0a1a0a');
assert.equal(chunkType, 'IHDR');
assert.equal(width >= 2400, true);
assert.equal(height >= 1350, true);
assert.equal(bitDepth, 16);
assert.equal(colorType, 6);
const probe = spawnSync('ffprobe', [
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=pix_fmt,color_transfer,color_primaries',
'-of',
'default=nw=1',
new URL('../media/hubble-westerlund-2-hdr.png', import.meta.url)
], { encoding: 'utf8' });
assert.equal(probe.status, 0);
assert.match(probe.stdout, /pix_fmt=rgba64be/);
assert.match(probe.stdout, /color_transfer=smpte2084/);
assert.match(probe.stdout, /color_primaries=bt2020/);
});