snapshot: `<video>` frames come out ~4% darker than the render on Chrome 152 (snapshot's frame PNGs keep the BT.709 cICP tag)
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 75/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- html, node.js, typescript
- Área
- cli, computer-graphics
Línea de trabajo
La extracción de fotogramas vive en packages/cli/src/commands/snapshot.ts (extractVideoFrameToBuffer); compárala con packages/engine/src/services/videoFrameExtractor.ts, que usa SDR_CANVAS_PASSTHROUGH_FILTER. Ejecuta repro.sh para reproducir la diferencia de luma y luego añade el mismo filtro de color de ffmpeg al export PNG de snapshot. Listo cuando measure.py muestre ~0.4% de diferencia de luma en stills de <video> en Chrome 152.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Summary
hyperframes snapshot shows footage darker than hyperframes render does at the same time. On the managed Chrome 152 headless shell, the video area of every still is 3.8% darker in mean luma than the rendered frame. Shadows take the biggest hit: a channel value of 16 in the render comes out as about 4 in the still. CSS colours are identical in both outputs. Only the <video> pixels differ.
This still happens on the latest release, 0.8.189.
It looks like the snapshot-side counterpart of #4549. That fix retags the render extractor's frames as sRGB. Snapshot extracts its own frames, which still carry the source's BT.709 transfer tag, and Chrome 152 colour-manages that tag.
Environment
- macOS 27.0.1 (26A434), arm64 (Apple Silicon)
- Node v22.23.2, ffmpeg 9.0.1
- hyperframes 0.8.177, 0.8.178, 0.8.186, 0.8.189
- chrome-headless-shell 152.0.7977.30 (the managed one in
~/.cache/hyperframes/chrome) and 131.0.6778.204 (an older one in~/.cache/puppeteer) - Env for every run:
HYPERFRAMES_NO_UPDATE_CHECK=1 HYPERFRAMES_NO_AUTO_INSTALL=1 HYPERFRAMES_SKIP_SKILLS=1
Repro
The repro uses a 1080x1920 composition with two flat CSS fields, a generated 16:9 testsrc2 clip with object-fit: cover (starting at 0.5 s), and a bordered box.
assets/testsrc2.mp4, BT.709 limited range with every tag set:
ffmpeg -f lavfi -i testsrc2=size=1280x720:rate=30:duration=3 -c:v libx264 -pix_fmt yuv420p \
-x264-params colorprim=bt709:transfer=bt709:colormatrix=bt709:range=tv assets/testsrc2.mp4
index.html (lint: 0 errors, 0 warnings):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1080, height=1920" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
body { margin: 0; background: #000; }
#root { position: relative; width: 100%; height: 100%; overflow: hidden; background: #000; }
#grey { position: absolute; left: 0; top: 0; width: 540px; height: 480px; background: #808080; }
#blue { position: absolute; left: 540px; top: 0; width: 540px; height: 480px; background: #3070c0; }
#clip { position: absolute; left: 0; top: 480px; width: 1080px; height: 1440px; object-fit: cover; }
#box { position: absolute; left: 340px; top: 1500px; width: 400px; height: 300px; box-sizing: border-box; border: 12px solid #ffffff; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-width="1080" data-height="1920" data-duration="3">
<div id="grey"></div>
<div id="blue"></div>
<video id="clip" src="assets/testsrc2.mp4" muted playsinline data-start="0.5" data-duration="2" data-track-index="0"></video>
<div id="box" class="clip" data-start="1" data-duration="1.5" data-track-index="1"></div>
</div>
<script>
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
Commands:
npx --yes [email protected] render project --format png-sequence -o out/render
npx --yes [email protected] snapshot project --at 0.2,0.467,0.5,0.55,1,1.55,2,2.05 --describe false --no-end -o out/stills
python3 measure.py out/render out/stills
Each still is compared with the rendered frame showing at the same instant (frame_{floor(t*30)+1}). The script reports:
- whole-frame mean absolute difference (0-255)
- mean BT.709 luma of the video area, as a percentage difference
- which neighbouring render frame (±3) matches best
- the translation of the video area, from phase correlation
repro.sh (under Repro files, below) runs the steps above for several versions.
measure.py
import re, sys
from pathlib import Path
import numpy as np
from PIL import Image
VIDEO = (slice(480, 1920), slice(0, 1080)) # the <video> box: rows, cols
def load(p): return np.asarray(Image.open(p).convert("RGB"), np.float64)
def luma(a): return a @ np.array([0.2126, 0.7152, 0.0722])
def shift(a, b):
fa, fb = np.fft.fft2(luma(a)), np.fft.fft2(luma(b))
r = fa * np.conj(fb)
c = np.abs(np.fft.ifft2(r / (np.abs(r) + 1e-9)))
dy, dx = np.unravel_index(c.argmax(), c.shape)
h, w = c.shape
return int(dx - w if dx > w // 2 else dx), int(dy - h if dy > h // 2 else dy)
rdir, sdir, fps = Path(sys.argv[1]), Path(sys.argv[2]), 30.0
frames = {int(p.stem.split("_")[1]): p for p in rdir.glob("frame_*.png")}
for p in sorted(sdir.glob("frame-*-at-*.png")):
t = float(re.search(r"at-([\d.]+)s", p.name).group(1))
k = int(t * fps + 1e-6) + 1 # frame_000001 is t = 0
s = load(p); sv = s[VIDEO]
cands = {d: load(frames[k + d]) for d in range(-3, 4) if k + d in frames}
mads = {d: np.abs(sv - r[VIDEO]).mean() for d, r in cands.items()}
best = min(mads, key=lambda d: (round(mads[d], 3), abs(d)))
r = cands[0]; yr, ys = luma(r[VIDEO]).mean(), luma(sv).mean()
print(p.name, f"mad {np.abs(s - r).mean():.2f}", f"dY {100*(ys-yr)/max(yr,1e-9):+.2f}%", f"best {best:+d}", "shift", shift(sv, cands[best][VIDEO]))
Expected
With the video showing (0.5 to 2.5 s), each still's video area should match the rendered frame at the same time, as it did on Chrome 131: about 1 mean absolute difference and 0.4% luma.
Actual
All renders are on Chrome 152. Renders from 0.8.177, 0.8.178 and 0.8.189 are bit-identical: 90 frames, maximum pixel difference 0.
| snapshot version | Chrome used by snapshot (hyperframes browser path) |
still vs render, video area, t = 0.5 to 2.05 s |
|---|---|---|
| 0.8.177 | 131.0.6778.204 (~/.cache/puppeteer) |
mad 0.96-0.98, luma -0.40 to -0.41% |
| 0.8.178 | 152.0.7977.30 (~/.cache/hyperframes) |
mad 2.46-2.50, luma -3.77 to -3.82% |
| 0.8.186 | 152.0.7977.30 | mad 2.46-2.50, luma -3.77 to -3.82% |
| 0.8.189 (latest) | 152.0.7977.30 | mad 2.46-2.50, luma -3.77 to -3.82% |
- No video on screen (t = 0.2 s and 0.467 s): the stills match the render exactly in every version (mad 0.00). The
#808080and#3070c0fields are identical in every still. - Timing and position: in every case the best-matching render frame is the expected one, and the translation is (0, 0).
- Tone change at t = 1 s, 0.8.189 (mean still value over pixels where the render channel is v): 16 → 4.0, 32 → 15.1, 64 → 50.6, 128 → 121.2, 192 → 189.4, 255 → 255.0. On 0.8.177 / Chrome 131: 16 → 14.8, 64 → 60.3, 128 → 128.1, 192 → 192.8.
Bisect
The bad output first appears in 0.8.178 (0.8.177 is good), but in this setup it follows the Chrome build, not the HyperFrames version.
-
snapshot.tsand the composition-page code are the same in 0.8.177 and 0.8.178 (dist diff, ignoring chunk hashes). -
What 0.8.178 changed is the browser lookup order in
src/browser/manager.tsfindFromCache(). 0.8.177 used a chrome-headless-shell found in~/.cache/puppeteerbefore its own~/.cache/hyperframesone; 0.8.178 checks its own first. On this machine that moves snapshot from Chrome 131 to 152.renderalready used 152 in both. -
Swapping the browser with
HYPERFRAMES_BROWSER_PATHmoves the darkening with it:- 0.8.177 on Chrome 152: -3.77 to -3.82% (bad)
- 0.8.178 on Chrome 131: -0.40 to -0.41% (good)
A machine without an old puppeteer cache probably shows this on 0.8.177 too.
Likely cause (a guess: not confirmed by patching HyperFrames)
extractVideoFrameToBuffer in packages/cli/src/commands/snapshot.ts runs ffmpeg to PNG with no colour filter. ffmpeg copies the source's tags into the PNG as cICP 01/01/00/01 (BT.709 transfer), plus gAMA.
The render extractor (packages/engine/src/services/videoFrameExtractor.ts) appends SDR_CANVAS_PASSTHROUGH_FILTER (setparams=color_primaries=bt709:color_trc=iec61966-2-1). Its PNGs are tagged sRGB, so Chrome leaves them alone. That is the #4549 fix, and snapshot doesn't seem to share it.
Chrome-only check (chrome-check/, under Repro files, below):
- Same frame, same decoded pixels, two tags: ffmpeg's default (
cICP 01/01/00/01) and the render filter (sRGB). - Both shown as
<img>and screenshotted with--force-color-profile=srgb:
| Chrome | cICP BT.709 half | sRGB half | difference |
|---|---|---|---|
| 131.0.6778.204 | 127.79 | 127.79 | +0.00% |
| 152.0.7977.30 | 123.03 | 127.79 | -3.72% |
So Chrome 131 ignored the cICP chunk and Chrome 152 applies it. That matches the -3.8% seen in snapshot.
A possible fix is to give snapshot's frame extraction the same SDR colour handling as the render extractor, or to have snapshot call the engine's extractor. Either way the two paths can't drift apart again.
Repro files
project/index.html and measure.py are inline above; project/assets/testsrc2.mp4 comes from the ffmpeg command above.
repro.sh
#!/bin/sh
# Render the project once, snapshot it with each version, compare every still with the rendered frame.
# ./repro.sh [out_dir] [snapshot versions...] (needs ffmpeg, python3 with numpy + Pillow)
set -e
here=$(cd "$(dirname "$0")" && pwd)
out=${1:-$here/out}; [ $# -gt 0 ] && shift
versions=${*:-0.8.177 0.8.178 0.8.189}
render_version=0.8.189
times=0.2,0.467,0.5,0.55,1,1.55,2,2.05
export HYPERFRAMES_NO_UPDATE_CHECK=1 HYPERFRAMES_NO_AUTO_INSTALL=1 HYPERFRAMES_SKIP_SKILLS=1
clip=$here/project/assets/testsrc2.mp4
[ -f "$clip" ] || ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc2=size=1280x720:rate=30:duration=3 \
-c:v libx264 -pix_fmt yuv420p -x264-params colorprim=bt709:transfer=bt709:colormatrix=bt709:range=tv "$clip"
mkdir -p "$out"
[ -d "$out/render" ] || npx --yes hyperframes@$render_version render "$here/project" --format png-sequence -o "$out/render" >"$out/render.log" 2>&1
for v in $versions; do
npx --yes hyperframes@$v snapshot "$here/project" --at $times --describe false --no-end -o "$out/stills-$v" >"$out/snapshot-$v.log" 2>&1
echo "== snapshot $v vs render $render_version, browser: $(npx --yes hyperframes@$v browser path 2>/dev/null | tail -1)"
python3 "$here/measure.py" "$out/render" "$out/stills-$v"
done
chrome-check/check.sh
#!/bin/sh
# ./check.sh <chrome-headless-shell>... (needs ffmpeg, node with puppeteer-core resolvable here, python3 + numpy + Pillow)
set -e
cd "$(dirname "$0")"
clip=../project/assets/testsrc2.mp4
# what snapshot's extractVideoFrameToBuffer writes: ffmpeg copies the source tags into cICP 1/1/0/1 (BT.709 transfer)
ffmpeg -hide_banner -loglevel error -ss 0.5 -i $clip -frames:v 1 -y snapshot-style.png
# what render's extractor writes: SDR_CANVAS_PASSTHROUGH_FILTER retags the transfer as sRGB
ffmpeg -hide_banner -loglevel error -ss 0.5 -i $clip -vf setparams=color_primaries=bt709:color_trc=iec61966-2-1 -frames:v 1 -y render-style.png
for exe in "$@"; do
v=$(node shot.mjs "$exe" shot.png)
python3 -c "
import numpy as np; from PIL import Image
y = np.asarray(Image.open('shot.png').convert('RGB'), float) @ [0.2126, 0.7152, 0.0722]
a, b = y[:360].mean(), y[360:].mean()
print(f'$v: snapshot-style (cICP BT.709) {a:.2f}, render-style (sRGB) {b:.2f}, diff {100*(a-b)/b:+.2f}%')"
done
chrome-check/shot.mjs
// Screenshot page.html (the same PNG pixels twice, with two different colour tags) in the given Chrome.
import puppeteer from "puppeteer-core";
const [exe, out] = process.argv.slice(2);
const b = await puppeteer.launch({ executablePath: exe, headless: true, args: ["--force-color-profile=srgb"] });
const p = await b.newPage();
await p.setViewport({ width: 640, height: 720 });
await p.goto(new URL("page.html", import.meta.url).href, { waitUntil: "load" });
await p.screenshot({ path: out });
console.log(await b.version());
await b.close();
chrome-check/page.html
<!doctype html><html><body style="margin:0;background:#000"><img src="snapshot-style.png" style="display:block;width:640px;height:360px"><img src="render-style.png" style="display:block;width:640px;height:360px"></body></html>
- Lenguaje dominante
- TypeScript
- Estrellas
- 54.1k
- Forks
- 4.9k
- Merge medio
- 1 d 16 h
- PR fusionados (30 d)
- 725
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de heygen-com/hyperframes
-
format.md:66 host-id rule is ambiguous for multi-mount sub-compositions; scoped selector silently yields static framesPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
heygen-com/hyperframes#6139 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 Medio día Aptitud para principiantes 62/100
heygen-com/hyperframes#6138 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
docs: clip window is inclusive in data-attributes.md but [start, start+duration) in router Contract 1Posiblemente ocupada @Mittalnew la tomó hace 2 días. Abierto
Dificultad 1/5 Menos de una hora Aptitud para principiantes 85/100
heygen-com/hyperframes#6136 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
inspector: build.mjs resolves paths with URL#pathname, fails ENOENT (F:\\F:\\...) and aborts bun run build on WindowsPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abiertotriage/ready
Dificultad 1/5 Menos de una hora Aptitud para principiantes 88/100
heygen-com/hyperframes#5863 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
Timeline: show a clip's full name when its label is cut offPosiblemente ocupada @DanielMcSheehy la tomó hace 7 días. Abiertodesktop-candidate difficulty/easy enhancement good first issue help wanted triage/ready
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
heygen-com/hyperframes#5582 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
Todos los issues de heygen-com/hyperframes
Issues similares
-
type: bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 79/100
Los mantenedores suelen responder en 1 día
-
0️⃣ phase/new
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 70/100
microsoft/playwright#43640 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100