From 22bff1bf6c91ff8490751775e9732333f4e70478 Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 19 Oct 2026 01:21:25 +0900 Subject: [PATCH 1/5] feat(sfx): add sound effects timed to the animation via ElevenLabs --- quality-baseline.json | 8 +- skills/motion-render/SKILL.md | 57 ++++++- skills/motion-render/scripts/audio.ts | 121 ++++++++++---- skills/motion-render/scripts/cli.ts | 41 ++++- skills/motion-render/scripts/sfx.ts | 190 +++++++++++++++++++++ skills/motion-render/scripts/work.ts | 68 +++++++- tests/audio.test.ts | 34 ++++ tests/sfx.test.ts | 229 ++++++++++++++++++++++++++ tests/work.test.ts | 89 +++++++++- 9 files changed, 782 insertions(+), 55 deletions(-) create mode 100644 skills/motion-render/scripts/sfx.ts create mode 100644 tests/sfx.test.ts diff --git a/quality-baseline.json b/quality-baseline.json index 5c5ddae..87a5cfb 100644 --- a/quality-baseline.json +++ b/quality-baseline.json @@ -4,16 +4,16 @@ "thresholds": {}, "issues": [ { - "id": "eslint|complexity|skills/motion-render/scripts/cli.ts|210|1", + "id": "eslint|complexity|skills/motion-render/scripts/cli.ts|157|1", "tool": "eslint", "category": "complexity", "path": "skills/motion-render/scripts/cli.ts", - "line": 210, + "line": 157, "column": 1, - "detail": "Async function 'main' has a complexity of 25. Maximum allowed is 20.", + "detail": "Async function 'main' has a complexity of 22. Maximum allowed is 20.", "metric": { "name": "complexity", - "value": 25, + "value": 22, "limit": 20, "direction": "lowerIsBetter" }, diff --git a/skills/motion-render/SKILL.md b/skills/motion-render/SKILL.md index b07e366..16bcd2f 100644 --- a/skills/motion-render/SKILL.md +++ b/skills/motion-render/SKILL.md @@ -1,6 +1,6 @@ --- name: motion-render -description: テーマを渡されたら、それを説明するアニメーション動画を Claude Motion で作り、ナレーション(Gemini TTS)とテーマに合う BGM(Lyria)を付けて MP4 にする。「〇〇を説明する動画を作って」「〇〇の紹介動画」「/motion-render 〇〇」、既存の Motion の動画を MP4 にしたいときに使う。 +description: テーマを渡されたら、それを説明するアニメーション動画を Claude Motion で作り、ナレーション(Gemini TTS)とテーマに合う BGM(Lyria)、アニメーションに合わせた効果音(ElevenLabs)を付けて MP4 にする。「〇〇を説明する動画を作って」「〇〇の紹介動画」「/motion-render 〇〇」、既存の Motion の動画を MP4 にしたいときに使う。 argument-hint: "<動画のテーマ。長さ・出力先などの指定があれば一緒に>" --- @@ -9,7 +9,7 @@ argument-hint: "<動画のテーマ。長さ・出力先などの指定があれ `/motion-render <テーマ>` の一言で、テーマを説明するアニメーション動画を作り、ナレーションと BGM を付けて MP4 にする。 **ナレーションと BGM は必ず付ける。** 音のない動画は作らない。既存の Motion の動画を MP4 にするときも、 -場面に合わせてナレーションを書き、BGM を作る。 +場面に合わせてナレーションを書き、BGM を作る。効果音は `ELEVENLABS_API_KEY` があるときだけ付ける(下の「効果音」)。 ## 決め方: 聞かずに決める @@ -37,7 +37,8 @@ Motion の Artifact 型は作成時に Style・Colors・Length を質問する Claude Code のプラグインとして入れ直すよう案内する(README の「インストール」。それで追加の作業は要らなくなる)。 `Unknown file extension ".ts"` で止まるのは Node が 22.18 より古いため。Node の更新を案内する 3. `GEMINI_API_KEY` が無いと `narrate`・`bgm` が止まる。止まったら、キーを Google AI Studio で発行して - シェルの設定に `export GEMINI_API_KEY=...` を足し、Claude Code を起動し直すよう案内する + シェルの設定に `export GEMINI_API_KEY=...` を足し、Claude Code を起動し直すよう案内する。 + `test -n "$ELEVENLABS_API_KEY" && echo set` で効果音のキーの有無も見ておく(無ければ効果音を付けずに進める) 4. `motion-render stills` などで Chromium が `bootstrap_check_in ... Permission denied` で起動しないのは、 Claude Code の sandbox の中で動いているため。利用者に `~/.claude/settings.json` へ次を足すよう案内する (Claude は自分の権限設定を変えない): @@ -57,7 +58,7 @@ Motion の Artifact 型は作成時に Style・Colors・Length を質問する / script.json # 原稿(Claude が書く) timing.json # 場面の秒数(narrate が書く) - audio/ # <場面id>.wav、bgm.mp3、mix.m4a + audio/ # <場面id>.wav、bgm.mp3、mix.m4a、sfx/(効果音と試聴用の preview.m4a) film/ # Motion から取得した映像データ(prepare が展開する) ``` @@ -69,7 +70,12 @@ Motion の Artifact 型は作成時に Style・Colors・Length を質問する "bgm": "Calm, focused electronic music for a cloud security explainer: soft pulsing synth, light percussion, 100 BPM", "scenes": [ { "id": "title", "name": "タイトル", "narration": "ワークロードアイデンティティフェデレーションで、…" }, - { "id": "token", "name": "トークン発行", "narration": "…" } + { + "id": "token", + "name": "トークン発行", + "narration": "…", + "sfx": [{ "at": 1.2, "prompt": "soft UI pop", "duration": 0.6 }] + } ] } ``` @@ -77,7 +83,8 @@ Motion の Artifact 型は作成時に Style・Colors・Length を質問する - `id` は英小文字・数字・`-`。Motion の場面の id と同じにする - `narration` は TTS に読ませる文。英語の固有名詞・略語はカタカナで書く(`GitHub Actions` → `ギットハブアクションズ`)。 画面の文字は元の表記のまま -- 任意: `voice`・`speed`・`bgmVolume`(既定 0.15)・`lead`・`tail`・`endHold` +- 任意: `voice`・`speed`・`bgmVolume`(既定 0.15)・`sfxVolume`(既定 0.5)・`lead`・`tail`・`endHold` +- 任意: 場面ごとの `sfx`。`at` は場面の頭からの秒、`duration` は 0.5〜10 秒(既定 1.5)、`volume` は 0〜1(既定 `sfxVolume`) ## 手順 @@ -107,7 +114,10 @@ motion-render narrate - 質問はしない(上の「決め方」) - `scenes.json` の場面の `id`・`dur` は `timing.json` と同じにする - Motion 内蔵の BGM(`score.js`)は作らない。音はこのスキルが付ける + - 効果音を付けるときは、タイトルの場面に `🎧:11.ai` を小さく入れる(下の「効果音」) 3. 並行して BGM を作ってよい: `motion-render bgm ` +4. 効果音を付けるときは、場面ごとに「何秒目に何が出るか」を決めた値で `script.json` の `sfx` を書き、 + `motion-render sfx ` で作る(下の「効果音」) ### 4. 映像データを取得して確認する @@ -116,6 +126,8 @@ motion-render narrate 3. `motion-render prepare /store /film` 4. `motion-render stills /film /stills <各場面の中ほどの秒をカンマ区切り>` で全場面を見る。 見出しの不自然な改行、はみ出し、重なりがあれば Motion 側を直し、手順 4 をやり直す +5. 効果音があれば、各効果音の秒(場面の開始 + `at`)の静止画も書き出し、鳴らす対象がその時点で画面に出ているかを見る。 + ずれていれば `at` を直して `motion-render sfx` をやり直す(作り直されるのは変えたものだけ) ### 5. MP4 にする @@ -123,12 +135,15 @@ motion-render narrate motion-render build [<出力.mp4>] ``` -ナレーションを各場面の頭に置き、BGM を一定の音量で重ねて MP4 にする。ナレーションか BGM が無いと止まる。 +ナレーションを各場面の頭に置き、BGM を一定の音量で重ね、効果音をそれぞれの秒に置いて MP4 にする。 +ナレーションか BGM、または `script.json` にある効果音が無いと止まる。 ### 6. 確認して報告する - `ffprobe` で、長さと映像・音声の両トラックがあることを見る - 報告: 出力のパス、長さ、場面の流れ、BGM の雰囲気、Motion の URL。耳で聞いていないことは聞いていないと書く +- 効果音を付けたときは、試聴用の `audio/sfx/preview.m4a` のパスと、その中の順番(`motion-render sfx` の出力)も報告し、 + 合わない音があれば教えてほしいと頼む。Claude には音の良し悪しが判定できないため、ここは人が聞いて決める ## BGM @@ -144,6 +159,33 @@ motion-render build [<出力.mp4>] 利用者が BGM を「ださい」などと言ったら、`bgm` を書き直して `motion-render bgm` → `motion-render build` をやり直す。 映像の描き直しは要らない(`build` は毎回描画するが、Motion 側の変更は不要)。 +## 効果音 + +`ELEVENLABS_API_KEY` があるときだけ付ける。無ければ付けずに進め、報告で「効果音はキーが無いので付けていない」と書く。 + +置き方: + +- 画面で目に見える出来事にだけ置く: カードが現れる、チップが着く、流れ図の段が光る、場面が切り替わる。 + `at` は Motion の場面で、その動きが始まる秒にする(自分で書いたアニメーションの秒をそのまま使う) +- 少なく: 1 場面 0〜2 個。毎回鳴る音は耳障りになる。同じ種類の出来事には同じ `prompt` と `duration` を使う + (音がそろい、同じものは 1 回しか作られない) +- 短く: 0.5〜1.5 秒。長い上昇音・環境音・音楽・声は頼まない +- `prompt` は英語で、柔らかさを指定する(`soft`・`subtle`・`gentle`)。ナレーションより前に出ないこと + +| 出来事 | `prompt` の例 | `duration` | +| ------------------ | -------------------------------- | ---------- | +| カード・部品が出る | soft UI pop | 0.6 | +| チップが移動する | quick airy whoosh | 0.8 | +| 段が光る・強調 | gentle glassy chime | 1.0 | +| 完了・成功 | soft positive notification chime | 1.2 | + +`build` は、効果音ごとに音量を一定の水準にそろえ(生成される音は 30 dB 以上ばらつく)、`duration` で切り、 +両端をフェードしてからナレーションの下に混ぜる。無音で返ってきた効果音は `sfx` が作り直し、3 回とも無音なら止まる。 + +**表記**: 月額プランなしで作った効果音は、商用に使えず、公開するときは題名に `elevenlabs.io` か `11.ai` を入れる +(ElevenLabs のヘルプ「Can I publish the content I generate on the platform?」)。効果音を付けた動画では、 +タイトルの場面に `🎧:11.ai` を小さく入れる。利用者が月額プランに入っていると言った場合は省いてよい。 + ## 既存の Motion の動画を MP4 にするとき URL を渡された場合も音を付ける。映像データを取得し(手順 4)、`scenes.json` の場面ごとに、その秒数に収まる @@ -155,6 +197,7 @@ URL を渡された場合も音を付ける。映像データを取得し(手 - `scripts/cli.ts`: `motion-render` コマンド(TypeScript。Node 22.18 以上がそのまま実行する) - `scripts/audio.ts`: Gemini TTS・Lyria の呼び出し(90 秒の上限と再試行付き)と音声の合成 +- `scripts/sfx.ts`: ElevenLabs による効果音の生成(`sfx`)、音量の測定とそろえ方、試聴用ファイル - `scripts/work.ts`: `script.json` の検証、`timing.json` の計算、ナレーションを動画の場面の開始時刻に置く処理 - `scripts/render.ts`: プレーヤーを開いてコマを返す。描画中は外部への通信を遮断し、Chromium の sandbox を有効にする - `scripts/json.ts`: ファイル・API・プレーヤーから来る JSON を、形を確かめながら読む diff --git a/skills/motion-render/scripts/audio.ts b/skills/motion-render/scripts/audio.ts index 200488e..22198f8 100644 --- a/skills/motion-render/scripts/audio.ts +++ b/skills/motion-render/scripts/audio.ts @@ -7,7 +7,7 @@ import { errorMessage, field, readJson } from "./json.ts"; import type { Script } from "./work.ts"; const API = "https://generativelanguage.googleapis.com/v1beta/models"; -const TIMEOUT_MS = 90_000; // a TTS request once hung for 15 minutes without this +export const TIMEOUT_MS = 90_000; // a TTS request once hung for 15 minutes without this const RETRIES = 3; interface Audio { @@ -15,10 +15,19 @@ interface Audio { data: Buffer; } -// Where each scene's narration goes on the film timeline. +// One sound effect on the film timeline. `gain` multiplies its volume (it already holds the leveling). +export interface SfxTrack { + file: string; + start: number; + duration: number; + gain: number; +} + +// Where each scene's narration and each sound effect go on the film timeline. export interface MixTiming { scenes: { id: string; start: number }[]; total: number; + sfx?: SfxTrack[]; } // ffmpeg-static downloads its binary in a postinstall script, which Claude Code's plugin dependency @@ -51,7 +60,7 @@ export function findFfmpeg(): string { throw new Error("ffmpeg not found: install ffmpeg or set FFMPEG_PATH"); } -function runFfmpeg(args: string[]): Promise { +export function runFfmpeg(args: string[]): Promise { return new Promise((ok, fail) => { const ff = spawn(findFfmpeg(), ["-y", "-loglevel", "error", ...args], { stdio: ["ignore", "inherit", "inherit"], @@ -63,6 +72,44 @@ function runFfmpeg(args: string[]): Promise { }); } +// Average and peak level of an audio file in dBFS (ffmpeg's volumedetect). Silence reads as -91 dB or lower. +export function volumeStats(file: string): Promise<{ mean: number; max: number }> { + return new Promise((ok, fail) => { + const args = ["-hide_banner", "-nostats", "-i", file, "-af", "volumedetect", "-f", "null", "-"]; + const ff = spawn(findFfmpeg(), args, { stdio: ["ignore", "ignore", "pipe"] }); + let log = ""; + ff.stderr?.on("data", (d: Buffer) => (log += d.toString())); + ff.on("error", fail); + ff.on("close", (code) => { + const level = (name: string): number => { + const v = new RegExp(`${name}: (-?[\\d.]+|-inf) dB`).exec(log)?.[1]; + return v === "-inf" ? -120 : Number(v ?? NaN); + }; + const mean = level("mean_volume"), + max = level("max_volume"); + if (code !== 0 || !Number.isFinite(mean) || !Number.isFinite(max)) + return fail( + new Error(`could not measure the level of ${file} (ffmpeg exited with ${code})`) + ); + ok({ mean, max }); + }); + }); +} + +// Runs `attempt` up to RETRIES times, logging each failure under `label`, and rethrows the last error. +export async function withRetries(label: string, attempt: () => Promise): Promise { + let last: unknown; + for (let i = 1; i <= RETRIES; i++) { + try { + return await attempt(); + } catch (e) { + last = e; + console.error(`motion-render: ${label} attempt ${i}/${RETRIES} failed: ${errorMessage(e)}`); + } + } + throw last; +} + // Picks the first inline audio part out of a generateContent response. function audioPart(json: unknown): { mimeType: string; data: string } | null { const candidates = field(json, "candidates"); @@ -82,33 +129,24 @@ function audioPart(json: unknown): { mimeType: string; data: string } | null { async function generateAudio(model: string, body: object): Promise