docs(skills): an agent adds media with the duration Studio gives a dropped file (#4144)

* docs(skills): an agent adds media with the duration Studio gives a dropped file

* docs(skills): add-media recipe matches Studio's full-frame geometry and z-index

* docs(skills): recipes start with hyperframes timeline to see tracks and clips

* test(skills): split the add-media drift test so each check stays small and formatted

* test(skills): split addMediaFacts into single-purpose helpers to clear the CRAP-score gate

* docs(skills): add-media recipe needs only data-start for video and audio

* docs(skills): add-media intro names the video and audio difference and a test guards it

* docs(skills): add-media image duration is optional and defaults to 3 seconds

* chore(skills): regenerate the skills manifest after rebasing onto main

* docs(skills): tracks-and-clips says image duration is optional, and a test pins the 3

* test(skills): split the add-media example check into small helpers

Clears the Fallow complexity finding on the test callback.
This commit is contained in:
Miguel Ángel
2026-09-19 09:07:32 -04:00
committed by GitHub
parent dc94692441
commit 2f24573395
4 changed files with 129 additions and 2 deletions
+75
View File
@@ -73,3 +73,78 @@ test("the Studio skill's safe boxes equal the preview's", async () => {
assert.match(skill, new RegExp(`Action-safe\\s*\\|\\s*${action}%`));
assert.match(skill, new RegExp(`Title-safe\\s*\\|\\s*${title}%`));
});
async function studioDefaultSeconds(kind) {
const helpers = await read("packages/studio/src/utils/studioHelpers.ts");
const block = helpers.match(/DEFAULT_TIMELINE_ASSET_DURATION[^=]*=\s*\{([^}]*)\}/);
const defaults = block ? block[1] : "";
const value = defaults.match(new RegExp(`${kind}:\\s*(\\d+(?:\\.\\d+)?)`));
return value ? value[1] : undefined;
}
async function addMediaSection() {
const doc = await read(OWNER);
const afterHeading = doc.split("## Add media")[1] ?? "";
const section = afterHeading.split("\n## ")[0];
const imageMatch = section.match(/<img[\s\S]*?\/>/);
const image = imageMatch ? imageMatch[0] : "";
return { section, image };
}
test("the add-media recipe uses Studio's default durations", async () => {
const { section, image } = await addMediaSection();
const imageSecs = await studioDefaultSeconds("image");
assert.ok(imageSecs, "could not read Studio's image default");
assert.match(section, new RegExp(`defaults to ${imageSecs} seconds`));
assert.match(section, /`data-start` is enough/);
assert.doesNotMatch(section, /ffprobe/);
assert.match(section, /root composition's `data-duration` is at least/);
assert.match(section, new RegExp(`${imageSecs} for an image unless you set another`));
assert.doesNotMatch(image, /data-duration/);
});
test("the add-media recipe uses Studio's full-frame geometry", async () => {
const { section, image } = await addMediaSection();
const dropOps = await read("packages/studio/src/hooks/useTimelineAssetDropOps.ts");
assert.match(
dropOps,
/fitTimelineAssetGeometry\(\s*null,/,
"Studio centres by natural size now; update the doc",
);
assert.match(section, /fill the whole frame/);
assert.match(image, /left: 0px; top: 0px; width: 1920px; height: 1080px/);
});
test("the add-media example carries every attribute Studio's drop writes", async () => {
const { image } = await addMediaSection();
const drop = await read("packages/studio/src/utils/timelineAssetDrop.ts");
for (const attr of ['class="clip"', "data-start", "data-duration", "data-track-index"]) {
assert.ok(drop.includes(attr), `Studio no longer writes ${attr}`);
}
for (const attr of ['class="clip"', "data-start", "data-track-index"]) {
assert.ok(image.includes(attr), `doc example lacks ${attr}`);
}
});
const CLIP_ATTRS = ["id=", 'class="clip"', "data-start", "data-track-index"];
const mediaExample = (section, tag) =>
section.match(new RegExp(`<${tag}[\\s\\S]*?</${tag}>`))?.[0] ?? "";
const assertNoAuthoredDuration = (example, tag) => {
assert.ok(example, `no <${tag}> example`);
assert.doesNotMatch(example, /data-duration/, `${tag} example must not author a duration`);
};
const assertHasAttrs = (example, tag, attrs) => {
for (const attr of attrs) assert.ok(example.includes(attr), `${tag} example lacks ${attr}`);
};
test("the video and audio add-media examples carry no data-duration and keep the clip attributes", async () => {
const { section } = await addMediaSection();
for (const tag of ["video", "audio"]) {
const example = mediaExample(section, tag);
assertNoAuthoredDuration(example, tag);
assertHasAttrs(example, tag, CLIP_ATTRS);
}
});
+1 -1
View File
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "ca3bc11c4e163225",
"hash": "99a07539f407ca0d",
"files": 11
},
"hyperframes-creative": {
@@ -2,6 +2,8 @@
Use these copyable contracts after `tracks-and-clips.md`. Global math: **consumed source = timeline duration × rate**; **natural timeline duration = remaining source / rate**.
Before any edit, run `npx hyperframes timeline` (add `--json` for a machine-readable list) to see the project's tracks and clips instead of reading the HTML.
These recipes keep sound on a separate `<audio>` element with the `<video>` muted, which is the pattern to reach for when picture and sound are cut independently. An unmuted `<video>` that declares `data-has-audio="true"` is also mixed, so a separate track is a choice, not a requirement.
**Every `<video>` and `<audio>` below carries an `id`, and that is not cosmetic**: `lint` errors with `media_missing_id` on timed media without one, and an id-less `<audio>` is never picked up by the mixer, so the render comes out silent. Keep the ids when you copy a recipe.
@@ -417,6 +419,56 @@ Timeline math: an audio element in the root composition has `data-start` in abso
Timeline math: pick the clips first and say which ones you picked (by id) if the request does not match the file exactly; then add one `delta` to every member's `data-start`, so relative spacing is preserved (here `delta = 40`). Give each copy a new unique `id` and the next unused `data-track-index`; keep `src`, `data-duration`, `data-media-start`, `data-volume` and any `data-automation` as they are. Leave the originals untouched. Check the copies still end inside the composition's duration. Owner: `/hyperframes-core`. Limit: copies of a `<video>` or a sub-composition host follow the same rule, and a copied sub-composition needs its own host `id`.
## Add media (image, video, audio)
Write what Studio writes when a person drops a file on the timeline, so an agent-added clip behaves the same as a dropped one; the one difference is that video and audio need no `data-duration`. Studio's source of truth is `DEFAULT_TIMELINE_ASSET_DURATION` in `packages/studio/src/utils/studioHelpers.ts` and `buildTimelineAssetInsertHtml` in `packages/studio/src/utils/timelineAssetDrop.ts`; a test keeps this section equal to them.
- **Image: `data-duration` is optional and defaults to 3 seconds**, the same as a dropped image, because a still has no length of its own. Write it only for another length. A test keeps the 3 equal to the default in code.
- **Video and audio: `data-start` is enough.** The length comes from the media itself. An authored `data-duration` shorter than the file is a trim, never a requirement; leave it out unless the request asks for a shorter clip.
- **Start: the playhead or the requested time, never a silent `0`.** Studio's asset-panel Add uses the playhead time on track `0`; a drop uses the drop point.
- Give every clip `id`, `class="clip"`, `data-start` and `data-track-index`. Video is `muted playsinline`; audio carries `data-volume="1"`.
- Then make sure the root composition's `data-duration` is at least the clip's end (`data-start` plus 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.
- **Images and video fill the whole frame**: absolutely positioned at `left: 0; top: 0`, `width` and `height` equal to the composition's `data-width` and `data-height`, `object-fit: contain`. Studio does not know a dropped file's natural size, so it does not centre a smaller one.
- `z-index` is the number of top-level clips already in that file plus one (at least `1`); later clips stack above earlier ones.
- Several files dropped together share the drop's track and run end to end.
```html
<img
id="photo"
class="clip"
src="assets/photo.png"
data-start="4"
data-track-index="1"
style="position: absolute; left: 0px; top: 0px; width: 1920px; height: 1080px; object-fit: contain; z-index: 2"
/>
```
```html
<video
id="broll"
class="clip"
src="assets/broll.mp4"
data-start="4"
data-track-index="2"
muted
playsinline
style="position: absolute; left: 0px; top: 0px; width: 1920px; height: 1080px; object-fit: contain; z-index: 3"
></video>
```
```html
<audio
id="whoosh"
class="clip"
src="assets/whoosh.mp3"
data-start="4"
data-track-index="3"
data-volume="1"
></audio>
```
Inside a sub-composition file, `data-start` is scene-local (see `## Align a sound to an on-screen event`). Owner: `/hyperframes-core`.
## Swap a media file
```html
@@ -10,7 +10,7 @@ A clip is any DOM element with `data-start` and, where required, `data-duration`
- **Sub-composition hosts** — `<div>` with `data-composition-src`. Always require `data-duration`.
- **Video clips** — `<video>` with `muted` and `playsinline`. Duration can default to media length.
- **Audio clips** — `<audio>`. Duration can default to media length.
- **Image clips** — `<img>`. Always require `data-duration`.
- **Image clips** — `<img>`. `data-duration` is optional and defaults to 3 seconds; write it only for another length.
Add `class="clip"` to authored visual clips. The runtime does not read it, but the scaffold's shared `.clip { position: absolute; inset: 0 }` rule is what gives a scene its full-frame box, Studio treats it as an edit hint, and `lint` warns without it.