Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Normalize text files to LF on checkout and in the repo, regardless of the
# contributor's OS. Without this, files saved on Windows can land in PRs with
# CRLF endings or a UTF-8 BOM, which makes every line differ at the byte level
Expand Down Expand Up @@ -47,3 +47,14 @@
.claude/skills/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
.agents/skills/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
.agents/skills/**/*.mp3 filter=lfs diff=lfs merge=lfs -text

# --- Fork override: stop tests/**/output/compiled.html self-converting to LFS pointers ----------
# The pattern above flags these regression-test fixtures filter=lfs, but they were committed
# UPSTREAM as PLAIN content (never migrated into LFS storage). So git-lfs's clean filter rewrites
# them to pointers on every `git add` — a phantom "change" that cloud video-build agents silently
# sweep into their PRs, tripping the scope gate on 20-60 files no one touched. This override (a
# later match wins) treats them as the plain content they already are: no pointer is ever computed,
# locally or in CI. `-text` keeps them byte-exact so no EOL renormalisation reintroduces a diff.
# Retires the local `git update-index --skip-worktree` workaround. Appended (not edited inline) to
# minimise merge friction on upstream sync.
packages/producer/tests/**/output/compiled.html -filter -diff -merge -text
82 changes: 77 additions & 5 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Copilot repository instructions

This fork produces **Microsoft Learn companion videos** with HyperFrames (HTML → MP4). The Learn
Expand All @@ -15,11 +15,12 @@
project into a render-ready composition that passes every gate. You do **not** render (that happens
locally, on licensed fonts + ffmpeg).

**Read `.github/agents/hyperframes-builder.agent.md` first — it is the authoritative builder
doctrine.** This file is the cloud-agent summary of it; the `.agent.md` carries the full craft
(scene density, modular sub-composition contract, seams, cue-anchoring). Also load the skills it
names: `hyperframes-core` (composition contract) and `motion-doctrine` (motion law), plus the
`learn-*` doctrine skills under `.github/skills/`.
**This file is self-contained — everything you need to build is here or in the skills it names.**
Load the skills `hyperframes-core` (composition contract) and `motion-doctrine` (motion law), plus
the `learn-*` doctrine skills under `.github/skills/`. A fuller VS Code-only doctrine exists at
`.github/agents/hyperframes-builder.agent.md`, but the **cloud sandbox blocks `.github/agents/**`
and you do NOT need it** — the craft that used to live only there (especially the cue-anchoring
convention) is reproduced below. **Never stop or block a build for lack of access to that file.**

## What arrives on the branch

Expand Down Expand Up @@ -66,6 +67,67 @@
Log stage timing at entry/exit:
`python ../../tools/stage_timing.py start|end --project . --stage builder --run-id <id> --status <passed|failed>`.

## Anchoring cues — the exact convention (this is where builds fail)

`transcript.json` is the clock. Every cue lands on the spoken word it belongs to — never on an
assumed words-per-second offset. The mechanism:

1. Write `anchors.json` in the project: a map of cue name -> the exact spoken phrase it lands on,
e.g. `{ "gateApproval": "approve the request", "softDelete30": "for 30 days" }`. **Match the
words as the transcript actually has them** — Dragon HD transcribes numbers as DIGITS, so
"thirty days" in the script is `"30 days"` in `transcript.json`. A phrase that does not match a
transcript word leaves the cue UNRESOLVED (this is a real, recurring prep bug — fix the phrase in
`anchors.json`, do not invent a numeric time).
2. Generate real times (an ambiguous phrase is an ERROR, not a silent first match — disambiguate
with `"phrase #2"`):
`python ../../tools/word_anchors.py transcript.json --spec anchors.json -o anchors.js --lead-in <lead>`
3. Load it before the timeline: `<script src="anchors.js"></script>`, then `const W = window.__anchors;`
4. Use `W.gateApproval` as a cue's position. **NEVER `B.b6 + 7.4`.**

`check_cue_anchors.py` classifies the POSITION ARGUMENT of every timeline call (switching to a raw
numeric literal does not fool it). It passes ONLY:
- `W.<name>` — a word-anchored time (use this for content cues)
- `B.b<n>` — a beat start (itself transcript-derived)
- an offset **<= 1s** off a beat/boundary — seam mechanics, relative to a boundary not to speech
- `0` — a t=0 pin

Anything larger is a DEFECT. If a line genuinely needs a bigger offset, justify it inline:
`// anchor-exempt: <reason>`. (The seam/bookend lines `assemble_scenes.py` generates already carry
that marker — you never add it to those, and you never edit the tool to add it.) Anchoring is
**per scene** in a modular build: a scene's cues anchor to words within that scene's window,
relative to the scene's own start.

## Custom scene contract (block scenes skip all of this — they are pre-built)

A `custom` body beat is authored on `templates/blocks/_foundation.css` as a SUB-COMPOSITION:
- root wrapped in `<template>`, styled via `#root` (never a class the stylesheet scopes away), with
`data-width` / `data-height` on the root; all of `<style>`, `<script>`, markup live INSIDE the
`<template>`;
- register its OWN scene-relative timeline on `window.__timelines["<scene-id>"]` — times measured
from the scene's start, `fromTo` never `from` (the host re-seeks each scene when its slot shows);
- the slot's `data-composition-id`, the scene root's `data-composition-id`, and the timeline key
must be IDENTICAL — a mismatch is invisible to lint/check, silently stalls the render, and only
`check_subcomps.py` catches it;
- seams are HOST-owned: set the scene's `seam` field in `scenes.json`
(`cut-left` default, `cut-right`, `cut-up`, `cut-down`, or `hard`) and re-assemble — never
hand-author a cross-scene transition inside a scene file.

## Two failures lint/check CANNOT see — pin and verify

- **Initial state (`check_initial_state.py`).** Children inherit `opacity:1` and
`immediateRender:false` defers the from-state, so an element is fully drawn the moment its beat
opens — long before its cue — and beats ACCUMULATE (a recap never subtracts) while lint+check
stay green. Pin every arriving element hidden at t=0:
`["#a","#b"].forEach(s => tl.set(s,{opacity:0},0));` (use `{scaleX:0}` for bars/wipes).
- **Cue anchors (`check_cue_anchors.py`)** — the convention above.

## Scene density — fewer, richer scenes, never many thin ones

Resolve the numbers from the profile (`python ../../tools/profile.py <PROFILE>`), do not guess. Aim
for the profile's scene-count target (companion-short ~5 - unit-video ~10 - skilling-session ~23),
floor every scene at `scene_seconds.min` (fold a too-thin beat into its neighbour, do not cut to a
3-5s near-empty beat), and if the plan exceeds the cap, send it back rather than building it as is.

## Hard rules

- **Determinism:** no `Date.now()`, no unseeded `Math.random()`, no render-time network fetch. GSAP
Expand All @@ -76,6 +138,16 @@
not receive, or any `*.woff2` (Segoe is licensed and git-ignored; the cloud gate uses the
fallback font by design).
- **Do not change** narration or palette; both are approved upstream.
- **`learn/tools/**` is READ-ONLY. Never edit the shared tools** (`assemble_scenes.py`,
`check_*.py`, etc.) to make a gate pass — they are shared across every video and an edit here
collides with every other branch. If a gate looks wrong, the composition is wrong, not the tool:
fix your scene/`scenes.json`. A genuine tool bug is an escalation to the human, not a per-video
patch. (The seam/bookend lines are already `// anchor-exempt` and the font `@font-face` is skipped
when the woff2 is absent — you do not need to touch the tool for either.)
- **Touch ONLY your own project.** The only files a video build may add or change are under
`learn/output/<slug>/` (plus the per-slug `learn/.gitignore` block the scaffolder wrote). Never
stage anything under `packages/producer/tests/**` — those fixtures can appear "modified" as an
LFS artifact; leave them alone. A CI scope gate fails any PR that reaches outside its project.
- **No scene-writer fan-out here.** The parallel `@hyperframes-scene-writer` path in the builder
doctrine is a local VS Code feature — in the cloud you author every scene yourself, sequentially.
- **Stay on the `video/<slug>` branch.** One video per branch; never touch `main` or another
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/close-issue-on-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# When a video PR merges into the `videos` archive branch, close its build issue. GitHub's native
# `Closes #n` auto-close only fires on merges to the DEFAULT branch (main) — these PRs target
# `videos`, so we close the issue ourselves. Primary link is a `Resolves #N` marker in the PR body;
# fallback matches the build issue by the branch slug ("Build video: <Slug>").

name: Close build issue on video merge

on:
pull_request:
types: [closed]

permissions:
issues: write
contents: read

jobs:
close:
if: >-
github.event.pull_request.merged == true
&& github.event.pull_request.base.ref == 'videos'
&& startsWith(github.event.pull_request.head.ref, 'video/')
runs-on: ubuntu-latest
steps:
- name: Close the linked build issue
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD: ${{ github.event.pull_request.head.ref }}
BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
# 1) explicit marker in the PR body: Resolves / Closes / Fixes / Tracking issue #N
num=$(printf '%s' "$BODY" | grep -oiE '(resolves|closes|fixes|tracking issue)[: ]+#[0-9]+' | grep -oE '[0-9]+' | head -1 || true)
# 2) fallback: match the build issue by the branch slug ("Build video: <Slug>")
if [ -z "${num:-}" ]; then
slug="${HEAD#video/}"
num=$(gh issue list --repo "$REPO" --state open --limit 100 --json number,title \
| jq -r --arg s "$slug" '.[] | select((.title | sub("^Build video: ";"") | ascii_downcase) == $s) | .number' | head -1 || true)
fi
if [ -n "${num:-}" ]; then
gh issue close "$num" --repo "$REPO" --reason completed \
--comment "Delivered — video PR #$PR merged into \`videos\`, and the rendered MP4 is archived on that branch. Closing automatically."
echo "closed issue #$num"
else
echo "::warning::no matching open build issue for $HEAD (PR #$PR)"
fi
76 changes: 71 additions & 5 deletions .github/workflows/render-video.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Renders a Learn video in GitHub Actions on a Windows runner — where Segoe UI is a system font,
# so `sync_fonts.py` regenerates the REAL brand woff2 on the runner (no Selawik, no committed fonts,
# pixel-identical to a local render). Same OS as the local pipeline, so render_and_package.py runs
Expand All @@ -23,9 +23,12 @@
required: false
default: ""
# Auto: the Copilot coding agent takes its PR out of draft when its session ends -> render it.
# Or add the `render` label to a video PR to (re-)render on demand. Both render pre-merge.
# Or add the `render` label to a video PR to (re-)render on demand (the label is removed after
# each run so it re-arms as a one-click button), or comment `/render` on the PR. All pre-merge.
pull_request:
types: [labeled, ready_for_review]
issue_comment:
types: [created]
# Called by the batch matrix (render-batch.yml) to fan a module's videos out in parallel.
workflow_call:
inputs:
Expand All @@ -43,19 +46,34 @@

jobs:
render:
# dispatch always; on a video PR when the agent marks it ready (session done) or you add `render`
# dispatch/call always; on a video PR when the agent marks it ready (session done), when you add
# the `render` label, or when you comment `/render` on the PR.
if: >-
github.event_name == 'workflow_dispatch'
|| github.event_name == 'workflow_call'
|| github.event.label.name == 'render'
|| (github.event.action == 'ready_for_review' && startsWith(github.event.pull_request.head.ref, 'video/'))
|| (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/render'))
runs-on: windows-latest
timeout-minutes: 120
steps:
- name: Resolve the branch to render
id: br
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$b = if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { '${{ inputs.branch }}' } else { '${{ github.event.pull_request.head.ref }}' }
switch ($env:GITHUB_EVENT_NAME) {
'workflow_dispatch' { $b = '${{ inputs.branch }}' }
'workflow_call' { $b = '${{ inputs.branch }}' }
'issue_comment' {
$b = gh pr view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json headRefName -q .headRefName
if (-not $b.StartsWith('video/')) { throw "comment /render is only allowed on video/<slug> PRs (got '$b')" }
}
default { $b = '${{ github.event.pull_request.head.ref }}' }
}
if (-not $b) { throw 'could not resolve the branch to render' }
Write-Host "Rendering branch: $b"
"branch=$b" | Out-File $env:GITHUB_OUTPUT -Append

- name: Checkout the video branch (with LFS media)
Expand All @@ -77,6 +95,18 @@
"dir=$p" | Out-File $env:GITHUB_OUTPUT -Append
Write-Host "Rendering project: $p"

# Fail in seconds with a clear message if the composition was never assembled, instead of
# dying deep inside packaging. A half-built branch (scenes authored, index.html never built)
# is the common cause of a render that "fails to kick off".
- name: Require an assembled index.html
shell: pwsh
run: |
$idx = Join-Path '${{ steps.proj.outputs.dir }}' 'index.html'
if (-not (Test-Path $idx)) {
throw "$idx is missing — the composition was never assembled. Run assemble_scenes.py and commit index.html, then re-render (add the 'render' label or comment /render)."
}
Write-Host "Found $idx"

- uses: actions/setup-node@v4
with:
node-version: 22
Expand Down Expand Up @@ -116,14 +146,50 @@

# Pre-merge review: drop the watch/download link right on the PR that triggered the render.
- name: Comment the artifact link on the PR
if: github.event_name == 'pull_request'
if: success() && (github.event_name == 'pull_request' || github.event_name == 'issue_comment')
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request?.number ?? context.payload.issue?.number;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
issue_number: prNumber,
body: `🎬 **Render complete** — download the MP4 + captions + thumbnail from this run's artifacts (\`video-${context.runId}\`): ${runUrl}#artifacts\n\nReview it, then merge to \`videos\` to archive.`,
});

# Tell the reviewer WHY a triggered render failed, right on the PR — so a failed render isn't
# a silent dead end.
- name: Comment on a failed render
if: failure() && (github.event_name == 'pull_request' || github.event_name == 'issue_comment')
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request?.number ?? context.payload.issue?.number;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `⚠️ **Render failed** — see the log: ${runUrl}\n\nFix the branch, then re-render: add the \`render\` label again, or comment \`/render\`.`,
});

# Make the `render` label a reusable button: remove it after every run (success OR failure) so
# re-adding it fires a fresh render. Without this, the label is a one-shot — the exact gap that
# made a failed render impossible to retry.
- name: Re-arm the render label
if: always() && github.event_name == 'pull_request' && github.event.label.name == 'render'
uses: actions/github-script@v7
with:
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
name: 'render',
});
} catch (e) {
core.info(`label re-arm skipped: ${e.message}`);
}
44 changes: 44 additions & 0 deletions .github/workflows/video-ci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Independent gate for cloud-built video PRs — runs OUR checks so a video PR earns a real green
# check, not just the agent's self-report. Runs on Linux: these gates are structure/color-based
# (no Segoe needed), so ubuntu-latest is fast and free. The brand-accurate render is separate
Expand All @@ -14,8 +14,42 @@

permissions:
contents: read
pull-requests: read

jobs:
# Fail fast if a video branch touched anything outside its own project. A cloud build should only
# ever add/edit learn/output/<slug>/** (plus the per-slug learn/.gitignore negation block the
# scaffolder writes). Edits to shared tools (learn/tools/**) or accidental LFS-pointer rewrites of
# packages/producer/tests/** fixtures are the two real contamination modes seen in practice — both
# are caught here before they can reach the `videos` archive.
scope:
if: startsWith(github.head_ref, 'video/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: A video PR may only touch its own project
uses: actions/github-script@v7
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const allowed = /^(learn\/output\/|learn\/\.gitignore$)/;
const bad = files.map(f => f.filename).filter(n => !allowed.test(n));
if (bad.length) {
for (const f of bad) core.error("out-of-scope change on a video branch", { file: f });
core.setFailed(
"A video branch must change ONLY learn/output/<slug>/** (plus learn/.gitignore). " +
`Out-of-scope files (${bad.length}):\n` + bad.join("\n") +
"\n\nShared tools and test fixtures are off-limits — fix the tool on main, then rebase."
);
} else {
core.info(`Scope OK — ${files.length} file(s), all within this video's project.`);
}

gate:
if: startsWith(github.head_ref, 'video/') # only video/<slug> PRs
runs-on: ubuntu-latest
Expand Down Expand Up @@ -50,6 +84,16 @@
if [ "$n" != "1" ]; then echo "::error::expected exactly one learn/output/<slug>, found $n"; exit 1; fi
echo "dir=$(ls -d learn/output/*/ | head -1 | sed 's:/*$::')" >> "$GITHUB_OUTPUT"

- name: Require an assembled index.html (the composition must be built)
shell: bash
run: |
idx="${{ steps.proj.outputs.dir }}/index.html"
if [ ! -f "$idx" ]; then
echo "::error file=$idx::index.html is missing — the composition was never assembled. Run assemble_scenes.py and commit index.html before marking the PR ready."
exit 1
fi
echo "Found $idx"

- name: Cross-file mount contract
run: python learn/tools/check_subcomps.py --project "${{ steps.proj.outputs.dir }}"
- name: No __FILL__ placeholders survive
Expand Down
Loading
Loading