chore: import upstream snapshot with attribution
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regenerate the sRGB → BT.2020 (HLG/PQ) LUT reference values pinned by
|
||||
packages/engine/src/utils/alphaBlit.test.ts.
|
||||
|
||||
This is a paste-helper for the *very rare* case the LUT genuinely needs to
|
||||
shift — e.g. a spec update changes one of the OETF constants, or we change
|
||||
the SDR-white reference level in the PQ branch. The reference values in
|
||||
alphaBlit.test.ts are byte-exact integers, and updating ~12 hand-edited
|
||||
literals (or all 256 of them, if the test grows) is exactly the kind of
|
||||
mechanical churn we want to keep out of the diff.
|
||||
|
||||
Usage:
|
||||
# Regenerate the probe table that lives in alphaBlit.test.ts (paste over
|
||||
# the SRGB_TO_HDR_REFERENCE literal):
|
||||
python3 packages/engine/scripts/generate-lut-reference.py --probes
|
||||
|
||||
# Dump the full 256-entry LUTs as JSON (for ad-hoc analysis or new tests):
|
||||
python3 packages/engine/scripts/generate-lut-reference.py
|
||||
|
||||
# Override the probe set:
|
||||
python3 packages/engine/scripts/generate-lut-reference.py --probes \
|
||||
--probe-indices 0,32,64,128,192,255
|
||||
|
||||
## How to use this when the LUT changes
|
||||
|
||||
1. Edit buildSrgbToHdrLut() in packages/engine/src/utils/alphaBlit.ts.
|
||||
2. Mirror the same edit here (constants, branch logic — keep them in sync).
|
||||
3. Run with --probes and paste the output over SRGB_TO_HDR_REFERENCE in
|
||||
alphaBlit.test.ts. Update the asymmetric-R/G/B and BT.2408-invariant
|
||||
tests by hand if those probe values shifted.
|
||||
4. Re-run `bun test src/utils/alphaBlit.test.ts` to confirm the engine LUT
|
||||
and the test-pinned values still agree.
|
||||
|
||||
## Why Python (not TS)?
|
||||
|
||||
A standalone script avoids dragging the engine's bun/Node/build environment
|
||||
into a one-off codegen flow, and matches the existing fixture-generation
|
||||
pattern at packages/producer/tests/hdr-regression/scripts/generate-hdr-photo-pq.py.
|
||||
Python's math.log / math.pow are libm-backed and produce IEEE-754-equivalent
|
||||
results to JS's Math.log / Math.pow for these inputs — see js_round_nonneg
|
||||
below for the one rounding quirk we have to match by hand.
|
||||
|
||||
## Drift contract
|
||||
|
||||
This file MIRRORS buildSrgbToHdrLut() in alphaBlit.ts. If the two diverge,
|
||||
this script silently emits wrong values. Any change to one MUST be reflected
|
||||
in the other; run the script and the test suite together to catch drift.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
|
||||
# HLG OETF constants (Rec. 2100) — keep in sync with alphaBlit.ts
|
||||
HLG_A = 0.17883277
|
||||
HLG_B = 1 - 4 * HLG_A
|
||||
HLG_C = 0.5 - HLG_A * math.log(4 * HLG_A)
|
||||
|
||||
# PQ (SMPTE 2084) OETF constants — keep in sync with alphaBlit.ts
|
||||
PQ_M1 = 0.1593017578125
|
||||
PQ_M2 = 78.84375
|
||||
PQ_C1 = 0.8359375
|
||||
PQ_C2 = 18.8515625
|
||||
PQ_C3 = 18.6875
|
||||
PQ_MAX_NITS = 10000.0
|
||||
SDR_NITS = 203.0 # BT.2408 SDR-reference white in PQ
|
||||
|
||||
|
||||
def js_round_nonneg(x: float) -> int:
|
||||
"""
|
||||
Match JS Math.round semantics for non-negative inputs.
|
||||
|
||||
JS Math.round rounds half toward +∞ (Math.round(0.5) === 1). Python's
|
||||
built-in round() uses banker's rounding (round half to even, so
|
||||
round(0.5) === 0 and round(2.5) === 2), which would diverge from
|
||||
Math.round for the ~ten or so probe values that fall on a half-integer
|
||||
after signal*65535. This helper is only correct for x >= 0 — that's
|
||||
fine because signal is always in [0, 1] here.
|
||||
"""
|
||||
return int(math.floor(x + 0.5))
|
||||
|
||||
|
||||
def srgb_eotf(i: int) -> float:
|
||||
"""sRGB 8-bit code value → linear light in [0, 1] relative to SDR white."""
|
||||
v = i / 255
|
||||
return v / 12.92 if v <= 0.04045 else math.pow((v + 0.055) / 1.055, 2.4)
|
||||
|
||||
|
||||
def hlg_oetf(linear: float) -> float:
|
||||
if linear <= 1 / 12:
|
||||
return math.sqrt(3 * linear)
|
||||
return HLG_A * math.log(12 * linear - HLG_B) + HLG_C
|
||||
|
||||
|
||||
def pq_oetf(linear: float) -> float:
|
||||
# Place SDR-reference white at 203 nits within the 10000-nit PQ peak.
|
||||
# This is what reserves headroom for HDR highlights above SDR-white.
|
||||
lp = max(0.0, (linear * SDR_NITS) / PQ_MAX_NITS)
|
||||
lm1 = math.pow(lp, PQ_M1)
|
||||
return math.pow((PQ_C1 + PQ_C2 * lm1) / (1.0 + PQ_C3 * lm1), PQ_M2)
|
||||
|
||||
|
||||
def build_lut(transfer: str) -> list[int]:
|
||||
out: list[int] = []
|
||||
for i in range(256):
|
||||
linear = srgb_eotf(i)
|
||||
signal = hlg_oetf(linear) if transfer == "hlg" else pq_oetf(linear)
|
||||
out.append(min(65535, js_round_nonneg(signal * 65535)))
|
||||
return out
|
||||
|
||||
|
||||
# Mirror SRGB_TO_HDR_REFERENCE indices in alphaBlit.test.ts. Endpoints
|
||||
# (0, 1, 254, 255) catch off-by-one regressions; mid-range values (32, 64,
|
||||
# 96, 128, 160, 192, 224) sample the middle of both transfer curves.
|
||||
DEFAULT_PROBES: tuple[int, ...] = (0, 1, 10, 32, 64, 96, 128, 160, 192, 224, 254, 255)
|
||||
|
||||
|
||||
def emit_json(hlg: list[int], pq: list[int]) -> None:
|
||||
print(json.dumps({"size": 256, "hlg": hlg, "pq": pq}, indent=2))
|
||||
|
||||
|
||||
def emit_probes(hlg: list[int], pq: list[int], probes: Iterable[int]) -> None:
|
||||
# Output is paste-ready TS for SRGB_TO_HDR_REFERENCE in alphaBlit.test.ts.
|
||||
print("const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [")
|
||||
for i in probes:
|
||||
if not 0 <= i <= 255:
|
||||
raise ValueError(f"probe index {i} out of range [0, 255]")
|
||||
print(f" {{ srgb: {i}, hlg: {hlg[i]}, pq: {pq[i]} }},")
|
||||
print("];")
|
||||
|
||||
|
||||
def parse_indices(s: str) -> list[int]:
|
||||
return [int(x.strip()) for x in s.split(",") if x.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate sRGB → BT.2020 (HLG/PQ) LUT reference values.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--probes",
|
||||
action="store_true",
|
||||
help="Emit a TS snippet ready to paste over SRGB_TO_HDR_REFERENCE.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--probe-indices",
|
||||
type=parse_indices,
|
||||
default=list(DEFAULT_PROBES),
|
||||
help="Comma-separated probe indices (default mirrors alphaBlit.test.ts).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
hlg = build_lut("hlg")
|
||||
pq = build_lut("pq")
|
||||
|
||||
if args.probes:
|
||||
emit_probes(hlg, pq, args.probe_indices)
|
||||
else:
|
||||
emit_json(hlg, pq)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Browser integration test for fitTextFontSize.
|
||||
*
|
||||
* Launches headless Chrome, loads the runtime IIFE into a page,
|
||||
* and verifies that window.__hyperframes.fitTextFontSize produces
|
||||
* correct results with real canvas measureText.
|
||||
*
|
||||
* Requires: puppeteer (dep of @hyperframes/engine)
|
||||
* Run: cd packages/engine && npx tsx scripts/test-fitTextFontSize-browser.ts
|
||||
*/
|
||||
|
||||
import { buildHyperframesRuntimeScript } from "../../core/src/inline-scripts/hyperframesRuntime.engine";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Dynamic import — puppeteer is a monorepo dep, not a core dep
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = (await import("puppeteer")).default;
|
||||
} catch {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "fitTextFontSize_browser_test_skipped",
|
||||
reason: "puppeteer not available",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSource = buildHyperframesRuntimeScript({ minify: false });
|
||||
assert(
|
||||
runtimeSource !== null,
|
||||
"buildHyperframesRuntimeScript returned null — entry.ts not found",
|
||||
);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@900&display=block');
|
||||
</style>
|
||||
</head><body>
|
||||
<script>${runtimeSource}</script>
|
||||
<script>
|
||||
window.__testResults = {};
|
||||
|
||||
// Test 1: Short text should fit at base size
|
||||
var r1 = window.__hyperframes.fitTextFontSize("HI");
|
||||
window.__testResults.shortText = r1;
|
||||
|
||||
// Test 2: Wide text should shrink below base size but still fit at 1600px
|
||||
var r2 = window.__hyperframes.fitTextFontSize(
|
||||
"CONGRATULATIONS TO EVERYBODY IN THE WORLD",
|
||||
{ fontFamily: "sans-serif", fontWeight: 900, maxWidth: 1600 }
|
||||
);
|
||||
window.__testResults.wideText = r2;
|
||||
|
||||
// Test 3: Extremely wide text that can't fit should return minFontSize
|
||||
var r3 = window.__hyperframes.fitTextFontSize(
|
||||
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
{ fontFamily: "sans-serif", fontWeight: 900, maxWidth: 400 }
|
||||
);
|
||||
window.__testResults.extremeText = r3;
|
||||
|
||||
// Test 4: Function exists and is callable
|
||||
window.__testResults.exists = typeof window.__hyperframes.fitTextFontSize === "function";
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "networkidle0", timeout: 10000 });
|
||||
|
||||
// Wait for font to potentially load (best effort — sans-serif fallback is fine for testing)
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const results = await page.evaluate(() => (window as any).__testResults);
|
||||
|
||||
// Test 1: Short text fits at base size (78px default)
|
||||
assert(results.exists === true, "fitTextFontSize should exist on window.__hyperframes");
|
||||
assert(
|
||||
results.shortText.fits === true,
|
||||
`Short text should fit, got fits=${results.shortText.fits}`,
|
||||
);
|
||||
assert(
|
||||
results.shortText.fontSize === 78,
|
||||
`Short text should use base size 78, got ${results.shortText.fontSize}`,
|
||||
);
|
||||
|
||||
// Test 2: Wide text should shrink below base size
|
||||
assert(
|
||||
results.wideText.fits === true,
|
||||
`Wide text should still fit, got fits=${results.wideText.fits}`,
|
||||
);
|
||||
assert(
|
||||
results.wideText.fontSize < 78,
|
||||
`Wide text should shrink below 78, got ${results.wideText.fontSize}`,
|
||||
);
|
||||
|
||||
// Test 3: Extreme text should hit floor
|
||||
assert(
|
||||
results.extremeText.fontSize === 42,
|
||||
`Extreme text should hit minFontSize 42, got ${results.extremeText.fontSize}`,
|
||||
);
|
||||
assert(
|
||||
results.extremeText.fits === false,
|
||||
`Extreme text should not fit, got fits=${results.extremeText.fits}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "fitTextFontSize_browser_test_passed",
|
||||
shortText: results.shortText,
|
||||
wideText: results.wideText,
|
||||
extremeText: results.extremeText,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
// fallow-ignore-file unused-file code-duplication complexity
|
||||
/**
|
||||
* Browser acceptance test: SDK moveElement edits survive GSAP animation
|
||||
* per-axis (the AI Studio embedded-editor per-axis loss bug).
|
||||
*
|
||||
* Launches headless Chrome with real GSAP + the real runtime IIFE, loads a
|
||||
* fixture whose elements carry committed moveElement state (data-x/data-y +
|
||||
* data-hf-edit-base-x/y), seeks the timeline across its range, and asserts
|
||||
* the rendered position reflects the edit on BOTH axes at every sample:
|
||||
* - an X-animated element keeps its edited offset while X animates
|
||||
* - a Y-animated element keeps its edited offset while Y animates
|
||||
* - a static element keeps both
|
||||
*
|
||||
* Runs in the plain embedded runtime — no Studio shell, no manual-edits
|
||||
* render script — matching what third-party SDK consumers load.
|
||||
*
|
||||
* Requires: puppeteer + gsap (monorepo deps, dynamically resolved; skips
|
||||
* with a notice when unavailable).
|
||||
* Run: cd packages/engine && npx tsx scripts/test-runtime-position-edits-browser.ts
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve as resolvePath, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildHyperframesRuntimeScript } from "../../core/src/inline-scripts/hyperframesRuntime.engine";
|
||||
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadGsapSource(): string | null {
|
||||
const req = createRequire(import.meta.url);
|
||||
// gsap is a dep of studio / player / sdk-playground, hoisted in the
|
||||
// workspace store — resolve through whichever package has it.
|
||||
for (const pkg of ["studio", "player", "sdk-playground"]) {
|
||||
try {
|
||||
const path = req.resolve("gsap/dist/gsap.min.js", {
|
||||
paths: [resolvePath(thisDir, `../../${pkg}`)],
|
||||
});
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
// try the next package
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
t: number;
|
||||
ax: { x: number; y: number };
|
||||
ay: { x: number; y: number };
|
||||
axy: { x: number; y: number };
|
||||
ts: { x: number; y: number };
|
||||
st: { x: number; y: number };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = (await import("puppeteer")).default;
|
||||
} catch {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "runtime_position_edits_browser_test_skipped",
|
||||
reason: "puppeteer not available",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const gsapSource = loadGsapSource();
|
||||
if (gsapSource === null) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "runtime_position_edits_browser_test_skipped",
|
||||
reason: "gsap not available",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeSource = buildHyperframesRuntimeScript({ minify: false });
|
||||
assert(
|
||||
runtimeSource !== null,
|
||||
"buildHyperframesRuntimeScript returned null — entry.ts not found",
|
||||
);
|
||||
|
||||
// Committed moveElement state: every element moved by (50, -70). data-x/y
|
||||
// hold the post-edit values; data-hf-edit-base-x/y hold the pre-edit ones
|
||||
// (absent → "0"), exactly as handleMoveElement serializes them.
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
body { margin: 0; }
|
||||
.el { position: absolute; left: 0; top: 0; width: 40px; height: 40px; }
|
||||
</style></head><body>
|
||||
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div id="ax" class="clip el" data-hf-id="hf-ax" data-start="0" data-duration="4"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
<div id="ay" class="clip el" data-hf-id="hf-ay" data-start="0" data-duration="4"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
<div id="axy" class="clip el" data-hf-id="hf-axy" data-start="0" data-duration="4"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
<div id="ts" class="clip el" data-hf-id="hf-ts" data-start="0" data-duration="4"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
<div id="st" class="clip el" data-hf-id="hf-st" data-start="0" data-duration="4"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
</div>
|
||||
<script>${gsapSource}</script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#ax", { x: 0 }, { x: 400, duration: 4, ease: "none" }, 0);
|
||||
tl.fromTo("#ay", { y: 0 }, { y: 300, duration: 4, ease: "none" }, 0);
|
||||
tl.fromTo("#axy", { x: 0, y: 0 }, { x: 400, y: 300, duration: 4, ease: "none" }, 0);
|
||||
tl.set("#ts", { x: 200, y: 100 }, 1.0);
|
||||
// #st is never targeted by GSAP.
|
||||
window.__timelines.main = tl;
|
||||
</script>
|
||||
<script>${runtimeSource}</script>
|
||||
</body></html>`;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "networkidle0", timeout: 10000 });
|
||||
|
||||
// The runtime applies position edits after it binds the timeline — wait
|
||||
// for the translate to land on a marked element. String form: tsx/esbuild
|
||||
// injects a __name helper into serialized closures that the page lacks.
|
||||
await page.waitForFunction(
|
||||
`(function () {
|
||||
var el = document.getElementById("ax");
|
||||
return el !== null && getComputedStyle(el).translate !== "none";
|
||||
})()`,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const sampleScript = `(function (time) {
|
||||
if (window.__player && typeof window.__player.renderSeek === "function") {
|
||||
window.__player.renderSeek(time);
|
||||
} else if (window.__hf && typeof window.__hf.seek === "function") {
|
||||
window.__hf.seek(time);
|
||||
} else {
|
||||
throw new Error("no runtime seek surface (__player.renderSeek / __hf.seek)");
|
||||
}
|
||||
function read(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) throw new Error("missing #" + id);
|
||||
var cs = getComputedStyle(el);
|
||||
var m = new DOMMatrix(cs.transform === "none" ? "" : cs.transform);
|
||||
var parts = cs.translate === "none" ? [] : cs.translate.split(" ");
|
||||
var tx = parts.length > 0 ? parseFloat(parts[0]) : 0;
|
||||
var ty = parts.length > 1 ? parseFloat(parts[1]) : 0;
|
||||
return { x: m.m41 + tx, y: m.m42 + ty };
|
||||
}
|
||||
return {
|
||||
t: time,
|
||||
ax: read("ax"),
|
||||
ay: read("ay"),
|
||||
axy: read("axy"),
|
||||
ts: read("ts"),
|
||||
st: read("st"),
|
||||
};
|
||||
})`;
|
||||
|
||||
const samples: Sample[] = [];
|
||||
for (const t of [0, 1, 2.5, 4]) {
|
||||
const sample = (await page.evaluate(`(${sampleScript})(${t})`)) as Sample;
|
||||
samples.push(sample);
|
||||
}
|
||||
|
||||
const close = (actual: number, expected: number): boolean => Math.abs(actual - expected) <= 0.5;
|
||||
|
||||
for (const s of samples) {
|
||||
// X-animated: x = animation (100·t) + edit (50); y = edit (−70).
|
||||
assert(
|
||||
close(s.ax.x, 100 * s.t + 50),
|
||||
`t=${s.t}: X-animated element x should be ${100 * s.t + 50}, got ${s.ax.x}`,
|
||||
);
|
||||
assert(close(s.ax.y, -70), `t=${s.t}: X-animated element y should keep -70, got ${s.ax.y}`);
|
||||
// Y-animated: y = animation (75·t) + edit (−70); x = edit (50).
|
||||
assert(close(s.ay.x, 50), `t=${s.t}: Y-animated element x should keep 50, got ${s.ay.x}`);
|
||||
assert(
|
||||
close(s.ay.y, 75 * s.t - 70),
|
||||
`t=${s.t}: Y-animated element y should be ${75 * s.t - 70}, got ${s.ay.y}`,
|
||||
);
|
||||
// Both-axis-animated: both axes = animation + edit (the shape that
|
||||
// originated the per-axis loss bug).
|
||||
assert(
|
||||
close(s.axy.x, 100 * s.t + 50),
|
||||
`t=${s.t}: XY-animated element x should be ${100 * s.t + 50}, got ${s.axy.x}`,
|
||||
);
|
||||
assert(
|
||||
close(s.axy.y, 75 * s.t - 70),
|
||||
`t=${s.t}: XY-animated element y should be ${75 * s.t - 70}, got ${s.axy.y}`,
|
||||
);
|
||||
// tl.set at t=1.0: before it fires, position = edit only; after, set + edit.
|
||||
const setX = s.t >= 1.0 ? 200 : 0;
|
||||
const setY = s.t >= 1.0 ? 100 : 0;
|
||||
assert(
|
||||
close(s.ts.x, setX + 50),
|
||||
`t=${s.t}: tl.set element x should be ${setX + 50}, got ${s.ts.x}`,
|
||||
);
|
||||
assert(
|
||||
close(s.ts.y, setY - 70),
|
||||
`t=${s.t}: tl.set element y should be ${setY - 70}, got ${s.ts.y}`,
|
||||
);
|
||||
// Static: both axes hold the edit.
|
||||
assert(close(s.st.x, 50), `t=${s.t}: static element x should be 50, got ${s.st.x}`);
|
||||
assert(close(s.st.y, -70), `t=${s.t}: static element y should be -70, got ${s.st.y}`);
|
||||
}
|
||||
|
||||
// GSAP-free composition: no window.gsap, no timelines — the edit must
|
||||
// still render (applied at runtime init, not only at timeline bind).
|
||||
const gsapFreeHtml = `<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
body { margin: 0; }
|
||||
.el { position: absolute; left: 0; top: 0; width: 40px; height: 40px; }
|
||||
</style></head><body>
|
||||
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="2">
|
||||
<div id="st" class="clip el" data-hf-id="hf-st" data-start="0" data-duration="2"
|
||||
data-x="50" data-y="-70" data-hf-edit-base-x="0" data-hf-edit-base-y="0"></div>
|
||||
</div>
|
||||
<script>${runtimeSource}</script>
|
||||
</body></html>`;
|
||||
|
||||
const page2 = await browser.newPage();
|
||||
await page2.setContent(gsapFreeHtml, { waitUntil: "networkidle0", timeout: 10000 });
|
||||
await page2.waitForFunction(
|
||||
`(function () {
|
||||
var el = document.getElementById("st");
|
||||
return el !== null && getComputedStyle(el).translate !== "none";
|
||||
})()`,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const gsapFree = (await page2.evaluate(
|
||||
`(function () {
|
||||
var cs = getComputedStyle(document.getElementById("st"));
|
||||
return { translate: cs.translate };
|
||||
})()`,
|
||||
)) as { translate: string };
|
||||
assert(
|
||||
gsapFree.translate === "50px -70px",
|
||||
`GSAP-free composition should render the edit as translate 50px -70px, got ${gsapFree.translate}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "runtime_position_edits_browser_test_passed",
|
||||
samples,
|
||||
gsapFree,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user