* feat(cli): one owner for init media placeholders, audio stays on the video Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cli): init keeps a video's sound on the clip; silent files scaffold muted Supersedes the patchVideoSrc changes in #3977 and #3942 and carries their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: miga-heygen <miga-heygen@users.noreply.github.com> Co-Authored-By: kapelame <kapelame@users.noreply.github.com> * feat(registry): example templates keep the a-roll's sound on the video Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(lint): video audio rules recommend keeping sound on the video Update video_missing_muted and video_audio_double_source message and fixHint text to recommend the new default: keep audio on the video with data-has-audio="true" (not muted). Tests verify the new wording is in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(parsers): a video is either audible (data-has-audio) or muted, never both Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): follow the compiler's audio rule in the audio-track toggle Confirm before background removal drops a video's sound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): hyperframes-core teaches sound on the video by default Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): workflows and scaffold guidance keep a video's sound on the clip Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: footage with sound keeps it on the video clip Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): init from-file a-roll is timed, muted strip is attribute-exact The from-file a-roll video gets data-start="0" so a scaffold lints clean, and the orphaned audio-only slot comment is dropped. setVideoAudio now walks the tag attribute by attribute, so a class value containing the word muted survives. The ffprobe-missing warning says the video is assumed to have sound and how to switch to muted, and DEFAULT_META notes the failure surfaces at render until lint support lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): keep has-audio in step with the muted toggle, test cutout confirm Muting a video now clears has-audio and unmuting sets it to true, in both the Design and flat media panels, matching the Has audio track toggle. Audio elements are unchanged. Adds coverage for both panels plus the cutout window.confirm guard: declined leaves src alone, accepted swaps src and mutes, a silent video never prompts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(parsers): updateElementInHtml hasAudio only applies to videos, true or false An audio element or an undefined hasAudio no longer picks up a muted attribute; only an explicit false on a video writes muted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: video_missing_muted rule text, silent-footage notes, consistent audible examples lint.mdx and cli.mdx describe the current video_missing_muted rule instead of the retired unmuted-video wording. The editing recipes say how to adapt for silent footage and require the outgoing clip's sound to end at a J-cut. The Remotion media example shows the audible mapping consistently, data-attributes states that has-audio is required unless muted, and the hairline frame note no longer claims the video is muted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): encode init media filenames and probe --audio duration Function replacers stop dollar-sign pattern expansion in filenames; src is percent-encoded. --audio now passes the probed track length as the composition duration in both init paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(lint): audio-state-aware media rules and time-aware double-source check video_missing_muted honors any explicit data-has-audio. video_audio_double_source only considers audible videos and only fires when the same-src clips overlap in time. duplicate_audio_track now covers audible videos. audio_file_without_element no longer claims silence when an audible video carries the sound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): audio-stream check for remotion ports, followable J-cut recipe Remotion Video maps to data-has-audio only when the file has an audio stream. The J/L-cut recipe now shows both shots with muted pictures and separate audio. embedded-captions removes the audio.mp3 scratch file it extracts. skills-manifest.json regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(lint): trim comments to satisfy the comment ratchet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): atomic muted edits, cutout keeps sound on a linked audio Muted and Has audio track commit one batched write (one persist, one undo entry) and remove data-has-audio instead of writing an empty marker. Unmuting a video probes its src: audible -> true, silent -> false, probe failed -> absent. Remove background on an audible video inserts a linked sibling <audio> with the original sound in the same write, replacing the drop-the-sound confirm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(producer): skip hidden videos when auto-adding video audio A video hidden in Studio is dropped by the mixer, so its src never reached existingAudioSrcs and the render re-added it at full volume. The video parser now records the shared hidden predicate and the auto-detect step skips hidden videos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): keep useDomEditSession under the 600-line cap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: miga-heygen <miga-heygen@users.noreply.github.com> Co-authored-by: kapelame <kapelame@users.noreply.github.com>
18 KiB
Creator Editing Recipes
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 a video's sound on the <video> (data-has-audio="true", no muted), so cutting the video cuts its sound. Use a separate <audio> only when picture and sound must be cut independently (J/L cuts, replacement audio) or for other sound (music, voiceover) — the recipes that do say so. Silent footage or b-roll: replace data-has-audio="true" with muted in these blocks.
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.
Hard cut
<video
id="a"
src="take.mp4"
data-start="0"
data-duration="2"
data-media-start="4"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
<video
id="b"
src="take.mp4"
data-start="2"
data-duration="3"
data-media-start="10"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: B starts at A start + duration. Source math: each range starts at data-media-start; consumed source = timeline duration × rate. Audio follows: the sound moves with each video clip. Owner: /hyperframes-core. Limit: adjacent windows only; author the two windows edge to edge. Same-track overlap is valid; both clips paint in CSS order.
Trim in/out
<video
id="shot-1"
src="take.mp4"
data-start="1"
data-duration="3"
data-media-start="6"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: visible window is [1,4]. Source math: in=6, out=6+3 at 1x; never invent source-end syntax. Audio follows: the sound moves with the video clip, using the same three attributes. Owner: /hyperframes-core. Limit: use another clip for another range.
Split / splice
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
data-media-start="0"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
<video
id="shot-2"
src="take.mp4"
data-start="2"
data-duration="2"
data-media-start="8"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: splice at t=2. Source math: independent source offsets select kept pieces. Audio follows: the sound moves with each video clip, so it splits identically. Owner: /hyperframes-core. Limit: source cuts are core, never keyframes.
Duplicate / reuse same source
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="1"
data-media-start="2"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
<video
id="shot-2"
src="take.mp4"
data-start="4"
data-duration="1"
data-media-start="2"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: copies may occupy different starts. Source math: identical offsets reuse identical source. Audio follows: the sound moves with each video clip, so each copy carries its own. Owner: /hyperframes-core. Limit: every element needs a unique id when ids are present.
Reorder
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
data-media-start="10"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
<video
id="shot-2"
src="take.mp4"
data-start="2"
data-duration="2"
data-media-start="2"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: data-start defines authored order. Source math: source offsets need not be chronological. Audio follows: the sound moves with the video clip, so reordering clips reorders their sound. Owner: /hyperframes-core. Limit: reordering changes placement only, not source ranges.
Freeze / hold
<img src="held-frame.png" data-start="2" data-duration="1" data-track-index="0" class="clip" />
Timeline math: the still owns its hold duration. Source math: final-source frame, subcomp final state, and visual pose holds are supported. Audio follows: continue, trim, or silence audio deliberately. Owner: /hyperframes-core + /media-use. Limit: arbitrary mid-source freeze requires preprocess of a still/segment.
Constant speed / slow motion
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
data-media-start="4"
data-playback-rate="0.5"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: duration is authored timeline time. Source math: consumed source = timeline duration × rate; natural timeline duration = remaining source / rate. Audio follows: the sound moves with the video clip and plays at the same constant rate. Owner: /hyperframes-core. Limit: normalized 0.1..10. For a speed ramp put a rate lane in data-automation, e.g. {"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":1},{"t":2,"v":4}]}]}; it wins over the constant.
Zoom / punch
tl.to("#clip .inner", { scale: 1.35, xPercent: -8, duration: 0.18 }, 1);
Timeline math: tween positions are composition seconds. Source math: unchanged; the core clip still selects source time. Audio follows: unchanged unless separately edited. Owner: /hyperframes-keyframes. Limit: target the inner wrapper, not the timed clip element.
Pan / Ken Burns
tl.fromTo(
"#clip .inner",
{ scale: 1.05, xPercent: 0 },
{ scale: 1.2, xPercent: -12, duration: 4, ease: "none" },
0,
);
Timeline math: move spans four authored seconds. Source math: unchanged. Audio follows: the sound stays on the video clip; the tween does not touch it. Owner: /hyperframes-keyframes. Limit: authored geometry, not automatic face tracking.
Crop / reframe
tl.to("#clip .inner", { clipPath: "inset(8% 12% 6% 10%)", xPercent: -4, duration: 1 }, 2);
Timeline math: crop interpolates over [2,3]. Source math: unchanged. Audio follows: no automatic change. Owner: /hyperframes-keyframes. Limit: inner wrapper only, not temporal trim.
Clip-path wipe / reveal / mask / split-screen
tl.fromTo(
"#next .inner",
{ clipPath: "polygon(0 0,0 0,0 100%,0 100%)" },
{ clipPath: "polygon(0 0,100% 0,100% 100%,0 100%)", duration: 0.5 },
2,
);
Timeline math: overlap placed clips for the 0.5s handoff. Source math: each clip keeps its own range. Audio follows: the sound stays on each video clip. Owner: /hyperframes-keyframes + /hyperframes-animation. Limit: visual mask/polygon/split-screen only; source cuts stay /hyperframes-core.
Crossfade
<div id="a-visual" class="inner">
<video
id="a"
data-start="0"
data-duration="3"
data-track-index="0"
src="a.mp4"
data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1},{"t":2.5,"v":1},{"t":3,"v":0}]}]}'
playsinline
data-has-audio="true"
></video>
</div>
<div id="b-visual" class="inner">
<video
id="b"
data-start="2.5"
data-duration="3"
data-track-index="1"
src="b.mp4"
data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0},{"t":0.5,"v":1},{"t":3,"v":1}]}]}'
playsinline
data-has-audio="true"
></video>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.set("#b-visual", { opacity: 0 }, 0)
.to("#a-visual", { opacity: 0, duration: 0.5 }, 2.5)
.to("#b-visual", { opacity: 1, duration: 0.5 }, 2.5);
window.__timelines["main"] = tl;
</script>
Timeline math: distinct tracks overlap by 0.5s with opposing opacity envelopes. Source math: each source range remains independent. Audio follows: opposing volume envelopes on each video's own data-automation, because the sound stays on the video. Owner: /hyperframes-core + /hyperframes-keyframes + /hyperframes-audio. Limit: the crossfade is the opacity/volume envelopes, not a source-level dissolve.
Volume fades / ducking
<audio
id="music-bed"
src="music.wav"
data-start="0"
data-duration="5"
data-track-index="10"
data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0},{"t":1,"v":1},{"t":2,"v":1},{"t":2.2,"v":0.3},{"t":3,"v":0.3},{"t":3.2,"v":1},{"t":4,"v":1},{"t":5,"v":0}]}]}'
></audio>
Timeline math: lane t is clip-local authored time: fade-in 0–1, duck down 2–2.2, hold 2.2–3, duck up 3–3.2, fade-out 4–5. Source math: source selection still uses core attributes. Audio follows: the explicit down-hold-up envelope affects this <audio> (music is separate sound). The same lane works on a <video data-has-audio="true">. Owner: /hyperframes-audio. Limit: automation is not source retiming.
One rule for volume over time: use the lane. lint accepts a timeline tween on volume too, but when a track has both, the lane wins and the tween is ignored (audio_volume_double_automation). Never add a lane to a track that already has a volume tween, and never add a tween to a track that has a lane; edit the one that exists. To ramp 0.1 to 0.5 over ten seconds, write {"t":0,"v":0.1},{"t":10,"v":0.5}. t is seconds from the clip's own start, so a ramp past data-duration never finishes: check the clip's length before choosing the times. data-volume stays as the static level of the clip and combines with nothing else you author here.
Audio alignment
<video
id="shot-1"
src="take.mp4"
data-start="3"
data-duration="2"
data-media-start="8"
data-playback-rate="2"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: picture and sound share start/duration because the sound stays on the clip (data-has-audio="true"). Source math: both consume four source seconds. Audio follows: identical timing, range, and rate, with nothing to keep in sync. Owner: /hyperframes-core + /hyperframes-audio. Limit: no waveform auto-sync or drift correction.
A J cut or L cut is the case that needs a separate <audio>: picture and sound are cut independently, so the sound gets its own element (the same goes for replacement audio, a voiceover, or music). Mute the video whose sound you are replacing.
<!-- Outgoing shot: picture runs 0-5, its own sound is a separate clip that ends at the audio cut (4). -->
<video
id="shot-1"
src="intro.mp4"
data-start="0"
data-duration="5"
data-track-index="0"
muted
playsinline
></video>
<audio
id="shot-1-audio"
src="intro.mp4"
data-start="0"
data-duration="4"
data-track-index="10"
></audio>
<!-- Incoming shot: picture starts at 5, its sound leads it by one second. -->
<video
id="shot-2"
src="take.mp4"
data-start="5"
data-duration="3"
data-media-start="12"
data-track-index="0"
muted
playsinline
></video>
<audio
id="shot-2-audio"
src="take.mp4"
data-start="4"
data-duration="4"
data-media-start="11"
data-track-index="10"
></audio>
The sound leads the picture by one second (a J cut): shot-2-audio starts at 4 and reads from source 11, while the picture starts at 5 and reads from 12. Both stay on the same source clock. Every video in a J or L cut is muted and its sound is its own <audio>: the outgoing shot's <audio> ends at the audio cut (4) while its picture carries on to 5, so two sounds never overlap on the same source. An audible <video> and an <audio> on the same file are only flagged when their time windows overlap.
Align a sound to an on-screen event
<audio
id="sfx-click-3"
src="click.mp3"
data-start="7.48"
data-duration="0.07"
data-track-index="103"
data-volume="0.85"
></audio>
Timeline math: an audio element in the root composition has data-start in absolute root time; audio inside a scene file uses scene-local time and the host's data-start is added for you. An event inside a sub-composition happens at the host's data-start plus the event's local time in that sub-composition's own timeline, so data-start = host start + local time. Move only the audio's data-start; leave the picture alone. Source math: if the sound's transient is not at the file's first sample, subtract that lead-in from data-start (or trim it with data-media-start). Audio follows: nothing links audio to picture, so re-derive after every retime of the host. Owner: /hyperframes-core. Limit: no waveform auto-sync; for a beat grid use hyperframes beats and place each start on a beat time.
Copy a group of clips to another time
<audio
id="sfx-click-0"
src="click.mp3"
data-start="1.6"
data-duration="0.07"
data-track-index="100"
></audio>
<audio
id="sfx-type-0"
src="typenew.mp3"
data-start="7.8"
data-duration="0.57"
data-track-index="109"
></audio>
<audio
id="sfx-click-0-copy"
src="click.mp3"
data-start="41.6"
data-duration="0.07"
data-track-index="186"
></audio>
<audio
id="sfx-type-0-copy"
src="typenew.mp3"
data-start="47.8"
data-duration="0.57"
data-track-index="187"
></audio>
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-durationis 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-startis enough. The length comes from the media itself. An authoreddata-durationshorter 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 track0; a drop uses the drop point. - Give every clip
id,class="clip",data-startanddata-track-index. A video with sound isplaysinline data-has-audio="true"; silent footage and b-roll ismuted playsinline. Audio carriesdata-volume="1". - 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. - Images and video fill the whole frame: absolutely positioned at
left: 0; top: 0,widthandheightequal to the composition'sdata-widthanddata-height,object-fit: contain. Studio does not know a dropped file's natural size, so it does not centre a smaller one. z-indexis the number of top-level clips already in that file plus one (at least1); later clips stack above earlier ones.- Several files dropped together share the drop's track and run end to end.
<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"
/>
<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>
<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
<video
id="hero"
src="assets/product-v2.mp4"
data-start="2"
data-duration="4"
data-media-start="0"
data-track-index="0"
playsinline
data-has-audio="true"
></video>
Timeline math: change only src. Source math: reset data-media-start to the offset you want in the NEW file, and set data-duration no longer than the new file's remaining length (probe it with ffprobe). Audio follows: the sound moves with the video clip; a separate <audio> that pointed at the old file (music, voiceover) needs its own src swap. Keep id, data-start, data-track-index and any data-automation so nothing else moves. Run lint: audio_src_not_found and media_src_kind_mismatch catch a wrong path or kind. Owner: /hyperframes-core. Limit: a still swapped for a video (or the reverse) is a tag change, not a swap.
Split a section and change its speed
Timeline math: a section that is a sub-composition or a group of clips has no data-playback-rate of its own to set; split it by giving each half its own host or clips and shift everything after the cut by the length change. New length of a part = old length / rate. Every later data-start (clips, audio, root-timeline tweens) moves by the same delta. Source math: <video> and <audio> parts use data-playback-rate (0.1 to 10, constant) per the constant-speed recipe above, the sound moves with the video. A speed ramp (a rate that changes within one clip) is a rate lane in data-automation on the <video>/<audio>; see docs/reference/speed-ramps. Say which you did.