mcpField
E
explainer-engaging

explainer-engaging

github.com/barty-bart创意与媒体9131.0k 次安装2 周前更新
代码仓库

关于 explainer-engaging

用于制作一镜到底式产品讲解视频的技能,通过自包含 HTML 页面构建并渲染为 MP4。单个主视觉元素穿行于会响应的界面站点,以展示产品工作流,并拉远呈现完整路径。

主要功能

  1. 01将工作流映射为界面站点
  2. 02单个主视觉元素与界面互动
  3. 03连续的镜头运动
  4. 04可循环的时间驱动 HTML 页面
  5. 05运动模糊与同步配乐

适用场景

  1. 01动态展示产品工作流
  2. 02制作循环播放的产品讲解视频
  3. 03呈现界面步骤间的交互

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/barty-bart/motion-graphics --skill explainer-engaging

安装后在 Claude Code 中输入/explainer-engaging确认已加载。

附带文件README.md

详细介绍

引人入胜的讲解视频

一镜到底的视觉故事。一个核心元素(在参考风格中,是一个白色小球,最初是开关的旋钮)穿过一连串小型界面“站点”,每个站点都会对它作出响应:它沿着滑块滚动,将滑块推到 100;从末端掉落,弹跳着穿过一叠通知,每经过一条通知,它就会弹出;它落在键盘上,逐个按键弹跳,拼出一个单词;它按下回车键,随即被弹回空中。镜头从不剪切:始终让核心元素靠近画面中央,并在每次互动时推近;到高潮时,镜头大幅拉远,让观众一眼看到完整的流程布局,然后再俯冲回故事的起点。

对于产品来说,这些站点就是铺陈在同一画布上的工作流步骤,而核心元素就是穿行于工作流中的事物。镜头拉远的那一刻,就是“现在你明白它怎么运作了”的瞬间。

将其编写成一个 HTML 页面,根据时间值渲染任意一帧,然后添加动态模糊和配乐,将其渲染为 MP4。

第 1 步:获取需求(只发一条消息,然后开始)

在一条消息中询问所有问题,然后独立完成后续工作。只需提供产品信息;其他任何留空项都由你自行决定,并说明你的选择。

  1. 产品:名称,以及用几句话说明它的功能。最好提供网站 URL。
  2. 观众必须记住的一件事(例如:“输入一个想法,获得一个实时应用”)。
  3. 品牌:颜色、字体、Logo 文件,或“使用网站上的品牌元素”。如果没有,就使用下方的默认配色。
  4. 格式:16:9(1920×1080)或 9:16(1080×1920)。默认为 16:9。
  5. 时长:默认为下方给出的此类视频时长。
  6. 音乐:提供自有音轨(文件路径),或让你合成一段。默认为合成。
  7. 方向(可选):想要的场景、标语、行动号召,以及任何需要避免的内容。

如果用户提供了 URL,请在开始撰写前读取网站内容(通过网页抓取),了解产品实际使用的措辞、功能、颜色和字体名称。界面标签和标语应使用产品的原文措辞。

绝不编造事实。 不得虚构客户数量、营收、评分、推荐语或引言。只有在用户或网站明确提供数字时才能使用;否则使用相对比例条、进度条和占位骨架线。界面中的示例内容(提示词、文件名、项目名、收据上的价格)可以合理编写;但有关产品本身的宣传内容(统计数据、用户数、评分、“免费”等优惠)不能编造。行动号召只能使用用户或网站提供的 URL 或优惠信息;如果都没有,就以产品名称和标语收尾。

Logo。 只有在用户提供文件,或这是用户自己的产品且用户明确要求使用时,才使用 Logo。否则,使用品牌字体将名称显示为文本。除非用户提供第三方 Logo(集成服务),否则使用带圆角的中性方块并标注名称。

也可询问(可选):由 3–6 个步骤组成的工作流(用户做什么 → 产品做什么 → 得到什么结果)。如果用户提供了 URL,就从网站中提取这些步骤。

此类视频的默认时长:12 秒,并无缝循环。时长范围为 10–30 秒(更长的视频应增加站点,而不是放慢站点的节奏)。

第 2 步:设计流程,然后构建

1. 选择核心元素

核心元素是一个简单、清晰的物体,它代表产品故事的核心,而且不会消失或被替换(它可以短暂改变形状,然后恢复):球/旋钮(默认选择,适用于任何产品)、文本插入符或光标、文件或文档方块、消息气泡、硬币或订单、数据点。它应该能自然融入某个站点的界面中(旋钮、拇指、键帽、图钉)。

2. 将工作流映射到各个站点

每个步骤都变成一个站点:一个紧凑的真实界面元素,主角会与它进行实体互动,并且它会因此产生明显变化。可以借鉴以下机制:

机制主角……站点的反应
切换开关 / 按钮化身旋钮,或按下按钮开关打开,颜色填充
滑块 / 进度条化身滑块上的圆点,沿着滑块滚动数值在工具提示中递增,轨道逐渐填满
通知堆栈在每张卡片上跌落并弹起主角碰到卡片时,卡片逐张弹出,并显示讲述故事的消息
键盘 / 输入框在按键之间弹跳每个按键向下按下,字母出现在上方的输入框中
列表 / 表格沿着各行滑下每一行高亮、打勾或排序
图表化身折线前端的圆点折线在它身后绘出,经过时柱形升起
聊天以气泡的形式乘入回复逐字输入
部署 / 发送按钮由按钮发射出去弹出提示或 URL

站点标签和消息用寥寥数语讲述故事,并带点机智(参考示例使用了 Slider · Reached 100、Gravity · Something is falling、Messages · What now?,然后输入 retry)。对于某个产品,请根据它的实际工作流编写(例如,编码工具:输入提示词 → Agent · Writing 12 files → 进度 → Preview · Ready → 按下部署按钮 → Live at myapp.app)。

3. 布置场景

将站点按主角访问的顺序放置在一张大画布上(约为画面尺寸的 3 倍),让路径呈锯齿形(顶部向右、穿过中间向下、底部横向移动)。重力方向要合理:下落向下,发射向上。整个视频中,每个站点的位置都保持固定。场景是一张深色画布,带有随镜头移动的淡色点阵网格(网格能让镜头运动更易辨识)。

4. 为主角设计运动路径

主角的位置是时间的纯函数,由接触点之间的片段构成(每个接触点都落在节拍上):

  • **沿站点滚动/滑动:**沿轨迹使用 ease 或弹簧来计算位置;站点的数值由主角的位置推导得出。
  • **在接触点之间跳跃和下落:**使用抛物线(x 随时间线性变化,y = y0 + v·t + ½·g·t²,求解 v,确保主角在节拍时落到下一个接触点)。整个视频使用相同的重力常数。
  • **撞击:**接触时挤压变形(缩放为 1.25 × 0.8,持续约 60 ms,然后弹回);飞行时沿速度方向拉伸(最高可达 1.15 × 0.9);被撞到的对象也轻微下压。
  • 发射:最后一个站点将主角向上并朝起点方向发射,形成一个长弧;与此同时,所有站点都在主角身后倒序重置(数值倒数、通知消失、输入清空),让循环闭合。让发射弧以慢动作运行:先选定想要的最高点(课程的顶部),由此根据重力确定实际飞行时间;然后调整时间,让这段飞行占满揭示窗口,并在最高点处最慢(通常慢 5–10 倍;例如,在弧线期间使用速度 1 − 0.9·sin(πu)^0.6)。安排站点在揭示停留期间重置,让观众先看到完整课程,而不是在发射瞬间就开始重置。
  • **循环衔接:**将循环点设在主角落到第一个站点的时刻。seek(duration) 必须与 seek(0) 完全一致;时间 0 也要显示落地挤压和按键动作。对于声音,在 buildAudio 中使用稍长的 OfflineAudioContext 渲染音轨,将 duration 之后的尾音折回到开头,再将该缓冲区播放到给定的上下文中。
  • **重力方向为侧向视角。**从侧面布置站点,让下落动作看起来自然:通知卡片排成阶梯(每张卡片的横向偏移都大于其宽度,避免主角穿过卡片);键盘则正对镜头,让主角以小弧线在按键之间跳跃。
  • **空位:**如果主角原本是某个站点的一部分(例如旋钮、拇指),离开后,站点要保留一个暗淡的空位轮廓;主角返回时再填上。
5. 摄像机

摄像机是最难的部分;先把它做好,再打磨其他内容。

  • 跟随:摄像机目标为主角位置(加上约 0.15 s 速度的前导量)与当前站点固定锚点的混合,并通过临界阻尼弹簧(w ≈ 6)平滑处理,避免撞击时突然跳动。互动期间锚点权重约为 0.7(保持站点在画面内);下落和跳跃期间为 0.15(让主角引领画面);最终停留期间为 0.85。平滑处理需要历史状态,因此要在加载时预先计算(见页面约定),从时间 0 的第一个站点固定取景开始,在前 0.8 s 内逐渐切换为跟随。主角始终留在画面中间一半的区域内。
  • **缩放:**每次互动期间都要紧密取景:站点约占画面宽度的一半,主角在屏幕上为 50–70 px(1080p 下)。下落和跳跃期间稍微拉远,让下一个站点提前进入视野。对准站点取景,不要对着空白画布。
  • **最终效果停留:**发射前,在最终结果(提示条、可访问的 URL)上停留 0.4–0.6 s,确保完整显示在画面内。
  • 揭示:发射时,摄像机停止跟随,并缓动到能容纳整个课程的固定取景(约 0.3×),再加上轻微的 3D 倾斜(perspective + rotateX 为 15–25°)以增加纵深感。停留约 1 s,让主角在画面中划过;放大主角并为其添加柔和光晕,让它始终是画面中最亮、最醒目的对象(屏幕上至少 24 px)。然后俯冲到第一个站点的固定取景,让主角落地时摄像机恰好抵达,并过渡回跟随状态。
  • 快速移动时要有真实的运动模糊:主角和场景都要产生拖影,这是整体视觉效果的一部分。
视觉
  • 默认配色(类似 iOS 的深色风格):画布 #0C0C0E,点阵网格 #1E1E22,界面表面 #1C1C1F / #2C2C2E,文本 #F2F2F7,次要文本 #8E8E93,强调色 #0A84FF,主视觉白色 #FFFFFF。品牌色替代强调色;主视觉始终是屏幕上最明亮的元素。如果品牌色较浅,也可以采用浅色版本(画布 #F2F2F4,主视觉 = 强调色)。
  • 采用逼真的系统 UI:开关、带刻度的滑块、通知卡片(包含应用图标方块、标题、一行正文和 now);带有按键键帽和回车键的键盘。字体:类似 SF 的字体(Inter 或 Geist)。
  • 光标可以启动整个链条(点击第一个站点),随后移开,不再遮挡画面。
  • **禁止:**切镜、淡出至黑屏、解释当前发生情况的文字叠层(站点自身的标签是唯一文本)、多个主视觉、没有反应的站点、看起来轻飘飘的物理效果。
音效

采用紧凑、极简的律动,速度为 110–120 BPM(轻柔的底鼓、清脆的踩镲、闷音拨弦贝斯);主视觉的接触是主要乐器:每次碰撞都播放一声短促的有音高的滴答声或木琴音符(随着故事推进,音高逐渐升高),按键时发出点击声,滑块移动时播放细密的滴答声且随数值增大而加快,通知出现时发出轻柔的弹出声,启动时配上向上的呼啸声,下潜时配上向下的呼啸声。每个音效都要与对应的视觉接触精准同步。

页面必须如何构建

使用一个独立的 HTML 文件(内嵌 CSS 和 JS;仅在用户提供图片时使用图片)。使用 DOM + CSS 变换、SVG 或一个全屏画布绘制,选择最合适的方式。

页面规范

  • window.VIDEO = {w, h, duration} 和 window.seek(t) 必须是时间的纯函数:它会将每个元素设为时间 t(秒)时的状态。不使用 CSS 过渡或动画,不使用 setTimeout,也不保留上一帧的状态。任何帧都必须能够单独以任意顺序正确渲染。在 seek 中测量布局(例如 offsetLeft、measureText)没有问题;这类测量是确定性的。
  • 随机效果(颗粒、粒子、音频中的噪声)必须来自带种子的生成器,绝不使用 Math.random。
  • 任何需要历史状态的内容(例如跟随移动物体的延迟摄像机、模拟)都必须在加载时预先烘焙一次:以 1/240 秒为步长,从 0 模拟到 duration,存储数值,并让 seek 通过插值读取这些数值。对于循环,应从循环开始时的状态启动模拟(参见该风格的接缝规则)。
  • 循环: seek(t) 使用 t % duration;跨越接缝的效果(例如落地挤压、声音尾音)必须在两端绘制或混合。
  • URL 中带有 ?render 时:不显示控件、不缩放预览;舞台在左上角,尺寸严格为 w × h。不带 ?render 时:使用 CSS 缩放舞台以适应窗口,并显示预览控件。
  • 嵌入字体。 下载 woff2 文件(来自 Google Fonts 或品牌字体),并将其以内嵌 base64 的 @font-face 形式放入页面,确保渲染时不会悄然回退到其他字体。若要改变字重,使用带有字重范围([email protected])的可变字体。
  • 图层: 场景图层使用透明背景,并由其后的一个舞台背景提供底色,这样后续图层就不会意外遮住前面的图层。绝对定位的文本需设置 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, …)))。
  • 摄像机是一个包装元素,其 transform: translate() scale() rotate() 由 kf 或 track 驱动。

声音

  • window.buildAudio(ctx) 会使用与视觉效果相同的时间线数值,从时间 0 开始,基于传入的 AudioContext 或 OfflineAudioContext 调度整段音轨(音乐 + 音效)。使用振荡器、滤波噪声(来自带种子的缓冲区)和包络合成声音。如果用户提供了音乐文件,则使用 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。

  1. 静帧。 node render.mjs demo.html check.png --stills 0.5,1.2,…,每个节拍表行对应一个时间点(表格以便于阅读的大小显示,每行 3 项)。查看图片。修复任何被裁切、重叠、难以辨认、不符合品牌风格、取景不佳或空白的内容。再次检查。
  2. 草稿。 node render.mjs demo.html draft.mp4 30 1 4(速度快:30 fps,无动态模糊,4 个并行工作进程;最后一个数字设为机器的核心数)。这是审核版:将其与 HTML 一起发送给用户,并在制作最终版前征求修改意见。
  3. 最终版。 node render.mjs demo.html demo.mp4 60 8 8。动态模糊会使渲染时间按子帧数倍增;60 秒的最终版在笔记本电脑上可能需要 20–60 分钟,因此请在开始前告知。它会生成 demo.mp4(音频已归一化至约 −16 LUFS)和 demo_silent.mp4。
  4. 交付 HTML(用户可以实时拖动播放进度)、MP4 文件,以及一段简短说明:节拍表、哪些内容是占位内容,以及第二版需要修改的内容。用一句话提供一个替代方向。
// 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();

评论

支持 Markdown