mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
* 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>
375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
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/);
|
||
});
|