From ef2f00327b091508f16ece107bb68ba8b21650f6 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:24:25 +0100 Subject: [PATCH 1/5] Keep parent finalize alive through child dispose failures Cursor bugbot caught that a throwing child asyncDispose (e.g. a popup screencast copy failure) aborted the parent's afterTest/afterTestFinalize, dropping the main page's video artifacts. Child wrap and dispose failures are now collected and rethrown only after the parent's own teardown runs. Co-Authored-By: Claude Fable 5 --- spec/popup.spec.ts | 35 +++++++++++++++++++++++++++++++++++ src/plugin-system.ts | 21 ++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/spec/popup.spec.ts b/spec/popup.spec.ts index 11c1202..e7ab131 100644 --- a/spec/popup.spec.ts +++ b/spec/popup.spec.ts @@ -87,6 +87,41 @@ test("wrapping an already-wrapped page throws", async ({ page: basePage, context ); }); +test("a failing popup plugin finalizer does not stop the parent finalizing", async ({ + page: basePage, + context, +}, testInfo) => { + await routeAuthDemoApp(context); + const events: string[] = []; + const plugin: Plugin = { + name: "flaky-on-popups", + forPopup: () => ({ + name: "flaky-on-popups-child", + testLifecycle: (emitter) => { + emitter.on("afterTestFinalize", () => { + throw new Error("popup teardown exploded"); + }); + }, + }), + testLifecycle: (emitter) => { + emitter.on("afterTestFinalize", () => { + events.push("parent finalized"); + }); + }, + }; + const page = await addPlugins({ page: basePage, testInfo, plugins: [plugin] }); + await page.goto("https://app.middlewright.test/"); + const popupPromise = basePage.waitForEvent("popup"); + await page.getByRole("button", { name: "Sign in" }).click(); + await (await popupPromise).getByRole("button", { name: "Approve" }).click(); + await page.getByText("Signed in as mmkal").waitFor(); + + // The child's failure surfaces, but only after the parent finalized — a + // popup teardown hiccup must not drop the main page's artifacts. + await expect(page[Symbol.asyncDispose]()).rejects.toThrow("popup teardown exploded"); + expect(events).toEqual(["parent finalized"]); +}); + test("popups: false leaves popups unwrapped", async ({ page: basePage, context }, testInfo) => { await routeAuthDemoApp(context); const actions: string[] = []; diff --git a/src/plugin-system.ts b/src/plugin-system.ts index fb05a2a..7788774 100644 --- a/src/plugin-system.ts +++ b/src/plugin-system.ts @@ -287,21 +287,28 @@ export const addPlugins = async (p page.off("popup", onPopup); } // Children dispose first (newest first) so their plugins can finalize -- - // and, later, feed facts to parent plugins -- before the parent's own - // lifecycle events run. A failed child wrap must not stop the parent - // finalizing; it rethrows below once cleanup is done. + // and feed facts to parent plugins -- before the parent's own lifecycle + // events run. A failed child wrap OR a throwing child dispose must not + // stop the parent finalizing (that would drop the main page's artifacts); + // failures rethrow below once the parent's own teardown has run. + const childFailures: unknown[] = []; const settledChildren = await Promise.allSettled(childWraps); for (const result of [...settledChildren].reverse()) { - if (result.status === "fulfilled") { + if (result.status === "rejected") { + childFailures.push(result.reason); + continue; + } + try { await result.value[Symbol.asyncDispose](); + } catch (error) { + childFailures.push(error); } } await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo }); await state.lifecycleEmitter.emitSerial("afterTestFinalize", { page, testInfo }); state.lifecycleCleanups.forEach((cleanup) => cleanup()); - const failedChildWrap = settledChildren.find((result) => result.status === "rejected"); - if (failedChildWrap) { - throw failedChildWrap.reason; + if (childFailures.length > 0) { + throw childFailures[0]; } }; From 444a8d9cbfae5ab1a9ef763e4f91cc2d8da3ead6 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:42:23 +0100 Subject: [PATCH 2/5] Pace popup entry so consumers don't need waitForTimeout Removing the demo's waitForTimeout calls (antithetical to this repo's goal) broke the render: instant flows act before the popup screencast's first captured frame, so synthetic fill annotations floated over a popup that wasn't visible yet, and action holds froze successive mid-slide frames - replaying the enter animation in slow motion. Three-part fix, all library-side: - Entry pacing: the child recorder holds the popup's first action until the popup fires load and the enter animation window (~450ms) has real footage. Video mode already paces deliberately at close (recorder settling); this is the entry-side counterpart. Real auth popups load slower than this, so it usually costs nothing. - Dead air recorded during an animation window is carved out before segment planning, so compression can't fast-forward a slide. - Render-time backstop: a projected child highlight starting mid-slide defers past the enter window, so holds can't freeze mid-slide frames even when pacing was bypassed. The demo and overlay specs now run with zero waitForTimeout calls. Co-Authored-By: Claude Fable 5 --- spec/popup-overlay-demo.spec.ts | 6 --- spec/popup-video.spec.ts | 7 +-- src/plugins/video-mode.ts | 93 ++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/spec/popup-overlay-demo.spec.ts b/spec/popup-overlay-demo.spec.ts index c386301..8625dfc 100644 --- a/spec/popup-overlay-demo.spec.ts +++ b/spec/popup-overlay-demo.spec.ts @@ -17,16 +17,11 @@ test("auth popup demo", async ({ page: basePage, context }, testInfo) => { const popupPromise = basePage.waitForEvent("popup"); await test.step("Open the sign-in popup", async () => { await page.goto("https://app.middlewright.test/"); - // Real frames on each side of the popup span keep the composite honest - // (and the demo watchable) — an instant flow would land before the - // screencast's first frame. - await page.waitForTimeout(500); await page.getByRole("button", { name: "Sign in" }).click(); }); const popup = await popupPromise; await test.step("Sign in as mmkal", async () => { - await popup.waitForTimeout(500); await popup.getByLabel("Username").fill("mmkal"); await popup.getByLabel("Password").fill("hunter2"); await popup.getByRole("button", { name: "Sign in" }).click(); @@ -34,7 +29,6 @@ test("auth popup demo", async ({ page: basePage, context }, testInfo) => { await test.step("Back on the app, signed in", async () => { await page.getByText("Signed in as mmkal").waitFor(); - await page.waitForTimeout(500); }); } diff --git a/spec/popup-video.spec.ts b/spec/popup-video.spec.ts index c40fbc6..ba6b74b 100644 --- a/spec/popup-video.spec.ts +++ b/spec/popup-video.spec.ts @@ -56,17 +56,14 @@ test("renders the popup as a dimmed overlay in one composed video", async ({ { await using page = await addPlugins({ page: basePage, testInfo, plugins: [video] }); await page.goto("https://app.middlewright.test/"); - // Let the screencast capture real frames on each side of the popup span — - // an instant flow lands entirely before the recorder's first frame. - await page.waitForTimeout(500); + // No pacing waits: videoMode holds the popup's first action itself until + // the popup has painted and the enter animation window has real footage. const popupPromise = basePage.waitForEvent("popup"); await page.getByRole("button", { name: "Sign in" }).click(); const popup = await popupPromise; - await popup.waitForTimeout(500); await popup.getByRole("button", { name: "Approve" }).click(); await page.getByText("Signed in as mmkal").waitFor(); - await page.waitForTimeout(500); } await expect(video.metadata()).resolves.toMatchObject({ diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts index cee6302..0c5e0ce 100644 --- a/src/plugins/video-mode.ts +++ b/src/plugins/video-mode.ts @@ -1703,6 +1703,32 @@ const mergeVideoSpans = (spans: VideoModeSpan[]) => { return merged; }; +/** + * Remove `holes` from `spans`. Used to carve popup enter/exit animations out + * of dead air, so compression can't fast-forward them. + */ +const subtractVideoSpans = (spans: VideoModeSpan[], holes: VideoModeSpan[]): VideoModeSpan[] => { + let result = spans; + + for (const hole of holes) { + result = result.flatMap((span) => { + if (hole.end <= span.start || hole.start >= span.end) { + return [span]; + } + const remainder: VideoModeSpan[] = []; + if (span.start < hole.start) { + remainder.push({ end: hole.start, start: span.start }); + } + if (hole.end < span.end) { + remainder.push({ end: span.end, start: hole.end }); + } + return remainder; + }); + } + + return result; +}; + const normalizeVideoHighlights = (highlights: VideoModeHighlight[]) => { return highlights .map((highlight) => ({ @@ -4357,6 +4383,14 @@ const RECORDER_FINAL_FRAME_MIN_PADDING_MS = 1000; const CHILD_OVERLAY_MAX_FRACTION = 0.9; const CHILD_OVERLAY_BACKDROP_OPACITY = 0.4; const CHILD_OVERLAY_FADE_MS = 300; +// The first action on a popup holds until this much wall-clock has passed +// since it opened (after its load event): the screencast needs painted frames +// for the enter animation to slide in, and an action landing mid-animation +// would freeze mid-slide frames into its hold — slow-motion in the render. +// Real auth popups take longer than this to load, so it rarely adds time. +const CHILD_ENTRY_PACING_MS = CHILD_OVERLAY_FADE_MS + 150; +// Bounds the load wait so a popup that never fires load can't hang the test. +const CHILD_ENTRY_LOAD_TIMEOUT_MS = 5000; /** A popup screencast placed on the parent timeline as a scaled overlay. */ type VideoModeChildLayer = { @@ -4561,6 +4595,22 @@ const projectChildHighlight = (options: { const closeFloorMs = Math.floor(layer.closeMs); const minSliceMs = options.video.frameDurationMs + 10; let { actionEnd, end, start } = highlight; + // Entry pacing keeps actions clear of the enter animation at runtime; this + // is the degraded-mode backstop (paused pacing, custom flows): a highlight + // starting mid-slide defers past the animation so its hold can't freeze a + // mid-slide frame — clamped so instant popups keep a viable slice. + const enterEndMs = layer.enableFromMs + CHILD_OVERLAY_FADE_MS; + if (start < enterEndMs) { + const deferMs = Math.min( + enterEndMs - start, + Math.max(0, closeFloorMs - minSliceMs - start), + ); + start += deferMs; + end += deferMs; + if (actionEnd !== undefined) { + actionEnd += deferMs; + } + } if (actionEnd !== undefined) { actionEnd = Math.min(actionEnd, closeFloorMs); if (actionEnd - start < minSliceMs) { @@ -4896,17 +4946,43 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => { startedAt: state.startedAt, }; + // Video mode paces the run deliberately where watchable output needs it + // (recorder settling does the same at close). Entry pacing runs before + // the first popup action: without it, an instant flow acts before the + // popup screencast's first captured frame — synthetic annotations float + // over a popup that isn't visible yet, and action holds freeze mid-slide + // frames, replaying the enter animation in slow motion. + let entryPacing: Promise | undefined; + const paceEntry = () => { + if (!entryPacing) { + entryPacing = (async () => { + // timeout bounds video-mode's internal pacing; spinner-waiter has no role inside the plugin + await popupPage.waitForLoadState("load", { timeout: CHILD_ENTRY_LOAD_TIMEOUT_MS }).catch(() => {}); + const sinceOpenMs = getVideoTimestamp() - child.openedAt; + const remainingMs = CHILD_ENTRY_PACING_MS - sinceOpenMs; + if (remainingMs > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingMs)); + } + })(); + } + return entryPacing; + }; + const childActionMiddleware = videoModeActionMiddleware({ + deadAirState: state, + highlight, + recordingState: childRecordingState, + skipMethods, + skipStackFrames, + }); + return { // Named video-mode so middleware wait-timing lookups match. name: "video-mode", forPopup: ({ page: grandchildPage }) => createPopupRecorder(grandchildPage), - middleware: videoModeActionMiddleware({ - deadAirState: state, - highlight, - recordingState: childRecordingState, - skipMethods, - skipStackFrames, - }), + middleware: async (ctx, next) => { + await paceEntry(); + return childActionMiddleware(ctx, next); + }, testLifecycle: (emitter) => { const onClose = () => { if (child.closedAt === undefined) { @@ -5287,6 +5363,9 @@ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => { { end: layer.enableToMs, start: layer.closeMs }, ); } + // Dead air recorded during an animation window (e.g. a child + // waitFor spanning the slide) must not compress it away. + renderTimeline.deadAir = subtractVideoSpans(renderTimeline.deadAir, renderKeepSpans); // A parent action right after a popup closes (waiting for the // signed-in state, say) would hold a freeze frame from inside the // overlay's exit animation — a ghost popup flashing back after it From d162baf3c6946ea5dae9b3b495367fe8a29b1c61 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:42:23 +0100 Subject: [PATCH 3/5] Lint: bare waitForTimeout sleeps need justification page.waitForTimeout joins the require-timeout-comment rule: a sleep waits whether or not the app is ready, so it needs the same nearby justification comment as an explicit timeout. New allowSleeps option exempts the video-mode footage specs via .oxlintrc overrides - there the sleeps ARE the test input (they shape the recorded timeline under test) and annotating every one would be noise. The timeout-option check stays active in those files. Co-Authored-By: Claude Fable 5 --- .oxlintrc.json | 14 ++++++++- spec/debug-mode.spec.ts | 1 + spec/lint-plugin.spec.ts | 63 ++++++++++++++++++++++++++++++++++++++++ src/lint/plugin.ts | 46 ++++++++++++++++++++++++++++- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index a886e02..094d95d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,5 +5,17 @@ "middlewright/prefer-locator-waits": "error", "middlewright/prefer-positive-waits": "error", "middlewright/require-timeout-comment": "error" - } + }, + "overrides": [ + { + "files": [ + "spec/video-mode.spec.ts", + "spec/video-mode-*.spec.ts", + "spec/scroll-pan-demo.spec.ts" + ], + "rules": { + "middlewright/require-timeout-comment": ["error", { "allowSleeps": true }] + } + } + ] } diff --git a/spec/debug-mode.spec.ts b/spec/debug-mode.spec.ts index 2becea6..e47662f 100644 --- a/spec/debug-mode.spec.ts +++ b/spec/debug-mode.spec.ts @@ -63,6 +63,7 @@ test("videoMode controls are inert when PWDEBUG is set", async ({ page: basePage page.videoMode.setStartTime(); await page.videoMode.deadAir(async () => { + // timeout sleep gives deadAir a nonzero span to (not) record in debug mode; spinner-waiter n/a await page.waitForTimeout(20); }); await page.getByRole("button", { name: "press" }).click(); diff --git a/spec/lint-plugin.spec.ts b/spec/lint-plugin.spec.ts index b5ee757..ed3bb52 100644 --- a/spec/lint-plugin.spec.ts +++ b/spec/lint-plugin.spec.ts @@ -432,6 +432,69 @@ test("reports static timeout properties without guessing dynamic shapes", async expect(await readFile(fixture.sourcePath, "utf8")).toBe(source); }); +test("reports bare waitForTimeout sleeps", async () => { + const source = `await page.waitForTimeout(500);\n`; + await using fixture = await lintFixture(source, requireTimeoutCommentRules); + + const result = await execFileAsync("pnpm", [ + "exec", + "oxlint", + "--config", + fixture.configPath, + fixture.sourcePath, + ]).catch((error: any) => error); + + expect(result).toMatchObject({ code: 1 }); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("middlewright(require-timeout-comment)"); + expect(output).toContain("a sleep waits whether or not the app is ready"); + expect(await readFile(fixture.sourcePath, "utf8")).toBe(source); +}); + +test("allows justified waitForTimeout sleeps", async () => { + const source = [ + `// timeout sleep paces footage for the render under test; spinner-waiter n/a`, + `await page.waitForTimeout(500);`, + ``, + ].join("\n"); + await using fixture = await lintFixture(source, requireTimeoutCommentRules); + + await execFileAsync("pnpm", [ + "exec", + "oxlint", + "--config", + fixture.configPath, + fixture.sourcePath, + ]); + + expect(await readFile(fixture.sourcePath, "utf8")).toBe(source); +}); + +test("allowSleeps exempts footage specs from the sleep check but not from timeout options", async () => { + const source = [ + `await page.waitForTimeout(500);`, + `await page.getByText("Export").click({ timeout: 10_000 });`, + ``, + ].join("\n"); + await using fixture = await lintFixture(source, { + "middlewright/require-timeout-comment": ["error", { allowSleeps: true }], + }); + + const result = await execFileAsync("pnpm", [ + "exec", + "oxlint", + "--config", + fixture.configPath, + fixture.sourcePath, + ]).catch((error: any) => error); + + expect(result).toMatchObject({ code: 1 }); + expect( + `${result.stdout}\n${result.stderr}`.match(/middlewright\(require-timeout-comment\)/g), + ).toHaveLength(1); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("a sleep waits"); +}); + async function lintFixture(source: string, rules: Record) { const directory = await mkdtemp(join(tmpdir(), "middlewright-lint-")); const sourcePath = join(directory, "fixture.ts"); diff --git a/src/lint/plugin.ts b/src/lint/plugin.ts index 7f8f862..fb800c8 100644 --- a/src/lint/plugin.ts +++ b/src/lint/plugin.ts @@ -66,13 +66,34 @@ const preferLocatorWaits = { }, }; +const requireTimeoutCommentSchema = [ + { + type: "object", + properties: { + requiredPatterns: { + type: "array", + items: { type: "string" }, + minItems: 1, + }, + /** + * Allow bare waitForTimeout sleeps. For spec files whose subject IS the + * recorded footage (video-mode renders), sleeps are the test input — + * annotating every one would be noise. Everywhere else they need the + * same justification as explicit timeouts. + */ + allowSleeps: { type: "boolean" }, + }, + additionalProperties: false, + }, +]; + const requireTimeoutComment = { meta: { type: "suggestion", docs: { description: "Require explicit timeout options to explain why the timeout is needed", }, - schema: requiredPatternsSchema, + schema: requireTimeoutCommentSchema, messages: { unexplained: dedent` Avoid locator timeouts by using spinnerWaiter. Best ways to resolve: @@ -83,6 +104,14 @@ const requireTimeoutComment = { - If it is truly impossible for there to be loading UI, add a nearby // comment matching every required pattern: {{patterns}}. - If you're in a block which has done \`await spinnerWaiter.settings.run({ disabled: true }, async () => ...)\`, you should probably *un-disable* for that block and apply the above suggestions to the inner code. + See https://github.com/iterate/middlewright#dont-fix-slow-tests-with-longer-timeouts for more details. + `, + sleep: dedent` + Avoid waitForTimeout — a sleep waits whether or not the app is ready. Best ways to resolve: + - Wait for positive UI instead: a locator wait covers readiness, and spinnerWaiter extends it while loading UI shows. + - If the sleep paces a recording, let video mode pace instead (it holds popup entry and settles the recorder itself); still-needed manual pacing is a library gap worth filing. + - If it is truly necessary, add a nearby // comment matching every required pattern: {{patterns}}. + See https://github.com/iterate/middlewright#dont-fix-slow-tests-with-longer-timeouts for more details. `, }, @@ -97,11 +126,26 @@ const requireTimeoutComment = { "spinner.?waiter", ]; const requiredPatterns = requiredPatternSources.map((source: string) => new RegExp(source, "i")); + const allowSleeps = context.options[0]?.allowSleeps === true; return { CallExpression(node: any) { if (node.callee.type !== "MemberExpression") return; + if ( + !allowSleeps && + !node.callee.computed && + node.callee.property.type === "Identifier" && + node.callee.property.name === "waitForTimeout" && + !hasNearbyComment(node, node.callee.property, lineComments, requiredPatterns, sourceLines) + ) { + context.report({ + node: node.callee.property, + messageId: "sleep", + data: { patterns: requiredPatternSources.join(", ") }, + }); + } + for (const argument of node.arguments) { if (argument.type !== "ObjectExpression") continue; From 643582254d84ad23dfefe2992cfe79871c945719 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:45:51 +0100 Subject: [PATCH 4/5] Lint spec fixtures load the plugin from src via tsx The fixture config pointed at the package's ./lint-plugin export, which resolves to dist - so rule changes silently no-oped in the spec until a manual pnpm build. Point it at lint-plugin.local.js through the node_modules symlink instead (a path bypasses the exports map): the plugin loads from ./src via tsx exactly like the repo's own config. Verified by running the spec with dist renamed away. Co-Authored-By: Claude Fable 5 --- spec/lint-plugin.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/lint-plugin.spec.ts b/spec/lint-plugin.spec.ts index ed3bb52..d46063a 100644 --- a/spec/lint-plugin.spec.ts +++ b/spec/lint-plugin.spec.ts @@ -510,7 +510,9 @@ async function lintFixture(source: string, rules: Record) { await writeFile( configPath, JSON.stringify({ - jsPlugins: ["middlewright/lint-plugin"], + // A path (not the package's ./lint-plugin export) so the plugin loads + // from ./src via tsx, like the repo's own config — no build required. + jsPlugins: ["./node_modules/middlewright/lint-plugin.local.js"], rules, }), ); From 6776d21d21700dbc456fa34c05f60e661a60c166 Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:36:55 +0100 Subject: [PATCH 5/5] Base fill reveals on the pre-action screenshot, not sparse footage The popup screencast emits frames only on change, so the fill reveal's frozen composite base could lag the logical state: the password fill's base frame usually predated any captured frame containing the username value, blanking 'mmkal' for the password reveal's whole hold before live footage brought it back. The pre-action screenshot has the exact right state (earlier values present, this field empty), so it now covers the whole overlay box on the frozen base; footage supplies only the backdrop dim and the page behind. The ring and typed-reveal overlays sit on top as before. Co-Authored-By: Claude Fable 5 --- src/plugins/video-mode.ts | 43 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/plugins/video-mode.ts b/src/plugins/video-mode.ts index 0c5e0ce..e1aee6d 100644 --- a/src/plugins/video-mode.ts +++ b/src/plugins/video-mode.ts @@ -3322,9 +3322,29 @@ const renderedVideoFilter = (options: { let composedLabel = baseLabel; if (preFillInput) { + // The popup screencast is sparse (static pages emit frames only on + // change), so the frozen base can lag the logical state — a previous + // fill's value may not have reached any captured frame yet, blanking + // it for this piece's whole hold. The pre-action screenshot has the + // exact right state (earlier values present, this field empty, + // unfocused): scale it over the whole overlay box, leaving footage to + // supply only the backdrop dim and the page behind. + const screenshotLabel = `fillshot${index}`; + filters.push( + [ + `[${preFillInput.inputIndex}:v]scale=w=${scaledImage.width}:h=${scaledImage.height}`, + `trim=start=0:end=${durationSeconds}`, + `setpts=PTS-STARTPTS[${screenshotLabel}]`, + ].join(","), + ); + filters.push( + [ + `[${composedLabel}][${screenshotLabel}]overlay=x=${transform.x}`, + `y=${transform.y}`, + `shortest=1[fillshotcomposed${index}]`, + ].join(":"), + ); const ringLabel = `fillring${index}`; - const emptyLabel = `fillempty${index}`; - const revealStartEnable = `enable='gte(t\\,${formatSeconds(revealStart)})'`; filters.push( [ `[${postFillInput.inputIndex}:v]scale=w=${scaledImage.width}:h=${scaledImage.height}`, @@ -3333,6 +3353,17 @@ const renderedVideoFilter = (options: { `setpts=PTS-STARTPTS[${ringLabel}]`, ].join(","), ); + filters.push( + [ + `[fillshotcomposed${index}][${ringLabel}]overlay=x=${ringAbsolute.x}`, + `y=${ringAbsolute.y}`, + `enable='gte(t\\,${formatSeconds(revealStart)})'`, + `shortest=1[fillringcomposed${index}]`, + ].join(":"), + ); + // The ring crop carries the field's filled text with it — cover the + // content box back to the pre-fill empty state so the bands reveal it. + const emptyLabel = `fillempty${index}`; filters.push( [ `[${preFillInput.inputIndex}:v]scale=w=${scaledImage.width}:h=${scaledImage.height}`, @@ -3341,14 +3372,6 @@ const renderedVideoFilter = (options: { `setpts=PTS-STARTPTS[${emptyLabel}]`, ].join(","), ); - filters.push( - [ - `[${composedLabel}][${ringLabel}]overlay=x=${ringAbsolute.x}`, - `y=${ringAbsolute.y}`, - revealStartEnable, - `shortest=1[fillringcomposed${index}]`, - ].join(":"), - ); filters.push( [ `[fillringcomposed${index}][${emptyLabel}]overlay=x=${contentAbsolute.x}`,