product-launch-video

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Product launch video

产品发布视频

Produces a ~40s film where motion graphics carry the narrative and every "product" pixel is really rendered by the real app.
This exists because of a false choice people keep making:
  • A pure motion-graphics film looks professional but proves nothing - no real product.
  • A raw screen recording proves everything but looks amateur - no motion design.
The answer is neither: Remotion is the master timeline, and the product shots are real frames captured by driving the actual app in a headless browser.
Build a small Remotion project beside the product: shared capture toolkit + primitives, one directory per app's film (brand, shots, capture script, timeline). See
references/toolkit.md
for the recommended layout.

制作一段约40秒的影片,动态图形承载叙事,且每一个「产品」像素均由真实应用程序渲染生成
这个方案的诞生,是为了解决人们一直以来面临的两难选择:
  • 纯动态图形影片看起来专业,但无法证明产品真实存在——没有真实产品展示。
  • 原始屏幕录制能证明一切,但观感业余——缺乏动态设计。
最优解并非二选一:Remotion作为主时间线,产品画面是通过无头浏览器驱动真实应用捕获的真实帧。
在产品项目旁搭建一个小型Remotion项目:共享捕获工具包+基础组件,每个应用的影片单独一个目录(包含品牌素材、镜头脚本、捕获脚本、时间线)。推荐目录结构详见
references/toolkit.md

Non-negotiables

不可妥协的规则

Read these before writing any code. Each one cost real debugging time, and every one fails silently - you get plausible-looking output that's wrong.
在编写任何代码前请阅读这些内容。每一条规则背后都耗费了实际调试时间,且每一种问题都会无提示地失效——你得到的输出看似合理,但实际上是错误的。

Capture

捕获环节

TrapWhat happensFix
setViewport({deviceScaleFactor: 2})
page.screencast()
measures its ffmpeg filter chain at the system DPR, so it builds a 1× chain and throws away the retina detail. No error.
Force it at the browser:
--force-device-scale-factor=2
+
deviceScaleFactor: 0
in the viewport (
0
= "system default").
Headless renders no cursor
page.mouse.move()
dispatches CDP events the page sees, but nothing is ever painted.
Inject a DOM cursor via
evaluateOnNewDocument
. Put it in the page, not composited in Remotion - then every Remotion transform (frame inset, zoom punch) scales it exactly like a real recording. A sprite requires inverting every transform, and 6px of drift reads as broken.
Headless has no GPUAny WebGL/three.js content records as a blank or black panel.
--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader
. Verify with a pixel readback, not just "did a context exist".
SwiftShader also composites the pageA software GL driver compositing 2560x1440 caps the whole tab at ~8fps, so every animated shot records as a slideshow. It reads as a slow product, not a broken capture, so nobody suspects the flags.Add
--disable-gpu-compositing
. Skia's CPU rasteriser is much quicker at plain 2D, WebGL still works, and A/B stills are identical. Measured on the same page: 8.2fps → 60.1fps.
Judging smoothness by eyeDuplicated frames look fine in a contact sheet and fine scrubbing one frame at a time. Only playback gives it away, and by then it's rendered.Count distinct frames:
ffmpeg -i take.mp4 -vf mpdecimate,showinfo -f null - 2>&1 | grep -c pts_time
. Well under the file's frame count means stutter.
page.mouse.click(x, y, {clickCount: 2})
Sends one press carrying clickCount metadata. Chrome never synthesizes
dblclick
. Two discrete clicks 60ms apart don't either.
If the app needs a real
dblclick
, click for the visible cursor motion then dispatch the event inside the frame at the element's own coords.
page.keyboard.type()
after clicking into an iframe
Keys go to whatever the parent page focused. If the parent forwards keys to the app (arrow-key nav, shortcuts), your typing silently drives the UI instead of entering text.Type into the frame:
frame.type(selector, text)
.
evaluateOnNewDocument
doing DOM work
Runs at document-start where
document.head
and
document.documentElement
can both be null. An unguarded
appendChild
throws and silently aborts everything after it in that script.
Guard, and defer to
DOMContentLoaded
.
Recording the navigationBuries the useful seconds deep into the file and bloats the mezzanine.Split each shot into
setup
(not recorded) and
action
(recorded).
Trusting marker timestampsScreencast is variable-frame-rate; the encoded timeline compresses relative to wall clock, so a marker at 22s is not at 22s in the file.Use markers to find roughly where to look, then pick
startAt
by extracting frames and looking.
Remote/hotlinked assetsThird-party CDNs intermittently refuse the capture browser; image grids paint as empty transparency checkerboards, which reads as a broken product.Wait for images and warn, then frame the shot to avoid them. Don't ship a take with blank art.
window.scrollTo
in an app shell
Many shells scroll an inner element (
<main class="overflow-y-auto">
), not the window. Scrolling the window is a silent no-op and the shot never moves.
Find the real scroller and set its
scrollTop
.
Guessing click coordinatesApps often only accept interactions on specific nodes - e.g. an inline editor that only accepts leaf elements (
childElementCount === 0
) while headings wrap their text in spans. Clicking the visually obvious target does nothing.
Read the app's own source for the rule, then ask the page where its valid targets are (
frame.evaluate
) and map frame coords → page coords via the iframe's measured box.
陷阱问题表现修复方案
setViewport({deviceScaleFactor: 2})
page.screencast()
会根据系统设备像素比(DPR)构建ffmpeg滤镜链,因此它会生成1倍的滤镜链并丢弃视网膜级细节。无错误提示。
在浏览器层面强制设置:
--force-device-scale-factor=2
+ 视口中设置
deviceScaleFactor: 0
0
表示「系统默认」)。
无头模式不渲染光标
page.mouse.move()
会向页面发送CDP事件,但不会绘制任何内容。
通过
evaluateOnNewDocument
注入DOM光标。将光标放在页面内,而非在Remotion中合成——这样Remotion的每一项变换(帧嵌入、缩放冲击)都会像真实录制一样精确缩放光标。使用精灵图需要反转所有变换,6像素的偏移都会被视为失效。
无头模式无GPU任何WebGL/three.js内容会被录制为空白或黑色面板。添加参数
--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader
。通过像素回读验证,而非仅确认「上下文是否存在」。
SwiftShader也会合成页面软件GL驱动合成2560x1440分辨率时,整个标签页会被限制在约8fps,因此每一个动画镜头都会被录制为幻灯片效果。这会被解读为产品运行缓慢,而非捕获失效,因此没人会怀疑参数设置。添加
--disable-gpu-compositing
。Skia的CPU光栅化器在处理普通2D内容时速度更快,WebGL仍可正常工作,且前后帧画面一致。同一页面测试数据:8.2fps → 60.1fps。
仅凭肉眼判断流畅度重复帧在接触式预览图和逐帧 scrub 时看起来都正常。只有播放时才会暴露问题,但此时已经完成渲染。统计不同的帧数:
ffmpeg -i take.mp4 -vf mpdecimate,showinfo -f null - 2>&1 | grep -c pts_time
。结果远低于文件的帧数说明存在卡顿。
page.mouse.click(x, y, {clickCount: 2})
发送一次带有clickCount元数据的按压事件。Chrome永远不会合成
dblclick
事件。两次间隔60ms的独立点击也不会触发。
如果应用需要真实的
dblclick
事件,先点击以显示光标移动,然后在帧内元素自身坐标处触发事件。
点击iframe后使用
page.keyboard.type()
按键会输入到父页面的焦点元素中。如果父页面会将按键转发给应用(如箭头键导航、快捷键),你的输入会无声地驱动UI而非输入文本。在帧内输入:
frame.type(selector, text)
evaluateOnNewDocument
执行DOM操作
会在document-start阶段运行,此时
document.head
document.documentElement
都可能为null。无防护的
appendChild
会抛出错误,并无声地中止该脚本中后续的所有操作。
添加防护,并延迟到
DOMContentLoaded
阶段执行。
录制导航过程将有用的内容深埋在文件深处,且会增加中间文件体积。将每个镜头拆分为
setup
(不录制)和
action
(录制)两个阶段。
信任标记时间戳屏幕录制是可变帧率;编码后的时间线相对于实际时间会被压缩,因此标记在22s处的内容并不会出现在文件的22s位置。使用标记大致定位,然后通过提取帧查看来选择
startAt
的位置。
远程/热链接资源第三方CDN会间歇性拒绝捕获浏览器的请求;图片网格会渲染为空白的透明棋盘格,这会被解读为产品失效。等待图片加载完成并发出警告,然后调整镜头构图避开这些资源。不要交付存在空白画面的素材。
应用外壳中使用
window.scrollTo
许多应用外壳会滚动内部元素(如
<main class="overflow-y-auto">
),而非窗口。滚动窗口是无声的无效操作,镜头画面不会移动。
找到真正的滚动元素并设置其
scrollTop
猜测点击坐标应用通常只接受特定节点上的交互——例如,内联编辑器只接受叶子元素(
childElementCount === 0
),而标题会将文本包裹在span标签中。点击视觉上明显的目标不会产生任何效果。
查看应用自身的源代码了解规则,然后通过
frame.evaluate
让页面告知有效目标的位置,并通过iframe的测量框将帧坐标映射为页面坐标。

Rendering

渲染环节

TrapWhat happensFix
Skipping the mezzanine transcodeRemotion seeks arbitrarily; with a normal GOP every seek decodes from the previous I-frame. A 3-minute render becomes 40.Transcode to all-intra:
-c:v libx264 -crf 14 -g 1 -keyint_min 1 -sc_threshold 0 -pix_fmt yuv420p
, plus explicit
bt709
tags so Chrome doesn't shift the brand colour.
page.screencast({format:'mp4'})
Emits VP9 inside a fragmented MP4 - badly supported.
format: 'webm'
, then transcode.
<TransitionSeries>
with a beat grid
Transitions overlap, so total duration = sum of sequences − sum of transitions. Your frame grid silently drifts.For a frame-exact cut use explicit
<Sequence from/durationInFrames>
and do dissolves locally. Assert the chain sums to the total.
A
<Sequence>
longer than its source
Freezes on the last decoded frame - reads as a stall, not an error.Bound every duration by the real take length. Record source durations in a comment.
premountFor
on overlapping sequences
A premounted sequence renders early on top of the current one - you get ghosted double-exposed text.Don't premount sequences that share screen space; premount only across hard cuts.
Overlays positioned in composition spaceIf the footage component crops and scales the plate internally, a mask or highlight placed in composition coordinates will not line up with anything in the frame - edges cut through the content.Put overlays inside the same transform as the plate, or do the treatment natively instead of over footage.
Math.random()
/
Date.now()
in a composition
Re-rolls every frame; text flickers. Remotion renders each frame independently.Make all randomness a pure function of the index (a
sin
-based hash).
CSS animations / transitions / Tailwind
animate-*
inside Remotion
Render frozen or garbage - Remotion drives time via
useCurrentFrame()
, not wall clock.
Rebuild motion on
useCurrentFrame()
. This also means you generally cannot reuse the marketing site's animated components.
Building a loop by offsetting into the filmTwo seam bugs: a black flash if the range opens on a dissolve, and a hard jump cut where it wraps.Give the loop its own composition and cross-fade its tail into a
<Freeze frame={0}>
of its own head. Choose a continuously moving shot; anything that dwells is near-static and bad to autoplay.

陷阱问题表现修复方案
跳过中间文件转码Remotion会任意跳转;使用正常GOP结构时,每次跳转都需要从之前的I帧解码。3分钟的渲染会变成40分钟。转码为全帧内编码
-c:v libx264 -crf 14 -g 1 -keyint_min 1 -sc_threshold 0 -pix_fmt yuv420p
,加上明确的
bt709
标签,避免Chrome偏移品牌颜色。
page.screencast({format:'mp4'})
输出的是封装在碎片化MP4中的VP9编码——兼容性极差。使用
format: 'webm'
,然后再进行转码。
结合节拍网格使用
<TransitionSeries>
过渡效果会重叠,因此总时长 = 序列总和 − 过渡效果总和。你的帧网格会无声地偏移。如需帧精确剪辑,请使用显式的
<Sequence from/durationInFrames>
并在本地实现溶解效果。断言序列链的总和等于总时长。
<Sequence>
长于其源素材
会冻结在最后一帧解码画面上——会被解读为卡顿,而非错误。将每个时长限制在真实素材长度内。在注释中记录源素材的时长。
重叠序列上使用
premountFor
预挂载的序列会提前渲染并覆盖当前序列——你会得到重影的双重曝光文本。不要预挂载共享屏幕空间的序列;仅在硬切时预挂载序列。
合成空间中定位叠加层如果素材组件在内部裁剪和缩放底板,放在合成坐标中的遮罩或高亮层将无法与帧内的任何内容对齐——边缘会切割内容。将叠加层放在与底板相同的变换中,或直接在素材上进行处理,而非叠加在素材上方。
合成中使用
Math.random()
/
Date.now()
每一帧都会重新生成随机值;文本会闪烁。Remotion会独立渲染每一帧。使所有随机性成为索引的纯函数(基于
sin
的哈希)。
Remotion中使用CSS动画/过渡效果/Tailwind
animate-*
渲染结果会冻结或混乱——Remotion通过
useCurrentFrame()
驱动时间,而非实际时间。
基于
useCurrentFrame()
重构动画。这也意味着你通常无法复用营销网站的动画组件
通过偏移影片内容构建循环存在两种接缝问题:如果范围从溶解效果开始会出现黑闪,以及循环处会出现硬跳切。为循环创建单独的合成,并将其尾部交叉淡入到自身头部的
<Freeze frame={0}>
帧中。选择持续移动的镜头;任何静止的镜头都接近静态,不适合自动播放。

Process

流程

1. Establish product truth before designing the film

1. 在设计影片前先明确产品真实能力

The single most important step. Read the code for every capability the film intends to claim. Marketing pages illustrate aspirations; do not assume the product does what its own landing page animation implies.
It has already happened once: a film's planned centrepiece was taken from an animated component on the marketing site showing one document morphing through four different aspect ratios. The product hardcoded a single aspect ratio and a fixed export stage - the component was an illustration of an idea, not a feature. Building the money shot on it would have put an unsupported claim on the homepage.
Check, specifically:
  • Which content actually renders? Old records may be un-renderable by the current engine.
  • Which routes need auth? Which redirect without prior state?
  • What does each interactive feature actually require to engage?
这是最重要的一步。阅读影片计划展示的每一项功能对应的代码。营销页面展示的是愿景;不要假设产品能做到其落地页动画所暗示的功能。
曾经发生过这样的事:某影片的核心镜头来自营销网站上的一个动画组件,展示一份文档在四种不同宽高比之间转换。但实际上产品硬编码了单一宽高比和固定导出阶段——该组件只是一个概念演示,而非功能。基于它制作核心镜头会在首页呈现未支持的功能宣称。
需要特别检查:
  • 哪些内容实际可渲染?旧记录可能无法被当前引擎渲染。
  • 哪些路由需要授权?哪些路由会在无前置状态时重定向?
  • 每个交互功能实际需要哪些条件才能触发?

2. De-risk the pipeline with the smallest possible test

2. 用最小测试验证流程风险

In order, before building anything on top:
  1. Prove the capture geometry (corner-marked page → is the file really 2×?).
  2. Prove auth (can the browser reach a signed-in route?).
  3. Prove WebGL if any 3D is involved.
  4. Prove one interaction end to end.
在构建任何上层内容前,按顺序完成以下验证:
  1. 验证捕获几何(标记角落的页面 → 文件是否真的是2倍分辨率?)。
  2. 验证授权(浏览器能否访问已登录路由?)。
  3. 如果涉及3D内容,验证WebGL功能。
  4. 验证完整的交互流程。

3. Scout, don't guess

3. 实地考察,不要猜测

Screenshot every candidate surface and show the user a contact sheet before spending time on video takes. Content choice dominates production value: mediocre demo content makes a beautiful film an advert for a mediocre product.
为每个候选界面截图,并向用户展示接触式预览图,再花费时间拍摄视频素材。内容选择决定了制作价值:平庸的演示内容会让精美的影片变成平庸产品的广告。

4. Pick the demo content deliberately

4. 精心选择演示内容

Prefer the product's own brand. Featuring a recognisable third party is more persuasive but puts someone else's trademark on the homepage, and swapping one third party for another doesn't change that. Self-referential content also closes the loop: the artefact selling the tool was made by the tool.
If the content doesn't exist yet, creating it is a design job, not a capture job - it is every frame of the film's back half. Draft it deliberately, produce a few candidates, and let the user pick.
优先使用产品自身的品牌内容。使用知名第三方内容更具说服力,但会在首页展示他人商标,且更换第三方内容也无法改变这一点。自引用内容还能形成闭环:销售工具的作品是由该工具制作的。
如果内容尚未存在,创建内容是设计工作,而非捕获工作——它是影片后半部分的每一帧。精心起草,制作几个候选版本,让用户选择。

5. Cut to a grid

5. 按网格剪辑

120 BPM ⇒ 1 bar = 60 frames = 2s at 30fps. Land every cut on a multiple of 30 and every act boundary on 60. Costs nothing, and cutdowns fall out for free.
120 BPM ⇒ 1小节 = 60帧 = 30fps下的2秒。让每个剪辑点落在30的倍数处,每个段落边界落在60的倍数处。无需额外成本,还能轻松生成精简版本。

6. Verify by looking

6. 通过视觉检查验证

After every render, extract frames and actually look at them. Build a labelled contact sheet. Every real defect found so far - blank logos, a shot showing the wrong page, a caption illegible on a light panel, a dark band from a mis-measured crop, a mask seam through a label - was found by looking at frames, not by any test passing.
Watch it muted, since that's how it will autoplay.

每次渲染后,提取帧并实际查看。制作带标签的接触式预览图。到目前为止发现的所有真实缺陷——空白logo、镜头显示错误页面、浅色面板上的文字难以辨认、测量错误导致的黑边、遮罩接缝穿过标签——都是通过查看帧发现的,而非通过测试通过。
静音观看,因为影片会自动静音播放。

Editorial rules

编辑规则

These are what separate a film that works from one that's good.
Every claim on screen must be supported by the footage next to it. One cut had "Share a link" over footage where nothing showed sharing. Either show it or state it plainly on a card - don't imply.
Watch for claims that are honest in context but not at video scale. A figure labelled "illustrative" in small type becomes a hard assertion the moment it's a held hero frame in a marketing film. Keep those out of held frames.
Quote the product's motion, don't reinvent it. Lift the app's own easing curve and entrance vocabulary. One curve for everything that enters or lands. Nothing bounces - overshoot reads as hesitation.
Two grounds, never mid-grey. A dark ground for statements, a light one for explanations. Pick them from the product's own tokens.
One accent, spent sparingly - the caret, the one word carrying the sentence, a state changing. Not decoration.
Nothing in frame that isn't for the audience. Real email addresses, org names, the operator's own conversation with an in-app assistant, dev-mode badges. Prefer the app's own mechanisms (cookies for a collapsed sidebar or a dismissed banner) over CSS hacks - no flash on camera.
A "money shot" earns the most time. Pick the one moment showing something competitors structurally cannot do, then: lock the camera, no cuts, no text over it, and let the music drop out. Stillness tells the eye the subject is moving, not the camera. Put the explanatory line after it - text before the reaction tells people what to think.
Legibility beats coverage. A shot spread across four screens can leave none of them readable. Dwelling on one is usually better.
Build the beat rather than filming it when the UI can't perform. A static screen cannot introduce three things one at a time. If the beat needs sequencing the product doesn't do, compose it natively in Remotion using the product's real copy and tokens - and keep it visually distinct from the footage so it reads as an explainer, not a fake screenshot.
If there's no voiceover, the cards carry 100% of the meaning. Budget reading time.

这些规则决定了影片是可用还是优秀。
屏幕上的每一项宣称都必须旁边的素材支持。曾经有一个剪辑在「分享链接」的文字旁展示了没有任何分享动作的素材。要么展示分享动作,要么在卡片上明确说明——不要暗示。
注意那些在上下文里诚实但在视频尺度上不严谨的宣称。小字体标注为「示例」的数据,一旦成为营销影片中的主视觉定格帧,就会变成确凿的断言。不要将这类内容放在定格帧中。
复用产品自身的动效,不要重新发明。提取应用自身的缓动曲线和入场动效词汇。所有入场或落地的元素使用同一种曲线。不要使用弹跳效果——过冲会被解读为犹豫。
两种底色,绝不使用中灰色。深色底色用于陈述,浅色底色用于解释。从产品自身的设计令牌中选择。
一种强调色,谨慎使用——光标、句子中的关键词、状态变化。不要用作装饰。
帧内所有内容都必须面向受众。真实邮箱地址、组织名称、操作员与应用内助手的对话、开发模式徽章。优先使用应用自身的机制(如用于折叠侧边栏或关闭横幅的cookie)而非CSS hack——避免镜头中出现闪烁。
「核心镜头」应获得最多时长。选择一个展示竞争对手从结构上无法实现的功能的时刻,然后:锁定镜头,不剪辑,不叠加文字,让音乐淡出。静止的镜头会让观众注意到主体在移动,而非镜头在移动。将解释性文字放在镜头之后——提前展示文字会引导观众的想法。
可读性优先于覆盖范围。一个镜头分散在四个屏幕上会导致所有屏幕内容都无法阅读。通常聚焦一个屏幕更好。
当UI无法实现时,合成节拍而非拍摄。静态屏幕无法逐一展示三个内容。如果节拍需要产品不支持的序列效果,使用产品真实的文案和令牌在Remotion中本地合成——并使其视觉上与素材区分开,让观众将其解读为说明内容,而非假截图。
如果没有旁白,卡片承载100%的信息。预留阅读时间。

Discuss with the user, don't decide alone

与用户讨论,不要独自决定

  • Slogans and on-screen copy. Offer several options grouped by angle, with a recommendation and the reasoning. Note that the film wants a demo cue while a website headline wants a durable positioning statement - they don't have to be the same line.
  • Which content to feature - show the contact sheet.
  • Music. You cannot license a track. Design the film to work silent and ship a cue sheet (BPM, where any drop lands) so a supplied track drops onto the grid later. When one arrives, check its shape before placing it: measure loudness over time rather than assuming it has an intro or a drop to align to.
  • Remotion licensing. Free for individuals, non-profits and companies up to 3 employees; above that a commercial licence is required. Flag it before it ships.

  • 标语和屏幕文案。按角度提供多个选项,并给出推荐和理由。注意影片需要的是演示提示语,而网站标题需要的是持久的定位声明——它们不必是同一句话。
  • 要展示的内容——展示接触式预览图。
  • 音乐。你无法授权曲目。设计影片使其在静音状态下也能生效,并交付提示表(BPM、任何降调的位置),以便后续添加的曲目能适配网格。当收到曲目时,先检查其结构:测量随时间变化的响度,而非假设它有前奏或降调可以对齐。
  • Remotion许可。个人、非营利组织和员工不超过3人的公司可免费使用;超过此规模的公司需要商业许可。在发布前告知用户。

Commands (shape)

命令(模板)

Capture never starts a server and never guesses a port — auth URLs are usually pinned.
bash
undefined
捕获环节从不启动服务器,也不猜测端口——授权URL通常是固定的。
bash
undefined

terminal 1 — product already serving

终端1 — 产品已启动服务

pnpm --filter <app> dev # or equivalent
pnpm --filter <app> dev # 或等效命令

terminal 2 — film project

终端2 — 影片项目

pnpm install pnpm check-webgl # if any 3D pnpm capture:<app> # or one shot: pnpm capture:<app> <shot> pnpm mezzanine:<app> # mandatory — see traps pnpm studio pnpm render:<app> # mp4 / webm / loop / poster as needed

Auth for signed-in surfaces: prefer borrowing a live session cookie over signing up a
throwaway user (empty accounts force slow content generation before every shoot).

---
pnpm install pnpm check-webgl # 如果涉及3D内容 pnpm capture:<app> # 或单个镜头:pnpm capture:<app> <shot> pnpm mezzanine:<app> # 必须执行 — 参见陷阱部分 pnpm studio pnpm render:<app> # 根据需要生成mp4 / webm / 循环视频 / 海报

已登录界面的授权:优先借用活跃会话cookie,而非注册临时用户(空账户会在每次拍摄前强制生成缓慢的内容)。

---

Shipping to a page

嵌入页面发布

Two shapes, and it's the user's call:
  • Short loop, click for the film. Lightest - a few hundred KB autoplays, the full film is only fetched on demand. Best when page weight matters.
  • The full film, autoplaying muted and looping. Heavier (megabytes for anyone who scrolls to it) but it is the film, and the sound toggle just unmutes.
Either way:
  • One
    <video>
    element, not two.
    Swapping between two elements in a conditional lets React patch the existing DOM node instead of replacing it - and changing
    <source>
    children on a live
    <video>
    does not reload it. Toggle
    muted
    on a single element, or force a remount with distinct
    key
    props.
  • muted loop playsInline autoPlay
    together, or iOS Safari refuses.
  • preload="metadata"
    , never
    auto
    , or you wreck LCP. Start it on an IntersectionObserver so visitors who never scroll there pay nothing.
  • Respect
    prefers-reduced-motion
    by rendering only the poster.
  • Verify in a real browser and assert on
    currentSrc
    , not just "is it playing".
两种方案,由用户决定:
  • 短循环视频,点击查看完整影片。最轻量——几百KB的视频自动播放,完整影片仅在用户点击时加载。当页面权重很重要时最佳。
  • 完整影片,静音自动播放并循环。更重(任何滚动到此处的用户都会加载数MB的内容)但它就是完整影片,声音切换按钮只需取消静音即可。
无论哪种方案:
  • 使用一个
    <video>
    元素,而非两个
    。在条件判断中切换元素会让React修补现有DOM节点而非替换它——在活跃的
    <video>
    元素上更改
    <source>
    子元素不会重新加载视频。在单个元素上切换
    muted
    属性,或使用不同的
    key
    属性强制重新挂载。
  • 同时设置
    muted loop playsInline autoPlay
    ,否则iOS Safari会拒绝播放。
  • 设置
    preload="metadata"
    ,绝不要设置
    auto
    ,否则会破坏LCP(最大内容绘制)。通过IntersectionObserver触发播放,让从未滚动到此处的访客无需加载内容。
  • 尊重
    prefers-reduced-motion
    ,仅渲染海报图。
  • 在真实浏览器中验证,并断言
    currentSrc
    ,而非仅检查「是否在播放」。