From f7911b8d1bd5e939c182cdd56ec19e5566b3c5ec Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 10:30:09 +0800 Subject: [PATCH 1/6] fix(cli): preserve ducking gain when music starts later --- packages/cli/src/media-use/lib/duck.mjs | 20 +++++++++- packages/cli/src/media-use/lib/duck.test.mjs | 40 ++++++++++++++++++++ skills-manifest.json | 2 +- skills/media-use/scripts/lib/duck.mjs | 20 +++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/media-use/lib/duck.mjs b/packages/cli/src/media-use/lib/duck.mjs index dcf81774f39..8541f589687 100644 --- a/packages/cli/src/media-use/lib/duck.mjs +++ b/packages/cli/src/media-use/lib/duck.mjs @@ -63,11 +63,27 @@ export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) { if (t > points.at(-1).t) points.push({ t: round3(t), v }); }; for (const kf of keyframes) { - const t = Math.max(0, kf.time - start); + const t = Math.max(0, kf.time); push(t, points.at(-1).v); push(t + kf.duration, kf.volume); } - return { version: 1, lanes: [{ target: "volume", points }] }; + + const next = points.findIndex((point) => point.t > start); + const before = points[next - 1] ?? points.at(-1); + const after = points[next]; + const initial = + next === 0 + ? points[0].v + : after + ? before.v + ((after.v - before.v) * (start - before.t)) / (after.t - before.t) + : before.v; + const localPoints = [ + { t: 0, v: initial }, + ...points + .filter((point) => point.t > start) + .map((point) => ({ t: round3(point.t - start), v: point.v })), + ]; + return { version: 1, lanes: [{ target: "volume", points: localPoints }] }; } function mergeIntervals(intervals, mergeGap) { diff --git a/packages/cli/src/media-use/lib/duck.test.mjs b/packages/cli/src/media-use/lib/duck.test.mjs index 164c12bbdc5..f20583ee8c2 100644 --- a/packages/cli/src/media-use/lib/duck.test.mjs +++ b/packages/cli/src/media-use/lib/duck.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs"; +import { sampleAutomationLane } from "@hyperframes/core/audio-automation"; const HERE = dirname(fileURLToPath(import.meta.url)); // Keep the skill-owned wrapper path under test until the compatibility layer @@ -169,6 +170,45 @@ test("duckLane turns composition-time keyframes into clip-local ramps", () => { }); }); +for (const [name, clipStart] of [ + ["before the attack", 0.5], + ["during the attack", 1.075], + ["during speech", 2], + ["at the release", 3], + ["during the release", 3.2], + ["after narration", 4], +]) { + test(`duckLane preserves composition-time gain when music starts ${name}`, () => { + const keyframes = duckKeyframes([{ start: 1, end: 3 }], { baseVolume: 0.6 }); + const full = duckLane(keyframes, { baseVolume: 0.6 }).lanes[0]; + const local = duckLane(keyframes, { clipStart, baseVolume: 0.6 }).lanes[0]; + + for (const time of [0, 0.025, 0.075, 0.1, 0.2, 0.5, 1, 2]) { + const expected = sampleAutomationLane(full, time + clipStart); + const actual = sampleAutomationLane(local, time); + assert.ok( + Math.abs(actual - expected) < 1e-9, + `gain at ${time}s: expected ${expected}, received ${actual}`, + ); + } + }); +} + +test("duckLane discards earlier narration and retains the next speech span", () => { + const keyframes = duckKeyframes([ + { start: 1, end: 2 }, + { start: 5, end: 6 }, + ]); + + assert.deepEqual(duckLane(keyframes, { clipStart: 3 }).lanes[0].points, [ + { t: 0, v: 1 }, + { t: 2, v: 1 }, + { t: 2.15, v: 0.25 }, + { t: 3, v: 0.25 }, + { t: 3.4, v: 1 }, + ]); +}); + test("--json spans match --merge-gap semantics exactly", () => { const dir = mkdtempSync(join(tmpdir(), "media-use-duck-")); try { diff --git a/skills-manifest.json b/skills-manifest.json index 49ddfb2a1e9..0c887274563 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "c8800249da2d1ce6", + "hash": "0958060e399463ae", "files": 109 }, "motion-graphics": { diff --git a/skills/media-use/scripts/lib/duck.mjs b/skills/media-use/scripts/lib/duck.mjs index dcf81774f39..8541f589687 100644 --- a/skills/media-use/scripts/lib/duck.mjs +++ b/skills/media-use/scripts/lib/duck.mjs @@ -63,11 +63,27 @@ export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) { if (t > points.at(-1).t) points.push({ t: round3(t), v }); }; for (const kf of keyframes) { - const t = Math.max(0, kf.time - start); + const t = Math.max(0, kf.time); push(t, points.at(-1).v); push(t + kf.duration, kf.volume); } - return { version: 1, lanes: [{ target: "volume", points }] }; + + const next = points.findIndex((point) => point.t > start); + const before = points[next - 1] ?? points.at(-1); + const after = points[next]; + const initial = + next === 0 + ? points[0].v + : after + ? before.v + ((after.v - before.v) * (start - before.t)) / (after.t - before.t) + : before.v; + const localPoints = [ + { t: 0, v: initial }, + ...points + .filter((point) => point.t > start) + .map((point) => ({ t: round3(point.t - start), v: point.v })), + ]; + return { version: 1, lanes: [{ target: "volume", points: localPoints }] }; } function mergeIntervals(intervals, mergeGap) { From 987d37cbdf4352f723def1d52010643f7764bb9e Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 10:51:31 +0800 Subject: [PATCH 2/6] test(cli): keep ducking regressions standalone --- packages/cli/src/media-use/lib/duck.test.mjs | 69 +++++++++++++++----- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/media-use/lib/duck.test.mjs b/packages/cli/src/media-use/lib/duck.test.mjs index f20583ee8c2..dcb39a36235 100644 --- a/packages/cli/src/media-use/lib/duck.test.mjs +++ b/packages/cli/src/media-use/lib/duck.test.mjs @@ -6,7 +6,6 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs"; -import { sampleAutomationLane } from "@hyperframes/core/audio-automation"; const HERE = dirname(fileURLToPath(import.meta.url)); // Keep the skill-owned wrapper path under test until the compatibility layer @@ -170,25 +169,65 @@ test("duckLane turns composition-time keyframes into clip-local ramps", () => { }); }); -for (const [name, clipStart] of [ - ["before the attack", 0.5], - ["during the attack", 1.075], - ["during speech", 2], - ["at the release", 3], - ["during the release", 3.2], - ["after narration", 4], +for (const [name, clipStart, expected] of [ + [ + "before the attack", + 0.5, + [ + { t: 0, v: 0.6 }, + { t: 0.5, v: 0.6 }, + { t: 0.65, v: 0.15 }, + { t: 2.5, v: 0.15 }, + { t: 2.9, v: 0.6 }, + ], + ], + [ + "during the attack", + 1.075, + [ + { t: 0, v: 0.375 }, + { t: 0.075, v: 0.15 }, + { t: 1.925, v: 0.15 }, + { t: 2.325, v: 0.6 }, + ], + ], + [ + "during speech", + 2, + [ + { t: 0, v: 0.15 }, + { t: 1, v: 0.15 }, + { t: 1.4, v: 0.6 }, + ], + ], + [ + "at the release", + 3, + [ + { t: 0, v: 0.15 }, + { t: 0.4, v: 0.6 }, + ], + ], + [ + "during the release", + 3.2, + [ + { t: 0, v: 0.375 }, + { t: 0.2, v: 0.6 }, + ], + ], + ["after narration", 4, [{ t: 0, v: 0.6 }]], ]) { test(`duckLane preserves composition-time gain when music starts ${name}`, () => { const keyframes = duckKeyframes([{ start: 1, end: 3 }], { baseVolume: 0.6 }); - const full = duckLane(keyframes, { baseVolume: 0.6 }).lanes[0]; - const local = duckLane(keyframes, { clipStart, baseVolume: 0.6 }).lanes[0]; + const points = duckLane(keyframes, { clipStart, baseVolume: 0.6 }).lanes[0].points; - for (const time of [0, 0.025, 0.075, 0.1, 0.2, 0.5, 1, 2]) { - const expected = sampleAutomationLane(full, time + clipStart); - const actual = sampleAutomationLane(local, time); + assert.equal(points.length, expected.length); + for (let i = 0; i < points.length; i++) { + assert.equal(points[i].t, expected[i].t); assert.ok( - Math.abs(actual - expected) < 1e-9, - `gain at ${time}s: expected ${expected}, received ${actual}`, + Math.abs(points[i].v - expected[i].v) < 1e-9, + `gain at ${points[i].t}s: expected ${expected[i].v}, received ${points[i].v}`, ); } }); From 59222748082fa02dfdba2d98aa9fb4fc1fc5bb1d Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Wed, 14 Oct 2026 04:22:52 +0800 Subject: [PATCH 3/6] chore: prepare conflict resolution against current main --- skills-manifest.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 0c887274563..960e5a88ac8 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,35 +2,35 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "643579aed5919847", - "files": 144 + "hash": "1dc7ec2fd4a6e3fc", + "files": 145 }, "faceless-explainer": { - "hash": "1f10a69e0f879698", - "files": 28 + "hash": "bb2225ebaf7f9622", + "files": 29 }, "figma": { "hash": "f8cf9f2c78ea8374", "files": 2 }, "general-video": { - "hash": "7194a47a82647a88", - "files": 5 + "hash": "17dfbb1d34efb012", + "files": 6 }, "hyperframes": { - "hash": "589ae40d15628917", - "files": 29 + "hash": "9787b91e4c16bf7f", + "files": 31 }, "hyperframes-animation": { - "hash": "3bd80388da91c689", + "hash": "a5d09375a50f71c8", "files": 122 }, "hyperframes-audio": { - "hash": "e96de47ed2e6097a", - "files": 7 + "hash": "780c9d4d8d23ab40", + "files": 8 }, "hyperframes-cli": { - "hash": "c053e99be2d209b1", + "hash": "56a46bc15c308d98", "files": 11 }, "hyperframes-core": { @@ -54,11 +54,11 @@ "files": 1 }, "media-use": { - "hash": "0958060e399463ae", - "files": 109 + "hash": "c4c3c723aa04bb7d", + "files": 112 }, "motion-graphics": { - "hash": "6db77031ad839f6a", + "hash": "7e4af0881390ac2e", "files": 23 }, "music-to-video": { @@ -66,12 +66,12 @@ "files": 169 }, "pr-to-video": { - "hash": "88f4c68788eaba9e", - "files": 34 + "hash": "d377b802ef54bc46", + "files": 35 }, "product-launch-video": { - "hash": "76149a9f3514626e", - "files": 36 + "hash": "965b9b93bf19b173", + "files": 37 }, "remotion-to-hyperframes": { "hash": "d02be65b7f3f4a63", @@ -82,7 +82,7 @@ "files": 2 }, "talking-head-recut": { - "hash": "9aac076ceddd2499", + "hash": "4b2cf6d7b6e336b4", "files": 28 } } From 48ae0dbb81dab080714caebf2a5a7dbb81305b75 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Wed, 14 Oct 2026 04:22:59 +0800 Subject: [PATCH 4/6] chore: preserve reviewed fix on current main --- skills-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills-manifest.json b/skills-manifest.json index 960e5a88ac8..64e773d3678 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "c4c3c723aa04bb7d", + "hash": "73f8c190a02e7b1d", "files": 112 }, "motion-graphics": { From b3b9a0fe8f0b5d953263692329053189f4354737 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:39:12 +0800 Subject: [PATCH 5/6] chore: prepare conflict resolution against current main --- skills-manifest.json | 78 +++++++------------------------------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 64e773d3678..e6442718b8a 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -1,89 +1,33 @@ { "source": "heygen-com/hyperframes", "skills": { - "embedded-captions": { - "hash": "1dc7ec2fd4a6e3fc", - "files": 145 - }, - "faceless-explainer": { - "hash": "bb2225ebaf7f9622", - "files": 29 - }, "figma": { - "hash": "f8cf9f2c78ea8374", + "hash": "af57c9b6411c81f5", "files": 2 }, - "general-video": { - "hash": "17dfbb1d34efb012", - "files": 6 - }, "hyperframes": { - "hash": "9787b91e4c16bf7f", - "files": 31 - }, - "hyperframes-animation": { - "hash": "a5d09375a50f71c8", - "files": 122 - }, - "hyperframes-audio": { - "hash": "780c9d4d8d23ab40", - "files": 8 + "hash": "8fc4b05db245cbbb", + "files": 77 }, "hyperframes-cli": { - "hash": "56a46bc15c308d98", - "files": 11 - }, - "hyperframes-core": { - "hash": "010a65915fc7b45e", - "files": 11 - }, - "hyperframes-creative": { - "hash": "8d0601ce26adc560", - "files": 79 - }, - "hyperframes-keyframes": { - "hash": "a3d43b98955b1366", - "files": 3 + "hash": "d82c156c1b2069d1", + "files": 13 }, "hyperframes-registry": { - "hash": "d7c1885fb4cce2cf", - "files": 12 - }, - "hyperframes-studio": { - "hash": "f5e0b44d68152c12", - "files": 1 + "hash": "221af9e7fbffe732", + "files": 13 }, "media-use": { - "hash": "73f8c190a02e7b1d", - "files": 112 - }, - "motion-graphics": { - "hash": "7e4af0881390ac2e", - "files": 23 - }, - "music-to-video": { - "hash": "b775f27b16efb1f2", - "files": 169 - }, - "pr-to-video": { - "hash": "d377b802ef54bc46", - "files": 35 - }, - "product-launch-video": { - "hash": "965b9b93bf19b173", - "files": 37 + "hash": "1082bd1c4e3f033b", + "files": 116 }, "remotion-to-hyperframes": { - "hash": "d02be65b7f3f4a63", + "hash": "3bf96d306215e384", "files": 77 }, "slideshow": { - "hash": "efc3c0be622fa9a1", + "hash": "c8a591e2aa7a6d86", "files": 2 - }, - "talking-head-recut": { - "hash": "4b2cf6d7b6e336b4", - "files": 28 } } } From 72b319cb474ed9642db2f44517446c09ff27d980 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:39:25 +0800 Subject: [PATCH 6/6] fix(cli): refresh delayed music ducking against main --- docs/guides/voice-and-audio.mdx | 5 +++++ skills-manifest.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/guides/voice-and-audio.mdx b/docs/guides/voice-and-audio.mdx index 4687b19db4b..31ba8df0205 100644 --- a/docs/guides/voice-and-audio.mdx +++ b/docs/guides/voice-and-audio.mdx @@ -113,6 +113,11 @@ When narration drives the project, time visual changes to its real transcript rather than estimated scene lengths. When music drives the project, cut to its analyzed structure (see [music to video](/guides/music-to-video)). +Music can start after narration begins. When the media-use skill creates a +ducking lane for that clip, its opening gain matches the speech at that point +in the composition. Starting during an attack or release continues that ramp +from the correct level; completed speech does not create a new dip. + ## Review the complete sound pass - The correct voice and source recordings are used. diff --git a/skills-manifest.json b/skills-manifest.json index e6442718b8a..6c88a545c50 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 13 }, "media-use": { - "hash": "1082bd1c4e3f033b", + "hash": "6550641984b19843", "files": 116 }, "remotion-to-hyperframes": {