<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Canvas on HanGR的碎碎念</title><link>https://han-gr.github.io/tags/canvas/</link><description>Recent content in Canvas on HanGR的碎碎念</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><lastBuildDate>Tue, 07 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://han-gr.github.io/tags/canvas/index.xml" rel="self" type="application/rss+xml"/><item><title>RuiToolAI 新功能 Shorts Maker 开发笔记：浏览器端图片轮播视频工具</title><link>https://han-gr.github.io/p/2026-07-07-ruitoolai-%E6%96%B0%E5%8A%9F%E8%83%BD-shorts-maker-%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0%E6%B5%8F%E8%A7%88%E5%99%A8%E7%AB%AF%E5%9B%BE%E7%89%87%E8%BD%AE%E6%92%AD%E8%A7%86%E9%A2%91%E5%B7%A5%E5%85%B7/</link><pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate><guid>https://han-gr.github.io/p/2026-07-07-ruitoolai-%E6%96%B0%E5%8A%9F%E8%83%BD-shorts-maker-%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0%E6%B5%8F%E8%A7%88%E5%99%A8%E7%AB%AF%E5%9B%BE%E7%89%87%E8%BD%AE%E6%92%AD%E8%A7%86%E9%A2%91%E5%B7%A5%E5%85%B7/</guid><description>&lt;img src="https://han-gr.github.io/p/2026-07-07-ruitoolai-%E6%96%B0%E5%8A%9F%E8%83%BD-shorts-maker-%E5%BC%80%E5%8F%91%E7%AC%94%E8%AE%B0%E6%B5%8F%E8%A7%88%E5%99%A8%E7%AB%AF%E5%9B%BE%E7%89%87%E8%BD%AE%E6%92%AD%E8%A7%86%E9%A2%91%E5%B7%A5%E5%85%B7/shorts.jpg" alt="Featured image of post RuiToolAI 新功能 Shorts Maker 开发笔记：浏览器端图片轮播视频工具" /&gt;&lt;h2 id="1-为什么做这个功能"&gt;1. 为什么做这个功能
&lt;/h2&gt;&lt;p&gt;RuiToolAI 准备在 YouTube 上做内容宣传。不做硬广，把用户爱看的东西，做成 Shorts 短视频。&lt;/p&gt;
&lt;p&gt;问题来了：用什么工具做？市面上的视频编辑软件要么太重，要么要导出再上传，流程割裂。最简单的方式是：在 RuiToolAI 网站上直接生成图片，然后一键拼成轮播视频——&lt;strong&gt;生成到发布，一个网站搞定&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;于是就有了 Shorts Maker，免费开放给所有用户。&lt;/p&gt;
&lt;h2 id="2-核心功能一览"&gt;2. 核心功能一览
&lt;/h2&gt;&lt;p&gt;操作起来很简单，三步出片：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;选图&lt;/strong&gt;：本地上传图片，或从文生图历史记录中挑选&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配乐&lt;/strong&gt;：上传背景音乐，拖动滑块调节音量，预览实时生效&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;导出&lt;/strong&gt;：点击导出，浏览器本地录制 WebM，直接下载&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;全程纯浏览器端处理，不上传任何文件到服务器。页面右侧有一个手机 Mockup 预览窗口，实时播放当前视频效果，所见即所得。&lt;/p&gt;
&lt;h2 id="3-技术实现"&gt;3. 技术实现
&lt;/h2&gt;&lt;p&gt;整个工具跑在浏览器里，核心依赖三个 Web API：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Canvas + &lt;code&gt;captureStream&lt;/code&gt;&lt;/strong&gt;：把逐帧绘制的画面转成视频流&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MediaRecorder&lt;/strong&gt;：录制视频流输出 WebM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web Audio API&lt;/strong&gt;：解码音频文件，混入视频流&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="31-canvas-录制从-raf-到-setinterval-的踩坑"&gt;3.1 Canvas 录制：从 rAF 到 setInterval 的踩坑
&lt;/h3&gt;&lt;p&gt;最初的实现用 &lt;code&gt;requestAnimationFrame&lt;/code&gt; 驱动录制循环：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsedMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;drawShortsFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;elapsedMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsedMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalDurationMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;测试时一切正常。但导出的视频偶尔会在某一张图片上卡住，画面停在那里不动。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code&gt;requestAnimationFrame&lt;/code&gt; 在浏览器标签页不可见时会&lt;strong&gt;被节流&lt;/strong&gt;（降到 1fps 甚至暂停）。用户导出时切到别的标签页，rAF 的时间戳 &lt;code&gt;now&lt;/code&gt; 就停住了，elapsed 不再推进，canvas 一直画同一帧，录出来的视频就卡死在那张图上。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;修复&lt;/strong&gt;：改用 &lt;code&gt;setInterval&lt;/code&gt; 驱动，用 &lt;code&gt;performance.now()&lt;/code&gt; 计算真实流逝时间，不受标签页可见性影响：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;EXPORT_FPS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsedMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totalDurationMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;drawShortsFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;elapsedMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsedMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalDurationMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;另外 &lt;code&gt;MediaRecorder.onstop&lt;/code&gt; 回调的绑定时机也有讲究。&lt;code&gt;recorder.stop()&lt;/code&gt; 是异步的，浏览器需要先 flush 最后一批数据才触发 &lt;code&gt;onstop&lt;/code&gt;。如果在 Promise 创建时就绑定 &lt;code&gt;onstop&lt;/code&gt;，中间如果有其他操作关闭了相关上下文，可能导致 &lt;code&gt;onstop&lt;/code&gt; 永远不触发。正确做法是在 &lt;code&gt;recorder.stop()&lt;/code&gt; &lt;strong&gt;之前&lt;/strong&gt;才绑定：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onstop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;resolveRecording&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mimeType&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rejectRecording&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Video export failed.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="32-音频处理绕过浏览器自动播放策略"&gt;3.2 音频处理：绕过浏览器自动播放策略
&lt;/h3&gt;&lt;p&gt;背景音乐需要同时满足两个需求：预览时能听到，导出时能混入视频。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;预览&lt;/strong&gt;：直接用 &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; 元素，&lt;code&gt;src&lt;/code&gt; 设为 &lt;code&gt;URL.createObjectURL(file)&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;导出&lt;/strong&gt;：不能用 &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt;，因为 &lt;code&gt;captureStream&lt;/code&gt; 只捕获 Canvas 的画面。需要用 Web Audio API 创建独立的音频轨道，手动连接到 &lt;code&gt;MediaStream&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arrayBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;musicTrack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decodeAudioData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gainNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;musicVolume&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createBufferSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioBuffer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;musicTrack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shouldLoop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;gainNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 把音频轨道加到导出流中
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;exportStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;这里踩过一个坑：一开始用 &lt;code&gt;createMediaElementSource(audioElement)&lt;/code&gt; 配合 &lt;code&gt;audioElement.play()&lt;/code&gt;，但浏览器的自动播放策略会拦截 &lt;code&gt;.play()&lt;/code&gt; 调用，导致 &lt;code&gt;The audio track could not start&lt;/code&gt; 错误。改用 &lt;code&gt;AudioBufferSourceNode&lt;/code&gt; + &lt;code&gt;decodeAudioData&lt;/code&gt; 完全绕过了这个问题。&lt;/p&gt;
&lt;p&gt;另一个坑：用 &lt;code&gt;fetch(musicTrack.url)&lt;/code&gt; 读取 blob URL 的音频数据，在某些浏览器中会报 &lt;code&gt;Failed to fetch&lt;/code&gt;。因为 blob URL 在不同上下文之间不可访问。改为直接用 &lt;code&gt;musicTrack.file.arrayBuffer()&lt;/code&gt; 读取原始文件。&lt;/p&gt;
&lt;h3 id="33-mp4-转码的放弃ffmpegwasm-不是银弹"&gt;3.3 MP4 转码的放弃：ffmpeg.wasm 不是银弹
&lt;/h3&gt;&lt;p&gt;最初计划支持 MP4 导出，用 ffmpeg.wasm 在浏览器端把 WebM 转成 MP4。&lt;/p&gt;
&lt;p&gt;遇到的问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;31MB 的 wasm 文件&lt;/strong&gt;：免费版 Cloudflare Workers 单文件限制 25MB，超限无法部署。改 CDN 加载后，COEP &lt;code&gt;require-corp&lt;/code&gt; 模式下 CDN 资源缺少 CORP 头，浏览器拒绝加载。改成 &lt;code&gt;credentialless&lt;/code&gt; 模式解决了加载问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;libx264&lt;/code&gt; 编码在 wasm 中极慢&lt;/strong&gt;：转码几分钟没反应，用户以为卡死。改 &lt;code&gt;-c:v copy&lt;/code&gt; 跳过视频重编码，但 VP8/VP9 in MP4 兼容性差，iOS Safari 不认。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;进度条不可靠&lt;/strong&gt;：ffmpeg.wasm 的 &lt;code&gt;progress&lt;/code&gt; 事件经常不触发，返回负数或超大值。用定时器模拟进度也只能让用户看到数字在动，实际转码可能已经挂了。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;综合考虑，&lt;strong&gt;WebM 已经足够&lt;/strong&gt;。YouTube 完全支持 WebM 上传，Android Chrome 也能播放。唯一问题是 iOS Safari 不支持 WebM 本地播放，但目标是上传到短视频平台，不是本地播放。最终决定只保留 WebM 导出，删除所有 ffmpeg 相关代码。&lt;/p&gt;
&lt;h3 id="34-音量实时预览不触发重渲染的-dom-操作"&gt;3.4 音量实时预览：不触发重渲染的 DOM 操作
&lt;/h3&gt;&lt;p&gt;音量滑块需要两个效果：拖动时实时改变预览音量，导出时按比例混音。&lt;/p&gt;
&lt;p&gt;最初把 &lt;code&gt;volume&lt;/code&gt; 放在 &lt;code&gt;musicTrack&lt;/code&gt; state 里，拖动滑块时 &lt;code&gt;setMusicTrack&lt;/code&gt; 触发重渲染，&lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; 元素的 &lt;code&gt;loop&lt;/code&gt; prop 被 React 重新设置，导致音频播放中断。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;修复&lt;/strong&gt;：把 &lt;code&gt;volume&lt;/code&gt; 拆成独立 state &lt;code&gt;musicVolume&lt;/code&gt;，&lt;code&gt;onChange&lt;/code&gt; 里直接操作 DOM：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;setMusicVolume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// 直接操作 DOM，不触发 &amp;lt;audio&amp;gt; 重渲染
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previewAudioRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;previewAudioRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;volume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vol&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; 元素不会被 React 重新渲染，播放状态保持不变。&lt;/p&gt;
&lt;h2 id="4-交互细节"&gt;4. 交互细节
&lt;/h2&gt;&lt;p&gt;几个小但重要的交互设计：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;历史图片空状态&lt;/strong&gt;：没有历史图片时，显示引导按钮跳转到文生图功能，而不是空着一片白&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图片悬浮 + 号&lt;/strong&gt;：鼠标移到历史图片上显示半透明覆盖层和 + 号，点击添加，干净不占空间&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ending Card&lt;/strong&gt;：每段视频末尾自动加上 RuiTool AI 的结尾卡，目前强制开启，后续有可能作为付费功能&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;导出提示&lt;/strong&gt;：导出中显示橙色警告文字，提醒用户不要切换标签页，但其实切换也没事，只要不关掉就行&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="5-总结"&gt;5. 总结
&lt;/h2&gt;&lt;p&gt;Shorts Maker 是一个典型的&amp;quot;浏览器能力边界探索&amp;quot;项目。Canvas 录制、Web Audio 混音、MediaRecorder 导出，这些 API 组合起来能做出一个完整的视频工具，但每个 API 都有坑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;requestAnimationFrame&lt;/code&gt; 在后台会被节流&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MediaRecorder.onstop&lt;/code&gt; 的绑定时序影响 Promise 是否 resolve&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createMediaElementSource&lt;/code&gt; 受自动播放策略限制&lt;/li&gt;
&lt;li&gt;ffmpeg.wasm 在浏览器里跑视频编码仍然太慢&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最终的方案是：&lt;strong&gt;用 WebM，做减法，保证稳定&lt;/strong&gt;。功能不一定最多，但每个功能都要可靠。&lt;/p&gt;</description></item></channel></rss>