在视频画面上直接框选裁剪区域,看起来是个纯交互问题,实际上把它做对需要处理三套坐标、两个死锁和一个编码器约束。这篇记 ffmpegtool.com 裁剪页和水印页在这件事上踩过的坑,全部有 e2e 实测背书。
两个自我死锁
视频裁剪页报"拖拽框不显示"。排查先走了个弯路:headless 下 videoWidth=0——那是测试 fixture 的问题(headless Chrome for Testing 没有 H.264 解码器,测试视频必须 VP8 WebM)。换成 VP8 之后,框还是不出现。
读代码才发现是个死锁,而且锁得很标准:
// 旧代码
if (!videoEl || !content) return null
// content 依赖 rootRef 测量,root 不渲染 → ref 恒 null → 测量永远不跑 → content 永远 null
content(内容区矩形)需要挂载后对根节点做 getBoundingClientRect 测量;而根节点因为 !content 压根没有渲染,rootRef.current 恒为 null,测量函数永远拿不到基准直接返回。组件自己把自己锁死了。
修法是把"根节点"和"裁剪框"的渲染条件拆开:根 div 始终渲染(absolute inset-0),裁剪框才按 content 是否存在条件渲染。现在代码里留着注释说明这条约束:根节点必须始终渲染,它是测量的基准。
水印页的覆盖层犯了一模一样的错:if (!root) return null,于是 ResizeObserver 从未 tick,框永不出现。同一个模式在两个页面独立出现,说明这是这类组件的系统性陷阱,不是手滑。
letterbox 偏移
死锁修完,下一个问题是位置偏。覆盖层的拖拽锚点用的是 canvasBox.left/top,但 <video> 是 object-contain,视频内容在画布内居中,四周留下黑边——锚点原点和实际内容原点不一致,旋转 90 度时 E2E 实测偏了 2 度(88° vs 90°),位置也随黑边宽度漂移。
修法是抽一个共享的 fitContentRect(letterbox 感知的内容矩形),两个组件都从 contentLeft/contentTop 取原点,画布盒只做容器。CropSelector 重构时也改成复用同一个函数——坐标逻辑一处实现,两处消费,否则下次改一处偏一处。
坐标纪律:像素、clamp、偶数
三套坐标贯穿这条链路,混一次错一次:
- 画面像素坐标:框选坐标一律按视频像素存(letterbox 感知换算),不存 CSS 像素——CSS 像素随容器缩放,输出滤镜参数时需要回到视频像素;
- 输出前收敛:
sanitizeCrop把坐标 clamp 进画面、宽高取偶。取偶用的是& ~1位运算——libx264/yuv420p 要求宽高是偶数,奇数尺寸直接编码失败; - 拖拽中的实时收敛:拖拽手柄时用
Math.min限在画面内再取偶,Math.max(MIN_SIZE, ...)保底最小尺寸。
验证方式
这类交互 bug 的验证必须打到命令层才算数:E2E 里从 640×360 起始框向东南拖,断言生成的滤镜参数是 crop=534:296:42:21——534/296 是拖拽值取偶的结果,42/21 是平移量,和 ffmpeg 实际收到的参数逐字节一致。只断言"框出现了"测不出坐标系错误。
还有个测试自身的教训:手柄的选择器原来找 style.right === '-7px',手柄改到框内渲染后变成了 0px,用例找不到元素——选择器断言的是实现细节而不是行为,这类断言在重构时假性失败。
复盘
这个组件族的经验浓缩成两条:
- ref 测量 + 条件渲染同框时警觉死锁。"没数据就不渲染、不渲染就拿不到数据"的模式,在 overlay/cropper/measure 类组件里反复出现。review 时看到
return null和ref.current互相依赖,基本就是。 <video>的 CSS 盒 ≠ 内容区。object-contain、object-fit、全屏模式都会让两者分离,一切坐标都要声明自己基于哪个矩形,并收敛到一套共享的换算函数里。