diff --git a/scripts/arcade/README.md b/scripts/arcade/README.md new file mode 100644 index 00000000000..d09b0c24bea --- /dev/null +++ b/scripts/arcade/README.md @@ -0,0 +1,52 @@ +# Arcade fork maintenance + +Public fork of Heygen Hyperframes with Arcade keep-local / pending-contrib patches. + +## Branches + +| Branch | Role | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `main` | Upstream mirror only — no Arcade commits | +| `patch/` | One patch; open upstream PRs from these | +| `arcade` | Disposable integration: `base` (`main`) + cherry-picks from `patches.json` | +| `patch/arcade-tooling` | This tooling (first entry in the manifest; never upstreamed) | + +## Rebuild `arcade` + +```bash +git fetch origin upstream +git checkout patch/arcade-tooling +node scripts/arcade/rebuild.mjs --yes # onto patches.json `base` (main) +node scripts/arcade/rebuild.mjs --yes --base upstream/main # onto latest upstream +node scripts/arcade/rebuild.mjs --yes --push # force-with-lease origin/arcade +``` + +Conflict → abort names the patch. Do not resolve on `arcade`. If the patch itself is clean on `main`, two patches share a hunk: keep them both on `main` and split the file (one owner, the other a new module). `createHostWindowMapper` is `packages/producer/src/services/hostWindowMapper.ts` so it does not rewrite `renderMediaCollector.ts`. If both must edit the same function, add a keep-local join patch. + +Rebase that `patch/*` onto the same `base` only when *that* patch failed on `main`, then re-run. + +## Tags + +Version the delta against `main`, not a merge of `main` into the patch. After a rebuild, tag this date (`YYYY-MM-DD`): + +- `sync//base` → `main` +- `sync//` → each `patch/*` tip (`playback-start-nested`, not `patch/playback-start-nested`) +- `sync//arcade` → `arcade` + +``` +git range-diff sync/2026-09-09/base..sync/2026-09-09/playback-start-nested \ + sync//base..sync//playback-start-nested +``` + +Push `refs/tags/sync//*`. Do not move a tag already on origin. If `.tmp` is mid-rebase, tag last week's tips from a clean sibling checkout (`../hyperframes-next`), not from a dirty worktree. + +When an upstream PR merges, delete its row from `patches.json` (and optionally the branch). + +Each `patches` entry is `{ branch, issue?, pr?, upstreamPr?, keepLocal? }`: + +| Field | Meaning | +| ------------ | ------------------------------------------------- | +| `issue` | Upstream issue we filed | +| `pr` | Our PR (usually from this `patch/*` branch) | +| `upstreamPr` | Their PR fixing our issue (when not `pr`) | +| `keepLocal` | `true` — intentional Arcade-only; do not upstream | diff --git a/scripts/arcade/patches.json b/scripts/arcade/patches.json new file mode 100644 index 00000000000..401d580cc56 --- /dev/null +++ b/scripts/arcade/patches.json @@ -0,0 +1,35 @@ +{ + "upstream": "upstream/main", + "base": "main", + "integrationBranch": "arcade", + "patches": [ + { + "branch": "patch/arcade-tooling", + "keepLocal": true + }, + { + "branch": "patch/probe-seek-swallow", + "keepLocal": true + }, + { + "branch": "patch/jump-seek-css-settle", + "keepLocal": true + }, + { + "branch": "patch/discover-host-window", + "keepLocal": true + }, + { + "branch": "patch/discover-video-opacity", + "keepLocal": true + }, + { + "branch": "patch/render-settle-pass", + "keepLocal": true + }, + { + "branch": "patch/vp9-alpha-probe", + "keepLocal": true + } + ] +} diff --git a/scripts/arcade/rebuild.mjs b/scripts/arcade/rebuild.mjs new file mode 100755 index 00000000000..be03917f273 --- /dev/null +++ b/scripts/arcade/rebuild.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Rebuild the disposable `arcade` integration branch: + * reset to `base` (--base, else patches.json `base`, else `upstream`), then cherry-pick + * each patch tip in patches.json order. + * + * Bootstrap (scripts may not be on current checkout): + * git fetch origin upstream + * git checkout patch/arcade-tooling + * node scripts/arcade/rebuild.mjs --yes + * + * On conflict: script aborts and names the failing patch — rebase that + * patch/* onto the same base, then re-run. + * + * Usage: + * node scripts/arcade/rebuild.mjs --yes + * node scripts/arcade/rebuild.mjs --yes --base main + * node scripts/arcade/rebuild.mjs --yes --base upstream/main + * node scripts/arcade/rebuild.mjs --yes --push + * node scripts/arcade/rebuild.mjs --dry-run + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const MANIFEST_PATH = join(dirname(fileURLToPath(import.meta.url)), "patches.json"); + +const argv = process.argv.slice(2); +const args = new Set(argv); +const dryRun = args.has("--dry-run"); +const push = args.has("--push"); +const yes = args.has("--yes"); + +function takeArg(name) { + const i = argv.indexOf(name); + if (i === -1) return null; + const value = argv[i + 1]; + if (!value || value.startsWith("-")) die(`${name} requires a git ref`); + return value; +} + +function git(gitArgs, opts = {}) { + return execFileSync("git", gitArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: opts.stdio ?? ["ignore", "pipe", "pipe"], + ...opts, + }).trim(); +} + +function gitOk(gitArgs) { + try { + git(gitArgs); + return true; + } catch { + return false; + } +} + +function die(message, code = 1) { + console.error(message); + process.exit(code); +} + +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); +const upstream = typeof manifest.upstream === "string" ? manifest.upstream : null; +const base = takeArg("--base") || (typeof manifest.base === "string" ? manifest.base : null) || upstream; +const branch = manifest.integrationBranch; +if (!base) die("patches.json: set base or upstream, or pass --base "); +if (!Array.isArray(manifest.patches) || manifest.patches.length === 0) { + die("patches.json: patches must be a non-empty array"); +} +const patches = manifest.patches.map((p) => { + if (typeof p === "string") return p; + if (p && typeof p.branch === "string") return p.branch; + die("patches.json: each patch must be a string or { branch }"); +}); + +if (!yes && !dryRun) { + die("Refusing to reset arcade without --yes (or pass --dry-run)."); +} + +const status = git(["status", "--porcelain"]); +if (status) { + die(`Working tree dirty:\n${status}`); +} + +if (!gitOk(["rev-parse", "--verify", base])) { + die(`Missing ${base}. Run: git fetch origin upstream`); +} + +for (const patch of patches) { + if (!gitOk(["rev-parse", "--verify", patch])) { + die(`Missing patch ref ${patch}. Fetch or create it before rebuilding.`); + } +} + +const baseSha = git(["rev-parse", base]); +console.log(`base: ${base} @ ${baseSha.slice(0, 12)}`); +if (upstream && upstream !== base && gitOk(["rev-parse", "--verify", upstream])) { + console.log(`upstream: ${upstream} @ ${git(["rev-parse", upstream]).slice(0, 12)}`); +} +console.log(`branch: ${branch}`); +console.log(`patches: ${patches.length}`); +for (const patch of patches) { + const tip = git(["rev-parse", patch]); + const commits = git(["rev-list", "--reverse", `${base}..${patch}`]) + .split("\n") + .filter(Boolean); + console.log( + ` - ${patch} (${commits.length} commit${commits.length === 1 ? "" : "s"} @ ${tip.slice(0, 12)})`, + ); + if (commits.length === 0) { + console.warn( + ` warn: no commits in ${base}..${patch} — already in base or empty; will skip`, + ); + } +} + +if (dryRun) { + console.log("dry-run: no refs updated"); + process.exit(0); +} + +git(["checkout", "-B", branch, base]); +// -B from a remote-tracking start-point inherits that upstream. +// arcade is a fork integration branch — it should track origin/arcade. +if (gitOk(["rev-parse", "--verify", `origin/${branch}`])) { + git(["branch", "--set-upstream-to", `origin/${branch}`]); +} +console.log(`reset ${branch} → ${base}`); + +for (const patch of patches) { + const commits = git(["rev-list", "--reverse", `${base}..${patch}`]) + .split("\n") + .filter(Boolean); + if (commits.length === 0) { + console.log(`skip ${patch} (empty range)`); + continue; + } + + console.log(`cherry-pick ${patch} (${commits.length})`); + try { + execFileSync("git", ["cherry-pick", ...commits], { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + }); + } catch { + console.error(`\nConflict while cherry-picking ${patch}.`); + console.error(`Fix that patch branch onto ${base}, then re-run:`); + console.error(` git checkout ${patch}`); + console.error(` git rebase ${base}`); + console.error(` # resolve, then:`); + console.error(` git checkout patch/arcade-tooling`); + console.error(` node scripts/arcade/rebuild.mjs --yes`); + console.error(`\nAborting cherry-pick and leaving ${branch} mid-rebuild.`); + try { + execFileSync("git", ["cherry-pick", "--abort"], { cwd: ROOT, stdio: "inherit" }); + } catch { + // already aborted or nothing to abort + } + process.exit(1); + } +} + +const tip = git(["rev-parse", "HEAD"]); +console.log(`\n${branch} tip: ${tip}`); +console.log(`commits on ${branch} not in ${base}:`); +console.log(git(["log", "--oneline", `${base}..HEAD`]) || "(none)"); + +if (push) { + execFileSync("git", ["push", "--force-with-lease", "origin", branch], { + cwd: ROOT, + stdio: "inherit", + }); + git(["branch", "--set-upstream-to", `origin/${branch}`]); + console.log(`pushed origin/${branch}`); +} else { + console.log(`\nPush when ready:\n git push --force-with-lease origin ${branch}`); +}