(
src: string,
out: string,
opts: StabilizeOpts = {},
)
| 231 | |
| 232 | /** Stabilize a clip: detect the plane, smooth, crop locked onto it, encode. */ |
| 233 | export async function stabilizeClip( |
| 234 | src: string, |
| 235 | out: string, |
| 236 | opts: StabilizeOpts = {}, |
| 237 | ): Promise<void> { |
| 238 | const cropFrac = Math.min(0.95, Math.max(0.3, opts.cropFrac ?? 0.62)); |
| 239 | const probe = await ffprobe(src); |
| 240 | const { width: W, height: H, fps } = probe; |
| 241 | const nFrames = probe.frames || Math.ceil((probe.duration || 30) * fps) + 15; |
| 242 | opts.onProgress?.(0.1); |
| 243 | |
| 244 | // Prefer the live track logged during recording (the tracker knew exactly |
| 245 | // where the plane was); fall back to offline motion-comp detection. |
| 246 | const sidecar = `${src}.track.json`; |
| 247 | let raw: ({ cx: number; cy: number } | null)[]; |
| 248 | if (existsSync(sidecar)) { |
| 249 | const sc = JSON.parse(readFileSync(sidecar, "utf8")); |
| 250 | raw = trackFromSidecar(sc, nFrames, fps, opts.alignMs ?? 0); |
| 251 | } else { |
| 252 | raw = await detectTrack(src); |
| 253 | } |
| 254 | opts.onProgress?.(0.5); |
| 255 | const track = smoothTrack(raw); |
| 256 | |
| 257 | // Crop window (even dims), 16:9, clamped inside the frame as it follows the plane. |
| 258 | const cw = Math.round((Math.round(H * cropFrac) * 16) / 9 / 2) * 2; |
| 259 | const ch = Math.round((Math.round(H * cropFrac)) / 2) * 2; |
| 260 | const cmds: string[] = []; |
| 261 | for (let i = 0; i < track.length; i++) { |
| 262 | const t = (i / fps).toFixed(4); |
| 263 | const x = Math.max(0, Math.min(W - cw, Math.round(track[i].cx * W - cw / 2))); |
| 264 | const y = Math.max(0, Math.min(H - ch, Math.round(track[i].cy * H - ch / 2))); |
| 265 | cmds.push(`${t} crop x ${x}, crop y ${y};`); |
| 266 | } |
| 267 | const cmdFile = `${out}.cmds.txt`; |
| 268 | await writeFile(cmdFile, cmds.join("\n")); |
| 269 | |
| 270 | const outH = opts.outH ?? 720; |
| 271 | const outW = Math.round((outH * 16) / 9 / 2) * 2; |
| 272 | await new Promise<void>((resolve, reject) => { |
| 273 | // niced + thread-capped so the offline encode yields to the live tracker on |
| 274 | // the already-busy Pi (no hardware H.264 encoder, so this is software x264). |
| 275 | const ff = spawn("nice", [ |
| 276 | "-n", "19", "ffmpeg", |
| 277 | "-hide_banner", "-loglevel", "error", "-y", "-threads", "2", "-i", src, |
| 278 | "-vf", `sendcmd=f=${cmdFile},crop=${cw}:${ch}:0:0,scale=${outW}:${outH}`, |
| 279 | "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", |
| 280 | "-movflags", "+faststart", out, |
| 281 | ]); |
| 282 | let err = ""; |
| 283 | ff.stderr.on("data", (c) => (err = (err + c.toString()).slice(-500))); |
| 284 | ff.on("error", reject); |
| 285 | ff.on("close", (code) => |
| 286 | code === 0 ? resolve() : reject(new Error(`ffmpeg ${code}: ${err}`)), |
| 287 | ); |
| 288 | }); |
| 289 | await unlink(cmdFile).catch(() => {}); |
| 290 | opts.onProgress?.(1); |
no test coverage detected