Document HDR plugin operations
This commit is contained in:
+11
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+1112
File diff suppressed because it is too large
Load Diff
+202
@@ -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><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></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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1191
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user