diff --git a/.github/workflows/issue3329-macos-real-pixel.yml b/.github/workflows/issue3329-macos-real-pixel.yml
new file mode 100644
index 0000000000..f1297e0e03
--- /dev/null
+++ b/.github/workflows/issue3329-macos-real-pixel.yml
@@ -0,0 +1,48 @@
+name: macOS treated-media real-pixel gate
+
+on:
+ pull_request:
+ paths:
+ - "scripts/issue3329-macos-real-pixel.mjs"
+ - ".github/workflows/issue3329-macos-real-pixel.yml"
+ - "packages/core/src/runtime/colorGrading.ts"
+ - "packages/engine/src/services/frameCapture.ts"
+ - "packages/engine/src/services/screenshotService.ts"
+
+permissions:
+ contents: read
+
+jobs:
+ macos-real-pixels:
+ runs-on: macos-14
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ lfs: true
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: 22
+ - name: Install FFmpeg
+ run: brew install ffmpeg
+ - name: Install dependencies
+ run: bash scripts/ci/install-workspace-dependencies.sh
+ - name: Build local CLI render stack
+ run: |
+ bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
+ bun run --cwd packages/core build
+ bun run --filter '@hyperframes/{engine,producer,studio}' build
+ bun run --filter @hyperframes/cli build
+ - name: Run real-pixel boundary matrix
+ env:
+ HF_ISSUE3329_ARTIFACT_DIR: ${{ github.workspace }}/.artifacts/issue3329-macos
+ run: node scripts/issue3329-macos-real-pixel.mjs
+ - name: Upload boundary evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: issue3329-macos-real-pixels-${{ runner.arch }}
+ path: .artifacts/issue3329-macos
+ if-no-files-found: error
+ include-hidden-files: true
diff --git a/scripts/issue3329-artifact-safety.mjs b/scripts/issue3329-artifact-safety.mjs
new file mode 100644
index 0000000000..a1070626d6
--- /dev/null
+++ b/scripts/issue3329-artifact-safety.mjs
@@ -0,0 +1,55 @@
+import { existsSync, realpathSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
+
+function rejectTraversal(raw) {
+ if (raw.split(/[\\/]+/).includes("..")) {
+ throw new Error("Artifact root must not contain traversal segments");
+ }
+}
+
+function isForbiddenRoot(target, repo, artifactSpace) {
+ return new Set([parse(target).root, resolve(homedir()), repo, artifactSpace]).has(target);
+}
+
+function isDedicatedDescendant(target, artifactSpace) {
+ const rel = relative(artifactSpace, target);
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
+}
+
+function canonicalizeTarget(target) {
+ const suffix = [];
+ let ancestor = target;
+ while (!existsSync(ancestor)) {
+ const parent = dirname(ancestor);
+ if (parent === ancestor) {
+ throw new Error("Artifact root has no canonical ancestor");
+ }
+ suffix.unshift(basename(ancestor));
+ ancestor = parent;
+ }
+ return resolve(realpathSync(ancestor), ...suffix);
+}
+
+function rejectUnsafeTarget(target, repo, artifactSpace) {
+ if (
+ isForbiddenRoot(target, repo, artifactSpace) ||
+ !isDedicatedDescendant(artifactSpace, repo) ||
+ !isDedicatedDescendant(target, artifactSpace)
+ ) {
+ throw new Error(
+ "Artifact root must be a dedicated descendant of the repository artifact space",
+ );
+ }
+}
+
+export function validateArtifactRoot(requestedPath, repoRoot) {
+ const raw = String(requestedPath ?? "");
+ rejectTraversal(raw);
+ const repo = realpathSync(resolve(repoRoot));
+ const artifactSpace = canonicalizeTarget(resolve(repo, ".artifacts"));
+ const requestedTarget = resolve(repo, raw);
+ const target = canonicalizeTarget(requestedTarget);
+ rejectUnsafeTarget(target, repo, artifactSpace);
+ return requestedTarget;
+}
diff --git a/scripts/issue3329-macos-real-pixel.mjs b/scripts/issue3329-macos-real-pixel.mjs
new file mode 100644
index 0000000000..1b0475ee96
--- /dev/null
+++ b/scripts/issue3329-macos-real-pixel.mjs
@@ -0,0 +1,358 @@
+import { createHash } from "node:crypto";
+import { execFileSync, spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { validateArtifactRoot } from "./issue3329-artifact-safety.mjs";
+
+const repo = process.cwd();
+const artifactRoot = validateArtifactRoot(
+ process.env.HF_ISSUE3329_ARTIFACT_DIR ?? join(".artifacts", "issue3329-macos"),
+ repo,
+);
+const projectsRoot = join(artifactRoot, "projects");
+const outputsRoot = join(artifactRoot, "outputs");
+rmSync(artifactRoot, { recursive: true, force: true });
+mkdirSync(projectsRoot, { recursive: true });
+mkdirSync(outputsRoot, { recursive: true });
+
+function run(binary, args, options = {}) {
+ const result = spawnSync(binary, args, {
+ cwd: repo,
+ encoding: "utf8",
+ timeout: 180_000,
+ ...options,
+ });
+ if (result.status !== 0) {
+ throw new Error(
+ `${binary} ${args.join(" ")} failed (${result.status})\n${result.stdout}\n${result.stderr}`,
+ );
+ }
+ return { stdout: result.stdout.trim(), stderr: result.stderr.trim() };
+}
+
+function ffmpeg(args) {
+ return run("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]);
+}
+
+function cli(args) {
+ return run("bun", ["packages/cli/src/cli.ts", ...args], {
+ env: { ...process.env, HYPERFRAMES_NO_TELEMETRY: "1" },
+ });
+}
+
+function sha256(path) {
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
+}
+
+function firstFile(dir, suffix) {
+ const name = readdirSync(dir).find((entry) => entry.endsWith(suffix));
+ if (!name) throw new Error(`No ${suffix} output under ${dir}`);
+ return join(dir, name);
+}
+
+function pixelStats(path) {
+ const result = spawnSync(
+ "ffmpeg",
+ [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ path,
+ "-frames:v",
+ "1",
+ "-vf",
+ "scale=160:90",
+ "-pix_fmt",
+ "rgba",
+ "-f",
+ "rawvideo",
+ "-",
+ ],
+ { encoding: null },
+ );
+ if (result.status !== 0) throw new Error(`Pixel decode failed for ${path}`);
+ const pixels = result.stdout;
+ let sum = 0;
+ let sumSq = 0;
+ let alphaMin = 255;
+ let alphaMax = 0;
+ const count = pixels.length / 4;
+ for (let offset = 0; offset + 3 < pixels.length; offset += 4) {
+ const luma =
+ 0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2];
+ sum += luma;
+ sumSq += luma * luma;
+ alphaMin = Math.min(alphaMin, pixels[offset + 3]);
+ alphaMax = Math.max(alphaMax, pixels[offset + 3]);
+ }
+ const mean = sum / count;
+ const variance = Math.max(0, sumSq / count - mean * mean);
+ const stddev = Math.sqrt(variance);
+ return {
+ mean: Number(mean.toFixed(3)),
+ stddev: Number(stddev.toFixed(3)),
+ alphaMin,
+ alphaMax,
+ solid: stddev < 2,
+ };
+}
+
+function ffprobe(path) {
+ const output = execFileSync(
+ "ffprobe",
+ [
+ "-v",
+ "error",
+ "-show_entries",
+ "stream=codec_name,codec_tag_string,pix_fmt,color_space,color_transfer,color_primaries",
+ "-of",
+ "json",
+ "--",
+ path,
+ ],
+ { encoding: "utf8" },
+ );
+ return JSON.parse(output).streams?.[0] ?? {};
+}
+
+const sourceDir = join(artifactRoot, "source");
+mkdirSync(sourceDir, { recursive: true });
+const framePath = join(sourceDir, "frame.png");
+const videoPath = join(sourceDir, "frame.mp4");
+ffmpeg(["-f", "lavfi", "-i", "testsrc2=size=160x90:rate=1", "-frames:v", "1", "-y", framePath]);
+ffmpeg([
+ "-loop",
+ "1",
+ "-i",
+ framePath,
+ "-t",
+ "1",
+ "-r",
+ "30",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ "-y",
+ videoPath,
+]);
+
+const gradings = {
+ ungraded: null,
+ adjustment: { version: 2, colorSpace: "rec709", adjust: { saturation: -0.3 } },
+ kuwahara: { version: 2, colorSpace: "rec709", effects: { kuwahara: 0.8 } },
+};
+const ledger = {
+ environment: {
+ platform: process.platform,
+ arch: process.arch,
+ runnerOs: process.env.RUNNER_OS ?? null,
+ uname: run("uname", ["-a"]).stdout,
+ osVersion: process.platform === "darwin" ? run("sw_vers", []).stdout : null,
+ ffmpeg: run("ffmpeg", ["-version"]).stdout.split("\n")[0],
+ node: process.version,
+ bun: run("bun", ["--version"]).stdout,
+ eligibleMacArm64: process.platform === "darwin" && process.arch === "arm64",
+ },
+ source: { frame: sha256(framePath), video: sha256(videoPath) },
+ rows: [],
+};
+
+const requestedCases = new Set(
+ (process.env.HF_ISSUE3329_CASES ?? "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+);
+for (const mediaKind of ["img", "video"]) {
+ for (const [gradingName, grading] of Object.entries(gradings)) {
+ const caseName = `${mediaKind}-${gradingName}`;
+ if (requestedCases.size > 0 && !requestedCases.has(caseName)) continue;
+ const projectDir = join(projectsRoot, caseName);
+ mkdirSync(projectDir, { recursive: true });
+ const sourceName = mediaKind === "img" ? "frame.png" : "frame.mp4";
+ copyFileSync(mediaKind === "img" ? framePath : videoPath, join(projectDir, sourceName));
+ const gradingAttr = grading ? ` data-color-grading='${JSON.stringify(grading)}'` : "";
+ const media =
+ mediaKind === "img"
+ ? ``
+ : ``;
+ writeFileSync(
+ join(projectDir, "index.html"),
+ `