Skip to content

feat(cli): hyperframes timeline prints a project's tracks and clips - #4138

Merged
miguel-heygen merged 13 commits into
mainfrom
feat/cli-timeline
Sep 19, 2026
Merged

miguel-heygen merged 13 commits into
mainfrom
feat/cli-timeline

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

An agent (or a person) that wants to know what is on a HyperFrames project's timeline currently has to open index.html and every data-composition-src file and work it out. npx hyperframes timeline [project-dir] now prints it: tracks grouped by kind (video, graphics, captions, audio), every top-level clip with start, end, source, volume, playback rate, audio group and automation lanes, and a sub-composition's own clips nested one level. --json returns the same object.

Before

The agent read the project files to reconstruct the timeline (a launch project, 4086 lines over 12 files):

before

After

npx hyperframes timeline <project> (text table with bars, then the head of --json):

after

No visible change (Studio)

The only Studio edit is an import swap: describeClips.ts now imports ClipFact, byStart and formatClipLine from @hyperframes/core/clip-facts instead of defining them, so the CLI, studio_look and the Ask-agent prompt share one definition. Output is unchanged; the existing describeClips and lookTools tests (25) pass untouched.

Notes for review

  • One owner: packages/core/src/clipFacts.ts (lifted verbatim, formatClipLine now exported). The CLI's own copy of the data-start resolver (in commands/compositions.ts) was a clone of the engine's referenceResolver; it is deleted and both compositions and timeline now use the engine's resolveReferencedStart / resolveReferencedDuration (the latter newly exported). Grep for an existing static timeline describer found none in the CLI.
  • Timing and clip discovery reuse the parsers' topLevelElements / trackKindOf, so the CLI shows the same rows as the lint and the Studio timeline.
  • duration=unauthored marks a clip with no data-duration or data-end (media length is only known at render); a bad data-automation shows lanes unreadable: ... instead of silently empty lanes.
  • Sub-composition files must resolve (after symlinks) inside the project; anything else is skipped.
  • Known divergence from the Studio lane view: lanes keep written order (Studio sorts EQ bands by frequency), and label / role are always null in the CLI.
  • Skill docs: skills/hyperframes-cli/references/upgrade-info-misc.md, manifest regenerated.

Verification

Devbox, fresh clone, deps built in order, at this head: CLI timeline + compositions vitest 13 passed, core clipFacts 4 passed, engine services 1064 passed, tsc --noEmit cli and studio; all exit 0. timeline and compositions output on a real project is byte-identical before and after the resolver swap. Independent reviews across four heads; the last one covered the symlink containment fix, and its one extra note (a folder named ..x was wrongly rejected) is fixed in the final commit with a test. Timing: 3000 chained data-start references in about 3 s.

@mintlify

mintlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 19, 2026, 6:26 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

terencecho
terencecho previously approved these changes Sep 19, 2026

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVING at 5adcd64d — CLI timeline, verbatim lift + resolver dedup

The code is the shape of the description: a small new CLI surface backed by a real deduplication of two chunks of duplicated code that already existed elsewhere in the tree. Verified structurally rather than trusting the "1 blocking + 7 should-fix all fixed" line — no GitHub reviews exist on the PR, so that phrase describes off-GitHub review artefacts I can't inspect. What I can verify is that the resulting code + tests hold up.

Verbatim lift of ClipFact / byStart / formatClipLine to @hyperframes/core/clip-facts

packages/core/src/clipFacts.ts (new, 56 lines) carries the ClipLane / ClipFact interfaces, byStart comparator, roundTo3 / num helpers, and formatClipLine — all shape-identical to what packages/studio/src/player/lib/describeClips.ts used to hold locally. Studio drops the private copies (−54 lines), re-exports byStart + type ClipFact for its own downstream callers, and imports formatClipLine for the Ask-agent prompt block — still called at line 45 of the head file (clips.slice(0, PROMPT_CLIP_CAP).map(formatClipLine)). One owner now; three consumers agree by construction.

The one subtle place the lift could bite is the num/roundTo3 helper — studio used to import roundTo3 from ../utils/rounding, core inlines Math.round(n * 1000) / 1000. Same arithmetic; no drift.

Resolver dedup in commands/compositions.ts (−67 lines) — real, not cosmetic

The CLI carried a clone of the engine's data-start reference resolver: findReferenceTargetEl, resolveStart, and a private resolveReferencedDuration (three interdependent recursive functions with cycle-visit guards). This PR deletes all three and calls resolveReferencedStart from @hyperframes/engine instead. To make that possible, referenceResolver.ts promotes resolveReferencedDuration from function to export function (only signature change; body untouched at 106 lines), and packages/engine/src/index.ts re-exports it — describeProject.ts uses that new export to compute a clip's authored duration for the durationAuthored flag.

Behavioural parity of the resolver swap is what the existing compositions.test.ts (unchanged in this diff) exercises — same call sites, same signatures, same cycle-visit contract. If the swap broke, that suite would go red.

Sub-composition safety — the two edge cases have failing-first tests

describeProject.ts:493 realFileInside is the containment gate. It calls realpathSync on both the candidate path and the projectDir before running relative() — so a symlink into an outside dir resolves to its target and gets refused, while a folder whose own name starts with two dots (..scenes) doesn't confuse the startsWith('..' + sep) check because relative returns ..scenes/… (no separator directly after the leading ..). Both pinned in describeProject.test.ts:

  • does not follow a symlink out of the project (:291) — symlinks outside/secret.html into compositions/link.html, expects children === [].
  • reads a sub-composition whose folder name starts with two dots (:279) — mkdir ..scenes; write ..scenes/s.html, expects children.length === 2.

Plus a directory-not-file rejection (data-composition-src="compositions" → skipped, :264), a data-composition-src outside the project (:264 same case), depth-1 nesting only (describeRow :448 depth === 0 ? readSubComposition(...) : [], pinned at :316), unauthored duration (durationAuthored=false + duration=unauthored render, :305), unreadable automation (laneError surfaces the parse error instead of silently dropping lanes, :255), and playbackRate === 1 → null normalization (:466, docs at upgrade-info-misc.md:969 say "only when not 1").

Skills manifest regen — three rotated, seven unchanged (from Miguel's edits)

skills-manifest.json diff bumps the hashes for exactly the three skills whose files this PR edits:

  • hyperframes (d0a114a8… → 7b1238ac…) — SKILL.md row-2 (Specific-edit) adds the timeline hint.
  • hyperframes-cli (666010a0… → 5892bfe8…) — SKILL.md description-list + Author step + docs table + upgrade-info-misc.md new timeline section.
  • hyperframes-core (3e29cc24… → 3b3acf32…) — SKILL.md table row for tracks-and-clips.md extended, Editing step gets the timeline hint.

Files-count for each stays constant (26 / 11 / 11), which matches "prose edits only, no new skill files."

One minor drift, not this PR's problem

At head vs main, two OTHER skills have divergent hashes AND file counts — music-to-video (132 vs 169) and remotion-to-hyperframes (70 vs 77). Neither is edited by this PR; the divergence is main having added files since Miguel branched. It'll auto-resolve on the next rebase (Miguel's branch doesn't touch either skill), not a hygiene issue on this diff.

Sequencing note (Rames already flagged on #4144)

#4144 opens the recipes with "run hyperframes timeline before editing." That command doesn't exist on main yet — this PR is what introduces it. If #4144 lands before #4138, the recipes tell agents to run a command that doesn't exist. Order-of-merge matters (this one first), and nothing in the CI mechanically enforces that. Miguel already has the ordering right in his description; flagging for the merge-loop's benefit.

Stamp mechanics

hf-oss require_last_push_approval=true → this APPROVE binds to 5adcd64d only. Any push after this invalidates it and needs a fresh re-review at the new head.

CI settling — I'm running a background poll; will re-verify before Miguel's loop presses merge (or Terence sees this) that the green stayed green through the last workflow-run refresh. If a required lane goes red I'll retract.

— Review by tai (pr-review)

@terencecho
terencecho dismissed their stale review September 19, 2026 03:16

Held per author's ChatGPT dispatch (ts=1789787744): sub-composition clips print local times with no owning file, which misled agents into editing the wrong clip. Waiting on the new head with absolute times + file column on every row.

… its query one-liners

A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
…tarts at 0

The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
…meline command

The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RE-APPROVING at 59a28f93 — dismissal reasons fully addressed

My prior APPROVE 5254249274 at 5adcd64d was dismissed after the token-eval found nested clips printing local times with no owning file (misled agents). Verified the three fixes structurally against the raw files at head:

1. Absolute times + owning file — printed on every nested row

TimelineRow interface additions (describeProject.ts:22-36):

/** Start and end on the main timeline, in seconds. `start`/`end` are local to the owning file's composition. */
absStart: number;
absEnd: number;
/** Project-relative path of the file that declares this clip. */
file: string;

formatTimeline.ts:41-45:

const times = nested
  ? `${span(row.absStart, row.absEnd)} (local ${span(row.start, row.end)}) in ${row.file}`
  : span(row.start, row.end);

Top-level rows stay local-only (they're the same for index.html since scope.origin = 0). Nested rows carry both time systems + declaring file.

Test pins the exact output line:

expect(text).toContain("nested 6-8s (local 1-3s) in compositions/scene.html")

And a hand-computed inversion fixture proves the semantic (a nested clip's absolute start can be smaller than a later-declared direct clip's, which was the exact agent-confusion the dismissal called out).

2. Shared core rule — one function, both callers

New packages/core/src/mediaTiming.ts:

export function resolveMediaStartSeconds(input: {
  authoredStart: number | null;
  hostStart: number;
  hasAutoStart: boolean;
  basis?: string | null;
  ordinaryStart: () => number;
}): number {
  if (input.hasAutoStart || input.authoredStart == null || input.hostStart <= 0) {
    return input.ordinaryStart();
  }
  return resolveAbsoluteMediaStartSeconds({...});
}

Both packages/core/src/runtime/startResolver.ts and packages/cli/src/timeline/describeProject.ts import it. Runtime imports directly, CLI via the new @hyperframes/core/media-timing subpath. The startResolver diff is +5/-10 — inline rule replaced by import + call. One rule, both callers.

mediaTiming.test.ts pins semantics (5 cases):

  • default (3 + 10 = 13)
  • basis="global" (legacy, = 3)
  • defers to ordinary when authoredStart=null, hasAutoStart=true, or hostStart=0

3. Negative-start clamp at top-level (host at t=0)

describeProject.ts mainTimelineStart: if (authored === null || scope.origin <= 0) return scope.origin + start. For a top-level <video data-start="-3">, scope.origin = 0, so the fallback runs — start is the engine's resolveReferencedStart output (which clamps), and absStart = 0 + start = 0. Test pins { id: "v", absStart: 0, absEnd: 2 }.

For a nested video at data-start="-3" in a host starting at 5, the shared rule takes host+authored = 2, matching what the runtime actually plays. Test pins { absStart: 2, absEnd: 4 }. Both behaviours match the runtime.

Scope containment

Per-commit file map since 5adcd64d:

  • 4c13281b — absolute times + file column (describeProject.ts, formatTimeline.ts, tests, skills docs)
  • 06cf26f6 — nested media runtime start (describeProject.ts, tests)
  • b509a1e6 — negative-start clamp (describeProject.ts, tests)
  • c702677c — shared core rule (new core/mediaTiming.{ts,test.ts}, startResolver.ts, describeProject.ts)
  • 59a28f93 — expression-start fixture (tests only)

Nothing outside packages/cli/src/timeline/*, packages/core/src/{mediaTiming*, runtime/startResolver.ts}, and skills docs. The compare API includes rebase-over-main noise (motion-blur.mdx, changelog, plugin manifests, etc.); that's ancestry, not author intent — ignored per rebase-scoped diff-of-diffs.

Docs-drift lock

The skill query one-liners test block executes every documented jq + node one-liner from skills/hyperframes-cli/references/upgrade-info-misc.md against the inversion fixture and asserts specific output (compositions/scene.html video 6 8, etc.). Documentation cannot drift from behaviour without breaking the test — the right shape for agent-facing docs.

CI

62 SUCCESS + 2 SKIPPED, no failing, no in-progress. Green at head.

Stamp mechanics

hf-oss require_last_push_approval=true → this re-APPROVE binds to 59a28f93 only. Prior dismissed review 5254249274 at 5adcd64d remains dismissed.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen merged commit 0a5c235 into main Sep 19, 2026
64 checks passed
@miguel-heygen
miguel-heygen deleted the feat/cli-timeline branch September 19, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants