Skip to content

Latest commit

 

History

History
146 lines (131 loc) · 42.1 KB

pitfalls.md

File metadata and controls

146 lines (131 loc) · 42.1 KB

踩过的坑

制作与迁移过程中实际遇到的问题,按主题整理。每条先写现象,再写原因与做法。新遇到的坑追加到对应的主题下。

页面与场景

  • 一个元素只做一次入场。 wipe、slam、slide、appear、show 都会把元素在入场之前设为不可见。同一个元素登记两次入场,状态会打架。需要「消失后再出现」时建两个元素,或用 appear + vanish + 再 set(el, t, { autoAlpha: 1 })。
  • 画面依赖播放历史。 现象:重新渲染后,个别时段的画面与原先不同;或并行渲染的片段接缝处有一帧跳变。原因是「顺着播到这里」与「直接跳到这里」的状态不同:同一个属性被两个补间在重叠的时间里改;带 overwrite 的补间互相覆盖;元素缺少入场前的初始状态;F() 里累加了变量;只在元素可见时才更新它的几何。scan.js 的 NONDET 专查这一条。首部作品里有四处这样的时刻,至今记在它的已知问题里。
  • 被补间的元素不要靠 CSS transform 定位或居中(例如 translate(-50%)):补间会覆盖 transform。用 left / top 定位;需要居中就包一层 flex 容器。
  • box() 量不到没挂到页面上的元素,也量不到 display: none 的元素。 先建好、挂好,再量。引擎构建场景时所有场景都是显示状态,可以放心量。
  • T(句号, 词) 取首次出现的位置。 同一句里一个词出现两次、想取第二个时,换一个只出现一次的相邻词。词在合成文本里找:读法表把词换掉之后,原来的写法就找不到了,用 timeline.js <视频> <句号> 查可用的词。
  • 章节卡与第一句旁白之间只隔零点几秒。 场景里第一个元素的入场不要早于 c0 + 0.1,否则会叠在章节卡上。
  • 多个 SVG 叠加层里的 <marker> 不能重名。 先出现的那个所在的场景隐藏时,后面的箭头会丢头。overlay() 已经给标记加了序号,画箭头一律用它。
  • SVG 图形做缩放、旋转的补间要用 svgOrigin: "x y"(SVG 自身的坐标)。用 transformOrigin 写像素值会按外接框解释,图形会飞走。
  • 给零尺寸的容器做 wipe 会把里面的东西全裁掉。 wipe 用 clip-path: inset(…),裁剪区按元素自己的盒子算;一个只用来打组、宽高为 0 的容器,盒子是空的,子元素即使溢出可见也全部被裁掉,而且不报错。对子元素分别登记入场,或给容器定宽高;同理,叠加层 SVG 的盒子是 1920×1080,画在它之外的内容一做 wipe 就消失,要把 SVG 自己放到内容所在的位置。
  • 同一个元素的同一个类名只登记一次 classAt。 两次登记各管各的开关,谁后变谁说了算,顺着播与直接跳到的结果就不同(NONDET)。两个触发时刻取其一,写成一次登记。
  • 部件的状态要在同一帧里生效。 一个帧函数改了另一个帧函数用的颜色或参数、却等「下一帧再重画」,换色的那一帧就是旧画面,也会被报成 NONDET。改参数的函数里立即重画。
  • fromTo 的起始状态在构建时就生效。 引擎的 fromTo 带 immediateRender:起点里的 autoAlpha: 1 会让元素在入场之前就可见,只起一个可见状态的 fromTo 同样让元素一开始就出现。只补间位置时不带 autoAlpha,显隐另用 appear(el, t) 管;需要「到某一刻才出现」的元素,先 appear(el, t),之后的动作用 immediateRender: false 的补间。
  • 改了 script.js 的文本之后必须重新运行 tts.js,否则时间线用的是旧时长。改了任何入场时刻之后必须重新运行 audio.js,因为音效事件是从场景里登记的。
  • 全片的第 0 帧,镜头不在起点。 cam.track 的每一段都是带 immediateRender 的 fromTo,构建时会套用自己的起点(补间库把这次写入延迟到下一拍),构建完镜头停在最后一站。别的时刻一跳过去时间线就会重算,唯独 t=0 不会:开机时时间线本来就在 0。于是第一个场景的第一帧机位是错的,而且时对时错。做法:在 makeCamera 之前登记一个帧函数,t 落在轨迹第一段开始之前时每帧都把 cam.st 放回起点(见 WSL 一片的 s0_open.js)。scan.js 的确定性检查是倒着每隔三帧抽一帧,t=0 不一定被抽到,这个问题可能潜伏好几轮才报出来。各版本补间库对这次写入的时机不同,表现也不同,以制作机上的检查为准。
  • 给场景根设 z-index 会盖住常驻层。 #scenes 自己不是层叠上下文,场景根一旦带正的 z-index,就排到了 #hud 上面,章节卡与底栏整个不见,字幕若是白字还会一起「消失」。后一个场景要压住前一个,靠 DOM 的先后顺序就够了。
  • 读数、计数这类动画,落点要在说这个数的那一句之内。 把起点挂在那个词上、再往后走两秒,数字就落到了这句话之后;逐句的总览图(每句说完那一刻)里显示的是走到一半的数。起点往前提,让它在读到那个数时正好走完。
  • 按词定时的动作,两种语言的落点不一样。 同一个词在中文里在句中、在英文里可能在句末;挂在它后面的一串动作会落到这一句之后,甚至落到镜头离开之后。给起点加一个上限(不晚于这一句结束前若干秒),两种语言的逐句总览图都要看。
  • 用 draw() 描的线,开始之前就露出一个点。 线帽是圆头时,虚线偏移到零长度仍会画出一个圆点;改成平头也还会留一丝抗锯齿的痕迹。做法是开始描之前把它整个藏起来,而且要用 display、每帧都设:draw() 的补间带 autoAlpha,会改 visibility,两边抢同一个属性、再加上「状态没变就不写」的缓存,画面就依赖播放历史了。
  • 时间线上不能有负的起点。 没有章节卡的场景,c0 只比场景起点晚 0.1 秒;开场里写 c0 - 0.6 就成了负时刻。补间库遇到负的起点会把整条时间线上的补间一起后移:全片扫描报「顺着播与直接跳到这里不一样」,由时间线驱动的动作也整体晚了那么多。第一个场景里的时刻一律从 s.start 算起。
  • 场景的 SVG 画布要盖住整张大图。 全片扫描只把落在画面内、面积不小于 16 的 SVG 元素算进画面指纹;画布若只有一点大(靠 overflow: visible 画出去),镜头移开后里面形状的运动就不算变化,会报 STILL、EMPTY。用 viewBox 对齐 world 坐标,把画布的位置与大小设成整张图的范围。
  • 只有边框的方框不算内容。 没有底色也没有文字的 div 不进画面指纹;场景开头若只有这样的框,会报空屏。开头放一段文字或一个 SVG 形状。
  • check.camera 按场景文件里是否出现 makeCamera 字样判断。 用部件函数包装建镜头会被判没有镜头;场景里自己 const cam = makeCamera(world)。
  • 场景的 SVG 画布在所有文字与卡片之下。 画布先挂上去,之后建的 div 都压在它上面:画在卡片上的虚线框、标记被卡片的底色盖住,写在 SVG 方块上的标签被扫描报成对比度 1:1。要压在卡片上的图形放进另一块 z-index 更高的画布(OpenClaw/OpenCode v2 一片 stage() 返回的 gt),要压字的色块改成 div。
  • 带标题的面板用 wipe 入场,标题被裁掉。 终端风格的面板把标题压在上边框上、伸出盒子之外;wipe 的 clip-path: inset(…) 按盒子裁,标题只剩下半截。裁剪区的终点向外放出标题的高度(同片的 wipeP,终点是 inset(-40px -8px -8px -8px))。
  • wipe 刷完之后,大号衬线字的下伸部被切掉。 《垃圾回收的艺术》英文章卡「Reference counting」的 g 底下少一截,策划者看片时指出。字形的下伸部(g、p、y)伸出行高,wipe 停在 inset(0%) 就按盒子切掉;这只在正式渲染里出现,look.js 的截图与总览图上看不出来,查成片要按原尺寸抽帧。引擎的 wipe 现在停在 inset(-30% -2% -30% -2%)(2026-10-20 起的新片自动带上;旧片是拷进视频目录的快照,要自己改 src/js/util.js)。
  • 镜头的两段补间在时间上重叠。 按句子时刻排的镜头关键帧,前一段还没走完、后一段就开始了,两段同时改镜头,扫描报 NONDET。轨迹函数里把每段的起点限制在上一段结束之后。
  • 镜头往下一站移动的那一秒落在空处。 上一站已经出画、下一站的第一个元素还要等下一句才入场,低清预演与成片检查报空画面。下一站的标题或面板在镜头起步时就入场(比下一句早 0.6 秒左右)。
  • 特写并排面板中的一块,另一块被画面边缘切成半截。 镜头推近,相邻面板必然露出一截,字被切成半行,读起来像取景失误。给不在讲的那块盖一层与底色相同的半透明遮罩,透明度在帧函数里按 t 算出;露出的部分就成了背景。
  • 场景根是透明的,换场时刷出来的是旧场景。 用 after() 编排「新场景跟在一道竖线后面刷出」时,新场景左侧还没有元素的地方是透明的,看到的仍是底下的旧场景;旧场景要等换场结束才消失,于是画面上没有刷出的动作,只有一道线扫过,随后一跳。给 .scene 设本片的底色,刷出的那一侧就是新场景(风格派一集的 style.css)。新场景左侧最先被刷到的地方,第一个元素在场景开始后 0.1–0.3 秒内入场,否则刷出的是一块空底色。
  • 换场时常驻元素的字色要跟着刷线走。 角标按「某段时间压在什么底色上」换字色(hudGround);前一场登记到「本场结束 + 换场时长」的红底,换场的刷线经过角标只要 0.1 秒左右,此后角标已经压在新场景的纸色上,纸色字就看不见了。底色登记到刷线经过角标的那一刻为止。
  • 用镜头往复平移凑「任意 3 秒有变化」会分散注意力。 InnoDB 第一版给每段停留加了 ±34px 的左右往复,策划者直接指出「镜头一直晃来晃去」(feedback.md P6)。静止检查要靠内容的节拍(跟着旁白的高亮、标注、图形变化)通过;镜头只做站间移动,停留时最多极慢地单向横移。换成往复缩放也一样:InnoDB 新版第三次重渲给每章加了「每 10 秒推近 4% 再拉回」,章节卡也慢推,策划者看片指出「镜头一直在缩放」(P78)。规则见 scenes.md 第 3 节,scan.js 的 ZOOM 会报。
  • 两种静止检查的口径不同。 scan.js 每 0.5 秒取一帧,元素坐标按 2 像素取整后比较:每秒移动 2 像素以下的变化可能测不出来。preview.js 在 1/4 分辨率上用 freezedetect(−48 dB)比整帧,Y/U/V 三个平面一起平均:纸面上只有一行标题和一行正在打字的命令时,镜头移动二十多像素也算「不动」。到站时就要有成块的内容,稀疏画面靠镜头救不回来。
  • 绝对定位、white-space: normal 的文字放在 world 右侧很远处会塌成一个词一行。 不给宽度时,它的可用宽度按 world(1920px)减去 left 计算,x 超过 1920 就是负数,文字按最小宽度折行,长串作者名竖着排到字幕带里。文献卡的作者行、引文都要显式给 width。 也可以把 world 本身设宽:InnoDB 第二版把 .world 设为 16000px,一次解决了站点在右侧的所有说明文字。
  • 类名里多出的空格会被判为「画面依赖播放历史」。 部件用 'panel ' + cls 拼类名,cls 为空时留下尾随空格;之后 classAt 一切换,classList 把整串规范成单个空格。顺着播到的元素切换过、直接跳到的没有,scan.js 的签名就不同,报 NONDET。部件里统一先把类名去掉多余空格(InnoDB 第二版 parts.js 的 CL)。同理,keyed(el) 每次切到新的关键帧都会把 className 整体设回它记下的值,抹掉 classAt 加上的类,而 classAt 有「状态没变就不写」的缓存、不会再加回来;同一个元素既要换内容又要换类名时,类名也写进 keyed 的关键帧(.at(t, { cls: 'row dk' }))。
  • 用极短的 fromTo 切显隐,倒着跳时元素不会再藏起来。 「到某一刻出现、过一段再消失」的元素,若用 0.01 秒、immediateRender: false 的 fromTo 管 autoAlpha,再用 out() 收走,从后往前跳回出现之前时,它停在 out() 的起点状态(可见)。这类区间显隐改用 F() 由 t 直接算出 opacity 与 visibility,位置另用补间。
  • 长句里只有一个小元素变化,低清预演仍判静止。 单元格换值、小标签弹出、细边框变色,在 1/4 分辨率的整帧平均差里几乎看不见;只有标题的开头句、只说结论的句子最容易连成 4–9 秒的静止。按句中的关键词让成块的东西出现:面板、一行 SQL、一排占位框、一条较粗的线。不要用镜头的慢推去补(停留时不缩放,见 scenes.md 第 3 节)。
  • 正方形转 90° 看不出变化。 Web 登录一集用「转 90° 拼不上」表示拒绝,用在方形的网站框上,转完和原样一模一样,look.js 量到的外接框也不变,看截图才发现。方形、圆形这类对称的形状,拒绝或倒下改用转 25° 加下沉、变暗;只有钥匙、长条这类不对称的形状才用 90°。
  • 浅色元素要很大才算画面变了。 preview.js 的 freezedetect 把 Y、U、V 三个平面的全部样本一起平均,跟一段静止开始时的那一帧比,平均差到约 1 个灰阶(−48 dB,即 3.98‰)才算变了,所以算数的是「面积 × 与底色的亮度差」。InnoDB 第二集在低清预演上逐帧量过:浅色面板(#FAFBFA 压在 #EEF1F0 上,只差约 10 个灰阶)要占到画面六分之一左右才刚过门限,第 05 章 560×600 的 MyISAM 面板入场升 5.3‰,第 06 章的磁盘面板升 5.0‰;深色块占画面 2% 就够,第 02 章一行(730×56)由标志蓝变回浅色升 8.3‰,另一行随后压暗,合计 18‰。小的白色元素不算:改之前的第 05 章,两个 120×160 的白底文件图标和两行小字标签都在一段 3.5 秒的静止里先后出现,没能打断它。修法是让讲到的东西成块出现,并且用深色:讲到「数据」「索引」时,数据文件下排出三条深青的记录、索引文件下长出一棵小树;第 03 章「锁的粒度」一句,读到术语时弹出一块标志蓝的释义标签。

文字与版面

  • 等宽字里的空格。 flex 容器会丢掉纯空白的文本节点,行内代码的空格要和文字写在同一个 span 里;整行代码的容器设 white-space: pre。
  • HTML 实体。 代码与终端内容里的 <、>、& 要写成实体,或用 esc()。>>、提示符里的 > 都在此列。JS 字符串里的反斜杠要写两个。
  • 等宽字体的连字。 JetBrains Mono 会把 -> 画成箭头;base.css 已全局关闭连字。
  • 放在画布右侧的文字被挤成一字一行。 绝对定位的块 left 超过容器宽度时,可用宽度为负。world 里的文字默认不换行;要换行就显式写 <br>,或给块定宽并设 white-space: normal。
  • 遮罩揭示时字头露出来。 遮罩留了上下内边距防止裁掉笔画,位移只给 110% 不够,给到 140% 左右。
  • 整行反白比上下行多出一截缩进。 用负外边距抵掉内边距。
  • 会转动、会长出标签的图越界。 按「转到最不利位置时」留边;成片前用 margin.js 把每句的画面都量一遍。
  • 大数字与紧挨着的标签被报为重叠。 文字项量到的是行框,上下各有一截空白。检查时已按字形大致占的范围计算;仍被报出时,多半是真的压住了。
  • 字号比直觉大。 先看缩小一半的总览图,缩小后仍读得清的才算够大。
  • 拉远看全图时,正常字号的字会低于最小字号。 镜头缩到 0.5 倍,28 像素的标签只剩 14 像素,SMALL 报错,实际也看不清。拉远之前把自身带字的元素收起,只留为全图准备的大字标签。
  • 一排并列的标签别按固定间距排。 英文比中文宽得多,固定间距在英文版里会互相压住。放进一个 display: flex 的容器,让它们按内容自然排开。
  • 英文里的弯撇号 ’ 在先列 CJK 字体的类里是全角字形。 屏幕上的英文文字用直撇号;字幕条与等宽类不受影响。
  • 两段内容共用同一块位置时,前一段的退场要锚在后一段的入场上。 引文的退场按「下一句的后半句」定时,接替它的金线与文字按「下一句的前半句」定时,中间就有约一秒两段文字叠在一起。这种相压落在两句之间:逐句末的总览图看不到,全片扫描也可能不报。退场时刻直接写成「接替者入场之前」,并用 sheets.js <视频> every 4 通看。
  • 镜头在大图上移动,正在讲的内容不能被画面边缘截断。 当前这句讲的那一行、或一个大字标题被截成半个词,就是缺陷;推近或平移让相邻区域露出半截,也常被看成取景失误(MyISAM 新版第一轮,P70–P72)。取景时把这一句要看的内容的最右端、最下端与画面边缘、字幕条的距离算出来,连同镜头漂移的幅度一起留够。
  • 文字伸出所在面板的边框,扫描不报。 全片扫描只查文字与文字、文字与画面边缘;一行说明从面板里伸出去、压在面板的边框上,不在检查之内。InnoDB 第二集第 07 章「← 加锁读:读最新的值,并且不让别人改」一行的末字压在面板右边框上(10:43–10:58),是看逐句总览图时发现的,删去「并且」两字才收进面板。放在面板里的说明,按「面板右边框减去起点」算好可用宽度;改了字数要再看一遍总览图。
  • 字幕会和图解的线条相压。 大图上到处是线,镜头怎么摆都难保字幕下面没有东西。给字幕加一块与当前底色相同的衬底(深底深衬、浅底浅衬),平时看不出,线条经过时被它挡住。

渲染

  • 成片悄悄少了一段。 无头 Chrome 的截图通常是不带透明通道的 PNG,但个别帧里某个合成层的边缘会留下一条 1 像素宽的透明缝(带 will-change: transform 的遮罩文字在切换反白的那一帧),这一帧就成了带透明通道的。图像流的像素格式一变,ffmpeg 重建滤镜图,用 setpts=N/… 给帧编号时计数归零,其后的帧被当成迟到帧整段丢掉;日志里没有任何报错,封装时的 -shortest 又把声音一起截短,成片看上去正常,却少了 28 秒。两次渲染丢的帧数一模一样,才看出不是偶发。现在的 render.js 在入管道前把这类帧改写成不带透明通道的,时间戳由输入时间戳平移得到,并逐段、整片数帧;finish.js 另外核对音画长度。排查用 pngprobe.js。
  • 多个无头浏览器必须逐个串行启动。 同时加载几十 MB 的字体会互相拖到导航超时。openPage 已如此。
  • 不要用 CSS blur() 滤镜。 单帧出图从约 120 毫秒变成 365 毫秒。
  • <canvas> 在场景显隐切换之后会丢瓦片。 预览时看不出来,渲染时才出现。画完之后用 freeze() 换成图片。
  • 编码进程不限线程时每个占约 1.6 GB。 九路就是 14 GB,内存紧张时会报 x264 malloc … failed。render.js 缺省每个编码进程 4 线程,降到约 0.7 GB,速度不变(瓶颈在截图)。
  • 低清预演的每一帧都被判为不合法。 报「截图重试后仍不是合法的 480x270 PNG」。新版 Chrome(实测 154)用调试协议直接截图时不再理会小于 1 的设备像素比,出来仍是 1920×1080。render.js 现在在缩小出图时显式给出裁剪区与比例(clip.scale);look.js 走的是另一条截图路径,不受影响。
  • 封面页的 canvas 截出来是空的(解析阶段画必空,字体就绪后画也偶发)。 封面里的图形在页面脚本一开始就建 canvas、立即作画,制作机的无头浏览器截出来那块是纸色;getImageData 读回的像素全为透明(0,0,0,0),fillRect 之后也是。同一段画法挪到 document.fonts.ready 之后的回调里执行就正常了;原因没有查明。正片的 canvas 本来就是在引擎等字体载入之后的构建阶段画的,没有遇到。挪到字体就绪之后也只是多数时候正常:同一集改版后连出封面,英文 16:9 仍两次整张空白(连黑框都没有,画完换成 <img> 也一样)。封面里的数据图形改用 SVG 矩形绘制,连出三轮都正常(风格派一集的 cover.html)。
  • 4K 封面在个别机器上底部缺瓦片。 封面以正式出图的那台机器为准,出图后看一眼底部。
  • 慢速缩放的大字,压缩后边缘有约 1 像素的抖动。 把已交付的画面与无损截图逐帧相减时,大号高反差的文字边缘会显出一圈细线。这是编码器对极慢的亚像素运动的处理,不是内容不同;比较两次渲染是否一致时,先把画面缩小再比,或用接近无损的码率重新渲染后再比。
  • 带 will-change: transform 的层,直接跳到与顺着播到的像素可能略有不同。 元素的位置与内容完全一致,差别只在浏览器对合成层的栅格化(在运动中的文字上最明显)。成片是顺着播放渲染的,各段内部一致。
  • 整屏只有一种颜色的画面会被成片检查判为空画面。 finish.js 的空画面检查以每帧最常见的灰度为底色,数与它不同的像素;一块同色幕布盖满屏幕、或镜头在大图上移动时扫过空白,都会连续几帧低于门限,check.js --final 不通过。幕布上放大字标题;长距离换位用整屏的小节幕盖住,在盖住的那一刻切镜头。
  • 空画面检查对采样相位敏感。 它每 0.1 秒取一帧,「持续 0.3 秒以上」是按取到的帧数算的;恰好在门限上的片段,时间轴平移零点几秒(例如改了一句旁白)就可能从不报变成报。两段内容交接处别留空档:前一段的元素留到后一段进来之后再收。
  • check.js --final 看的是 src/ 下的全部文件。 成片是否过期按 src/ 里每个文件的内容哈希判断,不管页面是否加载它。早已不用的设计稿、示范场景要在最后一次渲染之前清掉;渲染之后再删,成片即被判为过期。封面 cover.html 也在 src/ 里,同样要在最后一次渲染之前定稿。
  • 章名只有一个字,换章那半秒成片是空的。 InnoDB 第二集用整场淡入(sceneFade):章节标题在本场内容淡入前约 0.4 秒开始收向左上角。「08 锁」只有一个字,标题缩小时占画面不到 0.6%,本场的面板与记录又要等本场淡入之后才各自淡入,check.js --final 报 661.7–662.0 秒空画面;章名长的各章没有报。做法:本场的第一批元素(面板、记录、两个会话标签)在本场淡入之前就把自己的入场做完,随本场一起出现;深色的字和色块在本场不透明度只有一成时就算内容。只看低清预演的静止检查查不出这一项,要跑下一条的空画面检查。
  • 空画面检查可以先在低清预演上跑。 blank_check.py 接受一个视频文件作参数(环境变量 LVS_VIDEO、LVS_LANG、LVS_FFMPEG);对 out/<语言>/preview_video.mp4 跑一遍只要十几秒,不必等正式渲染之后才发现。
  • 稀疏的线条图上,镜头的慢移在低清预演里等于没动。 0.25 倍、15 帧/秒下,线条每帧的位移不到一个像素时,逐帧差低于静止检测的门限。不要靠加大镜头漂移去补:OpenClaw/OpenCode v2 一片一度让漂移每秒走二十几个像素、上限放到 240 像素,横向撑满画面的站点整排文字被推出画面,全片扫描报了 188 处「被切掉」。停留时的横移按 scenes.md 第 3 节(每秒不超过 2 像素);稀疏画面要让成块的内容变化。
  • 推近之后,画面下方的说明落到了字幕栏底下。 字幕栏是实心带时,压在底下的字直接看不见。MyISAM 新版第 03 章推近到字节表,「其余 15 个字节不变」一行落在字幕栏下(02:33),策划者截图指出。可读的纵向范围是顶栏与字幕栏之间,算取景时扣掉两条带;scan.js 的 CAPZONE 就是在报这件事。
  • 章节卡上的光标不会自己闪。 不能用 CSS 动画,静态的 <span class="cur"> 就是一个不动的色块;要在帧函数里按 t 切换它的不透明度(typeText 自带的光标已经这样做)。MyISAM 新版第一轮的章节卡光标不闪,策划者看片指出。
  • 用脚本批量改源码时,插进去的行内注释吞掉了同一行后面的代码。 把 // 说明 插到一行中间,这一行剩下的语句全成了注释:有一次是一处定位不再生效,有一次是整个声明断掉、页面报错。说明写在行尾或单独一行,改完对每个文件跑 node --check。
  • 全片渲染不能续渲,长片的默认并发会把内存用尽。 render.js 开头就删掉上一次的分段,中途被终止只能从头再来。9–10 分钟的片子在 40 GB 的机器上按旧的缺省并发渲染,被系统以内存不足终止过(2026-10-20)。缺省并发现在不超过「可用内存 ÷ 3 GB」;同机还有别的重任务时,显式加 --workers 4。
  • 两个开浏览器的工具不要同时跑。 一边跑 preview.js、一边跑自写的逐帧测量脚本,新页面的导航被 ERR_ABORTED 打断过。同一部片子的浏览器类工具一次只跑一个;一部片子整片渲染时,另一部片子起一个短渲染,同样被 ERR_ABORTED 打断过(2026-10-20)。
  • 低清预演的静止检测对节拍网格的量化很敏感。 改一点停顿,别处一段 2.8 秒的静止就可能变成 3.2 秒,来回修了四轮。先用报告里的时刻找到对应的句号与场景(timeline.js),再看那一段整体有没有足够的变化,不要只把边界挪一点点。
  • 低清预演没报的静止段,成片检查照样会报。 《垃圾回收的艺术》两种语言的低清预演最长只有 2.9 秒静止,整片渲染后 finish.js 在原尺寸上(−52 dB、裁掉字幕条)报了四段 3.0–4.0 秒:都是一块源码或指令石板出现之后,到下一个高亮之间只有背景的小方块在闪。低清预演里 2.5 秒以上的段落,正式渲染前先用 render.js --from --to --out 按正式参数渲那几秒,用 finish.js 里同一条 freezedetect 命令量一遍;补的动作要成块(整行的底色、一个方块),只变字色的面积太小。
  • 开了 --gpu 1,渲染偶尔停在收尾。 帧已全部写完,个别无头浏览器的 close() 一直不返回,进度行停在「frames N/N」反复打印(Windows、2026-10-20:几十秒的短片段上反复出现,一次 4 路里有 3 路挂住;一次整片渲染没有遇到)。lib.js 现在等 15 秒仍未关上就结束该浏览器的进程树,日志里会有一行「浏览器 15 秒内没有关上」;帧数照常逐段核对。

声音与读法

  • 连写的英文专名被读错或逐字母拼读。 拆开写(lua installer、My ISAM)。读法先试再定:tts_probe.js。
  • 同音词听不出区别。 「计数」与「技术」同音,「密钥和计数」听上去是另一个意思;定稿改成「计数器」。写完稿用 asr.js 回听时,留意识别结果里出现的同音近义词。
  • 多音字读错。 换成同音的单音字(数据「行」→「航」,「月相」→「月象」),只改合成文本,字幕与画面不变。
  • 两种语言的成片响度不一致。 不同音色的峰值与均值之比差别很大,按峰值归一会相差数 dB。混音按响度对齐旁白。
  • 合成铺底的低音过重。 用数值自检看三段能量占比,见 sound.md 第 5 节。
  • 封装报告的真峰值是编码前的。 finish.js 在编码前限幅、测的也是 WAV:InnoDB 第二集报 −1.4 dBTP,AAC 256k 编码之后成片实测 −0.9 dBTP,高出约 0.5 dB,超过 −1 dB 的目标;check.js --final 读的是编码前的数,照样判为通过。交付记录以成片实测为准:ffmpeg -i <成片> -map 0:a -af ebur128=peak=true -f null -。
  • loudnorm 单遍处理会抽动。 成片用「固定增益 + 限幅」。
  • 无配乐版重建后与原先不逐样本相同。 音效里的噪声成分由同一个随机数发生器按顺序生成,配乐不同则音效的噪声实现不同。现在的无配乐版直接取主混音里的那条音效分轨,与主混音完全一致。
  • 数据的「行」被读成 xíng。 Edge 的中文语音把「整行」「一行」「取行」「行上」「行级锁」读成 xíng(回听听成「整形」「异形」「平行」)。在 SAY 表里把表示数据行的「行」换成同音的「航」:[/(?<![执进串银])行/g, '航'],「执行」「进行」「串行」「银行」不换;写在 tts 字段里的句子要手动改。
  • 整段回听报的读错,逐句复听常常就没了。 Whisper 整段转写时靠上下文猜词:InnoDB 第二集里「回滚」被听成「毁滚」、「空隙」被听成「空气」,把成片用的旁白轨按句切出来单独转写(不带前后文),这些就都对了;单独转写仍错的(「Oracle」两次都听成「Boracow」),才是值得人去听的地方。
  • MiniMax 合成文本里的逗号会变成很长的停顿。 Web 登录一集想让几句偏快的英文慢一点,在读法里各加了一个逗号:o3b 从 1.8 秒变 3.4 秒(正好),j4 却从 3.9 秒变 5.8 秒、j7b 从 2.9 秒变 5.8 秒,全片英文一下超出 200 秒。加逗号要逐句试,不要一次改好几句;同一句重新合成,时长本身也会差零点几到一秒(j4 改回原读法后成了 5.1 秒),重新合成过的句子要重排场景定点。
  • 英文的 you know 会被读成口头禅。 「It proves you know a secret」合成出来是「It proves, you know, a secret」,单独转写这一句才听出来(整段回听只报了漏词)。改成「It proves that you know a secret」。英文稿里出现 you know、I mean、like 这类兼作插入语的词组时,单句转写核对一遍。
  • 重新配音之后要重跑 asr.js。 out/<语言>/asr.txt 不会随 tts.js 更新。InnoDB 第二集按 R8 重新配音后,回听记录仍是 R7 那一版的,直到收尾时重跑才有 R8 的结果。
  • 改读法或停顿会挪动整条时间线。 任何一句的时长变了,后面所有句子的起点都跟着变,已经开始的渲染作废。顺序是:tts.js → asr.js 回听 → stats.js 看「过快、过长、停顿过短」→ 全部改完,再渲染。

工具与环境

  • 字体规则误报文案里的词。 check.style.fonts 打开后,源码里出现 Microsoft、Arial 这类词就会被当成系统字体;片尾名单里写到「Microsoft Edge 在线语音」即中招。确属文案时在行尾注释 lint-ok: <原因>。
  • 远程会话会断,长任务跟着死。 经 SSH 起的渲染、实验,会话一断子进程就被带走。长任务脱离会话启动(Windows 上用 WMI 的 Win32_Process.Create,或计划任务),输出写进文件,结束时落一个标记文件,再轮询;中止时按进程树结束,只清理这一次留下的临时档案目录。
  • 别用整目录同步覆盖 timing.<语言>.js。 两台机器分工时,这两个文件由制作机上的 tts.js 生成,本机副本只是占位;整目录覆盖会让时间线退回按字数估算。
  • 在线语音服务会突然拒绝连接。 2026-10-18 用 edge-tts 7.2.8 合成时握手被拒(403),既不能试读法,也不能重新配音。已有配音缓存、旁白不改的片子不受影响:混音、渲染、封装都只用缓存。要改读法时先查服务能否连上,再动脚本。原因是客户端过旧,不是被封:2026-10-21 换成 edge-tts 7.3.1 即恢复,kit/requirements.txt 的下限随之提到 7.3.1。再遇到 403,先 pip index versions edge-tts 看有没有新版本。
  • 制作机离线时可以在本机看图,但检查以制作机为准。 本机的工具链与依赖库版本可能不同(例如补间库),同一份代码在两边的首帧状态都可能不一样。
  • 经 stdin 传给 Windows PowerShell 的脚本里,中文字面量会乱码。 ssh ... 'powershell -NoProfile -Command -' < script.ps1 按控制台代码页读脚本,Contains('两阶段锁') 永远匹配不到,却不报错。要搜的中文先在本机 base64 编码,脚本里用 [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(...)) 还原。
  • 擦除结束后留下的 clip-path: inset(0%) 曾让大字被报「被裁:T」。 衬线大字的字形框比行高略高,按元素盒子裁剪就误判为上边被裁,自写的可读时长脚本因此量不到这些字。inspect.js 已把全为 0 的 inset 当作不裁剪(2026-10-20)。

流程与交付

  • 交付与发布是两件事,记录各有各的。 2FA 于 2026-10-06 发布到哔哩哔哩与 YouTube,片单已经是「已成片」,但根 README 的「已成片」表没有它,片中 README 与 FACTS.md 还写着「成片还在制作机上,没有上传」。发布之后按 process.md 第 11 节回填,漏了这一步,仓库就与已经发生的事不一致。
  • 渲染之后又改了源码,会话却停在预演。 InnoDB 第二集第一次正式渲染(2026-10-10 09:36)之后又改了 12 个源文件,跑过一次低清预演(仍有两段静止)就中断了:out/ 里留着一版看上去完整的成片,README 的「成片」一节空着,片单仍是「制作中」。一周后接手,比对 out/<语言>/video.json 记下的源文件哈希才确认它早已过期。接手时先做这一步比对,再决定从哪一步重做;中断前在 README 写明停在哪一步、还差什么。
  • 改完之后结果反而变差,先确认改动真的生效了。 InnoDB 第二集第 02 章想让 notes 表在读到「两张表」时就出现,改的时候把说明写成了行尾注释:const tNt = …; showPanel(PU, tA); // 说明…… showPanel(PN, tNt); fin(…); …,同一行后面的五个入场调用被一并注释掉,notes 表与两张表的表头、记录变成本场一淡入就在。预演里那段静止从 2.9 秒变成 3.7 秒;当时没有回头看画面,就在预演视频上量差值,得出「浅色面板测不出来」的错误结论,写进了源码注释与本页(上文「浅色元素」一条是后来重测的数)。直到成片投稿之后、提交前复查改动才发现。改了出现时刻却没有预期的效果时,先用 look.js 看那一刻、在 git diff 里逐行读改动,再找原因;说明写在单独一行,不跟在代码后面。
  • 画面风格先取证讲解对象,再动手。 InnoDB 第一版的纸色底、墨色字、单一强调色,与同期另一个系列的 150 秒片子是同一套路子,被要求完全重做(feedback.md P7)。开工时先取讲解对象自己的视觉材料(标志、官网样式表、官方文档插图),测出配色与字体并写进本片的设计说明;再与近期各片并排比较,确认不是换色复用。
  • 术语逐个过一遍再交审。 第一版的稿子出现了 SDI、脏读、幻读等词却没有解释(feedback.md P9)。交审前列出稿中每个术语第一次出现的句号和解释它的那一句,没有解释句的要么补、要么删。
  • 片单是共享文件,new.js 会整份重写它。 new.js 在开始时读入 videos/catalog.json,结束时把整份文件写回。同一仓库里同时有别的制作者开工(另一个会话在登记自己的新集、或改了状态)时,你这次 new.js 结束的瞬间会把对方的改动一起覆盖掉。做法:改片单前先 git status 看有没有别人的未提交改动;批量新建时一条一条跑,每条之后重新核对片单里自己的条目与别人的条目都还在;提交时用路径限定的 git commit -F <提交信息文件> -- <pathspec>,只带自己改的条目;推送前重新 fetch 比对,别只看 git branch -vv 的快照。
  • Windows 上整篇 CRLF 的系列说明,会让片单检查一直判为不一致。 catalog.js 重写分集表时用的是 LF;syncSeries 比较的是整份文件,于是文件里若通篇是 CRLF,就永远比不相等,catalog.js --check 报「与 videos/catalog.json 不一致:videos/series/<系列>/README.md」,而表格内容其实一字不差。仓库规定全仓 LF(.gitattributes 的 * text=auto eol=lf),把这类文件规范化成 LF 即可。实测 2026-10-20:videos/agent-harnesses/README.md 是全仓唯一一篇通篇 CRLF 的系列说明。
  • 一部片子从双语改成只做一种语言,三处要一起改。 new.js 按片单里的 languages 生成 src/js/timing.<语言>.js,并把每个语言写成 src/index.html 里的 <script src="js/timing.<语言>.js">。只把 project.json 的 languages 收窄成一种语言,删掉另一个语言的 timing.*.js,index.html 里那行 <script> 还在,浏览器每次都报一次 404(look.js 的「页面日志」里看得到)。改语言范围时三处一起改:project.json 的 languages、videos/catalog.json 里本片的 languages、以及 src/index.html 的时间线 <script> 行。catalog.js --check 会核对片单与 project.json 的语言是否一致,少改一处就被它挡下。实测 2026-10-20:Firebird 一集只做中文。
  • 逐句对应表要在字幕断句之后再写。 一部片子在确认后把 61 句超宽句拆成 142 句,断句前写好的 expect.zh.json 有十几处要求某个词在「前一句」说完时就已上屏。句号一变,对应表按新句号重写。
  • 内容哈希包含章名。 校对确认的内容哈希覆盖语言、标题、场景号、章节卡的编号与标题、句号与字幕、是否无旁白;不含读法、tts、停顿与 lead、gap。确认之后改章名会让确认失效;章名需要调整时,同样交校对确认(见 文案校对 第 3 节)。
  • 任务书与策划者的现场指示冲突时,以策划者为准。 一部片子的任务书要求沿用系列的瑞士风格,策划者在会话里明确要求不要与那个系列同质化。规则见 角色与权限 第 2 节:照策划者的话做,原话记进 README 的「简报」。

取证

  • 推算出来的数字是错的。 一张文件布局图按「总大小减去各部分大小」推出,与实测不符。画之前先实测。
  • 安装器顺带装上了依赖。 用系统的包管理器装工具时,会连带装上编译器与头文件,「只缺某一样时会怎样」的实验因此做不出来。改用源码目录里的安装脚本。
  • 包装脚本吞掉了工具自己的报错。 经包管理器的包装脚本运行时,得到的是「找不到模块」而不是工具自身的报错。改为直接运行源码目录里的入口。
  • 回显里带出了本机信息。 用户目录、主机名会随回显进入取证材料。入库前替换成通用写法并注明;能在干净容器里做的实验就在容器里做。
  • 实验回显里的家目录,可以在运行时就是通用路径。 在一个新的用户命名空间里(unshare -Urm --fork)把实验目录绑定挂载成 /home/user,再运行被测程序:它看到的家目录、配置与数据目录都是这个路径,回显不必事后替换,也不需要容器。进程列表仍要过滤掉与实验无关的进程。被测程序启动时要联网安装依赖的,按它自己锁文件里的版本预先放进缓存目录。
  • 按脚本应答的模型,不能拿来说明真实模型的行为。 为了看清 harness 自己写了什么,实验里的「模型」是一段固定规则;它在恢复后重做了一次命令,是规则让它这么做。旁白若写「于是它又执行了一遍」,就把规则的结果说成了模型的推断。写成「按脚本,它又执行了一遍」,画面上标明「按脚本应答」。
  • 被计时的命令里混进了准备步骤。 把「清空目标目录」和「解压」写在同一条被计时的命令里,量到的就不是解压。准备步骤放到计时之外,并另量一条空命令作基线(进程启动的开销),两个数都列出来。
  • 容器里的小工具没有想当然的功能。 Alpine 的 busybox:date 不认 %N(算出来的耗时全是 0),httpd 不在最小根文件系统里。计时改在宿主一侧用秒表量;要一个能应答的端口用 nc -lk -p 80 -e cat <文件>。
  • 本地化的命令行回显,另一种语言的版本要有来路。 有的程序按用户的界面语言输出,没法按进程切换。不改测试机的系统语言,也不凭印象翻译:从它自己的字符串表里取两种语言的模板,用一种语言的模板把实测回显反解成各项的值,再套进另一种语言的模板,对不上就报错(见 WSL 一片的 tools/gen_data.py)。
  • 事后才发现的因果词。 「所以」「于是」「因此」各是一条事实陈述,要有出处支持这层因果;只有两件事各自的出处是不够的。脚本定稿后把这几个词单独搜一遍。
  • 取回的 README 标题里带 emoji。 回显原样保存,生成画面数据时剥掉,否则 check.style.no_emoji 在 data.js 上报错。
  • 锚点摘的行数要够。 按正则定位、只摘一两行的引文,被引的词可能在下一行;场景里的断言会报「引文不符」。加宽行数,或为那个词单独立一条锚点。