Files
hyperframes/scripts/creator-editing-capabilities.test.mjs
T
37f30b1d06 feat: keep a video's sound on the clip by default (1/8) (#4813)
* 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>
2026-10-01 23:56:36 +00:00

375 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8");
const files = {
router: "skills/hyperframes/SKILL.md",
keyframes: "skills/hyperframes-keyframes/SKILL.md",
cli: "skills/hyperframes-cli/SKILL.md",
audio: "skills/hyperframes-audio/SKILL.md",
webAudioTransport: "packages/core/src/runtime/webAudioTransport.ts",
coreClips: "skills/hyperframes-core/references/tracks-and-clips.md",
generalVideo: "skills/general-video/SKILL.md",
editingRecipes: "skills/hyperframes-core/references/creator-editing-recipes.md",
runtimeMedia: "packages/core/src/runtime/media.ts",
runtimeInit: "packages/core/src/runtime/init.ts",
timingNumbers: "packages/core/src/runtime/playbackRate.ts",
clipTree: "packages/core/src/runtime/clipTree.ts",
timeline: "packages/core/src/runtime/timeline.ts",
timingCompiler: "packages/core/src/compiler/timingCompiler.ts",
mediaWindow: "packages/engine/src/services/mediaTimelineWindow.ts",
videoExtractor: "packages/engine/src/services/videoFrameExtractor.ts",
audioMixer: "packages/engine/src/services/audioMixer.ts",
webAudioTransportTest: "packages/core/src/runtime/webAudioTransport.test.ts",
audioFxTest: "packages/core/src/runtime/audioFx.test.ts",
};
function requiresAll(text, patterns, surface) {
for (const pattern of patterns) {
assert.match(text, pattern, `${surface} is missing ${pattern}`);
}
}
test("router loads the owning skills for creator picture and sound edits", async () => {
const router = await read(files.router);
requiresAll(
router,
[
/cut this footage/i,
/trim[\s\S]{0,80}splice[\s\S]{0,80}reorder|trim[\s\S]{0,80}reorder[\s\S]{0,80}splice/i,
/source range/i,
/punch[- ]in.*punch[- ]out/i,
/multi-state zoom|smooth.*zoom.*reframe/i,
/Ken Burns/i,
/camera move/i,
/match cut/i,
/whip pan/i,
/fade.*crossfade/i,
/duck.*automation.*effects|automation.*duck.*effects/i,
/picture and sound|video and audio/i,
],
files.router,
);
assert.match(router, /cut.*trim.*splice.*reorder[\s\S]{0,500}hyperframes-core/i);
assert.match(
router,
/zoom.*punch.*reframe.*Ken Burns.*camera move[\s\S]{0,500}hyperframes-keyframes/i,
);
assert.match(
router,
/match cut.*whip pan[\s\S]{0,500}hyperframes-animation[\s\S]{0,300}hyperframes-keyframes[\s\S]{0,300}hyperframes-registry/i,
);
assert.match(router, /fade.*crossfade.*gain.*duck[\s\S]{0,700}hyperframes-audio/i);
assert.match(
router,
/picture and sound[\s\S]{0,700}hyperframes-core[\s\S]{0,300}hyperframes-audio/i,
);
assert.match(router, /media-use[\s\S]{0,180}sourc|sourc[\s\S]{0,180}media-use/i);
});
test("keyframes states truthful creator capabilities and ownership boundaries", async () => {
const keyframes = await read(files.keyframes);
requiresAll(
keyframes,
[
/hard cut/i,
/trim[\s\S]{0,80}splice[\s\S]{0,80}reorder|trim[\s\S]{0,80}reorder[\s\S]{0,80}splice/i,
/punch[- ]in.*punch[- ]out/i,
/multi-state zoom|multiple zoom.*reframe states/i,
/Ken Burns/i,
/camera move/i,
/match cut/i,
/whip pan/i,
/data-start/,
/data-duration/,
/data-media-start/,
/hyperframes-core/,
/hyperframes-audio/,
],
files.keyframes,
);
assert.match(
keyframes,
/source[\s\S]{0,80}cut[\s\S]{0,80}trim[\s\S]{0,80}reorder[\s\S]{0,500}hyperframes-core/i,
);
assert.match(keyframes, /non-timed|non-clip/);
assert.match(keyframes, /wrapper inside the clip|inner.*wrapper/i);
assert.match(keyframes, /speed ramps?[\s\S]{0,80}`rate` lane in `data-automation`/i);
assert.match(keyframes, /speed ramps?[\s\S]{0,220}wins over the constant/i);
assert.match(keyframes, /arbitrary mid-source freeze[\s\S]{0,300}(not supported|preprocess)/i);
assert.doesNotMatch(keyframes, /keyframe(?:d|ing)?\s+(?:the\s+)?data-playback-rate/i);
});
test("CLI requires domain skills before authoring or diagnosing creator edits", async () => {
const cli = await read(files.cli);
assert.match(
cli,
/before[\s\S]{0,120}(zoom|punch-in)[\s\S]{0,180}(reframe|camera)[\s\S]{0,180}keyframe[\s\S]{0,220}read `?\/hyperframes-keyframes/i,
);
assert.match(cli, /before `?hyperframes keyframes`?[\s\S]{0,180}read `?\/hyperframes-keyframes/i);
assert.match(cli, /cut.*trim.*splice.*source timing[\s\S]{0,250}hyperframes-core/i);
assert.match(
cli,
/fade[\s\S]{0,100}crossfade[\s\S]{0,100}volume automation[\s\S]{0,100}carve[\s\S]{0,100}FX[\s\S]{0,300}hyperframes-audio/i,
);
});
test("audio skill owns placed-track fades, automation, ducking, and effects", async () => {
const audio = await read(files.audio);
requiresAll(
audio,
[
/fade[- ]in.*fade[- ]out/i,
/crossfade/i,
/track gain|track volume/i,
/duck/i,
/data-automation/,
/gain.*EQ.*compressor.*limiter.*gate.*saturat.*delay.*reverb.*chorus.*phaser.*bitcrush/is,
/clip timing.*hyperframes-core|hyperframes-core.*clip timing/is,
/sourcing.*media-use|media-use.*sourcing/is,
],
files.audio,
);
assert.match(audio, /constant.*playback rate|data-playback-rate/i);
assert.match(audio, /speed ramps?[\s\S]{0,80}`rate` lane in `data-automation`/i);
assert.match(audio, /speed ramps?[\s\S]{0,220}wins over the constant/i);
});
test("WebAudio scheduling combines per-element and global transport playback rates", async () => {
const webAudioTransport = await read(files.webAudioTransport);
assert.match(
webAudioTransport,
/mediaRate[\s\S]{0,120}readElementPlaybackRate\(el\)[\s\S]{0,160}sourceRate\s*=\s*safeRate\s*\*\s*mediaRate/i,
);
});
test("keyframes routes visual crop and mask handoffs without claiming temporal source edits", async () => {
const keyframes = await read(files.keyframes);
requiresAll(
keyframes,
[
/interpolat(?:e|ed|ing)[\s\S]{0,100}(clip-path|mask)[\s\S]{0,100}(crop|reframe)/i,
/directional wipe cut/i,
/iris[\s\S]{0,40}reveal cut|reveal[\s\S]{0,40}iris cut/i,
/split-screen handoff/i,
/polygon[\s\S]{0,50}mask transition|mask[\s\S]{0,50}polygon transition/i,
/visual transition[\s\S]{0,120}(not|isn't|is not)[\s\S]{0,80}(temporal|source)[\s\S]{0,80}(trim|splice)/i,
/hyperframes-core[\s\S]{0,160}(timeline|clip timing)/i,
],
files.keyframes,
);
});
test("core and general-video author temporal edits as duplicated source-range clips", async () => {
const [coreClips, generalVideo, router, keyframes] = await Promise.all([
read(files.coreClips),
read(files.generalVideo),
read(files.router),
read(files.keyframes),
]);
for (const [surface, text] of [
[files.coreClips, coreClips],
[files.generalVideo, generalVideo],
]) {
requiresAll(
text,
[
/duplicate[\s\S]{0,120}(same|one)[\s\S]{0,80}(video|media) source/i,
/data-media-start[\s\S]{0,120}data-duration/i,
/data-start/,
/hard cut[\s\S]{0,100}trim[\s\S]{0,100}splice[\s\S]{0,100}reorder/i,
/sound stays on the clip[\s\S]{0,160}data-has-audio/i,
],
surface,
);
}
assert.match(router, /cut this footage[\s\S]{0,300}hyperframes-core/i);
requiresAll(keyframes, [/zoom/i, /punch/i, /pan/i, /crop|mask|clip-path/i], files.keyframes);
assert.match(keyframes, /inner[\s\S]{0,80}wrapper/i);
assert.match(keyframes, /not a temporal source trim or[\s\S]{0,40}splice/i);
});
test("creator editing recipes are copyable, owned, mathematical, and limitation-safe", async () => {
const recipes = await read(files.editingRecipes).catch(() => "");
const recipeNames = [
"Hard cut",
"Trim in/out",
"Split / splice",
"Duplicate / reuse same source",
"Reorder",
"Freeze / hold",
"Constant speed / slow motion",
"Zoom / punch",
"Pan / Ken Burns",
"Crop / reframe",
"Clip-path wipe / reveal / mask / split-screen",
"Crossfade",
"Volume fades / ducking",
"Audio alignment",
];
for (let index = 0; index < recipeNames.length; index += 1) {
const start = recipes.indexOf(`## ${recipeNames[index]}`);
const next =
index + 1 < recipeNames.length
? recipes.indexOf(`## ${recipeNames[index + 1]}`)
: recipes.length;
assert.ok(start >= 0, `missing recipe: ${recipeNames[index]}`);
const section = recipes.slice(start, next);
requiresAll(
section,
[
/```(?:html|js)/,
/Timeline math:/i,
/Source math:/i,
/Audio follows:/i,
/Owner:/i,
/Limit:/i,
],
recipeNames[index],
);
}
requiresAll(
recipes,
[
/data-start/,
/data-duration/,
/data-media-start/,
/data-playback-rate/,
/consumed source\s*=\s*timeline duration\s*[×*]\s*rate/i,
/natural timeline duration\s*=\s*remaining source\s*\/\s*rate/i,
/separate `?<audio>`?[\s\S]{0,160}(J\/L cut|replacement audio|voiceover|music)/i,
/final-source[\s\S]{0,100}subcomp[\s\S]{0,100}visual pose/i,
/arbitrary mid-source[\s\S]{0,120}preprocess/i,
/distinct tracks[\s\S]{0,120}overlap[\s\S]{0,120}opposing/i,
/same-track overlap is valid/i,
/inner wrapper[\s\S]{0,100}not[\s\S]{0,50}(clip element|timed clip)/i,
/source cuts[\s\S]{0,80}hyperframes-core/i,
],
files.editingRecipes,
);
const [core, general] = await Promise.all([
read("skills/hyperframes-core/SKILL.md"),
read(files.generalVideo),
]);
assert.match(core, /creator-editing-recipes\.md/);
assert.match(general, /creator-editing-recipes\.md/);
assert.doesNotMatch(recipes, /data-(?:media-end|source-end|trim-start|trim-end)/);
});
test("every preview reader shares the non-negative media-start helper", async () => {
for (const path of [
files.runtimeMedia,
files.runtimeInit,
"packages/core/src/runtime/clipTree.ts",
"packages/core/src/runtime/timeline.ts",
"packages/core/src/runtime/startResolver.ts",
]) {
const source = await read(path);
assert.match(
source,
/readElementPlaybackStart|readMediaStart|resolveNaturalMediaTimelineDuration/,
);
}
});
test("crossfade and volume recipes contain executable opposing envelopes", async () => {
const recipes = await read(files.editingRecipes);
const crossfade = recipes.slice(
recipes.indexOf("## Crossfade"),
recipes.indexOf("## Volume fades / ducking"),
);
requiresAll(
crossfade,
[
/data-track-index="0"[\s\S]*data-track-index="1"/,
/class="inner"/,
/gsap\.timeline\(\{\s*paused:\s*true\s*\}\)/,
/window\.__timelines/,
/autoAlpha|opacity/,
/data-has-audio="true"[\s\S]*data-has-audio="true"/,
/data-automation='/,
],
"crossfade recipe",
);
const crossfadeAutomation = [...crossfade.matchAll(/data-automation='([^']+)'/g)];
assert.equal(crossfadeAutomation.length, 2);
for (const match of crossfadeAutomation) JSON.parse(match[1]);
const volume = recipes.slice(
recipes.indexOf("## Volume fades / ducking"),
recipes.indexOf("## Audio alignment"),
);
requiresAll(
volume,
[/fade-in/i, /fade-out/i, /duck down/i, /hold/i, /duck up/i, /data-automation='/],
"volume recipe",
);
JSON.parse(volume.match(/data-automation='([^']+)'/)?.[1] ?? "");
});
test("natural media duration is rate-scaled across every preview surface", async () => {
for (const path of [
files.runtimeInit,
"packages/core/src/runtime/clipTree.ts",
"packages/core/src/runtime/timeline.ts",
]) {
assert.match(await read(path), /resolveNaturalMediaTimelineDuration/);
}
});
test("every temporal source-range recipe keeps sound on the video", async () => {
const recipes = await read(files.editingRecipes);
for (const name of [
"Hard cut",
"Trim in/out",
"Split / splice",
"Duplicate / reuse same source",
"Reorder",
]) {
const start = recipes.indexOf(`## ${name}`);
const next = recipes.indexOf("## ", start + 3);
const section = recipes.slice(start, next < 0 ? recipes.length : next);
const videos = [...section.matchAll(/<video\b[^>]*>/g)].map((m) => m[0]);
assert.ok(videos.length > 0, `${name}: no video markup`);
for (const video of videos) {
assert.match(video, /data-has-audio="true"/, `${name}: video must keep its sound`);
assert.doesNotMatch(video, /\bmuted\b/, `${name}: video must not be muted`);
}
assert.doesNotMatch(
section,
/<audio\b/,
`${name}: no duplicate <audio> for the video's own sound`,
);
}
});
test("all literal timing readers use one strict finite-number contract", async () => {
assert.match(await read(files.timingNumbers), /parseStrictFiniteTimingNumber/);
for (const path of [
files.clipTree,
files.timeline,
files.runtimeInit,
files.timingCompiler,
files.mediaWindow,
files.videoExtractor,
files.audioMixer,
]) {
assert.match(
await read(path),
/parseStrictFiniteTimingNumber/,
`${path} bypasses strict timing`,
);
}
});
test("attribute mocks dispatch by name instead of answering every attribute", async () => {
const webAudio = await read(files.webAudioTransportTest);
const audioFx = await read(files.audioFxTest);
assert.doesNotMatch(webAudio, /getAttribute:\s*\(\)\s*=>\s*(?:"1"|src)/);
assert.doesNotMatch(audioFx, /getAttribute:\s*\(\)\s*=>\s*"\{not json"/);
assert.match(webAudio, /name\s*===\s*"data-playback-rate"/);
assert.match(webAudio, /name\s*===\s*"src"/);
assert.match(audioFx, /name\s*===\s*HF_AUDIO_FX_ATTR/);
});