commit fe4428a5e902f35b66f901f4c4dbbe641a784780 Author: Test User Date: Tue Sep 15 00:04:00 2026 +0800 Document HDR plugin operations diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e6415c --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbca962 --- /dev/null +++ b/AGENTS.md @@ -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 `, 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 +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d740a5 --- /dev/null +++ b/README.md @@ -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 + +``` + +The script auto-runs in the browser. You can tune it without writing custom JS: + +```html + +``` + +For video-backed motion: + +```html + +``` + +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. diff --git a/_headers b/_headers new file mode 100644 index 0000000..fd74d3e --- /dev/null +++ b/_headers @@ -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 diff --git a/demos/fireworks.html b/demos/fireworks.html new file mode 100644 index 0000000..328cdaa --- /dev/null +++ b/demos/fireworks.html @@ -0,0 +1,27 @@ + + + + + + HDR Real Starfield Demo + + + + +
+ + +

Westerlund 2 · NASA, ESA, the Hubble Heritage Team

+ +
+ + + + diff --git a/demos/hello-world.html b/demos/hello-world.html new file mode 100644 index 0000000..0779393 --- /dev/null +++ b/demos/hello-world.html @@ -0,0 +1,76 @@ + + + + + + Hello HDR World + + + + +
+
+ + + HDR Web Injector + + +
+ +
+
+

Cosmic hello page

+

Hello HDR Galaxy

+

+ 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. +

+
+ View comparison + +
+
+ +
+
+
+
+
Hello
HDR
+
+
+ +
+
+

Before

+

A normal SDR page is readable, but the light range stays flat.

+
+ + + +
+
+
+

After

+

Injected CSS holds the page at black while HDR video carries the luminous layer.

+
+ + + +
+
+
+
+ + + + diff --git a/demos/neon-dashboard.html b/demos/neon-dashboard.html new file mode 100644 index 0000000..e319c22 --- /dev/null +++ b/demos/neon-dashboard.html @@ -0,0 +1,77 @@ + + + + + + Neon HDR Dashboard + + + + +
+
+ + + HDR Web Injector + + +
+ +
+
+
+

Dashboard target

+

Signal Supernova

+

+ 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. +

+
+ +
+
+
+
+
98.7
Lux
+
+
+ +
+
+

Peak Glow

+

146%

+

Dynamic-range query active when the output pipeline reports HDR capability.

+
+
+

Wide Gamut

+

P3

+

Color variables upgrade from SRGB fallbacks to display-p3 or Rec.2020 colors.

+
+
+

Motion Layer

+

Live

+

HDR motion is video-backed so the JavaScript layer stays low power.

+
+
+ +
+
window.HDREnhancer.enhance({ media: true, effects: false });
+window.HDREnhancer.disable();
+window.HDREnhancer.toggle();
+
+
+
+ + + + diff --git a/dist/hdr-web-plugin.js b/dist/hdr-web-plugin.js new file mode 100644 index 0000000..265fd8a --- /dev/null +++ b/dist/hdr-web-plugin.js @@ -0,0 +1,1112 @@ +/*! + * HDR Web Plugin v0.6.2 + * Drop-in pure-black HDR media stage for web pages. + */ +(function attach(root, factory) { + const api = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = api; + return; + } + + root.HDREnhancer = api; + + if (!root.document) return; + + const script = root.document.currentScript; + const shouldAutoRun = api.shouldAutoRun(script); + if (shouldAutoRun) { + const scriptOptions = api.readScriptOptions(script); + api.ready(() => api.install({ ...scriptOptions, window: root })); + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function createHDREnhancer() { + const PLUGIN_NAME = 'HDR Web Plugin'; + const VERSION = '0.6.2'; + const STYLE_ID = 'hdr-enhancer-style'; + const OVERLAY_ID = 'hdr-enhancer-overlay'; + const BADGE_ID = 'hdr-enhancer-badge'; + const SCAN_ID = 'hdr-enhancer-scan'; + const CANVAS_ID = 'hdr-enhancer-canvas'; + const VIDEO_ID = 'hdr-enhancer-video'; + const IMAGE_ID = 'hdr-enhancer-image'; + const DEFAULT_HDR_VIDEO = 'media/hdr-galaxy-fireworks.mp4'; + const DEFAULT_HDR_WEBM = 'media/hdr-galaxy-fireworks.webm'; + const DEFAULT_HDR_IMAGE = 'media/hdr-landing-prism.png'; + + function getWindow(input) { + if (input && input.window) return input.window; + if (input && input.document) return input; + if (typeof window !== 'undefined') return window; + return null; + } + + function mediaMatches(target, query) { + try { + return Boolean(target && target.matchMedia && target.matchMedia(query).matches); + } catch (_) { + return false; + } + } + + function cssSupports(target, property, value) { + try { + return Boolean(target && target.CSS && target.CSS.supports(property, value)); + } catch (_) { + return false; + } + } + + function detectCapabilities(input) { + const target = getWindow(input) || input || {}; + const rec2020 = mediaMatches(target, '(color-gamut: rec2020)'); + const p3 = mediaMatches(target, '(color-gamut: p3)'); + + return { + dynamicRange: mediaMatches(target, '(dynamic-range: high)') ? 'high' : 'standard', + videoDynamicRange: mediaMatches(target, '(video-dynamic-range: high)') ? 'high' : 'standard', + colorGamut: rec2020 ? 'rec2020' : p3 ? 'p3' : 'srgb', + supportsDisplayP3: cssSupports(target, 'color', 'color(display-p3 1 0 0)'), + supportsRec2020: cssSupports(target, 'color', 'color(rec2020 1 0 0)'), + supportsRelativeColor: cssSupports(target, 'color', 'color(from white srgb r g b)'), + supportsDynamicRangeLimit: cssSupports(target, 'dynamic-range-limit', 'no-limit'), + supportsHdrColor: cssSupports(target, 'color', 'hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2)'), + prefersReducedMotion: mediaMatches(target, '(prefers-reduced-motion: reduce)') + }; + } + + function clamp(value, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return min; + return Math.max(min, Math.min(max, number)); + } + + function readDatasetValue(script, name) { + if (!script || !script.dataset) return undefined; + return script.dataset[name]; + } + + function parseBoolean(value) { + if (value === undefined) return undefined; + if (value === true || value === 'true' || value === '') return true; + if (value === false || value === 'false') return false; + return undefined; + } + + function parseNumber(value) { + if (value === undefined || value === '') return undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + } + + function normalizeQuality(value) { + if (value === 'low' || value === 'balanced' || value === 'high') return value; + return undefined; + } + + function normalizeMotion(value) { + if (value === 'static' || value === 'burst' || value === 'ambient') return value; + return undefined; + } + + function normalizeMediaType(value) { + if (value === 'video' || value === 'image') return value; + return undefined; + } + + function scriptAssetBase(script) { + if (!script || !script.src || typeof URL === 'undefined') return undefined; + try { + return new URL('../', script.src).href; + } catch (_) { + return undefined; + } + } + + function readScriptOptions(script) { + const options = {}; + const intensity = parseNumber(readDatasetValue(script, 'hdrIntensity')); + const animated = parseBoolean(readDatasetValue(script, 'hdrAnimated')); + const overlay = parseBoolean(readDatasetValue(script, 'hdrOverlay')); + const scanlines = parseBoolean(readDatasetValue(script, 'hdrScanlines')); + const badge = parseBoolean(readDatasetValue(script, 'hdrBadge')); + const effects = parseBoolean(readDatasetValue(script, 'hdrEffects')); + const effect = readDatasetValue(script, 'hdrEffect'); + const quality = normalizeQuality(readDatasetValue(script, 'hdrQuality')); + const fps = parseNumber(readDatasetValue(script, 'hdrFps')); + const maxDpr = parseNumber(readDatasetValue(script, 'hdrMaxDpr')); + const motion = normalizeMotion(readDatasetValue(script, 'hdrMotion')); + const media = parseBoolean(readDatasetValue(script, 'hdrMedia')); + const blackout = parseBoolean(readDatasetValue(script, 'hdrBlackout')); + const text = parseBoolean(readDatasetValue(script, 'hdrText')); + const textBoost = parseNumber(readDatasetValue(script, 'hdrTextBoost')); + const videoSrc = readDatasetValue(script, 'hdrVideoSrc'); + const webmSrc = readDatasetValue(script, 'hdrWebmSrc'); + const imageSrc = readDatasetValue(script, 'hdrImageSrc'); + const textSrc = readDatasetValue(script, 'hdrTextSrc'); + const mediaType = normalizeMediaType(readDatasetValue(script, 'hdrMediaType')); + const assetBase = scriptAssetBase(script); + + if (intensity !== undefined) options.intensity = intensity; + if (animated !== undefined) options.animated = animated; + if (overlay !== undefined) options.overlay = overlay; + if (scanlines !== undefined) options.scanlines = scanlines; + if (badge !== undefined) options.badge = badge; + if (effects !== undefined) options.effects = effects; + if (effect !== undefined && effect !== '') options.effect = effect; + if (quality !== undefined) options.quality = quality; + if (fps !== undefined) options.fps = fps; + if (maxDpr !== undefined) options.maxDpr = maxDpr; + if (motion !== undefined) options.motion = motion; + if (media !== undefined) options.media = media; + if (blackout !== undefined) options.blackout = blackout; + if (text !== undefined) options.text = text; + if (textBoost !== undefined) options.textBoost = textBoost; + if (videoSrc !== undefined && videoSrc !== '') options.videoSrc = videoSrc; + if (webmSrc !== undefined && webmSrc !== '') options.webmSrc = webmSrc; + if (imageSrc !== undefined && imageSrc !== '') options.imageSrc = imageSrc; + if (textSrc !== undefined && textSrc !== '') options.textSrc = textSrc; + if (mediaType !== undefined) options.mediaType = mediaType; + if (assetBase !== undefined) options.assetBase = assetBase; + + return options; + } + + function shouldAutoRun(script) { + const hdrAuto = parseBoolean(readDatasetValue(script, 'hdrAuto')); + const legacyAuto = parseBoolean(readDatasetValue(script, 'auto')); + if (hdrAuto !== undefined) return hdrAuto; + if (legacyAuto !== undefined) return legacyAuto; + return true; + } + + function resolveAssetUrl(path, base) { + if (!path) return path; + if (typeof URL === 'undefined' || !base) return path; + try { + return new URL(path, base).href; + } catch (_) { + return path; + } + } + + function cssUrl(value) { + if (!value) return 'none'; + return `url("${String(value).replace(/["\\\n\r\f]/g, '\\$&')}")`; + } + + function createMediaConfig(options, capabilities) { + const settings = { + media: true, + mediaType: 'video', + videoSrc: '', + webmSrc: '', + imageSrc: '', + assetBase: '', + ...options + }; + const caps = capabilities || detectCapabilities(); + const enabled = settings.media !== false && caps.dynamicRange === 'high'; + const mediaType = normalizeMediaType(settings.mediaType) || 'video'; + const src = resolveAssetUrl(settings.videoSrc || DEFAULT_HDR_VIDEO, settings.assetBase); + const webm = resolveAssetUrl(settings.webmSrc || DEFAULT_HDR_WEBM, settings.assetBase); + const image = resolveAssetUrl(settings.imageSrc || DEFAULT_HDR_IMAGE, settings.assetBase); + + if (mediaType === 'image') { + return { + enabled, + type: 'image', + src: image, + sources: [ + { + src: image, + type: 'image/png' + } + ], + reason: enabled ? 'hdr-capable' : settings.media === false ? 'disabled' : 'standard-dynamic-range' + }; + } + + return { + enabled, + type: 'video', + src, + sources: [ + { + src, + type: 'video/mp4; codecs="hvc1"' + }, + { + src: webm, + type: 'video/webm; codecs="vp09.02.10.10.01.09.16.09.01"' + } + ], + reason: enabled ? 'hdr-capable' : settings.media === false ? 'disabled' : 'standard-dynamic-range' + }; + } + + function createEffectConfig(options, capabilities) { + const settings = { + intensity: 1, + animated: true, + effects: true, + effect: 'cosmic-fireworks', + quality: 'low', + motion: 'static', + ...options + }; + const caps = capabilities || detectCapabilities(); + const intensity = clamp(settings.intensity, 0.35, 1.85); + const reduced = Boolean(caps.prefersReducedMotion) || settings.animated === false; + const quality = normalizeQuality(settings.quality) || 'balanced'; + const profiles = { + low: { + dprCap: 1, + targetFps: 12, + starBase: 30, + galaxyBase: 36, + burstBase: 14, + maxSparks: 54, + maxComets: 1, + shadowBlur: 6 + }, + balanced: { + dprCap: 1.15, + targetFps: 24, + starBase: 54, + galaxyBase: 68, + burstBase: 20, + maxSparks: 90, + maxComets: 2, + shadowBlur: 10 + }, + high: { + dprCap: 1.35, + targetFps: 30, + starBase: 82, + galaxyBase: 98, + burstBase: 30, + maxSparks: 150, + maxComets: 3, + shadowBlur: 14 + } + }; + const profile = profiles[quality]; + const motion = normalizeMotion(settings.motion) || 'static'; + const shouldAnimate = motion === 'ambient' && !reduced; + const targetFps = Math.round(clamp(settings.fps || profile.targetFps, 12, 60)); + const dprCap = clamp(settings.maxDpr || profile.dprCap, 1, 1.75); + const motionScale = reduced ? 0.18 : 1; + const burstParticleCount = motion === 'static' ? 0 : Math.round(profile.burstBase + intensity * 10); + const maxSparks = motion === 'static' ? 0 : profile.maxSparks; + + return { + enabled: settings.effects !== false, + effect: settings.effect || 'cosmic-fireworks', + quality, + motion, + shouldAnimate, + intensity, + galaxy: settings.effect !== 'fireworks-only', + fireworkBursts: settings.effect !== 'galaxy-only', + stars: true, + motionScale, + targetFps: shouldAnimate ? targetFps : 1, + dprCap: reduced ? 1 : dprCap, + starCount: Math.round(profile.starBase + intensity * 10), + galaxyCount: Math.round(profile.galaxyBase + intensity * 14), + burstParticleCount, + maxSparks, + maxComets: motion === 'static' ? 0 : profile.maxComets, + shadowBlur: profile.shadowBlur, + colorSpace: caps.colorGamut === 'p3' || caps.colorGamut === 'rec2020' ? 'p3' : 'srgb' + }; + } + + function createStyleText(options, capabilities) { + const settings = { + intensity: 1, + animated: true, + text: true, + ...options + }; + const caps = capabilities || detectCapabilities(); + const intensity = clamp(settings.intensity, 0.35, 1.65); + const textBoost = clamp(settings.textBoost || 1.9, 1, 4); + const bloom = (0.65 * intensity).toFixed(2); + const glow = Math.round(34 * intensity); + const longGlow = Math.round(96 * intensity); + const saturation = (1.08 + intensity * 0.2).toFixed(2); + const contrast = (1.02 + intensity * 0.12).toFixed(2); + const motionState = settings.animated && !caps.prefersReducedMotion ? 'running' : 'paused'; + + return ` +:root { + --hdr-fallback-cyan: #38faff; + --hdr-fallback-magenta: #ff3df2; + --hdr-fallback-lime: #baff37; + --hdr-fallback-amber: #ffd95a; + --hdr-fallback-blue: #4d7cff; + --hdr-black: #000000; + --hdr-panel: rgba(0, 0, 0, 0.78); + --hdr-line: rgba(96, 255, 255, 0.26); + --hdr-cyan: var(--hdr-fallback-cyan); + --hdr-magenta: var(--hdr-fallback-magenta); + --hdr-lime: var(--hdr-fallback-lime); + --hdr-amber: var(--hdr-fallback-amber); + --hdr-blue: var(--hdr-fallback-blue); + --hdr-bloom: ${bloom}; + --hdr-glow: ${glow}px; + --hdr-long-glow: ${longGlow}px; + --hdr-text-boost: ${textBoost.toFixed(2)}; + --hdr-texture-url: none; +} + +@media (color-gamut: p3) { + :root { + --hdr-cyan: color(display-p3 0.08 0.98 1); + --hdr-magenta: color(display-p3 1 0.12 0.92); + --hdr-lime: color(display-p3 0.72 1 0.1); + --hdr-amber: color(display-p3 1 0.82 0.22); + --hdr-blue: color(display-p3 0.22 0.42 1); + --hdr-line: color(display-p3 0.3 1 1 / 0.32); + } +} + +@media (color-gamut: rec2020) { + :root { + --hdr-cyan: color(rec2020 0.12 0.95 1); + --hdr-magenta: color(rec2020 0.95 0.1 0.9); + --hdr-lime: color(rec2020 0.7 1 0.08); + --hdr-amber: color(rec2020 1 0.78 0.1); + --hdr-blue: color(rec2020 0.18 0.34 1); + } +} + +@supports (dynamic-range-limit: no-limit) { + html[data-hdr-boosted="true"], + body[data-hdr-enhanced="true"], + #${VIDEO_ID}, + #${IMAGE_ID}, + body[data-hdr-enhanced="true"] :is(.hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + dynamic-range-limit: no-limit; + } +} + +html[data-hdr-boosted="true"] { + background: #000000 !important; + color-scheme: dark; +} + +body[data-hdr-enhanced="true"] { + background: #000000 !important; + color: #eefcff !important; + color-scheme: dark; +} + +body[data-hdr-enhanced="true"]::before, +body[data-hdr-enhanced="true"]::after { + content: none !important; + display: none !important; + background: none !important; + box-shadow: none !important; + filter: none !important; +} + +body[data-hdr-enhanced="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title]) { + color: #f8feff !important; + text-shadow: + 0 0 0.5px #fff, + 0 0 calc(var(--hdr-glow) * 0.55) var(--hdr-cyan), + 0 0 var(--hdr-long-glow) color-mix(in srgb, var(--hdr-magenta), transparent 30%); +} + +body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + filter: brightness(var(--hdr-text-boost)) saturate(1.24) contrast(1.08); + transform: translateZ(0); +} + +@supports (color: hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2)) { + body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + color: hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2) !important; + } +} + +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(.hdr-title, [data-hdr-text="true"]) { + background-image: var(--hdr-texture-url); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + -webkit-text-fill-color: transparent; + text-shadow: + 0 0 1px rgba(255, 255, 255, 0.9), + 0 0 calc(var(--hdr-glow) * 0.72) color-mix(in srgb, var(--hdr-cyan), transparent 8%), + 0 0 calc(var(--hdr-long-glow) * 1.12) color-mix(in srgb, var(--hdr-magenta), transparent 12%); + } +} + +body[data-hdr-enhanced="true"] :is(a, button, .button, [role="button"], input[type="submit"]) { + border-color: color-mix(in srgb, var(--hdr-cyan), transparent 30%) !important; + box-shadow: + 0 0 calc(var(--hdr-glow) * 0.35) color-mix(in srgb, var(--hdr-cyan), transparent 28%), + inset 0 0 18px rgba(255, 255, 255, 0.08); +} + +body[data-hdr-enhanced="true"] :is(a, button, .button, [role="button"], input[type="submit"]):hover { + color: #ffffff !important; + text-shadow: 0 0 18px var(--hdr-cyan); + box-shadow: + 0 0 var(--hdr-glow) var(--hdr-cyan), + 0 0 var(--hdr-long-glow) color-mix(in srgb, var(--hdr-magenta), transparent 36%); +} + +body[data-hdr-enhanced="true"] :is(article, section, main, aside, header, footer, nav, .card, [class*="card"], [class*="panel"]) { + border-color: color-mix(in srgb, var(--hdr-cyan), transparent 76%) !important; +} + +body[data-hdr-enhanced="true"] ::selection { + background: var(--hdr-cyan); + color: #00050a; +} + +#${OVERLAY_ID}, +#${SCAN_ID}, +#${CANVAS_ID}, +#${VIDEO_ID}, +#${IMAGE_ID} { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 2147483000; +} + +#${VIDEO_ID}, +#${IMAGE_ID} { + width: 100vw; + height: 100vh; + z-index: 0; + object-fit: cover; + background: #000000; + opacity: 1; + mix-blend-mode: normal; +} + +:where(body[data-hdr-enhanced="true"] > :not(#${OVERLAY_ID}):not(#${SCAN_ID}):not(#${CANVAS_ID}):not(#${VIDEO_ID}):not(#${IMAGE_ID}):not(#${BADGE_ID})) { + position: relative; + z-index: 1; +} + +#${CANVAS_ID} { + width: 100vw; + height: 100vh; + z-index: 2147482999; + opacity: ${Math.min(0.84, 0.48 + intensity * 0.16).toFixed(2)}; + mix-blend-mode: screen; +} + +#${OVERLAY_ID} { + mix-blend-mode: screen; + opacity: ${Math.min(0.72, 0.34 + intensity * 0.17).toFixed(2)}; + background: + radial-gradient(circle at var(--hdr-orb-x, 20%) var(--hdr-orb-y, 20%), var(--hdr-cyan), transparent 18rem), + radial-gradient(circle at calc(100% - var(--hdr-orb-x, 20%)) 30%, var(--hdr-magenta), transparent 20rem), + linear-gradient(115deg, transparent 0%, rgba(255,255,255,0.08) 48%, transparent 58%); + filter: blur(18px) saturate(1.25); + animation: hdrAurora 8s ease-in-out infinite alternate; + animation-play-state: ${motionState}; +} + +#${SCAN_ID} { + opacity: ${Math.min(0.38, 0.1 + intensity * 0.12).toFixed(2)}; + background: + repeating-linear-gradient( + 180deg, + rgba(255,255,255,0.06) 0, + rgba(255,255,255,0.06) 1px, + transparent 1px, + transparent 7px + ); + mix-blend-mode: soft-light; + animation: hdrScan 5s linear infinite; + animation-play-state: ${motionState}; +} + +#${BADGE_ID} { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 2147483001; + border: 1px solid color-mix(in srgb, var(--hdr-cyan), transparent 30%); + border-radius: 999px; + padding: 8px 12px; + background: rgba(1, 2, 10, 0.72); + box-shadow: + 0 0 22px color-mix(in srgb, var(--hdr-cyan), transparent 30%), + inset 0 0 18px rgba(255, 255, 255, 0.08); + color: #f8feff; + font: 700 11px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; + backdrop-filter: blur(14px); +} + +#${BADGE_ID} strong { + color: var(--hdr-cyan); + text-shadow: 0 0 16px var(--hdr-cyan); +} + +@media (dynamic-range: high) { + #${OVERLAY_ID} { + opacity: ${Math.min(0.86, 0.44 + intensity * 0.2).toFixed(2)}; + filter: blur(15px) saturate(1.55) brightness(1.18); + } +} + +@media (prefers-reduced-motion: reduce) { + #${OVERLAY_ID}, + #${SCAN_ID}, + body[data-hdr-enhanced="true"] * { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +} + +@keyframes hdrAurora { + 0% { + --hdr-orb-x: 12%; + --hdr-orb-y: 22%; + transform: translate3d(-1.5%, -1%, 0) scale(1); + } + 45% { + --hdr-orb-x: 62%; + --hdr-orb-y: 14%; + transform: translate3d(1%, 0.5%, 0) scale(1.04); + } + 100% { + --hdr-orb-x: 38%; + --hdr-orb-y: 72%; + transform: translate3d(0.8%, 1.4%, 0) scale(1.08); + } +} + +@keyframes hdrScan { + from { transform: translateY(-7px); } + to { transform: translateY(7px); } +} +`.trim(); + } + + function ready(callback) { + const win = getWindow(); + if (!win || !win.document) return; + if (win.document.readyState === 'loading') { + win.document.addEventListener('DOMContentLoaded', callback, { once: true }); + return; + } + callback(); + } + + function appendNode(document, id, className, tagName) { + const existing = document.getElementById(id); + if (existing) return existing; + + const node = document.createElement(tagName || 'div'); + node.id = id; + if (className) node.className = className; + node.setAttribute('aria-hidden', 'true'); + document.body.appendChild(node); + return node; + } + + function appendHdrVideo(document, config) { + const video = appendNode(document, VIDEO_ID, 'hdr-enhancer-video', 'video'); + video.dataset.hdrMedia = 'true'; + video.dataset.hdrType = config.type; + video.dataset.hdrSource = config.src; + video.muted = true; + video.loop = true; + video.autoplay = true; + video.playsInline = true; + video.preload = 'auto'; + video.innerHTML = ''; + if (typeof video.removeAttribute === 'function') video.removeAttribute('src'); + for (const sourceConfig of config.sources) { + const source = document.createElement('source'); + source.src = sourceConfig.src; + source.type = sourceConfig.type; + source.setAttribute('src', sourceConfig.src); + source.setAttribute('type', sourceConfig.type); + video.appendChild(source); + } + video.setAttribute('muted', ''); + video.setAttribute('loop', ''); + video.setAttribute('autoplay', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('preload', 'auto'); + video.setAttribute('poster', ''); + + if (typeof video.load === 'function') { + try { + video.load(); + } catch (_) { + // Older embedded browsers may defer loading until playback starts. + } + } + + if (typeof video.play === 'function') { + try { + const playback = video.play(); + if (playback && typeof playback.catch === 'function') playback.catch(() => {}); + } catch (_) { + // Autoplay can still be denied in unusual browser policies; muted video is retried by the browser. + } + } + + return video; + } + + function appendHdrImage(document, config) { + const image = appendNode(document, IMAGE_ID, 'hdr-enhancer-image', 'img'); + image.dataset.hdrMedia = 'true'; + image.dataset.hdrType = config.type; + image.dataset.hdrSource = config.src; + image.src = config.src; + image.alt = ''; + image.decoding = 'async'; + image.loading = 'eager'; + image.setAttribute('src', config.src); + image.setAttribute('alt', ''); + image.setAttribute('decoding', 'async'); + image.setAttribute('loading', 'eager'); + return image; + } + + function stopVisualEffects(win) { + const state = win && win.__hdrEnhancerFx; + if (!state) return; + if (state.frame && win.cancelAnimationFrame) { + win.cancelAnimationFrame(state.frame); + } + if (state.timer && win.clearTimeout) { + win.clearTimeout(state.timer); + } + if (state.resize && win.removeEventListener) { + win.removeEventListener('resize', state.resize); + } + if (state.visibility && win.document && win.document.removeEventListener) { + win.document.removeEventListener('visibilitychange', state.visibility); + } + delete win.__hdrEnhancerFx; + } + + function startVisualEffects(win, canvas, config) { + if (!win || !canvas || !config.enabled || typeof canvas.getContext !== 'function') return; + + stopVisualEffects(win); + + let context; + try { + context = canvas.getContext('2d', { + alpha: true, + colorSpace: config.colorSpace === 'p3' ? 'display-p3' : 'srgb' + }); + } catch (_) { + context = null; + } + const ctx = context || canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + const random = () => Math.random(); + const palette = [ + [56, 250, 255], + [255, 61, 242], + [186, 255, 55], + [255, 217, 90], + [77, 124, 255], + [255, 255, 255] + ]; + const color = (rgb, alpha) => `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; + const state = { + frame: 0, + timer: 0, + width: 0, + height: 0, + ratio: 1, + ticks: 0, + stars: [], + galaxy: [], + sparks: [], + comets: [] + }; + state.frameInterval = 1000 / config.targetFps; + state.lastRender = 0; + state.animate = config.shouldAnimate; + state.paused = false; + + function resize() { + const width = Math.max(1, win.innerWidth || canvas.clientWidth || 1280); + const height = Math.max(1, win.innerHeight || canvas.clientHeight || 720); + const ratio = Math.min(config.dprCap, Math.max(1, win.devicePixelRatio || 1)); + state.width = width; + state.height = height; + state.ratio = ratio; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.dataset.hdrQuality = config.quality; + canvas.dataset.hdrMotion = config.motion; + canvas.dataset.hdrFps = String(config.targetFps); + canvas.dataset.hdrDpr = String(ratio); + canvas.dataset.hdrStars = String(config.starCount); + canvas.dataset.hdrGalaxy = String(config.galaxyCount); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + + state.stars = Array.from({ length: config.starCount }, () => ({ + x: random() * width, + y: random() * height, + r: random() * 1.7 + 0.35, + twinkle: random() * Math.PI * 2, + rgb: palette[Math.floor(random() * palette.length)] + })); + state.galaxy = Array.from({ length: config.galaxyCount }, () => ({ + radius: random() ** 0.55 * Math.min(width, height) * 0.48, + angle: random() * Math.PI * 2, + lane: random() > 0.5 ? 1 : -1, + size: random() * 2.2 + 0.4, + speed: (random() * 0.0007 + 0.00025) * (random() > 0.5 ? 1 : -1), + rgb: palette[Math.floor(random() * (palette.length - 1))] + })); + } + + function spawnBurst(forceX, forceY) { + if (!config.fireworkBursts) return; + if (state.sparks.length > config.maxSparks * 0.72) return; + const width = state.width; + const height = state.height; + const x = forceX || width * (0.16 + random() * 0.68); + const y = forceY || height * (0.12 + random() * 0.45); + const base = palette[Math.floor(random() * palette.length)]; + const count = Math.min( + config.maxSparks - state.sparks.length, + Math.round(config.burstParticleCount * (0.82 + random() * 0.38)) + ); + for (let i = 0; i < count; i += 1) { + const angle = (Math.PI * 2 * i) / count + random() * 0.22; + const speed = (1.4 + random() * 5.4) * config.motionScale * config.intensity; + state.sparks.push({ + x, + y, + lastX: x, + lastY: y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 64 + random() * 54, + age: 0, + size: 1.1 + random() * 2.4, + rgb: random() > 0.28 ? base : palette[Math.floor(random() * palette.length)] + }); + } + } + + function spawnComet() { + if (state.comets.length >= config.maxComets) return; + const height = state.height; + state.comets.push({ + x: -80, + y: height * (0.08 + random() * 0.42), + vx: (10 + random() * 8) * config.motionScale, + vy: (2.4 + random() * 2.8) * config.motionScale, + life: 90, + age: 0, + rgb: palette[Math.floor(random() * palette.length)] + }); + } + + function drawGlowCircle(x, y, radius, rgb, alpha) { + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); + gradient.addColorStop(0, color(rgb, alpha)); + gradient.addColorStop(0.35, color(rgb, alpha * 0.32)); + gradient.addColorStop(1, color(rgb, 0)); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + function drawBloomPoint(x, y, radius, rgb, alpha, blur) { + ctx.shadowColor = color(rgb, Math.min(0.9, alpha + 0.22)); + ctx.shadowBlur = blur; + ctx.fillStyle = color(rgb, alpha); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + function drawFrame(now) { + const timestamp = typeof now === 'number' ? now : Date.now(); + const raf = win.requestAnimationFrame || ((callback) => win.setTimeout(callback, 16)); + if (state.lastRender && timestamp - state.lastRender < state.frameInterval) { + state.frame = raf(drawFrame); + return; + } + state.lastRender = timestamp; + + if (state.paused) { + state.frame = raf(drawFrame); + return; + } + + const width = state.width; + const height = state.height; + state.ticks += config.motionScale; + + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = 'rgba(0, 0, 12, 0.12)'; + ctx.fillRect(0, 0, width, height); + + ctx.globalCompositeOperation = 'lighter'; + + if (config.stars) { + for (const star of state.stars) { + const pulse = 0.42 + Math.sin(state.ticks * 0.045 + star.twinkle) * 0.36; + drawBloomPoint( + star.x, + star.y, + star.r, + star.rgb, + Math.max(0.26, pulse * 0.7), + config.shadowBlur * 0.45 + ); + } + } + + if (config.galaxy) { + const cx = width * 0.58; + const cy = height * 0.5; + for (const dust of state.galaxy) { + dust.angle += dust.speed * state.ticks * config.motionScale; + const arm = dust.angle + dust.lane * dust.radius * 0.011 + state.ticks * 0.0025; + const x = cx + Math.cos(arm) * dust.radius * 1.34; + const y = cy + Math.sin(arm) * dust.radius * 0.38; + const alpha = 0.2 + (1 - dust.radius / (Math.min(width, height) * 0.5)) * 0.46; + drawBloomPoint(x, y, dust.size, dust.rgb, alpha * 0.78, config.shadowBlur); + } + drawGlowCircle(cx, cy, Math.min(width, height) * 0.22, [255, 255, 255], 0.22 * config.intensity); + drawGlowCircle(cx, cy, Math.min(width, height) * 0.36, [255, 61, 242], 0.11 * config.intensity); + } + + if (config.fireworkBursts && state.ticks % Math.max(22, 54 - config.intensity * 15) < 1) { + spawnBurst(); + } + if (state.ticks % 130 < 1) { + spawnComet(); + } + + ctx.lineCap = 'round'; + for (let i = state.sparks.length - 1; i >= 0; i -= 1) { + const spark = state.sparks[i]; + spark.age += 1; + spark.lastX = spark.x; + spark.lastY = spark.y; + spark.x += spark.vx; + spark.y += spark.vy; + spark.vx *= 0.986; + spark.vy = spark.vy * 0.986 + 0.038 * config.motionScale; + const left = 1 - spark.age / spark.life; + if (left <= 0) { + state.sparks.splice(i, 1); + continue; + } + ctx.strokeStyle = color(spark.rgb, left * 0.72); + ctx.lineWidth = spark.size * (0.8 + left); + ctx.shadowColor = color(spark.rgb, 0.85); + ctx.shadowBlur = config.shadowBlur * 0.95; + ctx.beginPath(); + ctx.moveTo(spark.lastX, spark.lastY); + ctx.lineTo(spark.x, spark.y); + ctx.stroke(); + drawBloomPoint(spark.x, spark.y, spark.size * 1.2, spark.rgb, left * 0.66, config.shadowBlur); + } + + for (let i = state.comets.length - 1; i >= 0; i -= 1) { + const comet = state.comets[i]; + comet.age += 1; + comet.x += comet.vx; + comet.y += comet.vy; + const left = 1 - comet.age / comet.life; + if (left <= 0 || comet.x > width + 220) { + state.comets.splice(i, 1); + continue; + } + ctx.strokeStyle = color(comet.rgb, 0.64 * left); + ctx.lineWidth = 3.8; + ctx.shadowColor = color(comet.rgb, 0.95); + ctx.shadowBlur = config.shadowBlur * 1.35; + ctx.beginPath(); + ctx.moveTo(comet.x - 150, comet.y - 36); + ctx.lineTo(comet.x, comet.y); + ctx.stroke(); + drawGlowCircle(comet.x, comet.y, 18 + 18 * config.intensity, comet.rgb, 0.52 * left); + } + + ctx.shadowBlur = 0; + if (state.animate) { + state.frame = raf(drawFrame); + } else { + state.frame = 0; + } + } + + resize(); + if (config.motion !== 'static') { + spawnBurst(state.width * 0.32, state.height * 0.34); + } + state.resize = resize; + if (win.addEventListener) win.addEventListener('resize', resize); + state.visibility = () => { + state.paused = Boolean(win.document && win.document.hidden); + if (!state.paused && state.animate && !state.frame) { + state.frame = (win.requestAnimationFrame || ((callback) => win.setTimeout(callback, 16)))(drawFrame); + } + }; + if (win.document && win.document.addEventListener) { + win.document.addEventListener('visibilitychange', state.visibility); + } + win.__hdrEnhancerFx = state; + drawFrame(); + } + + function enhance(options) { + const settings = { + intensity: 1, + animated: true, + overlay: false, + scanlines: false, + badge: true, + effects: false, + effect: 'cosmic-fireworks', + media: true, + blackout: true, + text: true, + ...options + }; + const win = getWindow(settings); + const document = win && win.document; + + if (!document || !document.head || !document.body) { + return { active: false, reason: 'missing-document' }; + } + + const capabilities = detectCapabilities(win); + const mediaConfig = createMediaConfig(settings, capabilities); + let reused = true; + let style = document.getElementById(STYLE_ID); + if (!style) { + reused = false; + style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = createStyleText(settings, capabilities); + document.head.appendChild(style); + } + + document.documentElement.dataset.hdrBoosted = 'true'; + document.documentElement.style.setProperty('--hdr-runtime-gamut', capabilities.colorGamut); + document.documentElement.style.setProperty( + '--hdr-texture-url', + cssUrl(resolveAssetUrl(settings.textSrc, settings.assetBase) || mediaConfig.src) + ); + document.body.dataset.hdrEnhanced = 'true'; + document.body.dataset.hdrMode = capabilities.colorGamut; + document.body.dataset.hdrEffect = settings.effect; + document.body.dataset.hdrMedia = mediaConfig.enabled ? 'true' : 'false'; + document.body.dataset.hdrText = settings.text === false ? 'false' : 'true'; + + if (mediaConfig.enabled && mediaConfig.type === 'image') { + appendHdrImage(document, mediaConfig); + } + if (mediaConfig.enabled && mediaConfig.type === 'video') { + appendHdrVideo(document, mediaConfig); + } + if (settings.effects !== false && !mediaConfig.enabled) { + const canvas = appendNode(document, CANVAS_ID, 'hdr-enhancer-canvas', 'canvas'); + startVisualEffects(win, canvas, createEffectConfig(settings, capabilities)); + } + if (settings.overlay) { + appendNode(document, OVERLAY_ID, 'hdr-enhancer-overlay'); + } + if (settings.scanlines) { + appendNode(document, SCAN_ID, 'hdr-enhancer-scan'); + } + if (settings.badge) { + const badge = appendNode(document, BADGE_ID, 'hdr-enhancer-badge'); + const mediaLabel = mediaConfig.enabled ? 'MEDIA' : 'CAPABLE'; + badge.innerHTML = `HDR ${mediaLabel} ${capabilities.dynamicRange.toUpperCase()} / ${capabilities.colorGamut.toUpperCase()}`; + } + + return { active: true, reused, capabilities, media: mediaConfig }; + } + + function install(options) { + return enhance(options); + } + + function disable(options) { + const win = getWindow(options || {}); + const document = win && win.document; + if (!document) return { active: false, reason: 'missing-document' }; + + stopVisualEffects(win); + + [STYLE_ID, OVERLAY_ID, SCAN_ID, BADGE_ID, CANVAS_ID, VIDEO_ID, IMAGE_ID].forEach((id) => { + const node = document.getElementById(id); + if (node && node.remove) node.remove(); + }); + + if (document.body) { + document.body.removeAttribute('data-hdr-enhanced'); + document.body.removeAttribute('data-hdr-mode'); + document.body.removeAttribute('data-hdr-effect'); + document.body.removeAttribute('data-hdr-media'); + document.body.removeAttribute('data-hdr-text'); + } + if (document.documentElement) { + delete document.documentElement.dataset.hdrBoosted; + if (document.documentElement.style) { + document.documentElement.style.removeProperty('--hdr-runtime-gamut'); + document.documentElement.style.removeProperty('--hdr-texture-url'); + } + } + + return { active: false }; + } + + function toggle(options) { + const win = getWindow(options || {}); + const document = win && win.document; + if (!document || !document.body) return enhance(options); + return document.body.dataset.hdrEnhanced === 'true' ? disable({ window: win }) : enhance(options); + } + + return { + pluginName: PLUGIN_NAME, + version: VERSION, + detectCapabilities, + readScriptOptions, + shouldAutoRun, + createMediaConfig, + createEffectConfig, + createStyleText, + install, + enhance, + disable, + toggle, + ready + }; +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..b7104b1 --- /dev/null +++ b/index.html @@ -0,0 +1,202 @@ + + + + + + + HDR Web Plugin + + + + +
+ + + HDR Web Plugin + + +
+ +
+
+
+

One-page HDR media for any website

+

+ HDR Web Plugin + +

+

+ 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. +

+ +
+ Everything below, same page +
+ +
+
+

Single page

+

Install, customize, and control it in one flow.

+

+ 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. +

+
+ +
+
+
+

Install

+

One tag. No rebuild.

+

Paste the hosted file into any page. The plugin detects HDR-capable output and injects the HDR layer behind your content.

+
+
+
+ 01 +

Load

+

Use the hosted script while testing.

+
+
+ 02 +

Choose

+

Point it at an HDR PNG or HDR video.

+
+
+ 03 +

Control

+

Toggle or swap sources after load.

+
+
+
+ +
<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>
+
+
+ +
+
+

Assets

+

Swap the source, keep the stage.

+

Your page remains a normal page; the HDR image or video is the luminous layer behind it.

+
+
+
+

HDR PNG

+

Best for polished landing pages and product hero art.

+
data-hdr-media-type="image"
+data-hdr-image-src="/media/your-hdr.png"
+
+
+

HDR Video

+

Best for motion. HEVC for Apple browsers, WebM fallback elsewhere.

+
data-hdr-media-type="video"
+data-hdr-video-src="/media/your-hdr.mp4"
+data-hdr-webm-src="/media/your-hdr.webm"
+
+
+
+ + + +
+
+

Runtime

+

Designed to disappear into your stack.

+

No framework, no canvas workload by default, and a reversible media stage.

+
+
+
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();
+
+
+ +
+
+

Specs

+

One consistent surface for the whole plugin.

+
+
+
+ Base + Pure black +

Forces the foundation to black so HDR highlights have contrast.

+
+
+ Media + PNG / HEVC / WebM +

Supports swappable HDR image and video sources.

+
+
+ Detection + HDR-aware +

Checks dynamic range, color gamut, and dynamic-range-limit support.

+
+
+ Power + Low JS load +

Uses browser media decoding instead of heavy JavaScript animation.

+
+
+
+
+
+
+ +
+ Real HDR output depends on the display, OS settings, browser support, and the HDR metadata inside your media. +
+ + + + diff --git a/media/hdr-galaxy-fireworks.mp4 b/media/hdr-galaxy-fireworks.mp4 new file mode 100644 index 0000000..7da3c51 Binary files /dev/null and b/media/hdr-galaxy-fireworks.mp4 differ diff --git a/media/hdr-galaxy-fireworks.webm b/media/hdr-galaxy-fireworks.webm new file mode 100644 index 0000000..4371444 Binary files /dev/null and b/media/hdr-galaxy-fireworks.webm differ diff --git a/media/hdr-landing-prism.png b/media/hdr-landing-prism.png new file mode 100644 index 0000000..63a925a Binary files /dev/null and b/media/hdr-landing-prism.png differ diff --git a/media/hdr-wordmark.png b/media/hdr-wordmark.png new file mode 100644 index 0000000..991e6e0 Binary files /dev/null and b/media/hdr-wordmark.png differ diff --git a/media/hubble-westerlund-2-hdr.png b/media/hubble-westerlund-2-hdr.png new file mode 100644 index 0000000..0607dc0 Binary files /dev/null and b/media/hubble-westerlund-2-hdr.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..d2a0c34 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/build-dist.mjs b/scripts/build-dist.mjs new file mode 100644 index 0000000..f9944bb --- /dev/null +++ b/scripts/build-dist.mjs @@ -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'); diff --git a/scripts/generate-hdr-image.mjs b/scripts/generate-hdr-image.mjs new file mode 100644 index 0000000..7fb1902 --- /dev/null +++ b/scripts/generate-hdr-image.mjs @@ -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}`); diff --git a/scripts/generate-hdr-media.mjs b/scripts/generate-hdr-media.mjs new file mode 100644 index 0000000..784d464 --- /dev/null +++ b/scripts/generate-hdr-media.mjs @@ -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}`); diff --git a/scripts/generate-hdr-wordmark.mjs b/scripts/generate-hdr-wordmark.mjs new file mode 100644 index 0000000..3e43d9c --- /dev/null +++ b/scripts/generate-hdr-wordmark.mjs @@ -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 = ` + + + + + + + + + + + HDR Web Plugin + HDR Web Plugin + HDR Web Plugin + HDR Web Plugin + HDR Web Plugin + HDR Web Plugin + +`; + +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}`); diff --git a/scripts/generate-real-starfield-image.mjs b/scripts/generate-real-starfield-image.mjs new file mode 100644 index 0000000..fcc161f --- /dev/null +++ b/scripts/generate-real-starfield-image.mjs @@ -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'); diff --git a/src/hdr-enhance.js b/src/hdr-enhance.js new file mode 100644 index 0000000..21d5198 --- /dev/null +++ b/src/hdr-enhance.js @@ -0,0 +1,1108 @@ +(function attach(root, factory) { + const api = factory(); + + if (typeof module === 'object' && module.exports) { + module.exports = api; + return; + } + + root.HDREnhancer = api; + + if (!root.document) return; + + const script = root.document.currentScript; + const shouldAutoRun = api.shouldAutoRun(script); + if (shouldAutoRun) { + const scriptOptions = api.readScriptOptions(script); + api.ready(() => api.install({ ...scriptOptions, window: root })); + } +})(typeof globalThis !== 'undefined' ? globalThis : this, function createHDREnhancer() { + const PLUGIN_NAME = 'HDR Web Plugin'; + const VERSION = '0.6.2'; + const STYLE_ID = 'hdr-enhancer-style'; + const OVERLAY_ID = 'hdr-enhancer-overlay'; + const BADGE_ID = 'hdr-enhancer-badge'; + const SCAN_ID = 'hdr-enhancer-scan'; + const CANVAS_ID = 'hdr-enhancer-canvas'; + const VIDEO_ID = 'hdr-enhancer-video'; + const IMAGE_ID = 'hdr-enhancer-image'; + const DEFAULT_HDR_VIDEO = 'media/hdr-galaxy-fireworks.mp4'; + const DEFAULT_HDR_WEBM = 'media/hdr-galaxy-fireworks.webm'; + const DEFAULT_HDR_IMAGE = 'media/hdr-landing-prism.png'; + + function getWindow(input) { + if (input && input.window) return input.window; + if (input && input.document) return input; + if (typeof window !== 'undefined') return window; + return null; + } + + function mediaMatches(target, query) { + try { + return Boolean(target && target.matchMedia && target.matchMedia(query).matches); + } catch (_) { + return false; + } + } + + function cssSupports(target, property, value) { + try { + return Boolean(target && target.CSS && target.CSS.supports(property, value)); + } catch (_) { + return false; + } + } + + function detectCapabilities(input) { + const target = getWindow(input) || input || {}; + const rec2020 = mediaMatches(target, '(color-gamut: rec2020)'); + const p3 = mediaMatches(target, '(color-gamut: p3)'); + + return { + dynamicRange: mediaMatches(target, '(dynamic-range: high)') ? 'high' : 'standard', + videoDynamicRange: mediaMatches(target, '(video-dynamic-range: high)') ? 'high' : 'standard', + colorGamut: rec2020 ? 'rec2020' : p3 ? 'p3' : 'srgb', + supportsDisplayP3: cssSupports(target, 'color', 'color(display-p3 1 0 0)'), + supportsRec2020: cssSupports(target, 'color', 'color(rec2020 1 0 0)'), + supportsRelativeColor: cssSupports(target, 'color', 'color(from white srgb r g b)'), + supportsDynamicRangeLimit: cssSupports(target, 'dynamic-range-limit', 'no-limit'), + supportsHdrColor: cssSupports(target, 'color', 'hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2)'), + prefersReducedMotion: mediaMatches(target, '(prefers-reduced-motion: reduce)') + }; + } + + function clamp(value, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return min; + return Math.max(min, Math.min(max, number)); + } + + function readDatasetValue(script, name) { + if (!script || !script.dataset) return undefined; + return script.dataset[name]; + } + + function parseBoolean(value) { + if (value === undefined) return undefined; + if (value === true || value === 'true' || value === '') return true; + if (value === false || value === 'false') return false; + return undefined; + } + + function parseNumber(value) { + if (value === undefined || value === '') return undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + } + + function normalizeQuality(value) { + if (value === 'low' || value === 'balanced' || value === 'high') return value; + return undefined; + } + + function normalizeMotion(value) { + if (value === 'static' || value === 'burst' || value === 'ambient') return value; + return undefined; + } + + function normalizeMediaType(value) { + if (value === 'video' || value === 'image') return value; + return undefined; + } + + function scriptAssetBase(script) { + if (!script || !script.src || typeof URL === 'undefined') return undefined; + try { + return new URL('../', script.src).href; + } catch (_) { + return undefined; + } + } + + function readScriptOptions(script) { + const options = {}; + const intensity = parseNumber(readDatasetValue(script, 'hdrIntensity')); + const animated = parseBoolean(readDatasetValue(script, 'hdrAnimated')); + const overlay = parseBoolean(readDatasetValue(script, 'hdrOverlay')); + const scanlines = parseBoolean(readDatasetValue(script, 'hdrScanlines')); + const badge = parseBoolean(readDatasetValue(script, 'hdrBadge')); + const effects = parseBoolean(readDatasetValue(script, 'hdrEffects')); + const effect = readDatasetValue(script, 'hdrEffect'); + const quality = normalizeQuality(readDatasetValue(script, 'hdrQuality')); + const fps = parseNumber(readDatasetValue(script, 'hdrFps')); + const maxDpr = parseNumber(readDatasetValue(script, 'hdrMaxDpr')); + const motion = normalizeMotion(readDatasetValue(script, 'hdrMotion')); + const media = parseBoolean(readDatasetValue(script, 'hdrMedia')); + const blackout = parseBoolean(readDatasetValue(script, 'hdrBlackout')); + const text = parseBoolean(readDatasetValue(script, 'hdrText')); + const textBoost = parseNumber(readDatasetValue(script, 'hdrTextBoost')); + const videoSrc = readDatasetValue(script, 'hdrVideoSrc'); + const webmSrc = readDatasetValue(script, 'hdrWebmSrc'); + const imageSrc = readDatasetValue(script, 'hdrImageSrc'); + const textSrc = readDatasetValue(script, 'hdrTextSrc'); + const mediaType = normalizeMediaType(readDatasetValue(script, 'hdrMediaType')); + const assetBase = scriptAssetBase(script); + + if (intensity !== undefined) options.intensity = intensity; + if (animated !== undefined) options.animated = animated; + if (overlay !== undefined) options.overlay = overlay; + if (scanlines !== undefined) options.scanlines = scanlines; + if (badge !== undefined) options.badge = badge; + if (effects !== undefined) options.effects = effects; + if (effect !== undefined && effect !== '') options.effect = effect; + if (quality !== undefined) options.quality = quality; + if (fps !== undefined) options.fps = fps; + if (maxDpr !== undefined) options.maxDpr = maxDpr; + if (motion !== undefined) options.motion = motion; + if (media !== undefined) options.media = media; + if (blackout !== undefined) options.blackout = blackout; + if (text !== undefined) options.text = text; + if (textBoost !== undefined) options.textBoost = textBoost; + if (videoSrc !== undefined && videoSrc !== '') options.videoSrc = videoSrc; + if (webmSrc !== undefined && webmSrc !== '') options.webmSrc = webmSrc; + if (imageSrc !== undefined && imageSrc !== '') options.imageSrc = imageSrc; + if (textSrc !== undefined && textSrc !== '') options.textSrc = textSrc; + if (mediaType !== undefined) options.mediaType = mediaType; + if (assetBase !== undefined) options.assetBase = assetBase; + + return options; + } + + function shouldAutoRun(script) { + const hdrAuto = parseBoolean(readDatasetValue(script, 'hdrAuto')); + const legacyAuto = parseBoolean(readDatasetValue(script, 'auto')); + if (hdrAuto !== undefined) return hdrAuto; + if (legacyAuto !== undefined) return legacyAuto; + return true; + } + + function resolveAssetUrl(path, base) { + if (!path) return path; + if (typeof URL === 'undefined' || !base) return path; + try { + return new URL(path, base).href; + } catch (_) { + return path; + } + } + + function cssUrl(value) { + if (!value) return 'none'; + return `url("${String(value).replace(/["\\\n\r\f]/g, '\\$&')}")`; + } + + function createMediaConfig(options, capabilities) { + const settings = { + media: true, + mediaType: 'video', + videoSrc: '', + webmSrc: '', + imageSrc: '', + assetBase: '', + ...options + }; + const caps = capabilities || detectCapabilities(); + const enabled = settings.media !== false && caps.dynamicRange === 'high'; + const mediaType = normalizeMediaType(settings.mediaType) || 'video'; + const src = resolveAssetUrl(settings.videoSrc || DEFAULT_HDR_VIDEO, settings.assetBase); + const webm = resolveAssetUrl(settings.webmSrc || DEFAULT_HDR_WEBM, settings.assetBase); + const image = resolveAssetUrl(settings.imageSrc || DEFAULT_HDR_IMAGE, settings.assetBase); + + if (mediaType === 'image') { + return { + enabled, + type: 'image', + src: image, + sources: [ + { + src: image, + type: 'image/png' + } + ], + reason: enabled ? 'hdr-capable' : settings.media === false ? 'disabled' : 'standard-dynamic-range' + }; + } + + return { + enabled, + type: 'video', + src, + sources: [ + { + src, + type: 'video/mp4; codecs="hvc1"' + }, + { + src: webm, + type: 'video/webm; codecs="vp09.02.10.10.01.09.16.09.01"' + } + ], + reason: enabled ? 'hdr-capable' : settings.media === false ? 'disabled' : 'standard-dynamic-range' + }; + } + + function createEffectConfig(options, capabilities) { + const settings = { + intensity: 1, + animated: true, + effects: true, + effect: 'cosmic-fireworks', + quality: 'low', + motion: 'static', + ...options + }; + const caps = capabilities || detectCapabilities(); + const intensity = clamp(settings.intensity, 0.35, 1.85); + const reduced = Boolean(caps.prefersReducedMotion) || settings.animated === false; + const quality = normalizeQuality(settings.quality) || 'balanced'; + const profiles = { + low: { + dprCap: 1, + targetFps: 12, + starBase: 30, + galaxyBase: 36, + burstBase: 14, + maxSparks: 54, + maxComets: 1, + shadowBlur: 6 + }, + balanced: { + dprCap: 1.15, + targetFps: 24, + starBase: 54, + galaxyBase: 68, + burstBase: 20, + maxSparks: 90, + maxComets: 2, + shadowBlur: 10 + }, + high: { + dprCap: 1.35, + targetFps: 30, + starBase: 82, + galaxyBase: 98, + burstBase: 30, + maxSparks: 150, + maxComets: 3, + shadowBlur: 14 + } + }; + const profile = profiles[quality]; + const motion = normalizeMotion(settings.motion) || 'static'; + const shouldAnimate = motion === 'ambient' && !reduced; + const targetFps = Math.round(clamp(settings.fps || profile.targetFps, 12, 60)); + const dprCap = clamp(settings.maxDpr || profile.dprCap, 1, 1.75); + const motionScale = reduced ? 0.18 : 1; + const burstParticleCount = motion === 'static' ? 0 : Math.round(profile.burstBase + intensity * 10); + const maxSparks = motion === 'static' ? 0 : profile.maxSparks; + + return { + enabled: settings.effects !== false, + effect: settings.effect || 'cosmic-fireworks', + quality, + motion, + shouldAnimate, + intensity, + galaxy: settings.effect !== 'fireworks-only', + fireworkBursts: settings.effect !== 'galaxy-only', + stars: true, + motionScale, + targetFps: shouldAnimate ? targetFps : 1, + dprCap: reduced ? 1 : dprCap, + starCount: Math.round(profile.starBase + intensity * 10), + galaxyCount: Math.round(profile.galaxyBase + intensity * 14), + burstParticleCount, + maxSparks, + maxComets: motion === 'static' ? 0 : profile.maxComets, + shadowBlur: profile.shadowBlur, + colorSpace: caps.colorGamut === 'p3' || caps.colorGamut === 'rec2020' ? 'p3' : 'srgb' + }; + } + + function createStyleText(options, capabilities) { + const settings = { + intensity: 1, + animated: true, + text: true, + ...options + }; + const caps = capabilities || detectCapabilities(); + const intensity = clamp(settings.intensity, 0.35, 1.65); + const textBoost = clamp(settings.textBoost || 1.9, 1, 4); + const bloom = (0.65 * intensity).toFixed(2); + const glow = Math.round(34 * intensity); + const longGlow = Math.round(96 * intensity); + const saturation = (1.08 + intensity * 0.2).toFixed(2); + const contrast = (1.02 + intensity * 0.12).toFixed(2); + const motionState = settings.animated && !caps.prefersReducedMotion ? 'running' : 'paused'; + + return ` +:root { + --hdr-fallback-cyan: #38faff; + --hdr-fallback-magenta: #ff3df2; + --hdr-fallback-lime: #baff37; + --hdr-fallback-amber: #ffd95a; + --hdr-fallback-blue: #4d7cff; + --hdr-black: #000000; + --hdr-panel: rgba(0, 0, 0, 0.78); + --hdr-line: rgba(96, 255, 255, 0.26); + --hdr-cyan: var(--hdr-fallback-cyan); + --hdr-magenta: var(--hdr-fallback-magenta); + --hdr-lime: var(--hdr-fallback-lime); + --hdr-amber: var(--hdr-fallback-amber); + --hdr-blue: var(--hdr-fallback-blue); + --hdr-bloom: ${bloom}; + --hdr-glow: ${glow}px; + --hdr-long-glow: ${longGlow}px; + --hdr-text-boost: ${textBoost.toFixed(2)}; + --hdr-texture-url: none; +} + +@media (color-gamut: p3) { + :root { + --hdr-cyan: color(display-p3 0.08 0.98 1); + --hdr-magenta: color(display-p3 1 0.12 0.92); + --hdr-lime: color(display-p3 0.72 1 0.1); + --hdr-amber: color(display-p3 1 0.82 0.22); + --hdr-blue: color(display-p3 0.22 0.42 1); + --hdr-line: color(display-p3 0.3 1 1 / 0.32); + } +} + +@media (color-gamut: rec2020) { + :root { + --hdr-cyan: color(rec2020 0.12 0.95 1); + --hdr-magenta: color(rec2020 0.95 0.1 0.9); + --hdr-lime: color(rec2020 0.7 1 0.08); + --hdr-amber: color(rec2020 1 0.78 0.1); + --hdr-blue: color(rec2020 0.18 0.34 1); + } +} + +@supports (dynamic-range-limit: no-limit) { + html[data-hdr-boosted="true"], + body[data-hdr-enhanced="true"], + #${VIDEO_ID}, + #${IMAGE_ID}, + body[data-hdr-enhanced="true"] :is(.hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + dynamic-range-limit: no-limit; + } +} + +html[data-hdr-boosted="true"] { + background: #000000 !important; + color-scheme: dark; +} + +body[data-hdr-enhanced="true"] { + background: #000000 !important; + color: #eefcff !important; + color-scheme: dark; +} + +body[data-hdr-enhanced="true"]::before, +body[data-hdr-enhanced="true"]::after { + content: none !important; + display: none !important; + background: none !important; + box-shadow: none !important; + filter: none !important; +} + +body[data-hdr-enhanced="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title]) { + color: #f8feff !important; + text-shadow: + 0 0 0.5px #fff, + 0 0 calc(var(--hdr-glow) * 0.55) var(--hdr-cyan), + 0 0 var(--hdr-long-glow) color-mix(in srgb, var(--hdr-magenta), transparent 30%); +} + +body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + filter: brightness(var(--hdr-text-boost)) saturate(1.24) contrast(1.08); + transform: translateZ(0); +} + +@supports (color: hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2)) { + body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(h1, h2, h3, .hdr-title, [data-hdr-title], [data-hdr-text="true"]) { + color: hdr-color(color(display-p3 1 1 1) 0, color(rec2100-pq 1 1 1) 2) !important; + } +} + +@supports ((-webkit-background-clip: text) or (background-clip: text)) { + body[data-hdr-enhanced="true"][data-hdr-text="true"] :is(.hdr-title, [data-hdr-text="true"]) { + background-image: var(--hdr-texture-url); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + -webkit-text-fill-color: transparent; + text-shadow: + 0 0 1px rgba(255, 255, 255, 0.9), + 0 0 calc(var(--hdr-glow) * 0.72) color-mix(in srgb, var(--hdr-cyan), transparent 8%), + 0 0 calc(var(--hdr-long-glow) * 1.12) color-mix(in srgb, var(--hdr-magenta), transparent 12%); + } +} + +body[data-hdr-enhanced="true"] :is(a, button, .button, [role="button"], input[type="submit"]) { + border-color: color-mix(in srgb, var(--hdr-cyan), transparent 30%) !important; + box-shadow: + 0 0 calc(var(--hdr-glow) * 0.35) color-mix(in srgb, var(--hdr-cyan), transparent 28%), + inset 0 0 18px rgba(255, 255, 255, 0.08); +} + +body[data-hdr-enhanced="true"] :is(a, button, .button, [role="button"], input[type="submit"]):hover { + color: #ffffff !important; + text-shadow: 0 0 18px var(--hdr-cyan); + box-shadow: + 0 0 var(--hdr-glow) var(--hdr-cyan), + 0 0 var(--hdr-long-glow) color-mix(in srgb, var(--hdr-magenta), transparent 36%); +} + +body[data-hdr-enhanced="true"] :is(article, section, main, aside, header, footer, nav, .card, [class*="card"], [class*="panel"]) { + border-color: color-mix(in srgb, var(--hdr-cyan), transparent 76%) !important; +} + +body[data-hdr-enhanced="true"] ::selection { + background: var(--hdr-cyan); + color: #00050a; +} + +#${OVERLAY_ID}, +#${SCAN_ID}, +#${CANVAS_ID}, +#${VIDEO_ID}, +#${IMAGE_ID} { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 2147483000; +} + +#${VIDEO_ID}, +#${IMAGE_ID} { + width: 100vw; + height: 100vh; + z-index: 0; + object-fit: cover; + background: #000000; + opacity: 1; + mix-blend-mode: normal; +} + +:where(body[data-hdr-enhanced="true"] > :not(#${OVERLAY_ID}):not(#${SCAN_ID}):not(#${CANVAS_ID}):not(#${VIDEO_ID}):not(#${IMAGE_ID}):not(#${BADGE_ID})) { + position: relative; + z-index: 1; +} + +#${CANVAS_ID} { + width: 100vw; + height: 100vh; + z-index: 2147482999; + opacity: ${Math.min(0.84, 0.48 + intensity * 0.16).toFixed(2)}; + mix-blend-mode: screen; +} + +#${OVERLAY_ID} { + mix-blend-mode: screen; + opacity: ${Math.min(0.72, 0.34 + intensity * 0.17).toFixed(2)}; + background: + radial-gradient(circle at var(--hdr-orb-x, 20%) var(--hdr-orb-y, 20%), var(--hdr-cyan), transparent 18rem), + radial-gradient(circle at calc(100% - var(--hdr-orb-x, 20%)) 30%, var(--hdr-magenta), transparent 20rem), + linear-gradient(115deg, transparent 0%, rgba(255,255,255,0.08) 48%, transparent 58%); + filter: blur(18px) saturate(1.25); + animation: hdrAurora 8s ease-in-out infinite alternate; + animation-play-state: ${motionState}; +} + +#${SCAN_ID} { + opacity: ${Math.min(0.38, 0.1 + intensity * 0.12).toFixed(2)}; + background: + repeating-linear-gradient( + 180deg, + rgba(255,255,255,0.06) 0, + rgba(255,255,255,0.06) 1px, + transparent 1px, + transparent 7px + ); + mix-blend-mode: soft-light; + animation: hdrScan 5s linear infinite; + animation-play-state: ${motionState}; +} + +#${BADGE_ID} { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 2147483001; + border: 1px solid color-mix(in srgb, var(--hdr-cyan), transparent 30%); + border-radius: 999px; + padding: 8px 12px; + background: rgba(1, 2, 10, 0.72); + box-shadow: + 0 0 22px color-mix(in srgb, var(--hdr-cyan), transparent 30%), + inset 0 0 18px rgba(255, 255, 255, 0.08); + color: #f8feff; + font: 700 11px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; + backdrop-filter: blur(14px); +} + +#${BADGE_ID} strong { + color: var(--hdr-cyan); + text-shadow: 0 0 16px var(--hdr-cyan); +} + +@media (dynamic-range: high) { + #${OVERLAY_ID} { + opacity: ${Math.min(0.86, 0.44 + intensity * 0.2).toFixed(2)}; + filter: blur(15px) saturate(1.55) brightness(1.18); + } +} + +@media (prefers-reduced-motion: reduce) { + #${OVERLAY_ID}, + #${SCAN_ID}, + body[data-hdr-enhanced="true"] * { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +} + +@keyframes hdrAurora { + 0% { + --hdr-orb-x: 12%; + --hdr-orb-y: 22%; + transform: translate3d(-1.5%, -1%, 0) scale(1); + } + 45% { + --hdr-orb-x: 62%; + --hdr-orb-y: 14%; + transform: translate3d(1%, 0.5%, 0) scale(1.04); + } + 100% { + --hdr-orb-x: 38%; + --hdr-orb-y: 72%; + transform: translate3d(0.8%, 1.4%, 0) scale(1.08); + } +} + +@keyframes hdrScan { + from { transform: translateY(-7px); } + to { transform: translateY(7px); } +} +`.trim(); + } + + function ready(callback) { + const win = getWindow(); + if (!win || !win.document) return; + if (win.document.readyState === 'loading') { + win.document.addEventListener('DOMContentLoaded', callback, { once: true }); + return; + } + callback(); + } + + function appendNode(document, id, className, tagName) { + const existing = document.getElementById(id); + if (existing) return existing; + + const node = document.createElement(tagName || 'div'); + node.id = id; + if (className) node.className = className; + node.setAttribute('aria-hidden', 'true'); + document.body.appendChild(node); + return node; + } + + function appendHdrVideo(document, config) { + const video = appendNode(document, VIDEO_ID, 'hdr-enhancer-video', 'video'); + video.dataset.hdrMedia = 'true'; + video.dataset.hdrType = config.type; + video.dataset.hdrSource = config.src; + video.muted = true; + video.loop = true; + video.autoplay = true; + video.playsInline = true; + video.preload = 'auto'; + video.innerHTML = ''; + if (typeof video.removeAttribute === 'function') video.removeAttribute('src'); + for (const sourceConfig of config.sources) { + const source = document.createElement('source'); + source.src = sourceConfig.src; + source.type = sourceConfig.type; + source.setAttribute('src', sourceConfig.src); + source.setAttribute('type', sourceConfig.type); + video.appendChild(source); + } + video.setAttribute('muted', ''); + video.setAttribute('loop', ''); + video.setAttribute('autoplay', ''); + video.setAttribute('playsinline', ''); + video.setAttribute('preload', 'auto'); + video.setAttribute('poster', ''); + + if (typeof video.load === 'function') { + try { + video.load(); + } catch (_) { + // Older embedded browsers may defer loading until playback starts. + } + } + + if (typeof video.play === 'function') { + try { + const playback = video.play(); + if (playback && typeof playback.catch === 'function') playback.catch(() => {}); + } catch (_) { + // Autoplay can still be denied in unusual browser policies; muted video is retried by the browser. + } + } + + return video; + } + + function appendHdrImage(document, config) { + const image = appendNode(document, IMAGE_ID, 'hdr-enhancer-image', 'img'); + image.dataset.hdrMedia = 'true'; + image.dataset.hdrType = config.type; + image.dataset.hdrSource = config.src; + image.src = config.src; + image.alt = ''; + image.decoding = 'async'; + image.loading = 'eager'; + image.setAttribute('src', config.src); + image.setAttribute('alt', ''); + image.setAttribute('decoding', 'async'); + image.setAttribute('loading', 'eager'); + return image; + } + + function stopVisualEffects(win) { + const state = win && win.__hdrEnhancerFx; + if (!state) return; + if (state.frame && win.cancelAnimationFrame) { + win.cancelAnimationFrame(state.frame); + } + if (state.timer && win.clearTimeout) { + win.clearTimeout(state.timer); + } + if (state.resize && win.removeEventListener) { + win.removeEventListener('resize', state.resize); + } + if (state.visibility && win.document && win.document.removeEventListener) { + win.document.removeEventListener('visibilitychange', state.visibility); + } + delete win.__hdrEnhancerFx; + } + + function startVisualEffects(win, canvas, config) { + if (!win || !canvas || !config.enabled || typeof canvas.getContext !== 'function') return; + + stopVisualEffects(win); + + let context; + try { + context = canvas.getContext('2d', { + alpha: true, + colorSpace: config.colorSpace === 'p3' ? 'display-p3' : 'srgb' + }); + } catch (_) { + context = null; + } + const ctx = context || canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + const random = () => Math.random(); + const palette = [ + [56, 250, 255], + [255, 61, 242], + [186, 255, 55], + [255, 217, 90], + [77, 124, 255], + [255, 255, 255] + ]; + const color = (rgb, alpha) => `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; + const state = { + frame: 0, + timer: 0, + width: 0, + height: 0, + ratio: 1, + ticks: 0, + stars: [], + galaxy: [], + sparks: [], + comets: [] + }; + state.frameInterval = 1000 / config.targetFps; + state.lastRender = 0; + state.animate = config.shouldAnimate; + state.paused = false; + + function resize() { + const width = Math.max(1, win.innerWidth || canvas.clientWidth || 1280); + const height = Math.max(1, win.innerHeight || canvas.clientHeight || 720); + const ratio = Math.min(config.dprCap, Math.max(1, win.devicePixelRatio || 1)); + state.width = width; + state.height = height; + state.ratio = ratio; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.dataset.hdrQuality = config.quality; + canvas.dataset.hdrMotion = config.motion; + canvas.dataset.hdrFps = String(config.targetFps); + canvas.dataset.hdrDpr = String(ratio); + canvas.dataset.hdrStars = String(config.starCount); + canvas.dataset.hdrGalaxy = String(config.galaxyCount); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + + state.stars = Array.from({ length: config.starCount }, () => ({ + x: random() * width, + y: random() * height, + r: random() * 1.7 + 0.35, + twinkle: random() * Math.PI * 2, + rgb: palette[Math.floor(random() * palette.length)] + })); + state.galaxy = Array.from({ length: config.galaxyCount }, () => ({ + radius: random() ** 0.55 * Math.min(width, height) * 0.48, + angle: random() * Math.PI * 2, + lane: random() > 0.5 ? 1 : -1, + size: random() * 2.2 + 0.4, + speed: (random() * 0.0007 + 0.00025) * (random() > 0.5 ? 1 : -1), + rgb: palette[Math.floor(random() * (palette.length - 1))] + })); + } + + function spawnBurst(forceX, forceY) { + if (!config.fireworkBursts) return; + if (state.sparks.length > config.maxSparks * 0.72) return; + const width = state.width; + const height = state.height; + const x = forceX || width * (0.16 + random() * 0.68); + const y = forceY || height * (0.12 + random() * 0.45); + const base = palette[Math.floor(random() * palette.length)]; + const count = Math.min( + config.maxSparks - state.sparks.length, + Math.round(config.burstParticleCount * (0.82 + random() * 0.38)) + ); + for (let i = 0; i < count; i += 1) { + const angle = (Math.PI * 2 * i) / count + random() * 0.22; + const speed = (1.4 + random() * 5.4) * config.motionScale * config.intensity; + state.sparks.push({ + x, + y, + lastX: x, + lastY: y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 64 + random() * 54, + age: 0, + size: 1.1 + random() * 2.4, + rgb: random() > 0.28 ? base : palette[Math.floor(random() * palette.length)] + }); + } + } + + function spawnComet() { + if (state.comets.length >= config.maxComets) return; + const height = state.height; + state.comets.push({ + x: -80, + y: height * (0.08 + random() * 0.42), + vx: (10 + random() * 8) * config.motionScale, + vy: (2.4 + random() * 2.8) * config.motionScale, + life: 90, + age: 0, + rgb: palette[Math.floor(random() * palette.length)] + }); + } + + function drawGlowCircle(x, y, radius, rgb, alpha) { + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); + gradient.addColorStop(0, color(rgb, alpha)); + gradient.addColorStop(0.35, color(rgb, alpha * 0.32)); + gradient.addColorStop(1, color(rgb, 0)); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + function drawBloomPoint(x, y, radius, rgb, alpha, blur) { + ctx.shadowColor = color(rgb, Math.min(0.9, alpha + 0.22)); + ctx.shadowBlur = blur; + ctx.fillStyle = color(rgb, alpha); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + function drawFrame(now) { + const timestamp = typeof now === 'number' ? now : Date.now(); + const raf = win.requestAnimationFrame || ((callback) => win.setTimeout(callback, 16)); + if (state.lastRender && timestamp - state.lastRender < state.frameInterval) { + state.frame = raf(drawFrame); + return; + } + state.lastRender = timestamp; + + if (state.paused) { + state.frame = raf(drawFrame); + return; + } + + const width = state.width; + const height = state.height; + state.ticks += config.motionScale; + + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = 'rgba(0, 0, 12, 0.12)'; + ctx.fillRect(0, 0, width, height); + + ctx.globalCompositeOperation = 'lighter'; + + if (config.stars) { + for (const star of state.stars) { + const pulse = 0.42 + Math.sin(state.ticks * 0.045 + star.twinkle) * 0.36; + drawBloomPoint( + star.x, + star.y, + star.r, + star.rgb, + Math.max(0.26, pulse * 0.7), + config.shadowBlur * 0.45 + ); + } + } + + if (config.galaxy) { + const cx = width * 0.58; + const cy = height * 0.5; + for (const dust of state.galaxy) { + dust.angle += dust.speed * state.ticks * config.motionScale; + const arm = dust.angle + dust.lane * dust.radius * 0.011 + state.ticks * 0.0025; + const x = cx + Math.cos(arm) * dust.radius * 1.34; + const y = cy + Math.sin(arm) * dust.radius * 0.38; + const alpha = 0.2 + (1 - dust.radius / (Math.min(width, height) * 0.5)) * 0.46; + drawBloomPoint(x, y, dust.size, dust.rgb, alpha * 0.78, config.shadowBlur); + } + drawGlowCircle(cx, cy, Math.min(width, height) * 0.22, [255, 255, 255], 0.22 * config.intensity); + drawGlowCircle(cx, cy, Math.min(width, height) * 0.36, [255, 61, 242], 0.11 * config.intensity); + } + + if (config.fireworkBursts && state.ticks % Math.max(22, 54 - config.intensity * 15) < 1) { + spawnBurst(); + } + if (state.ticks % 130 < 1) { + spawnComet(); + } + + ctx.lineCap = 'round'; + for (let i = state.sparks.length - 1; i >= 0; i -= 1) { + const spark = state.sparks[i]; + spark.age += 1; + spark.lastX = spark.x; + spark.lastY = spark.y; + spark.x += spark.vx; + spark.y += spark.vy; + spark.vx *= 0.986; + spark.vy = spark.vy * 0.986 + 0.038 * config.motionScale; + const left = 1 - spark.age / spark.life; + if (left <= 0) { + state.sparks.splice(i, 1); + continue; + } + ctx.strokeStyle = color(spark.rgb, left * 0.72); + ctx.lineWidth = spark.size * (0.8 + left); + ctx.shadowColor = color(spark.rgb, 0.85); + ctx.shadowBlur = config.shadowBlur * 0.95; + ctx.beginPath(); + ctx.moveTo(spark.lastX, spark.lastY); + ctx.lineTo(spark.x, spark.y); + ctx.stroke(); + drawBloomPoint(spark.x, spark.y, spark.size * 1.2, spark.rgb, left * 0.66, config.shadowBlur); + } + + for (let i = state.comets.length - 1; i >= 0; i -= 1) { + const comet = state.comets[i]; + comet.age += 1; + comet.x += comet.vx; + comet.y += comet.vy; + const left = 1 - comet.age / comet.life; + if (left <= 0 || comet.x > width + 220) { + state.comets.splice(i, 1); + continue; + } + ctx.strokeStyle = color(comet.rgb, 0.64 * left); + ctx.lineWidth = 3.8; + ctx.shadowColor = color(comet.rgb, 0.95); + ctx.shadowBlur = config.shadowBlur * 1.35; + ctx.beginPath(); + ctx.moveTo(comet.x - 150, comet.y - 36); + ctx.lineTo(comet.x, comet.y); + ctx.stroke(); + drawGlowCircle(comet.x, comet.y, 18 + 18 * config.intensity, comet.rgb, 0.52 * left); + } + + ctx.shadowBlur = 0; + if (state.animate) { + state.frame = raf(drawFrame); + } else { + state.frame = 0; + } + } + + resize(); + if (config.motion !== 'static') { + spawnBurst(state.width * 0.32, state.height * 0.34); + } + state.resize = resize; + if (win.addEventListener) win.addEventListener('resize', resize); + state.visibility = () => { + state.paused = Boolean(win.document && win.document.hidden); + if (!state.paused && state.animate && !state.frame) { + state.frame = (win.requestAnimationFrame || ((callback) => win.setTimeout(callback, 16)))(drawFrame); + } + }; + if (win.document && win.document.addEventListener) { + win.document.addEventListener('visibilitychange', state.visibility); + } + win.__hdrEnhancerFx = state; + drawFrame(); + } + + function enhance(options) { + const settings = { + intensity: 1, + animated: true, + overlay: false, + scanlines: false, + badge: true, + effects: false, + effect: 'cosmic-fireworks', + media: true, + blackout: true, + text: true, + ...options + }; + const win = getWindow(settings); + const document = win && win.document; + + if (!document || !document.head || !document.body) { + return { active: false, reason: 'missing-document' }; + } + + const capabilities = detectCapabilities(win); + const mediaConfig = createMediaConfig(settings, capabilities); + let reused = true; + let style = document.getElementById(STYLE_ID); + if (!style) { + reused = false; + style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = createStyleText(settings, capabilities); + document.head.appendChild(style); + } + + document.documentElement.dataset.hdrBoosted = 'true'; + document.documentElement.style.setProperty('--hdr-runtime-gamut', capabilities.colorGamut); + document.documentElement.style.setProperty( + '--hdr-texture-url', + cssUrl(resolveAssetUrl(settings.textSrc, settings.assetBase) || mediaConfig.src) + ); + document.body.dataset.hdrEnhanced = 'true'; + document.body.dataset.hdrMode = capabilities.colorGamut; + document.body.dataset.hdrEffect = settings.effect; + document.body.dataset.hdrMedia = mediaConfig.enabled ? 'true' : 'false'; + document.body.dataset.hdrText = settings.text === false ? 'false' : 'true'; + + if (mediaConfig.enabled && mediaConfig.type === 'image') { + appendHdrImage(document, mediaConfig); + } + if (mediaConfig.enabled && mediaConfig.type === 'video') { + appendHdrVideo(document, mediaConfig); + } + if (settings.effects !== false && !mediaConfig.enabled) { + const canvas = appendNode(document, CANVAS_ID, 'hdr-enhancer-canvas', 'canvas'); + startVisualEffects(win, canvas, createEffectConfig(settings, capabilities)); + } + if (settings.overlay) { + appendNode(document, OVERLAY_ID, 'hdr-enhancer-overlay'); + } + if (settings.scanlines) { + appendNode(document, SCAN_ID, 'hdr-enhancer-scan'); + } + if (settings.badge) { + const badge = appendNode(document, BADGE_ID, 'hdr-enhancer-badge'); + const mediaLabel = mediaConfig.enabled ? 'MEDIA' : 'CAPABLE'; + badge.innerHTML = `HDR ${mediaLabel} ${capabilities.dynamicRange.toUpperCase()} / ${capabilities.colorGamut.toUpperCase()}`; + } + + return { active: true, reused, capabilities, media: mediaConfig }; + } + + function install(options) { + return enhance(options); + } + + function disable(options) { + const win = getWindow(options || {}); + const document = win && win.document; + if (!document) return { active: false, reason: 'missing-document' }; + + stopVisualEffects(win); + + [STYLE_ID, OVERLAY_ID, SCAN_ID, BADGE_ID, CANVAS_ID, VIDEO_ID, IMAGE_ID].forEach((id) => { + const node = document.getElementById(id); + if (node && node.remove) node.remove(); + }); + + if (document.body) { + document.body.removeAttribute('data-hdr-enhanced'); + document.body.removeAttribute('data-hdr-mode'); + document.body.removeAttribute('data-hdr-effect'); + document.body.removeAttribute('data-hdr-media'); + document.body.removeAttribute('data-hdr-text'); + } + if (document.documentElement) { + delete document.documentElement.dataset.hdrBoosted; + if (document.documentElement.style) { + document.documentElement.style.removeProperty('--hdr-runtime-gamut'); + document.documentElement.style.removeProperty('--hdr-texture-url'); + } + } + + return { active: false }; + } + + function toggle(options) { + const win = getWindow(options || {}); + const document = win && win.document; + if (!document || !document.body) return enhance(options); + return document.body.dataset.hdrEnhanced === 'true' ? disable({ window: win }) : enhance(options); + } + + return { + pluginName: PLUGIN_NAME, + version: VERSION, + detectCapabilities, + readScriptOptions, + shouldAutoRun, + createMediaConfig, + createEffectConfig, + createStyleText, + install, + enhance, + disable, + toggle, + ready + }; +}); diff --git a/styles/demo.css b/styles/demo.css new file mode 100644 index 0000000..252a16a --- /dev/null +++ b/styles/demo.css @@ -0,0 +1,1191 @@ +:root { + --page-bg: #000000; + --panel-bg: rgba(0, 0, 0, 0.72); + --line: rgba(122, 255, 255, 0.2); + --text: #f4fbff; + --muted: #a9bdd0; + --cyan: #38faff; + --magenta: #ff3df2; + --lime: #baff37; + --amber: #ffd95a; + --blue: #4d7cff; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +@media (color-gamut: p3) { + :root { + --cyan: color(display-p3 0.08 0.98 1); + --magenta: color(display-p3 1 0.12 0.92); + --lime: color(display-p3 0.72 1 0.1); + --amber: color(display-p3 1 0.82 0.22); + --blue: color(display-p3 0.22 0.42 1); + } +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: var(--page-bg); + color-scheme: dark; +} + +body { + min-height: 100vh; + margin: 0; + background: #000000; + color: var(--text); + overflow-x: hidden; +} + +body::before { + content: none; +} + +a { + color: inherit; +} + +.site-shell { + position: relative; + z-index: 1; + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; + padding: 28px 0 56px; +} + +.landing-shell { + width: min(1160px, calc(100% - 36px)); +} + +.product-nav { + position: fixed; + left: 50%; + top: 0; + z-index: 20; + width: min(1220px, calc(100% - 32px)); + transform: translateX(-50%); + padding: 18px 0; +} + +.product-nav::before { + content: ""; + position: absolute; + inset: 8px -12px; + z-index: -1; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.34); + backdrop-filter: blur(18px); +} + +.product-page { + position: relative; + z-index: 1; +} + +.product-hero { + min-height: 88vh; + display: grid; + place-items: center; + padding: 112px 20px 70px; + text-align: center; +} + +.product-hero-copy { + width: min(940px, 100%); + margin: 0 auto; +} + +.product-kicker { + margin: 0 0 18px; + color: rgba(255, 255, 255, 0.88); + font-size: 18px; + font-weight: 700; +} + +.product-hero h1 { + margin: 0 auto 22px; + color: #ffffff; + font-size: 116px; + line-height: 0.88; + letter-spacing: 0; + text-transform: none; + text-shadow: + 0 0 2px rgba(255, 255, 255, 0.82), + 0 0 44px rgba(56, 250, 255, 0.42), + 0 0 120px rgba(255, 61, 242, 0.26); +} + +.product-wordmark-title { + width: min(1040px, 100%); + margin: 0 auto 20px !important; + background: none !important; + color: transparent !important; + filter: none !important; + font-size: 0 !important; + line-height: 0 !important; + text-shadow: none !important; +} + +.hdr-wordmark { + display: block; + width: 100%; + height: auto; + border-radius: 0; + background: transparent; + filter: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@supports (dynamic-range-limit: no-limit) { + .hdr-wordmark { + dynamic-range-limit: no-limit; + } +} + +.product-hero p { + width: min(760px, 100%); + margin: 0 auto; + color: rgba(244, 251, 255, 0.82); + font-size: 22px; + line-height: 1.5; +} + +.product-page .hero-actions { + justify-content: center; + margin-top: 34px; +} + +.product-page .primary-action, +.product-page .secondary-action, +.product-page .control-button, +.product-page .copy-button, +.product-nav .control-button { + min-height: 44px; + border-radius: 999px; + padding: 0 18px; + font-weight: 750; +} + +.product-page .primary-action { + border-color: rgba(255, 255, 255, 0.86); + background: rgba(255, 255, 255, 0.92); + color: #000000; + box-shadow: 0 0 38px rgba(255, 255, 255, 0.22); +} + +.product-page .secondary-action { + border-color: rgba(255, 255, 255, 0.34); + background: rgba(0, 0, 0, 0.32); + color: #ffffff; +} + +.scroll-cue { + position: absolute; + left: 50%; + bottom: 28px; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.76); + font-size: 13px; + font-weight: 700; + text-decoration: none; +} + +.scroll-cue::after { + content: ""; + display: block; + width: 1px; + height: 34px; + margin: 12px auto 0; + background: linear-gradient(180deg, rgba(255,255,255,0.85), transparent); +} + +.product-section { + min-height: auto; + display: grid; + align-content: center; + width: min(1160px, calc(100% - 36px)); + margin: 0 auto; + padding: 78px 0 104px; +} + +.unified-section { + padding-top: 34px; +} + +.centered-heading { + margin-right: auto; + margin-left: auto; + text-align: center; +} + +.product-section .section-heading h2, +.asset-copy h2 { + margin-bottom: 18px; + color: #ffffff; + font-size: 64px; + line-height: 0.95; + letter-spacing: 0; + text-shadow: + 0 0 28px rgba(56, 250, 255, 0.36), + 0 0 90px rgba(255, 61, 242, 0.18); +} + +.product-section .section-heading p, +.asset-copy p { + color: rgba(244, 251, 255, 0.76); + font-size: 19px; + line-height: 1.58; +} + +.setup-flow { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin: 42px 0 20px; +} + +.single-page-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; + margin-top: 34px; +} + +.landing-panel { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 24px; + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(18px); +} + +.landing-panel.wide { + grid-column: 1 / -1; +} + +.panel-heading { + max-width: 760px; +} + +.panel-heading h3 { + margin: 0 0 12px; + color: #ffffff; + font-size: 34px; + line-height: 1.02; + letter-spacing: 0; +} + +.panel-heading p { + color: rgba(244, 251, 255, 0.72); + font-size: 17px; + line-height: 1.55; +} + +.setup-flow.compact { + margin: 18px 0 0; +} + +.setup-flow.compact .setup-step { + min-height: 156px; + padding: 18px; +} + +.setup-flow.compact .setup-step span, +.spec-grid.compact .spec-item span { + margin-bottom: 24px; +} + +.setup-flow.compact .setup-step h3 { + font-size: 22px; +} + +.setup-step, +.asset-option, +.spec-item { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(0, 0, 0, 0.54); + backdrop-filter: blur(18px); +} + +.setup-step { + min-height: 220px; + padding: 22px; +} + +.setup-step span, +.spec-item span { + display: block; + margin-bottom: 52px; + color: rgba(255, 255, 255, 0.46); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.setup-step h3, +.asset-option h3 { + margin-bottom: 12px; + color: #ffffff; + font-size: 26px; + line-height: 1.05; +} + +.setup-step p, +.asset-option p, +.spec-item p { + color: rgba(244, 251, 255, 0.68); + line-height: 1.55; +} + +.product-section .code-box { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(0, 0, 0, 0.72); + box-shadow: 0 40px 120px rgba(0, 0, 0, 0.5); +} + +.asset-showcase { + grid-template-columns: minmax(0, 0.82fr) minmax(420px, 1.18fr); + align-items: center; + gap: 42px; +} + +.asset-copy { + max-width: 520px; +} + +.asset-options { + display: grid; + gap: 14px; +} + +.asset-options.compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 18px; +} + +.asset-option { + padding: 24px; +} + +.asset-option pre { + margin: 18px 0 0; + padding: 14px; + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(0, 0, 0, 0.66); + color: #d8fdff; + font-size: 13px; + line-height: 1.55; +} + +.demo-card { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 24px; + background: + linear-gradient(135deg, rgba(56, 250, 255, 0.12), transparent 40%), + linear-gradient(315deg, rgba(255, 61, 242, 0.14), transparent 46%), + rgba(0, 0, 0, 0.58); +} + +.demo-link { + display: grid; + gap: 6px; + min-height: 138px; + align-content: end; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + padding: 18px; + color: #ffffff; + font-size: 24px; + font-weight: 900; + line-height: 1; + text-decoration: none; + background: + radial-gradient(circle at 22% 28%, rgba(255, 255, 255, 0.72), transparent 9%), + radial-gradient(circle at 78% 36%, rgba(56, 250, 255, 0.86), transparent 10%), + radial-gradient(circle at 58% 76%, rgba(255, 61, 242, 0.72), transparent 13%), + #000000; + box-shadow: + inset 0 0 44px rgba(56, 250, 255, 0.18), + 0 0 54px rgba(255, 61, 242, 0.16); +} + +.demo-link span { + color: rgba(244, 251, 255, 0.68); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.product-page pre, +.product-page code { + overflow-wrap: anywhere; +} + +.product-page pre { + white-space: pre-wrap; +} + +.api-section { + grid-template-columns: minmax(0, 0.9fr) minmax(420px, 1.1fr); + align-items: center; + gap: 44px; +} + +.spec-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} + +.spec-grid.compact { + margin-top: 18px; +} + +.spec-item { + min-height: 230px; + padding: 22px; +} + +.spec-item strong { + display: block; + margin-bottom: 12px; + color: #ffffff; + font-size: 24px; + line-height: 1.05; +} + +.product-footer { + position: relative; + z-index: 1; + width: min(1160px, calc(100% - 36px)); + margin: 0 auto; + text-align: center; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 0; +} + +.topbar.product-nav { + position: fixed; + left: 50%; + top: 0; + z-index: 20; + width: min(1220px, calc(100% - 32px)); + transform: translateX(-50%); + padding: 18px 0; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: #fff; + font-weight: 800; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.brand-mark { + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--cyan), transparent 18%); + border-radius: 8px; + background: + linear-gradient(135deg, transparent 8%, rgba(255,255,255,0.9) 8% 12%, transparent 12% 38%, rgba(255,255,255,0.9) 38% 43%, transparent 43%), + radial-gradient(circle at 28% 22%, var(--cyan), transparent 46%), + radial-gradient(circle at 78% 78%, var(--magenta), transparent 42%); + box-shadow: 0 0 28px color-mix(in srgb, var(--cyan), transparent 30%); +} + +.nav-links { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.nav-links a, +.control-button, +.copy-button { + border: 1px solid rgba(122, 255, 255, 0.28); + border-radius: 999px; + padding: 9px 13px; + background: rgba(5, 9, 24, 0.72); + color: #f7feff; + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; + box-shadow: 0 0 22px rgba(56, 250, 255, 0.12); + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; +} + +.nav-links a:hover, +.control-button:hover, +.copy-button:hover { + transform: translateY(-1px); + border-color: var(--cyan); + box-shadow: 0 0 32px rgba(56, 250, 255, 0.36); +} + +.hero { + min-height: calc(100vh - 92px); + display: grid; + grid-template-columns: minmax(0, 1.04fr) minmax(320px, 0.96fr); + align-items: center; + gap: 38px; + padding: 24px 0 42px; +} + +.landing-hero { + min-height: calc(100vh - 92px); + display: flex; + align-items: center; + padding: 44px 0 90px; +} + +.hero-copy { + max-width: 680px; +} + +.landing-copy { + max-width: 760px; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + color: var(--cyan); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + text-shadow: 0 0 18px var(--cyan); +} + +.eyebrow::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--lime); + box-shadow: 0 0 18px var(--lime), 0 0 38px var(--lime); +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 22px; + font-size: clamp(54px, 8vw, 112px); + line-height: 0.88; + letter-spacing: 0; + text-transform: uppercase; + text-shadow: + 0 0 1px #fff, + 0 0 26px rgba(56, 250, 255, 0.72), + 0 0 82px rgba(255, 61, 242, 0.46); +} + +.landing-copy h1 { + max-width: 860px; + margin-bottom: 24px; + font-size: clamp(66px, 11vw, 150px); + line-height: 0.82; +} + +.hero-copy p { + max-width: 620px; + color: var(--muted); + font-size: 20px; + line-height: 1.62; +} + +.landing-copy p { + max-width: 700px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 28px; +} + +.primary-action, +.secondary-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + border: 1px solid color-mix(in srgb, var(--cyan), transparent 20%); + border-radius: 999px; + padding: 0 18px; + color: #fff; + font-weight: 800; + text-decoration: none; +} + +.primary-action { + background: linear-gradient(90deg, var(--cyan), var(--blue), var(--magenta)); + color: #02040b; + box-shadow: 0 0 36px rgba(56, 250, 255, 0.46), 0 0 80px rgba(255, 61, 242, 0.28); +} + +.secondary-action { + background: rgba(4, 8, 24, 0.78); +} + +.demo-stage { + min-height: 540px; + position: relative; + border: 1px solid rgba(122, 255, 255, 0.22); + border-radius: 8px; + overflow: hidden; + background: #000000; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.04), + 0 28px 90px rgba(0, 0, 0, 0.48), + 0 0 90px rgba(56, 250, 255, 0.16); +} + +.demo-stage::before, +.demo-stage::after { + content: ""; + position: absolute; + inset: auto; + border-radius: 999px; + filter: blur(14px); +} + +.demo-stage::before { + width: 230px; + height: 230px; + left: 14%; + top: 16%; + background: var(--cyan); + opacity: 0.28; + animation: orbFloatA 5s ease-in-out infinite alternate; +} + +.demo-stage::after { + width: 280px; + height: 280px; + right: 10%; + bottom: 12%; + background: var(--magenta); + opacity: 0.24; + animation: orbFloatB 6s ease-in-out infinite alternate; +} + +.stage-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(122, 255, 255, 0.13) 1px, transparent 1px), + linear-gradient(90deg, rgba(122, 255, 255, 0.13) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: radial-gradient(circle at 50% 52%, #000 0 50%, transparent 78%); + opacity: 0.72; +} + +.neon-ring { + position: absolute; + left: 50%; + top: 50%; + width: min(70%, 360px); + aspect-ratio: 1; + border: 2px solid var(--cyan); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: + 0 0 26px var(--cyan), + inset 0 0 34px rgba(56, 250, 255, 0.28), + 0 0 98px rgba(255, 61, 242, 0.46); + animation: ringPulse 2.4s ease-in-out infinite; +} + +.neon-ring:nth-child(3) { + width: min(52%, 270px); + border-color: var(--magenta); + box-shadow: + 0 0 26px var(--magenta), + inset 0 0 34px rgba(255, 61, 242, 0.28), + 0 0 98px rgba(56, 250, 255, 0.34); + animation-delay: -0.9s; +} + +.neon-word { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-size: clamp(52px, 8vw, 92px); + font-weight: 900; + line-height: 0.9; + text-align: center; + text-shadow: + 0 0 2px #fff, + 0 0 26px var(--cyan), + 0 0 88px var(--magenta); + text-transform: uppercase; +} + +.panel-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-top: 34px; +} + +.landing-section { + padding: 74px 0 8px; +} + +.section-heading { + max-width: 760px; + margin-bottom: 22px; +} + +.section-heading h2 { + margin-bottom: 14px; + color: #ffffff; + font-size: clamp(32px, 4vw, 56px); + line-height: 0.96; + letter-spacing: 0; + text-shadow: + 0 0 18px rgba(56, 250, 255, 0.48), + 0 0 54px rgba(255, 61, 242, 0.26); +} + +.section-heading p { + max-width: 680px; + color: var(--muted); + font-size: 18px; + line-height: 1.58; +} + +.info-panel { + min-height: 154px; + border: 1px solid rgba(122, 255, 255, 0.22); + border-radius: 8px; + padding: 18px; + background: var(--panel-bg); + box-shadow: 0 0 40px rgba(56, 250, 255, 0.08); +} + +.info-panel pre { + margin: 14px 0 0; + padding: 12px; + overflow: auto; + border: 1px solid rgba(122, 255, 255, 0.16); + border-radius: 8px; + background: rgba(0, 0, 0, 0.72); + color: #d8fdff; + font-size: 12px; + line-height: 1.5; +} + +.info-panel h2, +.info-panel h3 { + margin-bottom: 10px; + font-size: 22px; +} + +.info-panel p, +.info-panel li { + color: var(--muted); + line-height: 1.55; +} + +.code-box { + position: relative; + margin: 28px 0 0; + border: 1px solid rgba(122, 255, 255, 0.2); + border-radius: 8px; + background: rgba(1, 2, 10, 0.9); + overflow: hidden; +} + +.install-box { + margin-top: 18px; +} + +.code-box pre { + margin: 0; + padding: 18px; + overflow: auto; + color: #d7fbff; + font-size: 14px; + line-height: 1.55; +} + +.copy-button { + position: absolute; + right: 10px; + top: 10px; + padding: 7px 10px; +} + +.split-demo { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; + margin-top: 28px; +} + +.sample-page { + border: 1px solid rgba(122, 255, 255, 0.18); + border-radius: 8px; + padding: 22px; + min-height: 360px; + background: #000000; +} + +.sample-page.plain { + background: #f6f6f6; + color: #171717; +} + +.sample-page.plain p { + color: #555; +} + +.sample-page.enhanced { + background: #000000; +} + +.meter { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.meter span { + display: block; + height: 10px; + border-radius: 999px; + background: linear-gradient(90deg, var(--cyan), var(--magenta), var(--lime)); + box-shadow: 0 0 22px rgba(56, 250, 255, 0.38); +} + +.demo-footer { + padding: 52px 0 12px; + color: var(--muted); + font-size: 14px; +} + +.fireworks-page, +.starfield-page { + background: #000000; +} + +.immersive-fireworks-page, +.immersive-starfield-page { + width: 100%; + min-height: 100vh; + overflow: hidden; +} + +.fireworks-immersive, +.starfield-immersive { + position: fixed; + inset: 0; + overflow: hidden; + background: #000000; +} + +.immersive-webgl { + position: absolute; + inset: 0; + z-index: 2; +} + +.webgl-stage canvas { + display: block; + width: 100%; + height: 100%; +} + +.starfield-asset { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 48%; + dynamic-range-limit: no-limit; +} + +.immersive-fireworks-vignette, +.starfield-vignette { + pointer-events: none; + position: absolute; + inset: 0; + z-index: 3; + background: + radial-gradient(circle at 52% 45%, transparent 0 54%, rgba(0, 0, 0, 0.26) 78%, rgba(0, 0, 0, 0.68) 100%), + linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.38)); +} + +.starfield-credit { + position: fixed; + left: 18px; + bottom: 19px; + z-index: 8; + max-width: min(520px, calc(100vw - 180px)); + margin: 0; + color: rgba(235, 245, 255, 0.56); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + text-shadow: 0 0 16px rgba(0, 0, 0, 0.8); +} + +.immersive-controls { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 8; + display: flex; + gap: 8px; +} + +.immersive-controls a, +.immersive-controls button { + min-width: 46px; + min-height: 36px; + border: 1px solid rgba(122, 255, 255, 0.34); + border-radius: 999px; + padding: 0 13px; + background: rgba(0, 0, 0, 0.42); + color: rgba(244, 251, 255, 0.84); + font: inherit; + font-size: 12px; + font-weight: 800; + text-decoration: none; + backdrop-filter: blur(14px); + box-shadow: 0 0 20px rgba(56, 250, 255, 0.2); + cursor: pointer; +} + +@keyframes orbFloatA { + to { transform: translate(180px, 230px) scale(1.22); } +} + +@keyframes orbFloatB { + to { transform: translate(-160px, -210px) scale(0.88); } +} + +@keyframes ringPulse { + 50% { + transform: translate(-50%, -50%) scale(1.08); + opacity: 0.72; + } +} + +@media (max-width: 860px) { + .site-shell { + width: min(100% - 28px, 1180px); + } + + .topbar, + .hero { + grid-template-columns: 1fr; + } + + .hero { + min-height: auto; + } + + .panel-grid, + .split-demo { + grid-template-columns: 1fr; + } + + .demo-stage { + min-height: 410px; + } + + .product-nav { + width: min(100% - 22px, 1220px); + align-items: flex-start; + padding-top: 12px; + } + + .product-nav .brand { + min-height: 38px; + } + + .product-nav .nav-links { + gap: 6px; + } + + .product-nav .nav-links a { + display: none; + } + + .product-hero { + min-height: 88vh; + padding: 108px 18px 72px; + } + + .product-hero h1 { + max-width: 360px; + font-size: 62px; + line-height: 0.9; + } + + .product-hero p { + font-size: 17px; + } + + .product-page .hero-actions { + align-items: stretch; + flex-direction: column; + width: min(100%, 320px); + margin-right: auto; + margin-left: auto; + } + + .product-section { + min-height: auto; + width: min(100% - 28px, 1160px); + padding: 54px 0 74px; + } + + .product-section .section-heading h2, + .asset-copy h2 { + font-size: 42px; + } + + .setup-flow, + .setup-flow.compact, + .single-page-grid, + .asset-options.compact, + .asset-showcase, + .api-section, + .spec-grid { + grid-template-columns: 1fr; + } + + .landing-panel.wide { + grid-column: auto; + } + + .landing-panel { + padding: 18px; + } + + .panel-heading h3 { + font-size: 28px; + } + + .setup-step, + .asset-option, + .spec-item { + min-height: auto; + } + + .setup-step span, + .spec-item span { + margin-bottom: 28px; + } + + .asset-showcase, + .api-section { + gap: 22px; + } + + .product-footer { + width: min(100% - 28px, 1160px); + } + + .immersive-controls { + right: 12px; + bottom: 12px; + } + + .immersive-controls a, + .immersive-controls button { + min-height: 34px; + padding: 0 11px; + font-size: 11px; + } + + .starfield-credit { + left: 12px; + bottom: 55px; + max-width: calc(100vw - 24px); + font-size: 10px; + } +} + +@media (min-width: 861px) and (max-width: 1080px) { + .product-hero h1 { + font-size: 90px; + } + + .setup-flow, + .setup-flow.compact, + .spec-grid { + grid-template-columns: repeat(2, 1fr); + } + + .single-page-grid, + .asset-options.compact { + grid-template-columns: 1fr; + } + + .asset-showcase, + .api-section { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +} diff --git a/test/hdr-enhance.test.mjs b/test/hdr-enhance.test.mjs new file mode 100644 index 0000000..0871d67 --- /dev/null +++ b/test/hdr-enhance.test.mjs @@ -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); +}); diff --git a/test/landing-page.test.mjs b/test/landing-page.test.mjs new file mode 100644 index 0000000..e01264f --- /dev/null +++ b/test/landing-page.test.mjs @@ -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, /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/); +});