drupal-tutorial-video

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Drupal Tutorial Video

Drupal教程视频

Overview

概述

Records a narrated 1920x1080 MP4 that shows how to set up a Drupal module. The skill reads the module to learn the real setup, writes a storyboard for approval, then records each step as a beat (one narration sentence + its action) with a visible cursor and character-by-character typing, generates ElevenLabs narration per beat, length-fits each beat so the words stay in sync with the picture, overlays a caption bar, and concatenates the beats into one video.
The recording happens inside the ddev web container on a virtual X display (
:99
) at 1920x1080. Two tools split the work:
  • agent-browser (the brain): navigate, read the accessibility tree, locate an element, return its on-screen box, wait for page state. Its CDP input is synthetic and invisible, so it never does the visible clicking.
  • xdotool (the hands): move the real X11 pointer to that box in small steps for smooth motion, click, and type with a per-key delay. Because the pointer on
    :99
    is real,
    ffmpeg -f x11grab
    captures it natively. What moves is exactly what clicks.
Terminal commands that cannot be shown in the browser (
composer require
,
drush en
,
ddev ...
) are rendered as still command cards, never as a recorded terminal.
录制带有旁白的1920x1080分辨率MP4视频,展示Drupal模块的搭建流程。该技能会先读取模块代码以了解实际搭建步骤,编写分镜脚本待确认,然后将每个步骤作为一个**节拍(beat)**录制(一句旁白对应一个操作),包含可见光标和逐字符打字效果,为每个节拍生成ElevenLabs语音旁白,调整每个节拍的时长使旁白与画面保持同步,添加字幕栏,最后将所有节拍拼接为完整视频。
录制操作在ddev的web容器内的虚拟X显示器(
:99
)上以1920x1080分辨率进行,由两个工具分工完成:
  • agent-browser(核心控制工具):负责页面导航、读取无障碍树、定位元素、返回元素在屏幕上的坐标区域、等待页面状态加载完成。它通过CDP(Chrome DevTools Protocol)输入的操作是模拟且不可见的,因此不会产生可见的点击动作。
  • xdotool(操作执行工具):将真实的X11指针逐步移动到目标坐标区域以实现平滑移动,执行点击操作,并添加按键延迟模拟真实打字。由于
    :99
    虚拟显示器上的指针是真实存在的,
    ffmpeg -f x11grab
    可以原生捕获到它的动作,移动的指针就是实际执行点击的指针。
无法在浏览器中展示的终端命令(如
composer require
drush en
ddev ...
)会生成为静态命令卡片,而非录制终端画面。

Requirements (hard)

硬性要求

  • A running ddev project. You need its site URL, an admin username, and a password.
  • agent-browser in the web container (preflight installs it there with
    npm
    ; it drives Chromium over CDP on the container's localhost). See https://github.com/vercel-labs/agent-browser.
  • awaz (
    npm i -g awaz
    , https://github.com/ahmadawais/awaz), an ElevenLabs TTS wrapper. Needs
    ELEVENLABS_API_KEY
    in the environment (NOT
    @elevenlabs/cli
    , which has no TTS). The key must have the Text to Speech and Voices (read) permissions. TTS lives behind the
    speak
    subcommand:
    awaz speak --voice-id <id> --no-play -o out.mp3 "text"
    . The top-level
    -v
    is
    --version
    (it prints the version, exits 0, and writes no file, a silent failure that only surfaces at
    finish-beat.sh
    ), and
    --no-play
    is required headless or awaz tries to open a speaker device and fails. Store the key somewhere like
    ~/.config/elevenlabs/key
    and export it before recording; nothing sets it for you.
  • ffmpeg, Xvfb, xdotool, chromium in the web container (preflight installs these).
  • Montserrat TTF for the caption bar (preflight downloads it if missing).
Run
preflight.sh
first; it checks and sets up all of the above.
Contrib modules installed from a git source can block every
composer require
.
If a contrib module sits on a local branch with unpushed commits, composer refuses any require (not just ones touching that package) because it wants to restore the locked ref:
Source directory .../contrib/<name> has unpushed changes on the current branch
. Non-destructive workaround, after confirming the commit is genuinely local-only (
git -C <dir> branch -r --contains <sha>
returns nothing) and taking a safety bundle:
git -C web/modules/contrib/<name> bundle create /backup/<name>.bundle --all
git -C web/modules/contrib/<name> checkout <locked ref from composer.lock>
The branch still exists on disk and can be checked out again afterwards.
请先运行
preflight.sh
;它会检查并配置上述所有依赖项。
从Git源安装的贡献模块可能会阻止所有
composer require
操作
。如果某个贡献模块处于本地分支且存在未推送的提交,composer会拒绝任何
require
操作(不仅限于涉及该包的操作),原因是它希望恢复锁定的引用:
Source directory .../contrib/<name> has unpushed changes on the current branch
。在确认提交仅存在于本地(执行
git -C <dir> branch -r --contains <sha>
无返回结果)并做好安全备份后,可采用非破坏性解决方案:
git -C web/modules/contrib/<name> bundle create /backup/<name>.bundle --all
git -C web/modules/contrib/<name> checkout <locked ref from composer.lock>
本地分支仍会保留在磁盘上,后续可再次切换回去。

Helper scripts

辅助脚本

Run every script from the ddev project root, with
export TUT_SLUG=<tutorial-slug>
set (a short kebab-case name for this tutorial, e.g.
commerce-checkout
). Scripts read
lib.sh
for shared paths and settings.
ScriptRuns onPurpose
preflight.sh
hostCheck + set up ddev, container packages, agent-browser, awaz, Montserrat; create the build dir and copy the container helpers
session.sh start|stop [url]
hostBring up Xvfb
:99
, kiosk Chromium with remote debugging, wait for CDP; stop tears it down
record-beat.sh start|stop <NN>
hostStart/stop the x11grab capture for beat
NN
record-slide.sh <NN> [secs]
hostRecord a concept/intro slide beat (local HTML at
TUT_SLIDES_URL
), short capture
ui.sh <verb> ...
hostThe browser-action driver: find an element (
name
/
nth
/
link
/
any
/
sel
/
text
), scroll it into view, move + click with the visible cursor, type, paste
hands.sh move|click|type64|key|hover ...
containerThe raw cursor and typing (called by
ui.sh
via
ddev exec
);
type64
= base64 in, decoded in-container so metacharacters survive
make-card.sh <NN> <seconds> <command-text>
hostRender a command card into
beats/NN.mp4
trim.sh <NN> head|tail <secs>
hostTrim an over-long capture, keeping
beats/NN.orig.mp4
check-beat.sh <NN>
hostSanity-check one beat: duration, a still frame, stub/overlong flags
audit.sh
containerAudit the whole set at once: flag
AUDIO-TIGHT
and
DEAD-AIR
beats
fade-audio.sh
containerFade the abrupt tail of every narration file and pad real silence (before finishing)
deadair.sh dry|apply|restore
containerCut trailing frozen tails (
freezedetect -75dB
), verify each trim, revert bad ones
finish-beat.sh <NN>
hostPad video to the narration, add lead/tail silence, mux audio, draw the caption bar
finish-all.sh
hostRun
finish-beat.sh
over every beat; scene-final beats get a longer tail (derived, not hardcoded)
concat.sh
hostConcatenate
final/beat-*.mp4
into
final/tutorial.mp4
Container helpers (
hands.sh
,
audit.sh
,
fade-audio.sh
,
deadair.sh
) are copied into the build dir by preflight and run inside the container, e.g.
ddev exec bash /var/www/html/.tutorial-build/<slug>/audit.sh
(this container path is
$CDIR
in
lib.sh
, used as shorthand below). They loop over 90+ files in a single script file on purpose: an inline
ddev exec bash -lc "for ...; do"
loop breaks, because the host shell expands
$var
before the container ever sees it (see "Getting commands past
ddev exec
").
所有脚本需在ddev项目根目录运行,且需先设置
export TUT_SLUG=<tutorial-slug>
(为教程设置一个短横线命名的别名,例如
commerce-checkout
)。脚本会读取
lib.sh
中的共享路径和配置。
脚本运行环境用途
preflight.sh
主机检查并配置ddev、容器包、agent-browser、awaz、Montserrat字体;创建构建目录并复制容器辅助脚本
session.sh start|stop [url]
主机启动Xvfb
:99
、带远程调试的 kiosk 模式Chromium,等待CDP连接;stop参数用于关闭这些服务
record-beat.sh start|stop <NN>
主机启动/停止节拍
NN
的x11grab捕获
record-slide.sh <NN> [secs]
主机录制概念/介绍类幻灯片节拍(本地HTML路径为
TUT_SLIDES_URL
),短时长捕获
ui.sh <verb> ...
主机浏览器操作驱动工具:查找元素(
name
/
nth
/
link
/
any
/
sel
/
text
)、滚动到可见区域、移动并点击可见光标、输入文本、粘贴内容
hands.sh move|click|type64|key|hover ...
容器原生光标和打字操作工具(由
ui.sh
通过
ddev exec
调用);
type64
表示传入base64编码的文本,在容器内解码以保留特殊字符
make-card.sh <NN> <seconds> <command-text>
主机将命令生成为命令卡片并保存到
beats/NN.mp4
trim.sh <NN> head|tail <secs>
主机裁剪过长的捕获内容,保留原始文件
beats/NN.orig.mp4
check-beat.sh <NN>
主机对单个节拍进行 sanity 检查:时长、静态帧、是否为无效/过长片段
audit.sh
容器批量检查所有节拍:标记
AUDIO-TIGHT
(旁白后留白过短)和
DEAD-AIR
(视频远长于旁白)的节拍
fade-audio.sh
容器为每个旁白文件的结尾添加淡入淡出效果,并补充真实静音片段(在完成处理前)
deadair.sh dry|apply|restore
容器裁剪末尾的静止画面(使用
freezedetect -75dB
),验证每个裁剪操作,可恢复错误裁剪
finish-beat.sh <NN>
主机将视频时长匹配旁白时长,添加开头/结尾静音,混流音频,绘制字幕栏
finish-all.sh
主机对所有节拍执行
finish-beat.sh
;场景结尾的节拍会添加更长的结尾留白(自动推导,非硬编码)
concat.sh
主机
final/beat-*.mp4
拼接为
final/tutorial.mp4
容器辅助脚本(
hands.sh
audit.sh
fade-audio.sh
deadair.sh
)会被预检查脚本复制到构建目录,在容器内运行,例如
ddev exec bash /var/www/html/.tutorial-build/<slug>/audit.sh
(该容器路径在
lib.sh
中定义为
$CDIR
,以下用该别名指代)。这些脚本将90多个文件的循环逻辑整合到单个脚本中是有意为之:如果使用内联
ddev exec bash -lc "for ...; do"
循环,主机shell会在容器执行前先展开
$var
变量,导致循环失效(详见「让命令通过
ddev exec
正常执行」)。

Build directory

构建目录

Everything lives under the ddev mount so host and container share one filesystem:
<project>/.tutorial-build/<slug>/
  storyboard.md
  scene-final.txt          # optional: beat numbers that end a scene (finish-all.sh reads it)
  hands.sh                 # container helpers, copied here by preflight so the container can run them
  audit.sh  fade-audio.sh  deadair.sh
  assets/Montserrat-*.ttf
  slides/NN.html           # local concept/intro slides (served to the kiosk browser)
  beats/NN.mp4             # raw silent capture for beat NN (or command card)
  beats/NN.orig.mp4        # untouched capture kept by trim.sh
  beats/NN.pretrim.mp4     # untouched capture kept by deadair.sh
  audio/NN.mp3             # narration for beat NN, from awaz (faded)
  audio/NN.orig.mp3        # untouched narration kept by fade-audio.sh
  final/NN.caption.txt     # caption bar text for beat NN (one line)
  final/beat-NN.mp4        # padded + muxed + captioned
  final/tutorial.mp4       # concatenated result
Nothing is deleted at the end. The user may ask for changes. The
.orig
/
.pretrim
copies mean any trim or fade can be redone without re-recording.
所有文件都存储在ddev挂载目录下,以便主机和容器共享同一个文件系统:
<project>/.tutorial-build/<slug>/
  storyboard.md
  scene-final.txt          # 可选:标记场景结尾的节拍编号(finish-all.sh会读取该文件)
  hands.sh                 # 容器辅助脚本,由预检查脚本复制到此处供容器运行
  audit.sh  fade-audio.sh  deadair.sh
  assets/Montserrat-*.ttf
  slides/NN.html           # 本地概念/介绍类幻灯片(供kiosk浏览器加载)
  beats/NN.mp4             # 节拍NN的原始静音捕获文件(或命令卡片)
  beats/NN.orig.mp4        # trim.sh保留的未修改捕获文件
  beats/NN.pretrim.mp4     # deadair.sh保留的未修改捕获文件
  audio/NN.mp3             # 节拍NN的旁白文件,由awaz生成(已添加淡入淡出)
  audio/NN.orig.mp3        # fade-audio.sh保留的未修改旁白文件
  final/NN.caption.txt     # 节拍NN的字幕栏文本(单行)
  final/beat-NN.mp4        # 已调整时长、混流音频、添加字幕的节拍文件
  final/tutorial.mp4       # 拼接后的最终视频
处理结束后不会删除任何文件,因为用户可能会提出修改需求。
.orig
/
.pretrim
备份文件意味着无需重新录制即可重新执行裁剪或淡入淡出操作。

Scenes and beats

场景与节拍

A beat is the atomic unit: one narration sentence and the single action it describes, recorded as its own clip. A scene is just a storyboard grouping of consecutive beats (a heading like "Configure the provider"); it has no separate file.
Beats are the reason narration stays in sync with the picture. Each beat's video and its narration are the same clip, so the words cannot drift from the action:
finish-beat.sh
length-fits each beat to
max(action, speech)
, and the beats concatenate in order. Do not record a whole scene as one take with one long narration; that is what makes audio and video drift.
Beats are numbered globally,
01
,
02
,
03
, ... in play order. The scene grouping lives only in
storyboard.md
for human organization.
The beat number is the edit timeline; leave gaps.
concat.sh
orders by a plain filename sort of
final/beat-*.mp4
, so the number is the play order. Two consequences:
  • There is no room to insert. Adding a beat between 89 and 90 means renumbering, and a
    beat-89b
    scheme is unsafe (plain
    sort
    is locale-collated and may ignore punctuation). Reordering requests arrive after everything is recorded, so number in steps of 10 (
    010
    ,
    020
    ,
    030
    , ...) from the start; insertion then costs nothing. When a late edit needs a new beat mid-sequence and you did not leave gaps, the cheap move is to swap two adjacent beats whose content can trade places (two slides, say).
  • Removing beats is free. Gaps concatenate fine; cut a beat's file and nothing else changes.
Derive the scene-final list, never hardcode it.
finish-all.sh
gives the last beat of each scene a longer tail. Feed it the list from
scene-final.txt
(or a
beats.json
with
scene_final: true
), produced from the storyboard, so reordering cannot silently leave the pause on the wrong beat.
**节拍(beat)**是最小单元:对应一句旁白和它描述的单个操作,作为独立片段录制。**场景(scene)**只是分镜脚本中对连续节拍的分组(例如标题为「配置提供商」),没有单独的文件。
节拍是确保旁白与画面同步的关键。每个节拍的视频和旁白是同一个片段,因此旁白不会与画面脱节:
finish-beat.sh
会将每个节拍的时长调整为
max(操作时长, 旁白时长)
,然后按顺序拼接所有节拍。请勿将整个场景作为一个长片段录制并搭配一段长旁白,这会导致音画不同步。
节拍按播放顺序全局编号,例如
01
02
03
... 场景分组仅在
storyboard.md
中存在,用于人工整理。
节拍编号即编辑时间线,请预留间隙
concat.sh
会按
final/beat-*.mp4
的文件名排序来确定播放顺序,因此编号直接决定播放顺序。这会带来两个影响:
  • 无法插入新节拍。如果要在89和90之间插入节拍,需要重新编号,而
    beat-89b
    这类命名方式不安全(普通排序是基于区域设置的,可能会忽略标点符号)。修改顺序的需求通常在录制完成后提出,因此从一开始就以10为步长编号
    010
    020
    030
    ...);这样插入新节拍无需任何额外操作。如果后期编辑需要在序列中间添加新节拍但未预留间隙,简便方法是交换两个内容可互换的相邻节拍(例如两张幻灯片)。
  • 删除节拍无需额外操作。编号间隙不会影响拼接;只需删除对应节拍的文件即可,其他内容无需修改。
自动推导场景结尾列表,不要硬编码
finish-all.sh
会为每个场景的最后一个节拍添加更长的结尾留白。请从分镜脚本生成的
scene-final.txt
(或带有
scene_final: true
beats.json
)中读取该列表,这样即使调整节拍顺序,也不会错误地将留白添加到错误的节拍上。

Beat taxonomy

节拍分类

TypeShowsProduced by
intro
The module's drupal.org project page (
drupal.org/project/<machine_name>
), narration on why it matters / marketing. Only if the user opts in.
browser-record
module-page
The module page as referencebrowser-record
browser-action
A real setup step in the ddev site, visible cursor and typingagent-browser + hands.sh + record-beat.sh
command-card
A terminal command that cannot be shown in-browsermake-card.sh
outro
Recap / call to actionbrowser-record or command-card
Intro caveat: the kiosk Chromium is served HTTP 406 by
drupal.org
(looks like TLS or client-hint fingerprinting:
curl
from the same container with Chrome-like headers gets 200, the browser with a normal
Chrome/*
UA does not, and UA /
Accept*
/
--lang
flags do not fix it). So the
intro
and
module-page
types that open
drupal.org/project/<machine_name>
may not be recordable here. If
agent-browser open
fails with
ERR_HTTP_RESPONSE_CODE_FAILURE
, fall back to a locally rendered slide (
record-slide.sh
) that lists the project and why it matters, keep the project URL in the caption, and tell the user why.
Slides are cheap: machine-generated HTML slides beat screenshots for concept beats (regenerating all 34 after a "3 lanes -> 5 lanes" content change was one script run). Text-to-image is fine for backdrops (a hero image at
opacity:.42
behind real HTML type) but useless for text - render the type as HTML, and leave the bottom ~15% empty for the caption bar.
类型展示内容生成方式
intro
Drupal模块的drupal.org项目页面(
drupal.org/project/<machine_name>
),旁白介绍模块的重要性/推广内容。仅在用户选择启用时录制。
浏览器录制
module-page
模块页面作为参考浏览器录制
browser-action
ddev站点中的真实搭建步骤,包含可见光标和打字效果agent-browser + hands.sh + record-beat.sh
command-card
无法在浏览器中展示的终端命令make-card.sh
outro
内容回顾/行动号召浏览器录制或命令卡片
介绍类节拍注意事项:kiosk模式的Chromium访问
drupal.org
时会收到HTTP 406响应(看起来是TLS或客户端提示指纹识别问题:从同一容器使用类Chrome头信息执行
curl
可得到200响应,但使用正常
Chrome/*
UA的浏览器无法访问,且修改UA、
Accept*
--lang
参数均无法解决)。因此,打开
drupal.org/project/<machine_name>
intro
module-page
类型节拍可能无法录制。如果
agent-browser open
失败并提示
ERR_HTTP_RESPONSE_CODE_FAILURE
,请改用本地渲染的幻灯片(
record-slide.sh
),列出项目信息和重要性,在字幕中保留项目URL,并告知用户原因。
幻灯片制作成本低:机器生成的HTML幻灯片比截图更适合概念类节拍(当内容从「3个步骤」改为「5个步骤」时,重新生成34张幻灯片只需运行一次脚本)。文本转图像适合作为背景(例如不透明度为0.42的英雄图像叠加在真实HTML文本上),但不适合文本内容——请用HTML渲染文本,并为字幕栏预留底部约15%的空白区域。

Getting commands past
ddev exec

让命令通过
ddev exec
正常执行

Almost every hard bug in this skill traces to one fact:
ddev exec
(and the
cexec
helper) re-parse their arguments through an extra shell before the container sees them. Anything with quotes, braces, backslashes,
$(...)
,
$var
, or commas is mangled, and it almost always fails silently rather than erroring. Real failures from one run:
  • A wait loop with
    $(seq ...)
    inside
    cexec
    killed the container shell before its
    pkill
    ran, so captures never stopped and later beats came out as 48-byte stubs.
  • Typing the regex
    /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i
    produced
    ...+.[a-z]2/i
    on screen: brace expansion ate
    {2,}
    and
    \.
    lost its backslash. The beat looked fine but the pattern could not match an email, so the guardrail being demonstrated would not have worked.
  • agent-browser eval "location.hash='pre'"
    lost the quotes, evaluated
    location.hash=pre
    (named-element access to
    <div id="pre">
    ), and silently set the hash to garbage. Three beats recorded the wrong state; the only tell was byte-identical
    .mp4
    files.
Rules:
  • Keep each container command to a single simple statement. Do all control flow (loops, waits, retries) on the host, one plain
    cexec
    per iteration.
  • Type through
    hands.sh type64
    : base64 on the host (
    printf %s 'text' | base64 -w0
    ), decode past the boundary in the container. Never use the plain
    type
    path for anything but bare ASCII words.
  • For
    agent-browser eval
    , wrap the whole call in
    bash -lc
    with escaped double quotes and use single quotes only inside the JS expression, or avoid
    eval
    and navigate with a hash URL.
  • Loops break even inside
    bash -lc
    .
    ddev exec bash -lc "for t in 1 2 3; do ffmpeg -ss $t ...; done"
    fails with
    t: unbound variable
    because the host shell expands
    $t
    to empty first. Either loop on the host (one
    ddev exec
    per iteration) or put the loop in a script file and run
    ddev exec bash /var/www/html/.../script.sh
    (much faster for 90+ iterations; this is why the container helpers are files, not inline loops).
  • drush ev
    with non-trivial quoting silently produces nothing.
    A one-liner iterating plugin definitions returned empty output at exit 0; the same code as a file via
    drush php:script foo.php
    worked. Prefer
    php:script
    for anything beyond a bare expression.
该技能中几乎所有棘手的bug都源于一个事实:
ddev exec
(以及
cexec
辅助工具)会在容器执行前通过额外的shell重新解析参数。任何包含引号、大括号、反斜杠、
$(...)
$var
或逗号的内容都会被破坏,且几乎总是静默失败而非报错。以下是实际运行中遇到的失败案例:
  • cexec
    中的
    $(seq ...)
    等待循环在
    pkill
    执行前杀死了容器shell,导致捕获无法停止,后续节拍生成的是48字节的无效文件。
  • 输入正则表达式
    /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i
    时,屏幕上显示的是
    ...+.[a-z]2/i
    :大括号扩展吃掉了
    {2,}
    \.
    丢失了反斜杠。节拍看起来正常,但该模式无法匹配邮箱,导致演示的防护机制无法正常工作。
  • agent-browser eval "location.hash='pre'"
    丢失了引号,实际执行的是
    location.hash=pre
    (访问
    <div id="pre">
    的命名元素),静默地将哈希值设置为无效内容。录制了三个节拍的错误状态;唯一的线索是
    .mp4
    文件字节完全相同。
规则:
  • 每个容器命令保持为单个简单语句。所有控制流(循环、等待、重试)在主机上执行,每次迭代调用一次普通的
    cexec
  • 通过
    hands.sh type64
    输入文本:在主机上进行base64编码(
    printf %s 'text' | base64 -w0
    ),在容器内解码后传递。除非是纯ASCII单词,否则不要使用普通的
    type
    方式。
  • 对于
    agent-browser eval
    ,将整个调用包裹在带有转义双引号的
    bash -lc
    中,JS表达式内部仅使用单引号,或者避免使用
    eval
    ,改用哈希URL导航。
  • 即使在
    bash -lc
    中,循环也会失效
    ddev exec bash -lc "for t in 1 2 3; do ffmpeg -ss $t ...; done"
    会因
    t: unbound variable
    失败,因为主机shell会先将
    $t
    展开为空。要么在主机上循环(每次迭代调用一次
    ddev exec
    ),要么将循环逻辑写入脚本文件,然后执行
    ddev exec bash /var/www/html/.../script.sh
    (对于90+次迭代,这种方式快得多;这也是容器辅助脚本采用文件形式而非内联循环的原因)。
  • 带有复杂引号的
    drush ev
    会静默地无输出
    。一个遍历插件定义的单行命令返回空输出且退出码为0;将相同代码写入文件并通过
    drush php:script foo.php
    执行则正常工作。除了简单表达式外,优先使用
    php:script

Workflow

工作流程

Create a todo per step.
  1. Preflight.
    export TUT_SLUG=<slug>
    then
    ./skills/.../preflight.sh
    . Confirm the ddev URL, username, and password with the user.
  2. Ask the intro question. Ask whether the video should open with a why-this-matters / marketing intro over the module's drupal.org page, or go straight to the steps.
  3. Read the module first, and map the whole followable path. Read
    <machine_name>.info.yml
    , README, config forms, permissions, routing, and services to derive the actual setup steps from the code, not from assumptions. Same "read first" discipline as
    drupal-module-documentation
    . Two things a "technically correct" tutorial still gets wrong:
    • Module-set completeness. A tutorial you cannot follow is a defect. List every route the later scenes depend on and check each resolves with the set you install. Admin UIs often live in a separate submodule (ECA needs
      eca_ui
      for
      /admin/config/workflow/eca
      ; installing
      eca
      +
      eca_content
      +
      eca_tool
      alone leaves the viewer with no way in). One module filter can sometimes cover a whole set when the descriptions cross-reference each other.
    • Confirm a "missing" feature from a second angle before acting on it. A throwaway probe once reported zero action plugins and nearly got a working module written off; they existed the whole time. In plugin-land,
      getDefinition('<known-bad-id>')
      is a cheap oracle: its exception enumerates every valid id.
  4. Write the storyboard. Write
    .tutorial-build/<slug>/storyboard.md
    as scenes (headings) broken into beats. Each beat is one narration sentence and the single action it describes, and carries: the global beat number,
    type
    , the on-screen action(s), the narration sentence, and the one-line caption. For any beat that navigates to or acts on a route, put the admin path or link in that caption (see Caption bar). Keep beats to one sentence + one action so the narration cannot drift. Get the user's approval before recording.
  5. Pick a voice. Run
    awaz voices
    , present the options with their names and ids, and ask the user which voice to use. Remember the chosen voice id for
    --voice-id
    .
  6. Start the session.
    ./session.sh start "<site-url>"
    . This opens the kiosk browser on
    :99
    inside the container.
  7. Record beat by beat. For each beat, in order:
    • Write the caption to
      final/NN.caption.txt
      (one line, plain).
    • browser-action:
      1. ./record-beat.sh start NN
      2. Drive the browser with
        ui.sh
        , which resolves the element, scrolls it into the safe viewport band, and moves + clicks with the visible cursor (all the
        ddev exec
        quoting lives in one place; see "Finding and clicking elements"):
        ./ui.sh open  "<url>"
        ./ui.sh click name "modules[<machine_name>][enable]"   # find -> scroll into view -> move + click
        ./ui.sh type  "a value to type"                        # metacharacter-safe (type64)
        ./ui.sh click nth  "op:1"                              # the 2nd name="op" button, e.g. Save
        Pick the matcher for the target:
        name
        for form fields,
        nth
        for same-named submit buttons,
        link
        /
        any
        for admin links and React rows. Each beat is one action, so keep it short. Before typing into a field that may already hold text (search/filter fields keep their value across reloads), clear it first (
        ./ui.sh key ctrl+a
        then
        type
        ). Pace it like a human. Leave the result on screen for a moment before stopping.
      3. ./record-beat.sh stop NN
    • slide (concept / intro / outro):
      TUT_SLIDES_URL=<base> ./record-slide.sh NN
      . Local HTML is the practical answer for concept beats and for anything that would open
      drupal.org
      (which 406s the kiosk browser). Keep the project URL in the caption. Leave the bottom ~15% of each slide empty for the caption bar.
    • command-card:
      ./make-card.sh NN 4 "composer require drupal/<name>"
      .
    Beats that share one page load must be recorded as one continuous sequence. If beats 33-35 depend on checkbox state persisting across a client-side filter change, re-recording one in isolation loses that state.
  8. Generate narration. For each beat, generate its one-sentence voice-over with the
    speak
    subcommand (the top-level
    -v
    is
    --version
    and writes no file;
    --no-play
    is required headless):
    awaz speak --voice-id <voice-id> --no-play -o .tutorial-build/<slug>/audio/NN.mp3 "<beat narration sentence>"
    Optional flags:
    --speed 0.5-2.0
    ,
    --stability 0-1
    ,
    --style 0-1
    ,
    --model-id <id>
    .
  9. Post-production on the raw material (before finishing). Two passes must run before
    finish-beat.sh
    muxes and pads (see "Post-production: dead air and audio"):
    • Fade narration tails:
      ddev exec bash $CDIR/fade-audio.sh
      . ElevenLabs ends each line mid-sound, so unfaded audio clips audibly against the padded silence.
    • Trim trailing dead air:
      ddev exec bash $CDIR/audit.sh
      to see which beats run long, then
      ddev exec bash $CDIR/deadair.sh dry
      and
      ... apply
      (it verifies each cut and reverts bad ones). Use
      ./trim.sh NN head|tail <secs>
      for beats deadair leaves alone (a blinking cursor never reads as frozen).
  10. Finish the beats.
    ./finish-all.sh
    runs
    finish-beat.sh
    over every beat and gives scene-final beats a longer tail (from
    scene-final.txt
    ). Per beat it length-fits to
    max(action, speech)
    , adds lead/tail silence, freeze-pads the video so it never ends before the narration, muxes the audio, and draws the caption bar. (One beat:
    ./finish-beat.sh NN
    , or
    TUT_TAIL=1.5 ./finish-beat.sh NN
    for a scene-final pause.)
  11. Verify the whole set, not just that files exist. Five separate failures in one run left beat files that existed at non-zero size but showed the wrong state. Run
    ./check-beat.sh NN
    on anything suspect (duration + a still frame + stub/overlong flags) and
    audit.sh
    for a one-pass pacing sweep. Identical file size to the previous beat usually means nothing changed on screen. And after any form-submit beat, read the state back instead of eyeballing the video:
    drush config:get <id>
    for config (prefer specific keys over scanning YAML),
    drush pml
    for module state,
    drush sqlq
    for content. This is the strongest check that the on-camera action landed.
  12. Concatenate and present.
    ./concat.sh
    (the final encode of a long tutorial takes several minutes; it refuses to start if a prior encode is still running in the container), then show the user
    .tutorial-build/<slug>/final/tutorial.mp4
    . Do not clean up. Wait for change requests; re-record or re-finish only the affected beats and re-run
    concat.sh
    .
为每个步骤创建待办事项。
  1. 预检查。设置
    export TUT_SLUG=<slug>
    ,然后运行
    ./skills/.../preflight.sh
    。与用户确认ddev URL、用户名和密码。
  2. 询问介绍类内容需求。询问用户视频是否需要以模块drupal.org页面为背景,添加关于模块重要性/推广的介绍类旁白,还是直接进入搭建步骤。
  3. 先读取模块代码,梳理完整的可跟随路径。读取
    <machine_name>.info.yml
    、README、配置表单、权限、路由和服务文件,从代码中推导实际搭建步骤,而非基于假设。遵循与
    drupal-module-documentation
    相同的「先读取代码」原则。即使是「技术上正确」的教程也可能犯以下两类错误:
    • 模块集完整性。无法跟随的教程是缺陷。列出后续场景依赖的所有路由,并检查每个路由在已安装的模块集中是否可访问。管理UI通常存在于单独的子模块中(例如ECA需要
      eca_ui
      才能访问
      /admin/config/workflow/eca
      ;仅安装
      eca
      +
      eca_content
      +
      eca_tool
      会导致用户无法进入管理界面)。有时一个模块过滤器可以覆盖整个模块集,因为它们的描述会相互引用。
    • 从另一个角度确认「缺失」的功能后再行动。一次随意的探测曾报告没有动作插件,差点导致一个可用的模块被判定为无效;实际上这些插件一直存在。在插件体系中,
      getDefinition('<known-bad-id>')
      是一个简单有效的验证方式:它抛出的异常会列出所有有效的ID。
  4. 编写分镜脚本。在
    .tutorial-build/<slug>/storyboard.md
    中编写场景(标题),并拆分为节拍。每个节拍对应一句旁白和它描述的单个操作,包含:全局节拍编号、类型、屏幕操作、旁白语句和单行字幕。对于任何导航到或操作某个路由的节拍,将管理路径或链接添加到字幕中(见「字幕栏」部分)。每个节拍保持为一句旁白+一个操作,避免旁白与画面脱节。录制前需获得用户确认
  5. 选择语音。运行
    awaz voices
    ,展示语音选项的名称和ID,询问用户选择哪一种语音。记录所选的语音ID,用于
    --voice-id
    参数。
  6. 启动会话。运行
    ./session.sh start "<site-url>"
    ,这会在容器内的
    :99
    显示器上打开kiosk模式的浏览器。
  7. 逐个录制节拍。按顺序录制每个节拍:
    • 将字幕写入
      final/NN.caption.txt
      (单行纯文本)。
    • 浏览器操作类节拍
      1. 运行
        ./record-beat.sh start NN
      2. 使用**
        ui.sh
        **驱动浏览器,它会定位元素、滚动到安全视口区域、移动并点击可见光标(所有
        ddev exec
        的引号处理都集中在一处;见「定位和点击元素」):
        ./ui.sh open  "<url>"
        ./ui.sh click name "modules[<machine_name>][enable]"   # 定位 -> 滚动到可见区域 -> 移动并点击
        ./ui.sh type  "a value to type"                        # 支持特殊字符(使用type64)
        ./ui.sh click nth  "op:1"                              # 第二个name="op"按钮,例如「保存」
        根据目标选择匹配器:
        name
        用于表单字段,
        nth
        用于同名提交按钮,
        link
        /
        any
        用于管理链接和React行。每个节拍对应一个操作,保持简短。在可能已有文本的字段中输入前(搜索/过滤字段会在重载后保留值),先清空字段(
        ./ui.sh key ctrl+a
        然后输入)。以人类的节奏操作,停止录制前让结果在屏幕上停留片刻。
      3. 运行
        ./record-beat.sh stop NN
    • 幻灯片类节拍(概念/介绍/结尾):运行
      TUT_SLIDES_URL=<base> ./record-slide.sh NN
      。本地HTML是概念类节拍和所有需打开
      drupal.org
      (会返回406)的节拍的实用解决方案。在字幕中保留项目URL。为字幕栏预留每张幻灯片底部约15%的空白区域。
    • 命令卡片类节拍:运行
      ./make-card.sh NN 4 "composer require drupal/<name>"
    共享同一页面加载状态的节拍必须连续录制。如果节拍33-35依赖复选框状态在客户端过滤更改后保持不变,单独重新录制其中一个节拍会丢失该状态。
  8. 生成旁白。为每个节拍使用
    speak
    子命令生成单句语音旁白(顶层参数
    -v
    --version
    ,不会生成文件;
    --no-play
    是无头环境下的必填参数):
    awaz speak --voice-id <voice-id> --no-play -o .tutorial-build/<slug>/audio/NN.mp3 "<beat narration sentence>"
    可选参数:
    --speed 0.5-2.0
    --stability 0-1
    --style 0-1
    --model-id <id>
  9. 原始素材后期处理(在完成节拍处理前)。在
    finish-beat.sh
    混流和调整时长前,必须执行以下两个步骤(见「后期处理:静音片段和音频」):
    • 旁白结尾淡入淡出:运行
      ddev exec bash $CDIR/fade-audio.sh
      。ElevenLabs生成的旁白结尾没有衰减,未处理的音频片段与添加的静音片段拼接时会出现明显的剪辑痕迹。
    • 裁剪末尾静音片段:运行
      ddev exec bash $CDIR/audit.sh
      查看哪些节拍时长过长,然后运行
      ddev exec bash $CDIR/deadair.sh dry
      ... apply
      (它会验证每个裁剪操作,恢复错误裁剪)。对于deadair未处理的节拍(例如闪烁的光标不会被判定为静止),使用
      ./trim.sh NN head|tail <secs>
      手动裁剪。
  10. 完成节拍处理。运行
    ./finish-all.sh
    对所有节拍执行
    finish-beat.sh
    ,并为场景结尾的节拍添加更长的结尾留白(读取
    scene-final.txt
    )。对于每个节拍,它会将时长调整为
    max(操作时长, 旁白时长)
    ,添加开头/结尾静音,冻结画面使视频不会早于旁白结束,混流音频,并绘制字幕栏。(单个节拍处理:运行
    ./finish-beat.sh NN
    ,或运行
    TUT_TAIL=1.5 ./finish-beat.sh NN
    为场景结尾节拍设置更长的留白。)
  11. 验证整个视频集,不要仅检查文件是否存在。一次运行中出现的五个独立故障导致节拍文件存在且大小非零,但展示的是错误状态。对可疑的节拍运行
    ./check-beat.sh NN
    (检查时长+静态帧+无效/过长标记),运行
    audit.sh
    进行一次批量节奏检查。与前一个节拍文件大小相同通常意味着屏幕上没有发生任何变化。在任何表单提交节拍后,读取返回的状态而非仅查看视频:使用
    drush config:get <id>
    检查配置(优先查看特定键而非扫描YAML),
    drush pml
    检查模块状态,
    drush sqlq
    检查内容。这是验证屏幕操作是否生效的最可靠方式。
  12. 拼接并交付。运行
    ./concat.sh
    (长教程的最终编码需要几分钟;如果容器内已有编码进程在运行,脚本会拒绝启动),然后向用户展示
    .tutorial-build/<slug>/final/tutorial.mp4
    不要清理文件。等待用户的修改请求;仅重新录制或重新处理受影响的节拍,然后重新运行
    concat.sh

Caption bar

字幕栏

finish-beat.sh
draws a full-width bar across the bottom ~9% of the frame:
drawbox
filled
black@0.94
, then centered white Montserrat text from
final/NN.caption.txt
with
expansion=none
. Keep captions to one short line. An empty caption file means no bar for that beat. Two things learned the hard way: at
black@0.85
the page text showed through and fought the caption (hence
0.94
), and without
expansion=none
drawtext
parses
%{...}
and backslashes even from a
textfile
, so a caption containing a path,
%
, or a regex rendered as an empty black bar with no error.
Show the path or link in the caption. When a beat navigates somewhere or acts on a specific route, put the admin path (or URL) in the caption so a viewer can follow along without pausing. Use the route the user actually types or clicks, not the narration restated:
  • Navigating to a config page:
    Configuration > System > Site information (/admin/config/system/site-information)
  • Clicking a menu link or tab:
    Manage > Extend (/admin/modules)
  • A command-card beat: show the command itself, e.g.
    composer require drupal/<name>
    .
Keep it to one line: if the breadcrumb plus path is too long, show just the path (
/admin/config/system/site-information
). Paths are literal, so they are exempt from the prose style rules (a real path may contain characters the style section otherwise avoids).
finish-beat.sh
会在画面底部约9%的位置绘制一个全宽栏:使用
drawbox
绘制
black@0.94
的填充框,然后居中显示
final/NN.caption.txt
中的白色Montserrat文本,设置
expansion=none
。字幕保持为简短的单行。空字幕文件意味着该节拍不显示字幕栏。以下是从实践中总结的两点经验:设置为
black@0.85
时,页面文本会透过字幕栏显示并干扰字幕(因此改为
0.94
);如果不设置
expansion=none
drawtext
会解析
textfile
中的
%{...}
和反斜杠,导致包含路径、
%
或正则表达式的字幕显示为空黑栏且无错误提示。
在字幕中显示路径或链接。当节拍导航到某个位置或操作特定路由时,将管理路径(或URL)添加到字幕中,以便观众无需暂停即可跟随操作。使用用户实际输入或点击的路由,而非旁白的复述:
  • 导航到配置页面:
    Configuration > System > Site information (/admin/config/system/site-information)
  • 点击菜单链接或标签:
    Manage > Extend (/admin/modules)
  • 命令卡片类节拍:直接显示命令,例如
    composer require drupal/<name>
保持为单行:如果面包屑加路径过长,仅显示路径(
/admin/config/system/site-information
)。路径是字面内容,因此不受散文风格规则限制(实际路径可能包含风格规则中避免的字符)。

Narration writing style

旁白写作风格

The spoken narration and captions use the same voice as the other skills in this repo: plain, direct, terse, active voice. No em dashes or en dashes. No marketing hype or subjective qualifiers in the step narration (the opt-in intro may say why the module matters, but still in verifiable terms). No emojis.
口语旁白和字幕使用与本仓库中其他技能相同的风格:简洁、直接、简短、主动语态。不要使用破折号。步骤旁白中不要包含营销噱头或主观限定词(可选的介绍类内容可以说明模块的重要性,但仍需使用可验证的表述)。不要使用表情符号。

Finding and clicking elements (
ui.sh
)

定位和点击元素(
ui.sh

ui.sh
is the browser-action driver. It resolves an element to a screen coordinate, scrolls it into the safe band, refuses hidden/zero-size boxes, then moves and clicks with the visible cursor. Everything below was a real failure that produced a valid-looking
.mp4
of the wrong state.
Off-viewport clicks fail silently. A click below y≈1000 or above y≈80 lands outside the 1080 kiosk viewport.
xdotool
reports success, the beat records normally, and the form simply never submits (a Save button at y=1099, "below the fold").
ui.sh
scrolls the target to mid-screen and re-measures before clicking. agent-browser has no negative scroll, so a target above the fold needs
scroll up N
.
Four matcher kinds, not one:
KindUse for
name
Form fields (the default). Survives
#ajax
id regeneration.
nth
Same-named buttons: every Drupal submit is
name="op"
, so "Test Connection" and "Save" collide.
op:0
,
op:1
. DOM order is not visual order.
link
Anchors by exact text. Beats hidden sidebar
<button>Edit</button>
controls that a text search grabs first.
any
React UIs: clickable rows are plain
<div>
s a curated tag list never sees. Exact text, smallest visible match, so you get the row, not its container.
Plus
sel
(raw CSS) and
text
(substring over curated tags). Substring matching is dangerous on admin pages:
text "Lock"
matched "Blocks" in the sidebar and threw the cursor across the screen. Prefer
link
/
any
/exact for short words, or scope the search to a container.
Reject invisible and zero-size elements.
#states
-hidden fields (an Authorization-prefix that only appears once a key is chosen) return a box of
0,0
; moving there parks the cursor in the top-left corner on camera.
ui.sh
refuses
0,0
and filters matchers on
getBoundingClientRect().width > 0
so hidden duplicate controls do not win.
Field names worth remembering:
  • Node form title:
    title[0][value]
    (not
    title
    )
  • Module enable checkbox:
    modules[<machine_name>][enable]
  • Module filter:
    text
Verify with
check-beat.sh
, not a post-hoc screenshot.
An open
<select>
dropdown, a hover state, or a tooltip is gone by the time a screenshot runs; the recorded frame is the truth.
ui.sh
是浏览器操作驱动工具。它会将元素解析为屏幕坐标,滚动到安全区域,拒绝隐藏/零大小的元素,然后移动并点击可见光标。以下所有问题都是实际遇到的故障,会生成看似正常但状态错误的
.mp4
文件。
视口外点击会静默失败。点击位置低于y≈1000或高于y≈80会落在1080分辨率的kiosk视口之外。
xdotool
会报告成功,节拍录制正常,但表单从未提交(例如位于y=1099的「保存」按钮,在「折叠下方」)。
ui.sh
会将目标滚动到屏幕中间,并在点击前重新测量位置。agent-browser不支持向上滚动,因此位于折叠上方的目标需要执行
scroll up N
四种匹配器,而非一种
类型适用场景
name
表单字段(默认选项)。可在
#ajax
重新生成ID后仍生效。
nth
同名按钮:每个Drupal提交按钮的name都是
op
,因此「测试连接」和「保存」会冲突。使用
op:0
op:1
。DOM顺序不等于视觉顺序。
link
精确文本匹配锚点。避免匹配到侧边栏中隐藏的
<button>Edit</button>
控件,这类控件会被文本搜索优先匹配。
any
React UI:可点击的行是普通
<div>
,无法通过预设标签列表匹配。按精确文本匹配最小可见元素,确保匹配到行而非容器。
此外还有
sel
(原生CSS选择器)和
text
(在预设标签中进行子字符串匹配)。子字符串匹配在管理页面上很危险
text "Lock"
会匹配到侧边栏中的**"Blocks"**,导致光标跳转到屏幕另一侧。对于短单词,优先使用
link
/
any
/精确匹配,或限定搜索范围到某个容器。
拒绝不可见和零大小的元素
#states
隐藏的字段(例如仅在选择密钥后才显示的授权前缀)会返回
0,0
的坐标;移动到该位置会使光标停留在屏幕左上角。
ui.sh
会拒绝
0,0
的坐标,并过滤掉
getBoundingClientRect().width > 0
的匹配器,避免隐藏的重复控件被选中。
值得记住的字段名
  • 节点表单标题:
    title[0][value]
    (不是
    title
  • 模块启用复选框:
    modules[<machine_name>][enable]
  • 模块过滤器:
    text
使用
check-beat.sh
验证,而非事后截图
。打开的
<select>
下拉菜单、悬停状态或工具提示在截图运行时会消失;录制的帧才是真实状态。

Shadow DOM, tokens, and React fields

Shadow DOM、令牌和React字段

  • Shadow DOM is invisible to selectors but not to the screen. A Modeler component panel put its
    channel_id
    /
    text
    fields in a shadow root;
    document.querySelectorAll('input,textarea')
    returned 3 for the whole page while two more were plainly visible.
    xdotool
    needs only screen coordinates (from a screenshot), so click and type at raw coordinates, then verify by reading the saved config afterwards.
  • Native
    <select>
    dropdowns DO record.
    They are invisible to the DOM, not to the screen -
    x11grab
    captures the open dropdown and every option fine. Drive them by type-ahead (click the select,
    type64
    the option's visible label, press Return, which fires
    change
    so
    #ajax
    runs), and open the dropdown on camera when the options themselves are the point of the beat.
  • Typing
    [
    opens a token browser
    that eats the rest of the line:
    Node [node:nid]...
    leaves
    Node [
    in the field and the remainder in an "INSERT A TOKEN" popup. Insert via the clipboard, which fires no per-keystroke handlers:
    agent-browser --cdp $CDP clipboard write "Node [node:nid] with [node:title] got updated."
    ./ui.sh key ctrl+v
  • React controlled inputs ignore
    .value =
    .
    The
    ui.sh paste
    trick (
    .value
    +
    input
    event) works for Drupal core forms but not React; for a React field set through the native setter and dispatch, or the component state never updates.
  • Shadow DOM对选择器不可见,但对屏幕可见。Modeler组件面板将其
    channel_id
    /
    text
    字段放在Shadow根中;
    document.querySelectorAll('input,textarea')
    返回整个页面只有3个输入框,但实际上还有两个可见的输入框。
    xdotool
    只需要屏幕坐标(来自截图),因此可以直接点击和输入原始坐标,然后通过读取保存的配置进行验证。
  • 原生
    <select>
    下拉菜单可以被录制
    。它们对DOM不可见,但对屏幕可见——
    x11grab
    可以捕获打开的下拉菜单和所有选项。通过预输入(点击选择框,
    type64
    输入选项的可见标签,按回车键,这会触发
    change
    事件使
    #ajax
    运行)来驱动它们;当选项本身是节拍的重点时,在屏幕上打开下拉菜单。见「Shadow DOM、令牌和React字段」。
  • 输入
    [
    会打开令牌浏览器并吃掉剩余文本
    :输入
    Node [node:nid]...
    会导致
    Node [
    留在字段中,剩余内容进入「INSERT A TOKEN」弹窗。通过剪贴板插入,这不会触发按键事件:
    agent-browser --cdp $CDP clipboard write "Node [node:nid] with [node:title] got updated."
    ./ui.sh key ctrl+v
  • React受控输入忽略
    .value =
    赋值
    ui.sh paste
    技巧(
    .value
    +
    input
    事件)对Drupal核心表单有效,但对React无效;对于React字段,需通过原生设置器赋值并触发事件,否则组件状态不会更新。

Post-production: dead air and audio

后期处理:静音片段和音频

Run these once after all beats are recorded and narrated, before
finish-all.sh
. Both fixed user-visible defects on the first cut.
Audit the whole set first.
ddev exec bash $CDIR/audit.sh
compares video/speech/final duration across every beat in one pass (a single run surfaced 27 pacing problems). It flags
AUDIO-TIGHT
(too little breath after narration) and
DEAD-AIR
(video running well past speech).
Fade every narration tail. ElevenLabs gives no trailing decay - the last 150ms of every file sits at -16..-29 dB, audibly clipped against the padded silence.
ddev exec bash $CDIR/fade-audio.sh
fades the last 120ms and appends real silence, always deriving from an untouched
.orig
so a re-run cannot double-fade. Do this before
finish-beat.sh
muxes the audio. Verify with
volumedetect
over the final 150ms: it should read about -91 dB.
Trim trailing dead air, then verify the trim. Beats routinely ran 5-19s past the last on-screen change because the capture slept waiting for a page.
ddev exec bash $CDIR/deadair.sh apply
cuts the frozen tail. Two tunings were hard-won:
  • freezedetect=n=-75dB:d=0.7
    , not the
    -58dB
    default: at
    -58dB
    a checkbox tick counts as "frozen" and the trim silently cuts the click, ending the beat in the pre-click state. (A
    select='gt(scene,...)'
    approach was also tried and reported no changes at all - do not use it.)
  • After each cut it compares the trimmed clip's last frame against the original's (
    psnr
    , revert if < ~38 dB). A beat ending in the wrong state is worse than a slow beat.
Blind spot: a blinking text cursor in a focused input never registers as frozen, so those beats need a manual head-trim and a human look at the end frame. Static slides shorter than ~6.5s are left alone (
finish-beat.sh
freeze-pads them back anyway).
Head vs tail when trimming manually (
trim.sh
):
keep the tail when the payoff is the result (install confirmation, saved message, JSON response); keep the head when the action is the content (ticking boxes, typing, opening a picker).
trim.sh
keeps
NN.orig.mp4
so any cut can be redone.
在所有节拍录制和旁白生成完成后,
finish-all.sh
运行前,执行以下步骤。这两个步骤都修复了第一版视频中用户可见的缺陷。
先批量检查所有节拍。运行
ddev exec bash $CDIR/audit.sh
,一次性比较所有节拍的视频/旁白/最终时长(单次运行发现了27个节奏问题)。它会标记
AUDIO-TIGHT
(旁白后留白过短)和
DEAD-AIR
(视频远长于旁白)的节拍。
为每个旁白结尾添加淡入淡出。ElevenLabs生成的旁白没有结尾衰减——每个文件的最后150ms处于-16..-29 dB,与添加的静音片段拼接时会出现明显的剪辑痕迹。运行
ddev exec bash $CDIR/fade-audio.sh
为最后120ms添加淡入淡出效果,并补充真实静音片段,始终从未修改的
.orig
文件生成,避免重复淡入淡出。请在
finish-beat.sh
混流音频之前执行此操作。使用
volumedetect
验证最后150ms的音量:应约为-91 dB。
裁剪末尾静音片段,然后验证裁剪结果。节拍通常会在最后一次屏幕变化后继续录制5-19秒,因为捕获脚本会等待页面加载。运行
ddev exec bash $CDIR/deadair.sh apply
裁剪静止的结尾画面。以下两个参数是经过多次尝试确定的:
  • freezedetect=n=-75dB:d=0.7
    不要使用默认的
    -58dB
    :设置为
    -58dB
    时,复选框的勾选会被判定为「静止」,裁剪会静默地剪掉点击动作,导致节拍以点击前的状态结束。(也曾尝试
    select='gt(scene,...)'
    方法,但未报告任何变化——不要使用该方法。)
  • 每次裁剪后,将裁剪后的片段最后一帧与原始片段的最后一帧进行比较(使用
    psnr
    ,如果值<约38 dB则恢复)。节拍以错误状态结束比节奏慢更糟糕。
盲区:聚焦输入框中的闪烁文本光标永远不会被判定为静止,因此这些节拍需要手动裁剪开头,并人工检查结尾帧。短于约6.5秒的静态幻灯片无需处理(
finish-beat.sh
会自动冻结画面补充时长)。
手动裁剪时保留开头还是结尾:当重点是结果(安装确认、保存成功消息、JSON响应)时,保留结尾;当重点是操作(勾选复选框、输入文本、打开选择器)时,保留开头
trim.sh
会保留
NN.orig.mp4
,因此任何裁剪操作都可以重新执行。

Recording gotchas

录制注意事项

General lessons for recording a Drupal admin UI in a headless browser:
  • Clear text inputs before typing. GET filter and search fields keep their value across reloads, so typing again appends ("Powered byPowered by") and the filter breaks. Clear first with
    hands.sh key ctrl+a
    then type, or use
    agent-browser fill
    .
  • Pre-seed AJAX-dependent forms. Forms that rebuild dependent fields via Drupal AJAX (a provider select that repopulates a model select, etc.) are unreliable to drive live. Set the value first with
    drush config:set
    so the form loads already settled, then only demonstrate the final selection on camera.
  • Avoid batch operations on camera. Actions that trigger a batch (some imports, adding a language with interface translation) rely on a meta-refresh that stalls in the headless browser. Disable or pre-run the batch with
    drush
    before recording so the page redirects instantly.
  • Target fields by name, not id. Drupal
    #ajax
    rebuilds regenerate element ids, so a selector grabbed before the rebuild goes stale. Use
    getElementsByName('...')[0]
    .
  • Drive native
    <select>
    by type-ahead
    (click,
    type64
    the visible label, Return). The dropdown is invisible to the DOM so clicking options directly fails, but it does record on screen - open it on camera when the options are the point. See "Shadow DOM, tokens, and React fields".
  • Paste long text, don't type it. A ~1800-char field typed key-by-key is a >2-minute beat. Set
    .value
    via
    eval
    (base64 in,
    atob
    in the page), dispatch
    input
    +
    change
    , and narrate it as "paste in...".
  • Never put control flow or
    $(...)
    in a container command.
    See "Getting commands past
    ddev exec
    ": keep each
    cexec
    to one simple statement, and type through
    type64
    .
在无头浏览器中录制Drupal管理UI的通用经验:
  • 输入前清空文本框。GET过滤器和搜索字段会在重载后保留值,因此再次输入会追加内容(例如"Powered byPowered by"),导致过滤器失效。先使用
    hands.sh key ctrl+a
    清空,然后输入,或使用
    agent-browser fill
  • 预填充依赖AJAX的表单。通过Drupal AJAX重建依赖字段的表单(例如选择提供商后重新填充模型选择框等),实时驱动不可靠。先使用
    drush config:set
    设置值,使表单加载时已处于稳定状态,然后仅在屏幕上演示最终选择。
  • 避免在屏幕上录制批处理操作。触发批处理的操作(某些导入、添加带界面翻译的语言)依赖元刷新,这在无头浏览器中会停滞。在录制前禁用或使用
    drush
    预运行批处理,使页面立即重定向。
  • 按名称而非ID定位字段。Drupal
    #ajax
    重建会重新生成元素ID,因此重建前获取的选择器会失效。使用
    getElementsByName('...')[0]
  • 通过预输入驱动原生
    <select>
    (点击,
    type64
    输入可见标签,按回车键)。下拉菜单对DOM不可见,因此直接点击选项会失败,但它可以被屏幕录制——当选项本身是节拍的重点时,在屏幕上打开下拉菜单。见「Shadow DOM、令牌和React字段」。
  • 粘贴长文本,不要逐字输入。输入约1800字符的字段会生成超过2分钟的节拍。通过
    eval
    设置
    .value
    (主机上base64编码,页面内
    atob
    解码),触发
    input
    +
    change
    事件,并旁白为「粘贴内容...」。
  • 不要在容器命令中包含控制流或
    $(...)
    。见「让命令通过
    ddev exec
    正常执行」:每个
    cexec
    保持为单个简单语句,通过
    type64
    输入文本。

Known tuning points (verify on the first live run)

已知调试点(首次运行时验证)

  • CDP: use the container. Host CDP does not work: ddev maps the exposed port to a dynamic host port, and Chromium's DevTools rejects the forwarded connection because the Host-header port no longer matches its listening port (DNS-rebinding protection;
    --remote-allow-origins=*
    only covers Origin, not Host). Always drive agent-browser inside the container against localhost:
    ddev exec agent-browser --cdp http://127.0.0.1:9222 ...
    .
  • Cursor alignment.
    get box
    returns viewport coordinates. Kiosk Chromium at 0,0 with
    --force-device-scale-factor=1
    makes viewport pixels equal screen pixels, but a small fixed offset may be needed. Take a screenshot mid-beat and adjust if the click misses.
  • Window focus for typing.
    xdotool type
    goes to the focused window. session.sh activates the Chromium window; if typing lands nowhere, re-activate it before typing.
  • Non-Latin languages need CJK fonts. Preflight installs
    fonts-noto-cjk
    so Japanese, Chinese, and Korean render instead of tofu boxes. Chromium caches fonts at startup, so if you install fonts after a session is running, restart it (
    session.sh stop && start
    ); a page reload is not enough.
  • drupal.org serves the kiosk browser HTTP 406. Looks like TLS or client-hint fingerprinting:
    curl
    from the same container with Chrome-like headers gets 200, the browser (a normal
    Chrome/*
    UA) does not, and UA /
    Accept*
    /
    --lang
    flags do not fix it. Treat
    intro
    and
    module-page
    beats that open
    drupal.org
    as possibly unrecordable, and have a local-slide fallback ready (see the intro caveat under Beat taxonomy).
  • CDP:使用容器内的服务。主机CDP无法工作:ddev将暴露的端口映射到动态主机端口,Chromium的DevTools会拒绝转发连接,因为Host头端口不再匹配其监听端口(DNS重绑定保护;
    --remote-allow-origins=*
    仅覆盖Origin,不覆盖Host)。始终在容器内运行agent-browser并连接到本地服务:
    ddev exec agent-browser --cdp http://127.0.0.1:9222 ...
  • 光标对齐
    get box
    返回视口坐标。设置
    --force-device-scale-factor=1
    的kiosk模式Chromium位于0,0,使视口像素与屏幕像素相等,但可能需要微调固定偏移。在节拍录制过程中截取屏幕截图,若点击位置不准确则调整偏移。
  • 打字时的窗口焦点
    xdotool type
    会将输入发送到聚焦的窗口。session.sh会激活Chromium窗口;如果输入无响应,在输入前重新激活窗口。
  • 非拉丁语系语言需要CJK字体。预检查脚本会安装
    fonts-noto-cjk
    ,使日语、中文和韩语正常显示而非方块字符。Chromium在启动时缓存字体,因此如果在会话运行后安装字体,需重启会话(
    session.sh stop && start
    );仅重载页面无效。
  • drupal.org向kiosk浏览器返回HTTP 406。看起来是TLS或客户端提示指纹识别问题:从同一容器使用类Chrome头信息执行
    curl
    可得到200响应,但使用正常
    Chrome/*
    UA的浏览器无法访问,且修改UA、
    Accept*
    --lang
    参数均无法解决。将打开
    drupal.org
    intro
    module-page
    类型节拍视为可能无法录制,并准备好本地幻灯片作为备用方案(见「节拍分类」中的介绍类节拍注意事项)。

Common mistakes

常见错误

MistakeFix
Using
@elevenlabs/cli
for narration
It has no TTS. Use
awaz
(
npm i -g awaz
).
agent-browser doing the clickCDP clicks are invisible in the recording. Click with
hands.sh
; use agent-browser only to find the element.
Recording a terminalTerminal commands are command cards, not screen recordings.
Intro on the generic drupal.org siteThe intro shows the module's own project page,
drupal.org/project/<machine_name>
.
Ephemeral container packagesInstalls are lost on
ddev restart
unless in
.ddev/config.tutorial-video.yaml
(preflight writes this).
Cleaning up before approvalLeave the build dir intact until the user approves.
Narration and video out of syncAuthor one sentence + one action per beat;
finish-beat.sh
length-fits each beat. Never record a whole scene as one long take.
Driving agent-browser from the hostHost CDP is blocked; run it in the container:
ddev exec agent-browser --cdp http://127.0.0.1:9222 ...
.
Typing into a field that still holds textClear it first (
hands.sh key ctrl+a
then type, or
agent-browser fill
).
Recording an AJAX select or batch page livePre-seed with
drush config:set
and record the settled state.
Caption omits where the step happensFor any navigation or route action, show the admin path or link in the caption (e.g.
/admin/modules
).
Wrong
awaz
invocation
Use
awaz speak --voice-id <id> --no-play -o file.mp3 "text"
. Top-level
-v
is
--version
and writes nothing;
--no-play
is required headless.
Loops or
$(...)
inside
ddev exec
The container shell mangles them before running. Keep container commands to one simple statement; loop on the host.
hands.sh type
for text with metacharacters
Braces, backslashes, and quotes get mangled by
ddev exec
. Always
type64
(base64 in, decoded in-container).
Selecting by element id after an AJAX rebuildIds regenerate. Target by name (
getElementsByName
).
Typing a long prompt key-by-keyMinutes-long beat. Paste via
eval
(base64/
atob
) and narrate as "paste in...".
Numbers with comma decimals reaching ffmpegA comma-decimal locale breaks the filtergraph. Scripts export
LC_ALL=C LC_NUMERIC=C
; keep that when editing them.
Re-running
concat.sh
after killing it
The old container ffmpeg keeps writing; a second racing encode corrupts
tutorial.mp4
. Clear it (
ddev exec pkill -x ffmpeg
) first; the script now guards against it.
Trusting "file exists" as doneVerify with
check-beat.sh
, and for form beats read back the saved state (
drush config:get
/
pml
/
sqlq
). A valid-length
.mp4
of the wrong state is the common failure.
Off-viewport click that silently no-opsBelow y≈1000 / above y≈80 misses the 1080 viewport and never submits.
ui.sh
scrolls into view and re-measures.
getElementsByName('op')[0]
for a submit
Every Drupal submit is
name="op"
; DOM order ≠ visual order. Use
nth
(
op:0
,
op:1
) and confirm which is which.
Substring text match on an admin page"Lock" matches "Blocks". Use
link
/
any
/exact for short words.
Believing a
<select>
can't be recorded
It records fine; it is invisible to the DOM, not the screen. Drive by type-ahead.
Installing a module set with no admin UIList the routes later scenes need and check each resolves; the UI may be a separate submodule (e.g.
eca_ui
).
composer require
fails on a git-checkout contrib
The package has unpushed local commits. Bundle it, check out the locked ref, then require.
Narration tail sounds clippedElevenLabs has no decay; run
fade-audio.sh
before finishing.
Trimming dead air without verifying
freezedetect -58dB
cuts clicks. Use
-75dB
and psnr-verify the end frame (
deadair.sh
).
Numbering beats 1,2,3 with no gapsReorders arrive after recording and there is no room to insert. Number in 10s, or swap adjacent beats.
Hardcoding the scene-final beat listReordering leaves the pause on the wrong beat. Derive it (
scene-final.txt
/
beats.json
).
错误修复方法
使用
@elevenlabs/cli
生成旁白
该工具无TTS功能。使用
awaz
(执行
npm i -g awaz
安装)。
用agent-browser执行点击CDP点击在录制中不可见。使用
hands.sh
执行点击;仅用agent-browser定位元素。
录制终端画面终端命令应生成为命令卡片,而非屏幕录制。
介绍类内容使用通用drupal.org站点介绍类内容应展示模块自身的项目页面:
drupal.org/project/<machine_name>
容器包临时安装除非安装到
.ddev/config.tutorial-video.yaml
(预检查脚本会写入该文件),否则
ddev restart
后安装的包会丢失。
用户确认前清理文件在用户确认前保留构建目录。
旁白与画面不同步每个节拍对应一句旁白+一个操作;
finish-beat.sh
会调整每个节拍的时长。请勿将整个场景作为一个长片段录制。
从主机驱动agent-browser主机CDP被阻止;在容器内运行:
ddev exec agent-browser --cdp http://127.0.0.1:9222 ...
向已有文本的字段输入内容先清空字段(
hands.sh key ctrl+a
然后输入,或使用
agent-browser fill
)。
实时录制AJAX选择框或批处理页面使用
drush config:set
预填充,录制稳定状态。
字幕未说明步骤所在位置对于任何导航或路由操作,在字幕中显示管理路径或链接(例如
/admin/modules
)。
awaz
调用方式错误
使用
awaz speak --voice-id <id> --no-play -o file.mp3 "text"
。顶层参数
-v
--version
,不会生成任何文件;
--no-play
是无头环境下的必填参数。
ddev exec
中包含循环或
$(...)
容器shell会在执行前破坏这些内容。每个容器命令保持为单个简单语句;在主机上执行循环。
使用
hands.sh type
输入包含特殊字符的文本
大括号、反斜杠和引号会被
ddev exec
破坏。始终使用
type64
(主机上base64编码,容器内解码)。
AJAX重建后按元素ID选择ID会重新生成。按名称定位(
getElementsByName
)。
逐字输入长提示文本生成的节拍时长过长。通过
eval
粘贴(base64/
atob
),旁白为「粘贴内容...」。
向ffmpeg传入带逗号的小数逗号小数的区域设置会破坏滤镜图。脚本会导出
LC_ALL=C LC_NUMERIC=C
;编辑脚本时保留该设置。
终止
concat.sh
后重新运行
旧的容器ffmpeg进程会继续写入;并行的第二次编码会损坏
tutorial.mp4
。先清理进程(
ddev exec pkill -x ffmpeg
);脚本现在会对此进行防护。
以「文件存在」作为完成标志使用
check-beat.sh
验证,对于表单节拍,读取保存的状态(
drush config:get
/
pml
/
sqlq
)。存在有效大小但状态错误的
.mp4
是常见故障。
视口外点击导致静默失败低于y≈1000 / 高于y≈80的点击会错过1080视口,永远不会提交。
ui.sh
会将元素滚动到可见区域并重新测量。
使用
getElementsByName('op')[0]
选择提交按钮
每个Drupal提交按钮的name都是
op
;DOM顺序≠视觉顺序。使用
nth
op:0
op:1
)并确认对应按钮。
在管理页面上使用子字符串文本匹配"Lock"会匹配到"Blocks"。对于短单词,优先使用
link
/
any
/精确匹配。
认为
<select>
无法被录制
它可以被正常录制;它对DOM不可见,但对屏幕可见。通过预输入驱动。
安装无管理UI的模块集列出后续场景依赖的路由并检查每个路由是否可访问;管理UI可能存在于单独的子模块中(例如
eca_ui
)。
基于Git检出的贡献模块执行
composer require
失败
该包存在未推送的本地提交。先打包备份,切换到锁定的引用,然后执行require。
旁白结尾有剪辑痕迹ElevenLabs生成的旁白没有衰减;在完成节拍处理前运行
fade-audio.sh
未验证就裁剪静音片段
freezedetect -58dB
会剪掉点击动作。使用
-75dB
并通过psnr验证结尾帧(
deadair.sh
已实现)。
节拍编号为1,2,3且无间隙修改顺序的需求在录制完成后提出,无法插入新节拍。以10为步长编号,或交换相邻节拍。
硬编码场景结尾节拍列表修改顺序后,留白会添加到错误的节拍上。自动推导(
scene-final.txt
/
beats.json
)。