docs(skills): an agent adds media with the duration Studio gives a dropped file - #4144
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
Approved. Sole reviewer, so I read it closely. Stacked on docs/agent-editing, so I took the PR's own three files rather than a two-dot diff; base and merge base agree here at 4a6b1751.
The load-bearing claim is the drift guard, and it is real. "Tied to Studio's defaults by a test" is the kind of sentence that is usually satisfied by a test hard-coding both sides. This one does not: it reads DEFAULT_TIMELINE_ASSET_DURATION out of studioHelpers.ts and builds the expected doc strings from the captured values, so the doc is compared against Studio's source rather than against a second copy of the same literal.
It also fails closed where it matters. If the DEFAULT_TIMELINE_ASSET_DURATION match breaks, defaults collapses to "" and assert.ok(secs("image"), "could not read Studio's image default") fires rather than the suite quietly passing on an empty capture. Same for the section split and the <img> extraction — both degrade to "", and every downstream assert.match against "" fails. "Cannot read the source" and "matches the source" are distinguishable states here, which is the part this shape usually gets wrong.
I verified it currently holds rather than assuming it: Studio really is { image: 3, video: 5, audio: 5 }, useTimelineAssetDropOps.ts:106 really does call fitTimelineAssetGeometry(null, …), and every assertion in the new test passes against the ## Add media section as written. And the guard genuinely runs — scripts/creator-editing-recipes.test.mjs is enumerated in test:scripts, so this isn't a test that exists without executing.
Tying the geometry claim to fitTimelineAssetGeometry(null, …) is the nicest touch: "fill the whole frame" is only correct because Studio passes null for natural size, so pinning the call is what makes the prose falsifiable. The doc naming its own source of truth inline is the right call too.
Non-blocking — the guard's reach is narrower than "cannot drift". What it pins is the duration defaults, the fill-frame geometry, and four attribute names. The section also teaches muted playsinline for video, data-volume="1" for audio, object-fit: contain, z-index as "top-level clips plus one", that Studio raises the root data-duration to cover the new clip, and that several files dropped together run end to end. I checked each against timelineAssetDrop.ts — muted playsinline at :129, data-volume="1" at :132, object-fit: contain at :122 — so all of it is accurate today; it is simply unpinned, and those are the lines that will drift silently. Cheap to extend the existing loop to cover them.
Minor: in the attribute loop, the source side checks the full class="clip" but the doc side checks attr.split("=")[0], i.e. only class. A doc example that drifted to class="shot" would still pass. The other three entries have no =, so they are unaffected.
One cross-PR note, from reviewing #4142 alongside this. Studio's image default is 3, and #4142's timeline drop preview hard-codes its own DROP_PREVIEW_SECONDS = 3 in TimelineCanvas.tsx. Two unlinked literals that agree by coincidence: change DEFAULT_TIMELINE_ASSET_DURATION.image and this doc updates under its guard while the drop preview keeps drawing a 3-second box, with nothing to catch it. Worth noting that an image's length is the one case that is knowable before the drop, so the preview could read the same constant. For video and audio the real fallback is 5, so the 3-second box is nominal there regardless — #4142 discloses the width as a default, so this is a refinement rather than a defect in either PR.
— Rames
4a6b175 to
351eb48
Compare
788cfd0 to
43f08ed
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approving after the restack onto #4125. I re-verified the delta rather than assuming a rebase is a no-op.
The restack changed nothing this PR owns.
- The PR's own patch is content-identical to the head I approved (
788cfd01): samegit patch-id(d3fed48b…) across both merge-base→head ranges. A first-commit-only control hashes differently, so that comparison actually discriminates. scripts/creator-editing-recipes.test.mjsandskills/hyperframes-core/references/creator-editing-recipes.mdare byte-identical on both sides — same pre- and post-image blob SHAs.- Only
skills-manifest.jsondiffers, and only in its neighbours: the new base carriesmusic-to-videoandremotion-to-hyperframeshash updates. This PR's own hunk (hyperframes-core) is unchanged.
The manifest hash is correct at this head. I reimplemented hashSkillBundle from source and validated it against two independently committed values (hyperframes-core → 054fe5577b064927/11 files, motion-graphics → 32641ae2b94c4a8f/23) before trusting it. It then reproduces 6a1e879292035eed/11 exactly. A full --check equivalent across all 21 skills is in sync, and a perturbed-file control reports drift — so that "in sync" is a measurement, not an empty pass.
The guard still runs, and still bites. All 15 paths the test reads are untouched by the base move — control: the same pathspec query does find skills/music-to-video/sub-agents/frame-worker.md, so the absence is real and not a silent no-match. Running the file at this head: 6/6 pass. Two negative controls each fail it — changing the doc's data-duration="3", and changing Studio's own image: 3 — so it catches drift from either side, then returns to green.
Carrying forward from the prior review, both non-blocking and unchanged:
- The guard's reach is narrower than "cannot drift":
muted playsinline,data-volume="1",object-fit: contain, the z-index rule and the root-duration raise are accurate today but unpinned. DEFAULT_TIMELINE_ASSET_DURATION.image = 3and #4142'sDROP_PREVIEW_SECONDS = 3are two unlinked literals agreeing by coincidence; if the drop default moves, the preview width won't follow.
Mechanics, not an objection: base is docs/agent-editing, so this lands after #4125.
— Rames
jrusso1020
left a comment
There was a problem hiding this comment.
Third pass, at c663bf9d. Delta from the head I last approved is one doc line plus the manifest bump — I read it rather than assuming a one-liner is safe.
The new line's every promise checks out — against #4138, not against this branch.
Before any edit, run npx hyperframes timeline(add--json …). In #4138: packages/cli/src/commands/timeline.ts exists, meta.name is timeline, json is a real boolean flag (Output as JSON), the command's own description — "Print the project's tracks and clips (start, duration, source, volume, rate)" — matches what the doc claims it shows, and it's wired into cli.ts. So the sentence is accurate.
It is not accurate yet at this head. There is no timeline command in packages/cli/src at c663bf9d or in this PR's base — the same query does find the render command registrations, so that's a real absence and not a silent no-match. You've got the order right in the description (#4138, then #4125); the note is just that nothing enforces it. If #4144 landed first, the recipes would open by telling an agent to run a command that doesn't exist, and the drift guard wouldn't catch it — no assertion in creator-editing-recipes.test.mjs references the command (control: the file does contain timeline tokens, so the grep was looking). Non-blocking, and your call whether it's worth pinning.
Re-verified the mechanical parts at this head:
- Manifest
hyperframes-core→e03aa330b66fca65/11 files, reproduced with the same reimplementation I validated earlier against two already-committed values (054fe5577b064927, and6a1e879292035eedfrom the previous head). - Guard runs 6/6. Negative control — break the doc's
data-duration="3"— takes it to 5/1, then it returns green.
One gate mechanic worth stating, since it cuts against the usual assumption: docs/agent-editing is not a protected branch, so unlike a PR targeting main, an approval here is not invalidated when you push — my earlier stamps at 788cfd01 and 43f08ed2 stayed live against heads they never covered. That's why I've re-read each head rather than leaning on the existing approval.
Earlier non-blocking notes still stand unchanged.
— Rames
c663bf9 to
25c0628
Compare
The base branch was changed.
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approved at 25c0628b. I re-read and re-ran this head rather than leaning on the approval already on record — for a reason worth writing down.
Why this needed a fresh pass
My previous approval (5254081264) is recorded against 25c0628b63ec…, i.e. this exact head. That association does not hold up here. The commit object at that SHA carries committer date 2026-09-19T02:50:55Z (raw git: committer … 1789786255 -0400), while the review was submitted at 01:58:19Z — 53 minutes earlier than the commit it claims to have reviewed. The other two approvals on this PR are ordered correctly (788cfd01 committed 01:06:39Z / reviewed 01:15:11Z; 43f08ed2 committed 01:17:01Z / reviewed 01:27:42Z), so this is the latest review being carried across the 03:07:36Z force-push, not a clock offset.
Operationally: on this PR commit_id == headRefOid did not establish that the approval covered the bytes now at the head. So everything below was re-derived against 25c0628b directly.
What I verified
Merge base computed rather than taken from base.sha; they agree at a2a05389.
skills-manifest.json is correct at this head. I reimplemented hashSkillBundle from packages/cli/src/utils/skillsManifest.ts independently — sorted full paths, .DS_Store skipped, POSIX relative path + \0 + CRLF-normalised text (or raw bytes for non-text) + \0, sha256, first 16 hex — and recomputed all 21 skills: 21/21 match the committed manifest, including this PR's changed hyperframes-core → e03aa330b66fca65. The 20 hashes this PR doesn't touch are the control that the reimplementation is faithful rather than tuned to the answer.
The new test passes at this head, and it measures. scripts/creator-editing-recipes.test.mjs reads three files that sit outside this PR's diff — studioHelpers.ts, useTimelineAssetDropOps.ts, timelineAssetDrop.ts — so a restack can break it without touching a changed line. It didn't: 6/6 green. Five negative controls each turn it red: image duration 3→4 in the prose, 3→4 in the example, frame 1920×1080→1280×720, dropping "fill the whole frame", removing data-track-index.
One non-blocking finding
The doc-side assertion in the new loop is weaker than its Studio-side twin:
for (const attr of ["class=\"clip\"", "data-start", "data-duration", "data-track-index"]) {
assert.ok(drop.includes(attr), `Studio no longer writes ${attr}`);
assert.ok(image.includes(attr.split("=")[0]), `doc example lacks ${attr}`);
}attr.split("=")[0] reduces class="clip" to class, so on the doc side only the attribute name is checked — and class="clip" is the one entry in that list whose value is what Studio keys on. Measured: changing the example to class="banner" leaves the suite green, 6/6. The Studio side is fine (drop.includes('class="clip"') compares the full string); the asymmetry is doc-side only.
The fix is deleting .split("=")[0]. attr is already a substring of every example attribute (data-start matches data-start="4"), so the plain attr works for all four entries. Verified: baseline stays 6/6, the class="banner" arm goes red, restoring returns to green.
Not a blocker — the section is accurate today, and this only weakens a future regression check on it.
Gate mechanics
This PR's base is docs/agent-editing, whose ruleset is ["required_signatures"] only — no pull_request rule, so no approval is required at this base and mergeStateStatus was already CLEAN before this review. The approval requirement (pull_request, require_last_push_approval: true) binds at main, which this stack reaches through #4125 and #4138.
— Rames
|
Addendum — the base moved out from under this review about a minute after I posted it. This supersedes the "Gate mechanics" paragraph in my approval above. When my pre-post guard ran, this PR's base was
The conflict is not in your work. #4125 was squash-merged, so its head A restack onto the new Two things change with the retarget:
— Rames |
25c0628 to
59b959c
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approved at 59b959c5 after the rebase onto main. This is a re-run, not a carry-over of 5254313671 — main sets require_last_push_approval: true, so that one stopped counting the moment you pushed, exactly as flagged.
The rebase claim checks out, two independent ways. git range-diff reports all three commits = (6774fc5e→bca737ed, 205a4536→0267c0a8, 25c0628b→59b959c5), and the net diff against each PR's own base is byte-identical — 7043 bytes both sides, cmp clean. The merge base is now the main tip (2db126d4c8) exactly, so this is a clean rebase with nothing replayed.
But an identical diff is not evidence the PR still works, which is the reason to re-run rather than wave it through: creator-editing-recipes.test.mjs reads three files that are outside this diff — studioHelpers.ts, useTimelineAssetDropOps.ts, timelineAssetDrop.ts — and the base it reads them from is a different main than an hour ago (#4125 squashed, #4146, the v0.8.50 release). Re-run against the new tree: 6/6 green, and a perturbation control (image duration 3→9) still turns it red and restores to green, so the suite is measuring at this head and not just passing.
Manifest re-verified against the new base, since main moved underneath it. Independently recomputing hashSkillBundle over all 21 skills: 21/21 match, hyperframes-core still e03aa330b66fca65, and no files-count drift either — worth noting because gen-skills-manifest --check compares only name:hash through signature(), so a count drift would not have been caught by CI.
The one finding from my previous review carries over unchanged, same file, same line: the doc-side assertion image.includes(attr.split("=")[0]) reduces class="clip" to class, so changing the example to class="banner" leaves the suite green. Deleting .split("=")[0] closes it — verified the plain attr keeps the baseline at 6/6 and turns that case red. Still non-blocking.
Gate now: base main, ruleset carries pull_request (required_approving_review_count: 1, require_last_push_approval: true), so this approval opens it and any further push closes it again.
— Rames
jrusso1020
left a comment
There was a problem hiding this comment.
Re-stamp at bdfa80fd3d90. My previous approval was at 59b959c56adb; the push reset it, so this one sits on the exact head.
The delta is ahead=2, behind=0 — additive, no rebase — and touches exactly one file, scripts/creator-editing-recipes.test.mjs (+38/-11). That matches the claim.
I checked the split assertion by assertion, since a complexity-driven refactor is the easy place to lose one. All eight survive: the image-default ok guard, the three duration matches (section image, section video, <img> element), the fitTimelineAssetGeometry(null, probe, the "fill the whole frame" prose match, the full-frame geometry match, and the four-attribute loop with both its drop.includes and image.includes halves. Nothing was dropped or loosened; the three new test names partition the old body cleanly.
Two things I looked at specifically because they are where this kind of split usually breaks:
studioDefaultSecondswent from a sync closure to anasyncfunction, and both call sitesawaitit. An un-awaited call would have madeassert.ok(imageSecs)pass on the Promise and then interpolated[object Promise]into the regex — silently green on a broken check. Both are awaited.addMediaSectionrewrotesplit("## Add media")[1]?.split("\n## ")[0] ?? ""as a two-step with the?? ""moved earlier. Both forms yield""when the heading is absent, so the empty-section behaviour is unchanged.
Unchanged from before and not introduced here: there is no assert.ok(videoSecs) to match the image one, so a missing video default would fail through the regex rather than the guard. Same in the original; only worth a line if someone touches this again.
— Rames
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 2dac3dbfd190. My approve 5254659840 was at bdfa80fd3d90; two commits landed since (7a50edf8, 2dac3dbf), and they are not the test-only change the lane note described — they reverse the doc's video/audio duration rule. Gate mechanic: hyperframes-OSS sets require_last_push_approval: true, so this reads REVIEW_REQUIRED at this head regardless of the six earlier approvals.
The behavioral claim is correct, and I checked it first. compileHtml probes media when data-duration is absent (packages/core/src/compiler/htmlCompiler.ts:25) and phase 2 clamps an authored value down to the real source length (:68); timingCompiler.ts:12 names "videos relying on source duration" explicitly. So "video and audio need no data-duration" is true, and dropping it from the two examples is right. The new example test is a good addition — it genuinely holds the three examples to the bullets.
Two things the push broke.
1. The recipe now requires a number it no longer tells the agent how to get — and the test forbids restoring it
Bullet 5 is unchanged in substance:
make sure the root composition's
data-durationis at least the clip's end (data-startplus its length: 3 for an image, the media's length for video and audio)
The agent still has to know the media's length to do that step. The one thing in the section that supplied it — ffprobe -v error -show_entries format=duration -of csv=p=0 file — was deleted in this push, and scripts/creator-editing-recipes.test.mjs:100 now asserts assert.doesNotMatch(section, /ffprobe/). addMediaSection() scopes that to this section, so Add-media can never state the answer again without failing the suite.
The same file already prescribes it 58 lines later, in Swap a media file: "set data-duration no longer than the new file's remaining length (probe it with ffprobe)" — outside the test's scope. So the document now bans ffprobe in the one section that needs it and teaches it in the next. It is also what the compiler itself uses: htmlCompiler.ts documents probeMediaDuration as "probe media duration (e.g., via ffprobe)".
Line 5 of the doc ("Before any edit, run npx hyperframes timeline") does not close this — it is a before-the-edit survey of existing clips, and the file being added is not in the timeline yet.
Cheapest fix: put the length source back in bullet 5, and drop or narrow the doesNotMatch(/ffprobe/) assertion.
One thing that cuts your way, which I'd rather you had than not: for video and audio the runtime player already carries a media floor — init.ts:605-619 walks video[data-start], audio[data-start] and raises the playable window to start + duration. So the bullet's stated consequence ("the clip lies past the end and never plays") looks softer than it claims for exactly the two kinds this push changes, since plain clips don't feed resolveAuthoredCompositionDurationFloorSeconds (:642 requires data-composition-id) but media clips do feed the media floor. Either the rationale in bullet 5 is stale for media, or the root step matters on a path I haven't traced (render, not player). Worth pinning down — if the media-duration resolver PR you mention makes the root extend itself, say so in the bullet and this whole finding dissolves.
2. The section still advertises a drift guard that no longer covers video or audio
The intro still says a test keeps the section "equal to" DEFAULT_TIMELINE_ASSET_DURATION and buildTimelineAssetInsertHtml. It isn't, by design: buildTimelineAssetInsertHtml:120 builds sharedAttrs with data-duration and hands it to <img> (:125), <video> (:129) and <audio> (:132) alike, so Studio writes a duration for all three kinds. Your "the one difference is..." wording covers the divergence; the equality sentence sitting next to it no longer holds.
What concerns me more is that the assertion which would have caught drift went with it:
const videoSecs = await studioDefaultSeconds("video");
assert.match(section, new RegExp(`\`${videoSecs}\` only when the length cannot be read`));studioDefaultSeconds reads DEFAULT_TIMELINE_ASSET_DURATION out of studioHelpers.ts — a real cross-source check. The three replacements (match(/`data-start` is enough/), doesNotMatch(/ffprobe/), match(/root composition's `data-duration` is at least/)) compare the doc to string literals this same commit wrote. None can fail if Studio's drop path changes. The test is still named "the add-media recipe uses Studio's default durations" and now verifies that for images only.
If the divergence is deliberate, guard the divergence itself: assert buildTimelineAssetInsertHtml still emits data-duration in sharedAttrs, so "the one difference" is provably still exactly one difference.
— Rames
6ffeb5f to
df5577a
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 3d5aba94. My prior CHANGES_REQUESTED 5255060617 is pinned to 2dac3dbfd190, which the rebase replaced, so this re-derives both findings against the code that exists now rather than re-citing the old reasoning.
A note on reading this one: the head-to-head diff from my last pin is 85 commits, almost all of it main's work pulled in by the rebase (the long-form render stack, Tailwind v4, the storyboard removal). The PR's own diff is 4 files, +118/-2. Everything below is from that.
Finding 2 is resolved — and guarded the way I hoped
studioDefaultSeconds("image") is now used, twice: defaults to ${imageSecs} seconds and ${imageSecs} for an image unless you set another. That reads DEFAULT_TIMELINE_ASSET_DURATION out of studioHelpers.ts, so the 3 in the doc is pinned to the 3 in code and a change on either side goes red.
Better, the new "the add-media example carries every attribute Studio's drop writes" test reads timelineAssetDrop.ts and asserts it still contains data-duration. That is the divergence guard I asked for: the intro's claim is that the one difference is video and audio needing no data-duration, and this now fails if Studio stops writing one — so "exactly one difference" is provably still one, not asserted by prose. Nothing to add here.
Finding 1 still stands, and this push made it sharper rather than smaller
Bullet 5 is unchanged in the part that matters:
make sure the root composition's
data-durationis at least the clip's end (data-startplus its length: 3 for an image unless you set another, the media's length for video and audio)
The agent still has to obtain the media's length. Three things I checked at this head:
-
ffprobeappears exactly once in the whole file — line 487, in Swap a media file: "setdata-durationno longer than the new file's remaining length (probe it withffprobe)". The Add-media section has no length source at all, andscripts/creator-editing-recipes.test.mjsstill assertsdoesNotMatch(section, /ffprobe/), scoped byaddMediaSection(). So the document still forbids naming the tool in the one section that needs it and prescribes it in the next. -
The instrument this PR adds cannot supply the number. The new intro line says "Before any edit, run
npx hyperframes timeline… instead of reading the HTML". #4138 merged at 08:27, so that command is real now — and inpackages/cli/src/timeline/describeProject.tsa row's duration isconst duration = authored ?? inner, whereauthoredisresolveReferencedDuration(...)andinnerfolds overchildren. For a<video>/<audio>with nodata-durationand nodata-composition-src,childrenis[], soinneris0and the row reportsduration=0,end=start. The field's own doc says it outright: "False when the source does not author a duration (media length is only known at render)." (Read at source; I did not run the CLI.) -
Which makes the failure quiet rather than loud. An agent that follows this section literally — run
timeline, then make the rootdata-durationat least the clip's end — readsend = data-startfor the video it just added, concludes the root already covers it, and writes nothing. The check passes while the answer is wrong. That is worse than the agent simply not knowing the number, because there is no point at which it looks stuck.
Two possible fixes, and I can't tell which one is right from here. The PR body says this lands after a media-duration resolver PR. I could not find that PR open in this repo, and @hyperframes/parsers/media-duration is not on main, so I can't read what it does to the root:
- If the resolver makes the root cover media on its own, then bullet 5's "the media's length for video and audio" clause should be deleted, not kept — the agent would have nothing to compute, and leaving the instruction in is what creates the trap above.
- If it doesn't, the section needs a length source, and the next section already has the right one. Drop or narrow
doesNotMatch(/ffprobe/)so it can say so.
Either way the current wording asks for a number the section refuses to help obtain, so I'm holding the block rather than guessing which branch you intend.
Gate state
reviewDecision is CHANGES_REQUESTED and it is mine. The PR is still a draft, and the body is explicit that it stays one until the resolver lands ("On main today an image with no data-duration never ends"), so I'm not treating this as a ready-to-merge stamp either way — that's the PR's own posture, not a caveat I'm adding.
The rest of the push is good work: the tracks-and-clips.md image line now matches the recipe, and the PR body says plainly where the doc is ahead of main instead of hiding it. Fix the bullet-5 gap and this is a clean approve.
— Rames
…r the CRAP-score gate
…d a test guards it
Clears the Fallow complexity finding on the test callback.
3d5aba9 to
d0f48c5
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at d0f48c54. Lifting my own CHANGES_REQUESTED 5255236476. The branch I said I couldn't pick last time is now decidable at source, and it rules out the harmful reading — so the finding survives, but it survives as a should-fix rather than a block.
The two things my block was waiting on
1. #4166 is merged (09aadd86), and the image half of this PR is true at runtime now. resolveTimedImageDurationSeconds (packages/core/src/runtime/playbackRate.ts:76) gives a timed <img> — one carrying data-start or data-track-index — resolveMediaDuration({ tag: "img", ... }), which returns DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS = 3 when nothing is authored. It is consumed by clipTree.ts:79, startResolver.ts and timeline.ts. So tracks-and-clips.md's "data-duration is optional and defaults to 3 seconds" is a statement about shipped behaviour, not an anticipation of it. That was the PR's own gating claim and it checks out.
2. The media half is covered by the pipeline, not by the agent. The producer probes every video/audio without a data-duration and injects the real length before capture (packages/producer/src/services/htmlCompiler.ts:426; core's compileHtml phase 1 does the same). Then at runtime resolveMediaWindowDurationSeconds (init.ts:913) walks video[data-start], audio[data-start] and folds max(start + duration) into the duration floor, and getSafeTimelineDurationSeconds (init.ts:1209-1220) takes Math.max(timelineDuration, durationFloor, fallbackDuration) — the root's declared data-duration is a floor there, never a cap.
That rules out the branch I was actually afraid of. Leaving the clause in does not create a trap; it creates a redundant instruction. I'm not holding a docs PR on that.
Should-fix: bullet 5 has the two kinds the wrong way round
creator-editing-recipes.md:430:
Then make sure the root composition's
data-durationis at least the clip's end (data-startplus its length: 3 for an image unless you set another, the media's length for video and audio): Studio raises a declared root duration to cover the new clip, so an agent must too, or the clip lies past the end and never plays.
- Video and audio — the clause the agent cannot perform, and the one it does not need to. There is still no length source in the section:
ffproberemains banned inside it byscripts/creator-editing-recipes.test.mjs:100(assert.doesNotMatch(section, /ffprobe/)) while line 487, 57 lines later, prescribes it for Swap a media file. And the floor above already covers the clip, so the bolded consequence is false for exactly the two kinds this PR changes. - Images — the clause that is load-bearing, and the one the agent can do. An
<img class="clip" data-start="4">is in no floor at all:resolveMediaWindowDurationSecondsqueries onlyvideo[data-start], audio[data-start], andresolveAuthoredCompositionDurationFloorSeconds(init.ts:933) only the root's owndata-durationplus[data-composition-id][data-start]children. So an image past the root's declared end is cut at that end — and the doc already hands the agent the number.
The wording that matches the code keeps the root-raise, scopes it to images and to authored trims, and says media resolves its own length. Roughly: "at least the clip's end — for an image that is data-start + 3 unless you set another length. Video and audio resolve their own length at render, so the root only needs raising when you author a trim."
Ordering note, explicitly not a hold
The new intro line sends the agent to npx hyperframes timeline "instead of reading the HTML". At this head that command reports duration = 0, end = start for both kinds this PR makes duration-less: describeProject.ts:120 is still const duration = authored ?? inner, and a media leaf or an <img> with nothing authored has no children, so inner is 0. #4169 is what fixes it — durationSource: "default", duration: 3 for an unauthored image, and a real probed length for media — and it is approved but not yet merged.
Nothing breaks if this lands first; the render is still correct either way. It is just the PR that makes the intro line's promise true, and it is also the one that would make bullet 5 performable as written, if you'd rather keep the clause than cut it.
What this push did well
The complexity fix reads clean — CLIP_ATTRS + mediaExample / assertNoAuthoredDuration / assertHasAttrs collapse the case into a loop over ["video", "audio"] without dropping an assertion, and the resulting test is a real guard: it fails if either example regains a data-duration or loses a clip attribute. The divergence check from the last round is intact — it still reads data-duration out of Studio's own timelineAssetDrop.ts, so "the one difference" is provably still exactly one difference.
Two body claims verified against the diff: "the resolver that makes this real is on main" is true (#4166, merged 12:01 today); "an npx version test this docs PR does not touch" is true — the PR is 4 files (creator-editing-recipes.test.mjs, skills-manifest.json, and the two references), none of them npxCommand.test.ts.
— Rames
What changes for agents
An agent that adds an image, video or audio clip now writes the same markup Studio writes when a person drops the file on the timeline, including the length an image gets (3 seconds), where it starts, and the frame-filling geometry. Before, no doc said, so agent-added stills had no end and stayed on screen for the rest of the video.
creator-editing-recipes.mdgains "Add media (image, video, audio)": defaults, start rule, root-duration reminder, one copyable example per kind.No visible change
Docs and a drift test only; no UI or runtime behaviour changes, nothing to capture.
Image default and the resolver
An image's
data-durationis optional and defaults to 3 seconds, the same as a dropped image;tracks-and-clips.mdsays the same. The drift test reads that 3 from Studio's default table today and switches toDEFAULT_IMAGE_TIMELINE_DURATION_SECONDSin@hyperframes/parsers/media-durationonce the resolver is on main. On main today an image with nodata-durationnever ends, so this stays a draft behind the resolver.Not in this PR
<img>with nodata-duration(today's lint reports nothing for it) belongs to the structure-lint chain; this doc does not claim it.Verification (offload box, exit codes)
gen-skills-manifest --check: exit 0.Independent review