|
20 | 20 |
|
21 | 21 | import { afterAll, beforeAll, describe, expect, it } from "bun:test"; |
22 | 22 | import { spawnSync } from "node:child_process"; |
23 | | -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| 23 | +import { |
| 24 | + copyFileSync, |
| 25 | + cpSync, |
| 26 | + mkdirSync, |
| 27 | + mkdtempSync, |
| 28 | + readFileSync, |
| 29 | + readdirSync, |
| 30 | + rmSync, |
| 31 | + writeFileSync, |
| 32 | +} from "node:fs"; |
24 | 33 | import { tmpdir } from "node:os"; |
25 | 34 | import { join } from "node:path"; |
26 | 35 | import { type CaptureOptions, extractVideoFramesRange } from "@hyperframes/engine"; |
27 | 36 | import { HOST_CHROME_FAILURE_PATTERNS } from "./__test_utils__/hostChromeFailures.js"; |
| 37 | +import { defaultLogger } from "../../logger.js"; |
28 | 38 | import { RenderQualityError } from "../renderOrchestrator.js"; |
29 | 39 | import { plan } from "./plan.js"; |
| 40 | +import { materializePlanV2Target, planV2, readPlanV2Manifest } from "./planV2.js"; |
30 | 41 | import { |
31 | 42 | CHUNK_INDEX_OUT_OF_RANGE, |
32 | 43 | distributedCaptureSessionDependencies, |
@@ -530,6 +541,98 @@ describe("renderChunk() — script failure", () => { |
530 | 541 | }, 60_000); |
531 | 542 | }); |
532 | 543 |
|
| 544 | +describe("renderChunk() — a video the chunk does not show", () => { |
| 545 | + it("renders the same without the video's file, with no media warning", async () => { |
| 546 | + if (!hasChrome) { |
| 547 | + console.warn( |
| 548 | + "[renderChunk.test] skipping unshown-video test — chrome-headless-shell not available on this host", |
| 549 | + ); |
| 550 | + return; |
| 551 | + } |
| 552 | + |
| 553 | + const videoDir = join(runRoot, "project-two-videos"); |
| 554 | + mkdirSync(videoDir, { recursive: true }); |
| 555 | + for (const [name, seconds] of [ |
| 556 | + ["short.mp4", 2], |
| 557 | + ["long.mp4", 3], |
| 558 | + ] as const) { |
| 559 | + const synth = spawnSync("ffmpeg", [ |
| 560 | + "-v", |
| 561 | + "error", |
| 562 | + "-y", |
| 563 | + "-f", |
| 564 | + "lavfi", |
| 565 | + "-i", |
| 566 | + `testsrc2=s=64x36:r=30:d=${seconds}`, |
| 567 | + "-c:v", |
| 568 | + "libx264", |
| 569 | + "-pix_fmt", |
| 570 | + "yuv420p", |
| 571 | + join(videoDir, name), |
| 572 | + ]); |
| 573 | + if (synth.status !== 0) throw new Error(synth.stderr.toString()); |
| 574 | + } |
| 575 | + writeFileSync( |
| 576 | + join(videoDir, "index.html"), |
| 577 | + // Clips over a second long, so the planner defers them to the chunks. |
| 578 | + FIXTURE_HTML.replace('data-duration="0.16667"', 'data-duration="2.4"').replace( |
| 579 | + "</div>", |
| 580 | + '<video id="short" src="short.mp4" muted data-start="0" data-duration="1.2" style="position:absolute;left:0;top:0;width:64px"></video>' + |
| 581 | + '<video id="long" src="long.mp4" muted data-start="0" data-duration="2.4" style="position:absolute;right:0;bottom:0;width:64px"></video></div>', |
| 582 | + ), |
| 583 | + "utf-8", |
| 584 | + ); |
| 585 | + const v2Dir = join(runRoot, "plan-two-videos"); |
| 586 | + await planV2( |
| 587 | + videoDir, |
| 588 | + { fps: 30, width: 160, height: 120, format: "png-sequence", chunkSize: 36 }, |
| 589 | + v2Dir, |
| 590 | + ); |
| 591 | + const manifest = readPlanV2Manifest(v2Dir); |
| 592 | + expect(manifest.artifacts.find((a) => a.path === "compiled/short.mp4")?.chunks).toEqual([0]); |
| 593 | + |
| 594 | + const without = join(runRoot, "chunk1-without-short"); |
| 595 | + const withShort = join(runRoot, "chunk1-with-short"); |
| 596 | + materializePlanV2Target(v2Dir, { role: "chunk", chunkIndex: 1 }, without); |
| 597 | + cpSync(without, withShort, { recursive: true }); |
| 598 | + copyFileSync(join(videoDir, "short.mp4"), join(withShort, "compiled", "short.mp4")); |
| 599 | + |
| 600 | + const warnings: unknown[] = []; |
| 601 | + const skipped: (readonly string[] | undefined)[] = []; |
| 602 | + const originalWarn = defaultLogger.warn; |
| 603 | + const originalCreate = distributedCaptureSessionDependencies.createCaptureSession; |
| 604 | + defaultLogger.warn = (message, meta) => { |
| 605 | + warnings.push(meta); |
| 606 | + originalWarn(message, meta); |
| 607 | + }; |
| 608 | + distributedCaptureSessionDependencies.createCaptureSession = ((...args) => { |
| 609 | + skipped.push(args[2].skipReadinessVideoIds); |
| 610 | + return originalCreate(...args); |
| 611 | + }) as typeof originalCreate; |
| 612 | + let a, b; |
| 613 | + try { |
| 614 | + a = await renderChunk(without, 1, join(runRoot, "chunk1-out-without")); |
| 615 | + b = await renderChunk(withShort, 1, join(runRoot, "chunk1-out-with")); |
| 616 | + } catch (err) { |
| 617 | + const message = err instanceof Error ? err.message : String(err); |
| 618 | + if (!HOST_CHROME_FAILURE_PATTERNS.test(message)) throw err; |
| 619 | + console.warn( |
| 620 | + "[renderChunk.test] skipping unshown-video test — host Chrome stack can't render. Diagnostic:", |
| 621 | + message.slice(0, 240), |
| 622 | + ); |
| 623 | + return; |
| 624 | + } finally { |
| 625 | + defaultLogger.warn = originalWarn; |
| 626 | + distributedCaptureSessionDependencies.createCaptureSession = originalCreate; |
| 627 | + } |
| 628 | + |
| 629 | + expect(JSON.stringify(warnings)).not.toContain("media_load_failed"); |
| 630 | + // Only the unshown video is skipped; the shown one keeps today's readiness wait. |
| 631 | + expect(new Set(skipped.map(String))).toEqual(new Set(["short"])); |
| 632 | + expect(a.sha256).toBe(b.sha256); |
| 633 | + }, 90_000); |
| 634 | +}); |
| 635 | + |
533 | 636 | describe("resolvePresetForLockedEncoder", () => { |
534 | 637 | // Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg — |
535 | 638 | // exists so a refactor that moves the override (e.g. into |
|
0 commit comments