From 081e1ce7e43ed120ae49a135e1f2f176da3abf0c Mon Sep 17 00:00:00 2001 From: larrylicuanan Date: Sat, 19 Sep 2026 23:51:27 +0800 Subject: [PATCH 1/5] fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice transcript-cut extracts each kept range to its own final-codec file, then concat-copies them. That produces two audible artifacts at every cut: 1. No boundary fades. Concat splices raw segment edges together, so the waveform steps discontinuously and each cut clicks. 2. Encoder priming silence. Each segment is encoded to AAC independently, so the encoder pads every one with priming samples. Stream-copy concat bakes that padding in as a silent gap at each splice. Measured 24.4 ms and 24.1 ms at the two splices of a three-range cut. It accumulates: the nth splice lands roughly 25n ms late, so splice positions drift away from the sum of the preceding segment durations. The second survives the first -- adding fades alone leaves the gap. Fix: intermediates carry PCM (.mkv for video, .wav for audio-only) instead of the final codec, so audio is encoded exactly once, at concat. Boundary fades of 30 ms are applied per segment, scaled down on segments shorter than 4x the ramp and skipped on degenerate ones. Measured on the same three-range cut, widest run of digital silence at each splice: before 24.4 ms / 24.1 ms after 0.1 ms / 0.1 ms --copy is unchanged: stream copy cannot filter, so it keeps producing final-codec segments and a copy concat. Audio-only output is byte-identical in duration before and after. scripts/lib test suite: 34/35, the one failure (lut-preset-provider) pre-exists on main and is unrelated. Co-Authored-By: Claude Opus 5 --- skills/media-use/scripts/transcript-cut.mjs | 50 ++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/skills/media-use/scripts/transcript-cut.mjs b/skills/media-use/scripts/transcript-cut.mjs index d76bd022fb..7eef9c9476 100644 --- a/skills/media-use/scripts/transcript-cut.mjs +++ b/skills/media-use/scripts/transcript-cut.mjs @@ -92,10 +92,17 @@ function run() { try { const parts = segments.map((segment, index) => { - const out = join( - tmpDir, - `segment-${String(index).padStart(4, "0")}${extname(outPath) || ".mp4"}`, - ); + // Intermediates carry PCM audio, not the final codec. Encoding each + // segment to a lossy codec separately makes the encoder pad every segment + // with priming silence (~25-35ms for AAC), which concat then bakes in as a + // gap at each cut -- a defect distinct from, and surviving, the fades + // below. PCM has no priming, so audio is encoded exactly once, at concat. + const ext = Boolean(args.copy) + ? extname(outPath) || ".mp4" + : isAudioOnly(outPath) + ? ".wav" + : ".mkv"; + const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`); cutSegment(inputPath, segment, out, Boolean(args.copy)); return out; }); @@ -107,9 +114,16 @@ function run() { // Encode to a sibling temp (same extension so ffmpeg picks the right muxer), // then atomic-rename so a SIGKILL mid-encode can't leave a truncated outPath. const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`; + // --copy already produced final-codec segments, so concat can stream-copy. + // Otherwise the PCM intermediates are encoded here, once, for the whole file. + const concatCodecs = Boolean(args.copy) + ? ["-c", "copy"] + : isAudioOnly(outPath) + ? encodeArgsFor(extname(outPath).toLowerCase()) + : ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]; execFileSync( "ffmpeg", - ["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", tmpOut], + ["-y", "-f", "concat", "-safe", "0", "-i", listPath, ...concatCodecs, tmpOut], { stdio: "ignore", }, @@ -171,15 +185,41 @@ function cutSegment(inputPath, segment, outPath, copy) { ]; if (copy) { argv.push("-c", "copy", "-avoid_negative_ts", "make_zero"); + } else if (extname(outPath).toLowerCase() === ".mkv") { + // Video intermediate: keep the picture cheap and the audio uncompressed. + argv.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "pcm_s16le"); + } else if (extname(outPath).toLowerCase() === ".wav") { + argv.push("-c:a", "pcm_s16le"); } else { + // Concat splices raw segment edges together; without a short ramp the + // waveform steps discontinuously at every boundary and you hear a click. + // Stream copy cannot filter, so --copy trades pop-free cuts for speed. + const fade = fadeFilterFor(segment.end - segment.start); + if (fade) argv.push("-af", fade); argv.push(...encodeArgsFor(extname(outPath).toLowerCase())); } argv.push(outPath); execFileSync("ffmpeg", argv, { stdio: "ignore" }); } +// 30ms in/out ramps kill the click at every concat boundary. A segment shorter +// than 4x the ramp would spend its whole length fading, so scale down there and +// skip entirely on a degenerate one. +function fadeFilterFor(durationSeconds) { + const FADE_SECONDS = 0.03; + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0.01) return null; + const d = Math.min(FADE_SECONDS, durationSeconds / 4); + const out = round3(durationSeconds - d); + if (out <= 0) return null; + return `afade=t=in:st=0:d=${round3(d)},afade=t=out:st=${out}:d=${round3(d)}`; +} + // Codec set per output container. Audio-only outputs must not get the // video-centric aac/x264 set (aac inside .wav breaks timing entirely). +function isAudioOnly(filePath) { + return [".wav", ".mp3", ".m4a", ".aac", ".flac"].includes(extname(filePath).toLowerCase()); +} + function encodeArgsFor(ext) { if (ext === ".wav") return ["-c:a", "pcm_s16le"]; if (ext === ".mp3") return ["-c:a", "libmp3lame", "-q:a", "2"]; From 961be077ff3b77f696e82a066b9f2ef972ce25d5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 16:07:46 -0400 Subject: [PATCH 2/5] chore(skills): regenerate manifest for media-use --- skills-manifest.json | 84 ++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index c305a0fdbb..658a93da53 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,88 +2,88 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "f1b298aa899d86df", - "files": 142 + "hash": "efa9f5cc37198d74", + "files": 304 }, "faceless-explainer": { - "hash": "39e1e4d42e9a5ebe", - "files": 24 + "hash": "5e0a63152087fcd9", + "files": 52 }, "figma": { - "hash": "642cce1b5e211182", - "files": 2 + "hash": "a88551b3ecc03d3c", + "files": 5 }, "general-video": { - "hash": "079d3479295e27b4", - "files": 4 + "hash": "aa5038d23ecffba0", + "files": 10 }, "hyperframes": { - "hash": "05525e9c53582e4b", - "files": 26 + "hash": "a1e4f3e6fcb3fc73", + "files": 56 }, "hyperframes-animation": { - "hash": "479c716293a26531", - "files": 122 + "hash": "7f1f33ee83223684", + "files": 251 }, "hyperframes-audio": { - "hash": "b39bac771e873eae", - "files": 7 + "hash": "6f1269c35579cdb6", + "files": 16 }, "hyperframes-cli": { - "hash": "986414090bf6442f", - "files": 11 + "hash": "82bda6787c2536d6", + "files": 23 }, "hyperframes-core": { - "hash": "77ce4deba31148a2", - "files": 11 + "hash": "eded968a9f467aa2", + "files": 23 }, "hyperframes-creative": { - "hash": "0803c90800fda4ce", - "files": 79 + "hash": "64181e3365cfa7f4", + "files": 177 }, "hyperframes-keyframes": { - "hash": "6bc62531ecea9a52", - "files": 3 + "hash": "fa9d766ac7b9ebf9", + "files": 8 }, "hyperframes-registry": { - "hash": "51e6dba95a8dfb45", - "files": 12 + "hash": "2c031aba3041b999", + "files": 26 }, "hyperframes-studio": { - "hash": "b063eaa9eb1e4b9b", - "files": 1 + "hash": "7972f83b426c084d", + "files": 2 }, "media-use": { - "hash": "8f21655cd07a62ea", - "files": 158 + "hash": "b6793bfd89b1e820", + "files": 327 }, "motion-graphics": { - "hash": "32641ae2b94c4a8f", - "files": 23 + "hash": "436890aa2c246145", + "files": 64 }, "music-to-video": { - "hash": "190e9885dab9b11d", - "files": 169 + "hash": "a66d6e9d6e31eb1f", + "files": 395 }, "pr-to-video": { - "hash": "da3a68c5a2a86f77", - "files": 30 + "hash": "67cebeedb653d8fe", + "files": 64 }, "product-launch-video": { - "hash": "ced501f76c52716b", - "files": 30 + "hash": "bcbf5e68332249ef", + "files": 64 }, "remotion-to-hyperframes": { - "hash": "c8eb6f48889f5c06", - "files": 77 + "hash": "289a53f80c891c4d", + "files": 179 }, "slideshow": { - "hash": "71174bff2d869f18", - "files": 2 + "hash": "45296fe836028a3a", + "files": 5 }, "talking-head-recut": { - "hash": "7018bf017e8606d2", - "files": 28 + "hash": "92b8a64246218a3b", + "files": 63 } } } From d20e27b86c1f8ddd99f12f31fbea17fa325485fe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 18:45:28 -0400 Subject: [PATCH 3/5] fix(media-use): honor the fadeIn/fadeOut flags fadeFilterFor was passed The caller already computed {fadeIn, fadeOut} per segment, but fadeFilterFor still took one parameter and always emitted both afade ramps -- so a single kept segment (no splice at all) and every segment's true start/end got faded regardless. Extracted the pure function into lib/transcriptCutFade.mjs, made it honor both flags, and added its first unit test: first/middle/last/single-segment filter strings, no ffmpeg needed, fails against the previous version. Also installs ffmpeg in the Test: skills CI job (same action the Producer job uses), so transcript-cut.test.mjs's real-tone RED/GREEN proof runs in CI instead of skipping silently for lack of the binary. --- .github/workflows/ci.yml | 1 + skills-manifest.json | 4 +-- .../scripts/lib/transcriptCutFade.mjs | 21 ++++++++++++ .../scripts/lib/transcriptCutFade.test.mjs | 33 +++++++++++++++++++ skills/media-use/scripts/transcript-cut.mjs | 13 +------- 5 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 skills/media-use/scripts/lib/transcriptCutFade.mjs create mode 100644 skills/media-use/scripts/lib/transcriptCutFade.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0502cd4865..81fd4e1349 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -483,6 +483,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - uses: ./.github/actions/install-ffmpeg-linux - name: Discover and run skills tests # We expand the test list via bash so the job fails loudly when the # matcher comes back empty, rather than silently no-op'ing (which diff --git a/skills-manifest.json b/skills-manifest.json index d9eb6616b5..7b4e6b2837 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "53675f49824d0e92", - "files": 89 + "hash": "b571d75da5dbaa79", + "files": 91 }, "motion-graphics": { "hash": "9c2ca2e8c384875d", diff --git a/skills/media-use/scripts/lib/transcriptCutFade.mjs b/skills/media-use/scripts/lib/transcriptCutFade.mjs new file mode 100644 index 0000000000..bc1676074a --- /dev/null +++ b/skills/media-use/scripts/lib/transcriptCutFade.mjs @@ -0,0 +1,21 @@ +function round3(n) { + return Math.round(Number(n) * 1000) / 1000; +} + +// 30ms in/out ramps kill the click at every concat boundary. A segment shorter +// than 4x the ramp would spend its whole length fading, so scale down there and +// skip entirely on a degenerate one. fadeIn/fadeOut are false at the export's +// own true start/end, where there is no splice to smooth. +export function fadeFilterFor(durationSeconds, { fadeIn, fadeOut }) { + if (!fadeIn && !fadeOut) return null; + const FADE_SECONDS = 0.03; + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0.01) return null; + const d = Math.min(FADE_SECONDS, durationSeconds / 4); + const parts = []; + if (fadeIn) parts.push(`afade=t=in:st=0:d=${round3(d)}`); + if (fadeOut) { + const out = round3(durationSeconds - d); + if (out > 0) parts.push(`afade=t=out:st=${out}:d=${round3(d)}`); + } + return parts.length ? parts.join(",") : null; +} diff --git a/skills/media-use/scripts/lib/transcriptCutFade.test.mjs b/skills/media-use/scripts/lib/transcriptCutFade.test.mjs new file mode 100644 index 0000000000..55c97ebf08 --- /dev/null +++ b/skills/media-use/scripts/lib/transcriptCutFade.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fadeFilterFor } from "./transcriptCutFade.mjs"; + +test("fades in only at the first segment's trailing splice", () => { + assert.equal(fadeFilterFor(2, { fadeIn: false, fadeOut: true }), "afade=t=out:st=1.97:d=0.03"); +}); + +test("fades both edges of a middle segment's two splices", () => { + assert.equal( + fadeFilterFor(2, { fadeIn: true, fadeOut: true }), + "afade=t=in:st=0:d=0.03,afade=t=out:st=1.97:d=0.03", + ); +}); + +test("fades out only at the last segment's leading splice", () => { + assert.equal(fadeFilterFor(2, { fadeIn: true, fadeOut: false }), "afade=t=in:st=0:d=0.03"); +}); + +test("a single kept segment borders no splice at all", () => { + assert.equal(fadeFilterFor(2, { fadeIn: false, fadeOut: false }), null); +}); + +test("still scales the ramp down on a short segment with both edges faded", () => { + assert.equal( + fadeFilterFor(0.08, { fadeIn: true, fadeOut: true }), + "afade=t=in:st=0:d=0.02,afade=t=out:st=0.06:d=0.02", + ); +}); + +test("skips a degenerate segment even when a splice is requested", () => { + assert.equal(fadeFilterFor(0.005, { fadeIn: true, fadeOut: true }), null); +}); diff --git a/skills/media-use/scripts/transcript-cut.mjs b/skills/media-use/scripts/transcript-cut.mjs index 82a2306a41..88b4556006 100644 --- a/skills/media-use/scripts/transcript-cut.mjs +++ b/skills/media-use/scripts/transcript-cut.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, extname, join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { compileCutList } from "./lib/cutlist.mjs"; +import { fadeFilterFor } from "./lib/transcriptCutFade.mjs"; import { track } from "./lib/telemetry.mjs"; const { values: args } = parseArgs({ @@ -208,18 +209,6 @@ function cutSegment(inputPath, segment, outPath, copy, fade) { execFileSync("ffmpeg", argv, { stdio: "ignore" }); } -// 30ms in/out ramps kill the click at every concat boundary. A segment shorter -// than 4x the ramp would spend its whole length fading, so scale down there and -// skip entirely on a degenerate one. -function fadeFilterFor(durationSeconds) { - const FADE_SECONDS = 0.03; - if (!Number.isFinite(durationSeconds) || durationSeconds <= 0.01) return null; - const d = Math.min(FADE_SECONDS, durationSeconds / 4); - const out = round3(durationSeconds - d); - if (out <= 0) return null; - return `afade=t=in:st=0:d=${round3(d)},afade=t=out:st=${out}:d=${round3(d)}`; -} - // Codec set per output container. Audio-only outputs must not get the // video-centric aac/x264 set (aac inside .wav breaks timing entirely). function isAudioOnly(filePath) { From 298c1b60fe779dc39e5605088a187a515a995af9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 19:45:36 -0400 Subject: [PATCH 4/5] fix(media-use): fix the packed-CLI import and lint on transcript-cut.mjs build-copy.mjs replaces dist/skills/media-use/scripts/lib with a copy of packages/cli/src/media-use/lib, so transcriptCutFade.mjs needed a byte-identical twin there (and in MEDIA_USE_COPY_NAMES) or the packed CLI's transcript-cut.mjs imported a file that no longer existed. Moved its test alongside, matching every other lib helper's tests. Also drops three redundant Boolean(args.copy) wrappers (a ternary condition already coerces) that oxlint's no-extra-boolean-cast flagged. --- .../src/media-use/lib/transcriptCutFade.mjs | 21 +++++++++++++++++++ .../media-use}/lib/transcriptCutFade.test.mjs | 0 scripts/check-media-use-copy-parity.test.mjs | 1 + skills-manifest.json | 4 ++-- skills/media-use/scripts/transcript-cut.mjs | 8 +++---- 5 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/media-use/lib/transcriptCutFade.mjs rename {skills/media-use/scripts => packages/cli/src/media-use}/lib/transcriptCutFade.test.mjs (100%) diff --git a/packages/cli/src/media-use/lib/transcriptCutFade.mjs b/packages/cli/src/media-use/lib/transcriptCutFade.mjs new file mode 100644 index 0000000000..bc1676074a --- /dev/null +++ b/packages/cli/src/media-use/lib/transcriptCutFade.mjs @@ -0,0 +1,21 @@ +function round3(n) { + return Math.round(Number(n) * 1000) / 1000; +} + +// 30ms in/out ramps kill the click at every concat boundary. A segment shorter +// than 4x the ramp would spend its whole length fading, so scale down there and +// skip entirely on a degenerate one. fadeIn/fadeOut are false at the export's +// own true start/end, where there is no splice to smooth. +export function fadeFilterFor(durationSeconds, { fadeIn, fadeOut }) { + if (!fadeIn && !fadeOut) return null; + const FADE_SECONDS = 0.03; + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0.01) return null; + const d = Math.min(FADE_SECONDS, durationSeconds / 4); + const parts = []; + if (fadeIn) parts.push(`afade=t=in:st=0:d=${round3(d)}`); + if (fadeOut) { + const out = round3(durationSeconds - d); + if (out > 0) parts.push(`afade=t=out:st=${out}:d=${round3(d)}`); + } + return parts.length ? parts.join(",") : null; +} diff --git a/skills/media-use/scripts/lib/transcriptCutFade.test.mjs b/packages/cli/src/media-use/lib/transcriptCutFade.test.mjs similarity index 100% rename from skills/media-use/scripts/lib/transcriptCutFade.test.mjs rename to packages/cli/src/media-use/lib/transcriptCutFade.test.mjs diff --git a/scripts/check-media-use-copy-parity.test.mjs b/scripts/check-media-use-copy-parity.test.mjs index a5bedac0c5..8ebab828d0 100644 --- a/scripts/check-media-use-copy-parity.test.mjs +++ b/scripts/check-media-use-copy-parity.test.mjs @@ -19,6 +19,7 @@ export const MEDIA_USE_COPY_NAMES = [ "prefs-store.mjs", "recipe-store.mjs", "telemetry.mjs", + "transcriptCutFade.mjs", "words.mjs", ]; diff --git a/skills-manifest.json b/skills-manifest.json index 7b4e6b2837..666620367f 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,8 +54,8 @@ "files": 1 }, "media-use": { - "hash": "b571d75da5dbaa79", - "files": 91 + "hash": "1b30afb089f7319a", + "files": 90 }, "motion-graphics": { "hash": "9c2ca2e8c384875d", diff --git a/skills/media-use/scripts/transcript-cut.mjs b/skills/media-use/scripts/transcript-cut.mjs index 88b4556006..730ddb07e6 100644 --- a/skills/media-use/scripts/transcript-cut.mjs +++ b/skills/media-use/scripts/transcript-cut.mjs @@ -98,7 +98,7 @@ function run() { // with priming silence (~25-35ms for AAC), which concat then bakes in as a // gap at each cut -- a defect distinct from, and surviving, the fades // below. PCM has no priming, so audio is encoded exactly once, at concat. - const ext = Boolean(args.copy) + const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" @@ -107,13 +107,13 @@ function run() { // --copy stays fade-free (stream copy cannot filter). A segment's true // start/end (index 0's start, the last segment's end) borders nothing // kept, so only an interior splice edge gets a ramp. - const fade = Boolean(args.copy) + const fade = args.copy ? null : fadeFilterFor(segment.end - segment.start, { fadeIn: index > 0, fadeOut: index < segments.length - 1, }); - cutSegment(inputPath, segment, out, Boolean(args.copy), fade); + cutSegment(inputPath, segment, out, args.copy, fade); return out; }); const listPath = join(tmpDir, "list.txt"); @@ -126,7 +126,7 @@ function run() { const tmpOut = `${outPath}.part${extname(outPath) || ".mp4"}`; // --copy already produced final-codec segments, so concat can stream-copy. // Otherwise the PCM intermediates are encoded here, once, for the whole file. - const concatCodecs = Boolean(args.copy) + const concatCodecs = args.copy ? ["-c", "copy"] : isAudioOnly(outPath) ? encodeArgsFor(extname(outPath).toLowerCase()) From fcb06a1a3f6099914cd7a793d30d4fe522ac7477 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 20:02:19 -0400 Subject: [PATCH 5/5] style(media-use): reflow the shortened ext ternary to oxfmt's shape Dropping the Boolean() wrapper shortened the line enough that oxfmt 0.41 now collapses the ternary onto one line; format:check was failing on the stale multi-line version. --- skills-manifest.json | 2 +- skills/media-use/scripts/transcript-cut.mjs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index 666620367f..f0ed394bc9 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -54,7 +54,7 @@ "files": 1 }, "media-use": { - "hash": "1b30afb089f7319a", + "hash": "7263711f70b299d6", "files": 90 }, "motion-graphics": { diff --git a/skills/media-use/scripts/transcript-cut.mjs b/skills/media-use/scripts/transcript-cut.mjs index 730ddb07e6..b7e4f468e0 100644 --- a/skills/media-use/scripts/transcript-cut.mjs +++ b/skills/media-use/scripts/transcript-cut.mjs @@ -98,11 +98,7 @@ function run() { // with priming silence (~25-35ms for AAC), which concat then bakes in as a // gap at each cut -- a defect distinct from, and surviving, the fades // below. PCM has no priming, so audio is encoded exactly once, at concat. - const ext = args.copy - ? extname(outPath) || ".mp4" - : isAudioOnly(outPath) - ? ".wav" - : ".mkv"; + const ext = args.copy ? extname(outPath) || ".mp4" : isAudioOnly(outPath) ? ".wav" : ".mkv"; const out = join(tmpDir, `segment-${String(index).padStart(4, "0")}${ext}`); // --copy stays fade-free (stream copy cannot filter). A segment's true // start/end (index 0's start, the last segment's end) borders nothing