feat(cli): hyperframes timeline prints a project's tracks and clips - #4138
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
terencecho
left a comment
There was a problem hiding this comment.
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) — symlinksoutside/secret.htmlintocompositions/link.html, expectschildren === [].reads a sub-composition whose folder name starts with two dots(:279) —mkdir ..scenes; write ..scenes/s.html, expectschildren.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.mdrow-2 (Specific-edit) adds the timeline hint.hyperframes-cli(666010a0… → 5892bfe8…) —SKILL.mddescription-list + Author step + docs table +upgrade-info-misc.mdnewtimelinesection.hyperframes-core(3e29cc24… → 3b3acf32…) —SKILL.mdtable row fortracks-and-clips.mdextended, 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)
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.
…keeps small volumes
…how to read a project
… file for nested clips Nested rows carry absStart/absEnd on the main timeline and their owning file, alongside existing local start/end. Text prints absolute time first, local time in parentheses. Adds jq/node one-liners for common queries, tested against a fixture.
d9d3bdf to
4c13281
Compare
… 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
left a comment
There was a problem hiding this comment.
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, orhostStart=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 (newcore/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)
An agent (or a person) that wants to know what is on a HyperFrames project's timeline currently has to open
index.htmland everydata-composition-srcfile 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.--jsonreturns the same object.Before
The agent read the project files to reconstruct the timeline (a launch project, 4086 lines over 12 files):
After
npx hyperframes timeline <project>(text table with bars, then the head of--json):No visible change (Studio)
The only Studio edit is an import swap:
describeClips.tsnow importsClipFact,byStartandformatClipLinefrom@hyperframes/core/clip-factsinstead of defining them, so the CLI,studio_lookand the Ask-agent prompt share one definition. Output is unchanged; the existingdescribeClipsandlookToolstests (25) pass untouched.Notes for review
packages/core/src/clipFacts.ts(lifted verbatim,formatClipLinenow exported). The CLI's own copy of thedata-startresolver (incommands/compositions.ts) was a clone of the engine'sreferenceResolver; it is deleted and bothcompositionsandtimelinenow use the engine'sresolveReferencedStart/resolveReferencedDuration(the latter newly exported). Grep for an existing static timeline describer found none in the CLI.topLevelElements/trackKindOf, so the CLI shows the same rows as the lint and the Studio timeline.duration=unauthoredmarks a clip with nodata-durationordata-end(media length is only known at render); a baddata-automationshowslanes unreadable: ...instead of silently empty lanes.label/roleare always null in the CLI.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 --noEmitcli and studio; all exit 0.timelineandcompositionsoutput 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..xwas wrongly rejected) is fixed in the final commit with a test. Timing: 3000 chaineddata-startreferences in about 3 s.