explainer-engaging について
ワンカット風の製品説明動画を、単体のHTMLページで作成してMP4にするスキルです。1つのヒーロー要素が反応するUIの各ステーションを巡り、製品のワークフローを示し、全体を見渡す場面で締めます。
主な機能
- 01ワークフローをUIステーションに配置
- 02単一のヒーロー要素による操作
- 03途切れないカメラ移動
- 04ループ可能な時間駆動HTMLページ
- 05モーションブラーと同期した音声
活用シーン
- 01製品ワークフローを動きで説明
- 02ループ再生する製品説明動画の制作
- 03UIの各段階における操作の可視化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ barty-bart/ motion-graphics --skill explainer-engagingインストール後、Claude Code で/explainer-engagingを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
魅力的な解説
ワンカットで描くビジュアルストーリーです。1つの主役の要素(参考スタイルでは、トグルのつまみとして始まる白いボール)が、小さなインターフェースの「ステーション」を次々と進み、各ステーションが反応します。スライダーの上を転がってつまみを100まで押し上げ、端から落ちて、通り過ぎるたびに通知がポップアップする通知の積み重ねを弾みながら下り、キーボードに着地してキーからキーへ弾みながら単語を入力し、Returnキーを押して空中に打ち上げられます。カメラは一度もカットしません。主役を画面の中央付近に保ちながら、操作のたびにズームインし、クライマックスでは大きく引いてコース全体が一望できるようにしてから、最初の場所に向かって再びズームインします。
製品の場合、ステーションはワークフローのステップで、1つのキャンバス上に空間的に配置されます。そして主役は、そのワークフローを進む要素です。カメラが引く場面は、「これで仕組みがわかった」と視聴者が感じる瞬間です。
時間の値から任意のフレームを描画するHTMLページを1つ作成し、モーションブラーとサウンドトラックを加えてMP4にレンダリングします。
ステップ1:概要を確認する(質問は1回、その後は作業を進める)
必要な質問を1回のメッセージですべて尋ね、その後は自分で作業を進めます。必須なのは製品だけです。空欄があれば自分で決め、選んだ内容を伝えてください。
- 製品:名称と、どのようなものかを数文で説明してください。WebサイトのURLがあれば、さらに参考になります。
- 視聴者に覚えてもらうべき最も重要なこと(例:「アイデアを入力すると、実際に動くアプリができる」)。
- ブランド:色、フォント、ロゴファイル、または「Webサイトを使う」。指定がない場合は、以下のデフォルトパレットを使用します。
- 形式:16:9(1920×1080)または9:16(1080×1920)。デフォルトは16:9です。
- 長さ:このスタイルのデフォルトは以下に記載しています。
- 音楽:独自のトラック(ファイルパス)を使うか、こちらで合成します。デフォルトは合成です。
- 演出の希望(任意):入れたいシーン、タグライン、行動喚起、避けたい要素など。
URLが提示された場合は、制作に着手する前にWeb取得でページを読み、製品で実際に使われている表現、機能、色、フォント名を確認してください。UIラベルやタグラインには、実際に使われている表現を使用します。
事実を決して捏造しないでください。 顧客数、収益、評価、推薦文、引用を作り上げてはいけません。数値を使うのは、ユーザーまたはWebサイトに記載されている場合に限ります。それ以外の場合は、相対的なバー、進捗表示、プレースホルダーのスケルトン行を使ってください。UI内のコンテンツ例(プロンプト、ファイル名、プロジェクト名、レシートの価格など)は、もっともらしいものであれば問題ありません。ただし、製品についての主張(統計、ユーザー数、評価、「無料」などの特典)は不可です。行動喚起には、ユーザーまたはWebサイトが提示したURLや特典のみを使用します。どちらもなければ、名称とタグラインで締めくくります。
ロゴ。 ロゴを使うのは、ユーザーからファイルが提供された場合、またはユーザー自身の製品で、使用を希望している場合に限ります。それ以外の場合は、ブランドフォントで名称をテキストとして表示します。サードパーティのロゴ(連携先)を使うのも、提供された場合に限ります。それ以外の場合は、名称を記した角丸タイルを使用します。
また、(任意で)3~6ステップのワークフロー(ユーザーが行うこと → 製品が行うこと → 結果)も尋ねてください。URLが提示されている場合は、サイトから導き出してください。
このスタイルのデフォルトの長さは12秒で、シームレスにループします。範囲は10~30秒です(長くする場合は、ステーションを増やし、各ステーションの速度は落としません)。
ステップ2:コースを設計してから構築する
1. 主役を選ぶ
主役は、製品のストーリーを表す、シンプルで見分けやすいオブジェクトです。途中で消えたり、別のものに置き換わったりしてはいけません(短時間形を変え、その後元に戻るのはかまいません)。ボール/つまみ(デフォルトで、あらゆる用途に合います)、テキスト入力カーソルまたはポインター、ファイルまたはドキュメントのタイル、メッセージ吹き出し、コインまたは注文、データポイントなどが考えられます。ステーションのインターフェースに自然になじむもの(つまみ、サム、キーキャップ、ピンなど)にしてください。
2. ワークフローをステーションに割り当てる
各ステップを1つのステーションにします。ステーションは、主人公が実際に操作するコンパクトなインターフェース要素で、操作によって目に見える変化が起こるものです。次のような仕組みを参考にしてください。
| 仕組み | 主人公の動き... | ステーションの反応 |
|---|---|---|
| トグル/ボタン | 主人公がノブになる、またはボタンを押す | オンに切り替わる、色が塗られる |
| スライダー/進捗状況 | つまみになってスライダーに沿って進む | ツールチップの値がカウントアップし、トラックが塗られる |
| 通知スタック | カードごとに落下して跳ね返る | 当たるたびにカードがポップインし、ストーリーを語るメッセージが表示される |
| キーボード/入力欄 | キーからキーへと跳ねる | キーが押し下げられ、上の入力欄に文字が表示される |
| リスト/テーブル | 行に沿って滑り降りる | 各行がハイライト表示される、チェックが付く、または並べ替えられる |
| グラフ | 線の先頭の点になる | 後ろに線が描かれ、通過すると棒が伸びる |
| チャット | 吹き出しに乗って入ってくる | 返信がタイプされる |
| デプロイ/送信ボタン | ボタンによって発射される | トーストまたはURLが表示される |
ステーションのラベルやメッセージは、それぞれ数語でストーリーを伝え、少し気の利いた表現にします(参考例では、Slider · Reached 100、Gravity · Something is falling、Messages · What now? と表示した後、retry と入力します)。製品の場合は、実際のワークフローに基づいて作成してください(例:コーディングツールなら、プロンプトを入力 → Agent · Writing 12 files → 進捗表示 → Preview · Ready → デプロイボタンを押す → Live at myapp.app)。
3. 世界をレイアウトする
ステーションを1つの大きなキャンバス(フレームの約3倍)に、主人公が訪れる順に配置し、経路がジグザグになるようにします(上部を右に進み、中央を通って下り、下部を横切ります)。重力が自然に感じられるようにします。落下は下向き、発射は上向きにします。動画全体を通して、各ステーションの位置は固定しておきます。世界は、カメラの動きに合わせて移動する、薄いドットグリッドがある暗いキャンバスです(このグリッドによって、カメラの動きが分かりやすくなります)。
4. ヒーローの軌道をスクリプトで定義する
ヒーローの位置は、接地点(各接点はビート上にあります)間のセグメントから成る、時間の純粋関数です。
- ステーションに沿った転がり/スライド: トラックに沿った
easeまたはスプリングで位置を決めます。ステーションの値はヒーローの位置から導きます。 - 接点間のホップと落下: 放物線を使います(時間に対して
xは線形、y = y0 + v·t + ½·g·t²とし、ビートのタイミングで次の接点に着地するようvを求めます)。重力定数は動画全体で一定にします。 - 衝突: 接触時にスクワッシュさせます(約 60 ms の間、スケールを 1.25 × 0.8 にし、その後スプリングで戻します)。飛行中は速度方向に伸ばします(最大 1.15 × 0.9)。さらに、衝突したものを少し下に押し込みます。
- 発射: 最後のステーションからヒーローを上方へ発射し、長い弧を描いて開始地点へ戻します。その間、後方ではすべてのステーションを逆順にリセットします(値をカウントダウンし、通知を閉じ、入力をクリアします)。これでループが閉じます。発射の弧はスローモーションで再生します。まず希望する頂点の高さ(コースの最上部)を決めると、重力下での実際の飛行時間が決まります。次に時間を変形して、その飛行がリビールの時間枠いっぱいに収まるようにし、頂点で最も遅くします(多くの場合、ここでは 5〜10 倍です。たとえば、弧に沿って速度を
1 − 0.9·sin(πu)^0.6にします)。ステーションのリセットは、発射時ではなく、視聴者がコース全体を見た後のリビール中のホールド中に行います。 - 継ぎ目: ループのポイントは、ヒーローが最初のステーションに着地する位置にします。
seek(duration)の見た目はseek(0)と完全に同じでなければなりません。着地時のスクワッシュとキー押下も時刻 0 で表示します。音声については、buildAudio内で少し長めの独自のOfflineAudioContextにサウンドトラックをレンダリングし、durationを過ぎた末尾を先頭に折り返して、与えられたコンテキストでそのバッファを再生します。 - 重力は横から見た向きです。 横から見て落下が自然に見えるよう、ステーションを配置します。通知カードは階段状に並べます(ヒーローがカードを通り抜けないよう、各カードを横方向にカード幅より大きくずらします)。キーボードは正面から見た状態にし、ヒーローが小さな弧を描いてキー間をホップするようにします。
- 空のスロット: ヒーローがステーションの一部(ノブやつまみ)だった状態から離れたとき、ステーションには離れたスロットの薄い輪郭を残し、ヒーローが戻ると再び埋まるようにします。
5. カメラ
カメラは最も難しい部分です。ほかを仕上げる前に、まずカメラを正しく設定してください。
- 追従: カメラのターゲットは、ヒーローの位置(速度の約 0.15 秒分の先行分を加算)と、現在のステーションに固定されたアンカーをブレンドしたものにします。衝突時にカメラが急に動かないよう、臨界減衰スプリング(
w≈ 6)で滑らかにします。操作中はアンカー側の重みを約 0.7 にします(ステーションをフレーム内に保ちます)。落下やホップ中は 0.15 にします(ヒーローを先行させます)。決めのホールド中は 0.85 にします。スムージングには履歴が必要なので、ページの契約に従って読み込み時にベイクします。時刻 0 では最初のステーションを固定フレーミングし、最初の 0.8 秒で追従に移行します。ヒーローはフレームの中央半分から外れないようにします。 - ズーム: 各操作中は寄ります。ステーションがフレーム幅の約半分を占め、ヒーローが画面上で 50〜70 px(1080p の場合)になるようにします。落下やホップ中は少し引き、次のステーションがあらかじめ見えるようにします。何もないキャンバスではなく、ステーションをフレームに収めます。
- 決めのホールド: 発射前に最終結果(トースト、公開された URL)を 0.4〜0.6 秒間、すべてフレーム内に収めて表示します。
- リビール: 発射時にカメラの追従を停止し、コース全体が収まる固定フレーミング(約 0.3×)へ滑らかに移行します。奥行きを出すため、3D の傾きを少し加えます(
perspectiveと 15〜25° のrotateX)。約 1 秒間その状態を保ち、ヒーローがその中を弧を描いて進む様子を見せます。ヒーローを拡大し、柔らかなハローを付けて、最も明るく目立つ要素として保ちます(画面上で少なくとも 24 px)。次に、ヒーローが到着するタイミングに合わせて、最初のステーションの固定フレーミングへカメラを急降下させ、追従に戻します。 - 実際のモーションブラーを使います。速く動くときはヒーローと周囲の世界が流れて見えるようにします。これも演出の一部です。
見た目
- デフォルトのパレット(ダークな iOS 風):キャンバス
#0C0C0E、ドットグリッド#1E1E22、サーフェス#1C1C1F/#2C2C2E、テキスト#F2F2F7、セカンダリ#8E8E93、アクセント#0A84FF、ヒーローの白#FFFFFF。アクセントはブランドカラーに置き換えます。ヒーローは画面上で最も明るい要素にします。ブランドカラーが明るい場合は、ライトバージョン(キャンバス#F2F2F4、ヒーロー = アクセント)でもかまいません。 - 実物らしいシステム UI:トグル、目盛り付きスライダー、アプリアイコンの四角、タイトル、本文の一行、「now」を含む通知カード、キーキャップとリターンキーのあるキーボード。フォント:SF 風(Inter または Geist)。
- カーソルで連鎖を始める(最初のステーションをクリックする)ことはできますが、その後は邪魔にならないようにします。
- **禁止事項:**カット、黒へのフェード、何が起きているかを説明するテキストオーバーレイ(テキストはステーション自体のラベルのみ)、ヒーローが複数あること、反応しないステーション、浮遊しているように見える物理表現。
サウンド
110~120 BPM のタイトでミニマルなグルーヴ(柔らかなキック、歯切れのよいハイハット、ミュートしたプラックベース)にします。ヒーローの接触音をメインの楽器にします。衝撃のたびに短い音程付きのティック音またはマリンバの音を鳴らし、物語の進行に合わせて音程を上げます。キー入力ではクリック音、スライダーの移動では値に応じて速くなる繊細なティック音、通知では柔らかなポップ音を鳴らします。起動時には上昇するウーシュ音、潜るときには下降するウーシュ音を鳴らします。すべてのサウンドを、対応する視覚的な接触の瞬間に正確に合わせます。
ページの構築方法
自己完結型の HTML ファイルを 1 つ作成します(CSS と JS はインライン、画像はユーザーから提供されたもののみ使用)。DOM と CSS の変換、SVG、または全画面キャンバスなど、目的に合った方法で描画します。
ページの仕様
window.VIDEO = {w, h, duration}とwindow.seek(t)を用意します。seek(t)は時間の純粋関数で、時刻t(秒)におけるすべての要素を設定します。CSS トランジションやアニメーション、setTimeout、前のフレームから引き継ぐ状態は使いません。どのフレームも、どの順番でも単独で正しく描画できる必要があります。seek内でのレイアウト計測(例:offsetLeft、measureText)は問題ありません。結果が決定論的であることが条件です。- 乱数(粒子、ノイズ、音声内のノイズなど)は、
Math.randomではなく、シード付きジェネレーターから生成します。 - 履歴が必要なもの(動く物体に遅れて追従するカメラ、シミュレーションなど)は、読み込み時に一度だけ事前計算します。0 から
durationまで 1/240 秒間隔で進め、値を保存します。seekでは補間しながら値を読み取ります。ループの場合は、ループ開始時の状態からシミュレーションを開始します(スタイルごとの継ぎ目のルールを参照)。 - ループ:
seek(t)ではt % durationを使用します。継ぎ目をまたぐエフェクト(着地時のスクワッシュ、サウンドの余韻など)は、両端で描画またはミックスします。 - URL に
?renderがある場合:コントロールやプレビューの拡大縮小は行わず、ステージを左上にw × hのサイズで表示します。ない場合:ウィンドウに収まるよう CSS でステージを拡大縮小し、プレビューコントロールを表示します。 - フォントは埋め込みます。 woff2 ファイル(Google Fonts またはブランドのフォント)をダウンロードし、base64 の
@font-faceとしてインライン化します。これにより、レンダリング時に気づかないままフォールバックすることを防げます。ウェイトを変化させる場合は、[email protected]のような範囲を持つ可変フォントを使います。 - レイヤー: シーンのレイヤーは背景を透明にし、背後にある 1 つのステージ背景でベースカラーを指定します。これにより、後のレイヤーが誤って前のレイヤーを隠すのを防げます。絶対配置のテキストには
white-space: nowrapを指定します。 - 速度: 大きな CSS の
filter: blur()や、大きくぼかしたグラデーションはスクリーンショットの生成が非常に遅くなります。グラデーションやグローは読み込み時に一度だけキャンバス(または画像)に描画し、それを動かします。
ヘルパー:以下を貼り付けて、その上にすべてを構築します
// damped spring step response 0 -> 1. w = speed (8 slow, 14 normal, 22 snappy), z = damping (1 = no overshoot)
function spring(t, w = 14, z = 0.82) {
if (t <= 0) return 0;
if (z >= 1) return 1 - Math.exp(-w * t) * (1 + w * t);
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + (z * w / wd) * Math.sin(wd * t));
}
// a value that changes target many times, springing to each: track(start, [[time, value, w?, z?], ...]) -> f(t)
const track = (v0, keys) => t => { let v = v0, p = v0; for (const [tk, val, w, z] of keys) { v += (val - p) * spring(t - tk, w, z); p = val; } return v; };
// precise eased keyframes (best for camera moves): kf([[t0, v0], [t1, v1, easeFn?], ...]) -> f(t)
const kf = keys => t => { if (t <= keys[0][0]) return keys[0][1]; for (let i = 1; i < keys.length; i++) { const [a, va] = keys[i - 1], [b, vb, e = ease] = keys[i]; if (t <= b) return lerp(va, vb, e((t - a) / (b - a))); } return keys[keys.length - 1][1]; };
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ease = x => (x = clamp(x), x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); // easeInOutCubic
const lerp = (a, b, k) => a + (b - a) * k;
const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b]
const rng = s => () => (s = (s + 0x6D2B79F5) | 0, s = Math.imul(s ^ (s >>> 15), 1 | s), s ^= s + Math.imul(s ^ (s >>> 7), 61 | s), ((s ^ (s >>> 14)) >>> 0) / 4294967296);
- タイピング:表示する文字列は
full.slice(0, n(t))で、キャレットはtに基づいて点滅させます。カウントアップ:lerp(a, b, ease(seg(t, …)))で書式を整えます。 - カメラは、
kfまたはtrackで制御するtransform: translate() scale() rotate()を持つラッパー要素です。
サウンド
window.buildAudio(ctx)は、渡されたAudioContextまたはOfflineAudioContextを使い、時刻 0 からサウンドトラック全体(音楽 + 効果音)をスケジュールします。映像と同じタイムラインの値を使います。オシレーター、フィルタリングしたノイズ(シード付きバッファから生成)、エンベロープで音を合成します。ユーザーから音楽ファイルが提供された場合は、fetchとdecodeAudioDataで読み込み、テンポを計測してビートグリッドを設定し、その上に効果音のみを追加します。- プレビュー再生: 最初の再生時にサウンドトラックを一度バッファにレンダリングし、その後はシークした任意の位置から再生できるようにします。クロックで
seekを制御します。
let buf, ac, src, startAt = 0, offset = 0, playing = false;
async function play() {
ac ??= new AudioContext();
if (!buf) { const off = new OfflineAudioContext(2, Math.ceil(48000 * VIDEO.duration), 48000); await buildAudio(off); buf = await off.startRendering(); }
src = ac.createBufferSource(); src.buffer = buf; src.connect(ac.destination);
src.start(0, offset % VIDEO.duration); startAt = ac.currentTime; playing = true;
}
function pause() { if (!playing) return; offset += ac.currentTime - startAt; src.stop(); playing = false; }
function now() { return playing ? (offset + ac.currentTime - startAt) % VIDEO.duration : offset; }
(function loop() { seek(now()); requestAnimationFrame(loop); })(); // the scrubber sets offset (pause, set, play)
- スクリプトの先頭にコメントとしてビートシートを記述します。各行に、時刻、発生する内容、使用するサウンドを記載します。
ステップ 3:確認、レンダリング、納品
作業フォルダーで一度だけセットアップします:npm i playwright を実行し、Chromium がまだインストールされていない場合は npx playwright install chromium を実行します。ffmpeg が PATH にある必要があります。このファイルの末尾にあるスクリプトを render.mjs として保存します。
- 静止画。
node render.mjs demo.html check.png --stills 0.5,1.2,…を実行します。ビートシートの各行につき 1 つの時刻を指定します(シートは読みやすいサイズで、1 行に 3 つ並びます)。画像を確認し、切れている箇所、重なり、読みにくさ、ブランドイメージとの不一致、構図の悪さ、空白を修正します。もう一度確認します。 - ドラフト。
node render.mjs demo.html draft.mp4 30 1 4を実行します(高速:30 fps、モーションブラーなし、並列ワーカー 4 つ。最後の数値はマシンのコア数に設定します)。これはレビュー用コピーです。HTML とともにユーザーに送り、最終版の前に変更点を確認してもらいます。 - 最終版。
node render.mjs demo.html demo.mp4 60 8 8を実行します。モーションブラーにより、レンダリング時間はサブフレーム数に比例して増加します。60 秒の最終版はノートパソコンで 20~60 分かかることがあるため、開始前にその旨を伝えます。demo.mp4(音声は約 −16 LUFS に正規化)とdemo_silent.mp4が出力されます。 - 納品するものは、HTML(ライブでスクラブできます)、MP4 ファイル、短いメモです。メモにはビートシート、プレースホルダーになっているもの、2 つ目のバージョンで変更する内容を記載します。別の方向性を 1 文で提案します。
// render.mjs: turn a seek(t) page into an MP4 with motion blur and its own audio.
// node render.mjs page.html out.mp4 [fps=60] [subframes=4] [workers=4]
// node render.mjs page.html sheet.png --stills 0.5,1.2,3 (full-size contact sheet for checking)
// Optional: window.buildAudio(ctx) schedules the soundtrack on any (Offline)AudioContext.
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const [, , html, out, a3 = '60', a4 = '4', a5 = '4'] = process.argv;
if (!html || !out) { console.log('usage: node render.mjs page.html out.mp4 [fps] [subframes] [workers]'); process.exit(1); }
const run = (args) => new Promise((res, rej) => { const p = spawn('ffmpeg', args, { stdio: 'inherit' }); p.on('close', c => c ? rej(new Error('ffmpeg ' + c)) : res()); });
const url = pathToFileURL(path.resolve(html)).href + '?render';
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const browser = await chromium.launch({ args: ['--allow-file-access-from-files'], ...(proxy ? { proxy: { server: proxy } } : {}) });
// Read the size first, then open every page at exactly that size (pages that measure themselves on load stay correct).
const probe = await browser.newPage();
await probe.goto(url);
await probe.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
const V = await probe.evaluate(() => window.VIDEO);
await probe.close();
async function openPage() {
const page = await browser.newPage({ viewport: { width: V.w, height: V.h }, deviceScaleFactor: 1 });
await page.goto(url);
await page.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
// load every declared font, even ones only used by scenes hidden at t=0
const bad = await page.evaluate(async () => {
await Promise.all([...document.fonts].map(f => f.load().catch(() => {})));
await document.fonts.ready;
return [...document.fonts].filter(f => f.status !== 'loaded').map(f => f.family);
});
if (bad.length) console.warn('WARNING: fonts did not load (fallback in use):', [...new Set(bad)].join(', '), '- embed them as base64.');
return page;
}
const shot = async (page, t) => { await page.evaluate(t => window.seek(t), t); return page.screenshot({ type: 'jpeg', quality: 95 }); };
if (a3 === '--stills') {
const page = await openPage();
const times = a4.split(',').map(Number), dir = out + '.frames';
mkdirSync(dir, { recursive: true });
for (let i = 0; i < times.length; i++) writeFileSync(`${dir}/${String(i).padStart(3, '0')}.jpg`, await shot(page, times[i]));
await browser.close();
const cols = Math.min(3, times.length), rows = Math.ceil(times.length / cols);
await run(['-v', 'error', '-y', '-framerate', '1', '-i', `${dir}/%03d.jpg`, '-vf', `scale=${Math.min(960, V.w)}:-1,tile=${cols}x${rows}:padding=6:color=gray`, '-frames:v', '1', out]);
rmSync(dir, { recursive: true });
console.log('stills ->', out); process.exit(0);
}
const fps = +a3, sub = Math.max(1, +a4), workers = Math.max(1, +a5), N = Math.round(V.duration * fps);
const base = out.replace(/\.mp4$/, ''), silent = base + '_silent.mp4';
const vf = sub > 1 ? ['-vf', `tmix=frames=${sub},select='eq(mod(n\\,${sub})\\,${sub - 1})',setpts=N/(${fps}*TB)`] : [];
const t0 = Date.now(); let doneFrames = 0;
const per = Math.ceil(N / workers), segs = [];
await Promise.all(Array.from({ length: workers }, async (_, w) => {
const a = w * per, b = Math.min(N, a + per); if (a >= b) return;
const seg = `${base}_seg${w}.mp4`; segs[w] = seg;
const page = await openPage();
const ff = spawn('ffmpeg', ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps * sub), '-i', '-', ...vf,
'-r', String(fps), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-preset', 'medium', seg], { stdio: ['pipe', 'inherit', 'inherit'] });
const closed = new Promise(r => ff.on('close', r));
for (let f = a; f < b; f++) {
for (let s = 0; s < sub; s++) { // 180-degree shutter: sub-frames spread over half a frame
const buf = await shot(page, (f + (sub > 1 ? (s / sub) * 0.5 : 0)) / fps);
if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
}
if (++doneFrames % 30 === 0) process.stdout.write(`\r${doneFrames}/${N} frames ${((Date.now() - t0) / 1000).toFixed(0)}s`);
}
ff.stdin.end(); await closed; await page.close();
}));
const list = base + '_segs.txt';
writeFileSync(list, segs.filter(Boolean).map(s => `file '${path.resolve(s)}'`).join('\n'));
await run(['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
segs.filter(Boolean).forEach(s => rmSync(s)); rmSync(list);
console.log(`\nvideo -> ${silent} (${((Date.now() - t0) / 1000).toFixed(0)}s)`);
const page = await openPage();
const hasAudio = await page.evaluate(() => typeof window.buildAudio === 'function');
if (hasAudio) {
const b64 = await page.evaluate(async (dur) => {
const sr = 48000, ctx = new OfflineAudioContext(2, Math.ceil(sr * dur), sr);
await window.buildAudio(ctx);
const b = await ctx.startRendering(), L = b.getChannelData(0), R = b.getChannelData(b.numberOfChannels > 1 ? 1 : 0);
const i16 = new Int16Array(L.length * 2);
for (let i = 0; i < L.length; i++) { i16[2 * i] = Math.max(-1, Math.min(1, L[i])) * 32767; i16[2 * i + 1] = Math.max(-1, Math.min(1, R[i])) * 32767; }
const u8 = new Uint8Array(i16.buffer); let s = '';
for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));
return btoa(s);
}, V.duration);
const pcm = base + '.pcm'; writeFileSync(pcm, Buffer.from(b64, 'base64'));
await run(['-v', 'error', '-y', '-i', silent, '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', pcm,
'-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', out]);
rmSync(pcm); console.log('with audio ->', out);
}
await browser.close();