();
+
+const videoModeOutputPaths = (
+ testInfo: TestInfo,
+ artifactSuffix: string,
+): VideoModeOutputPaths => {
+ const name = (fileName: string) => suffixArtifactFileName(fileName, artifactSuffix);
return {
- metadata: join(testInfo.outputDir, VIDEO_MODE_METADATA_FILE),
- player: join(testInfo.outputDir, VIDEO_MODE_PLAYER_FILE),
- raw: join(testInfo.outputDir, VIDEO_MODE_RAW_FILE),
- rendered: join(testInfo.outputDir, VIDEO_MODE_RENDERED_FILE),
- reportPlayer: join(testInfo.outputDir, VIDEO_MODE_REPORT_PLAYER_FILE),
+ metadata: join(testInfo.outputDir, name(VIDEO_MODE_METADATA_FILE)),
+ player: join(testInfo.outputDir, name(VIDEO_MODE_PLAYER_FILE)),
+ raw: join(testInfo.outputDir, name(VIDEO_MODE_RAW_FILE)),
+ rendered: join(testInfo.outputDir, name(VIDEO_MODE_RENDERED_FILE)),
+ reportPlayer: join(testInfo.outputDir, name(VIDEO_MODE_REPORT_PLAYER_FILE)),
};
};
@@ -1258,8 +1322,8 @@ const recordHighlight = async (options: {
: undefined;
const image = pan
- ? `video-mode-pan-${options.state.highlightImageIndex}.png`
- : `video-mode-highlight-${options.state.highlightImageIndex}.png`;
+ ? `video-mode-pan${options.state.artifactSuffix}-${options.state.highlightImageIndex}.png`
+ : `video-mode-highlight${options.state.artifactSuffix}-${options.state.highlightImageIndex}.png`;
options.state.highlightImageIndex += 1;
const imagePath = join(options.testInfo.outputDir, image);
await mkdir(options.testInfo.outputDir, { recursive: true });
@@ -1402,8 +1466,7 @@ const recordFillReveal = async (options: {
value.length === 0 ||
value.length > captureOptions.maxCharacters ||
style.direction === "rtl" ||
- !["left", "start"].includes(style.textAlign) ||
- (element instanceof HTMLInputElement && element.type === "password")
+ !["left", "start"].includes(style.textAlign)
) {
return { ...geometry, kind: "fallback" as const };
}
@@ -1510,10 +1573,16 @@ const recordFillReveal = async (options: {
return { ...geometry, kind: "fallback" as const };
}
context.font = style.font;
- const graphemes = Array.from(
- new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value),
- ({ segment }) => segment,
- );
+ // A password input renders one bullet per character, so measure those
+ // glyphs — the reveal only ever shows the screenshot's dots, never the
+ // value.
+ const masked = element instanceof HTMLInputElement && element.type === "password";
+ const graphemes = masked
+ ? Array.from(value, () => "•")
+ : Array.from(
+ new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value),
+ ({ segment }) => segment,
+ );
const letterSpacing = pixels(style.letterSpacing);
const textIndent = pixels(style.textIndent);
const revealStops = graphemes.map((_, index) => {
@@ -1546,7 +1615,7 @@ const recordFillReveal = async (options: {
return;
}
- const image = `video-mode-fill-${options.state.highlightImageIndex}.png`;
+ const image = `video-mode-fill${options.state.artifactSuffix}-${options.state.highlightImageIndex}.png`;
options.state.highlightImageIndex += 1;
await mkdir(options.testInfo.outputDir, { recursive: true });
await options.locator.page().screenshot({
@@ -2262,6 +2331,12 @@ const videoPieces = (options: {
addressBars: VideoModeAddressBar[];
frameDurationMs: number;
highlights: VideoModeHighlight[];
+ /**
+ * Source spans that must reach the output (popup enter/exit animations).
+ * Overlapping-hold skips normally jump the footage between two highlights;
+ * a skip is cancelled when it would leap across one of these.
+ */
+ keepSpans: VideoModeSpan[];
preActionStabilizationMs: number;
segments: RenderVideoSegment[];
}): VideoPiece[] => {
@@ -2309,8 +2384,10 @@ const videoPieces = (options: {
const nextHighlight = highlights[highlightIndex + 1];
if (highlight.start > cursor) {
- const postAction = previousHighlight?.fillReveal ? previousHighlight : undefined;
- const preAction = highlight.fillReveal ? highlight : undefined;
+ const stabilizable = (candidate: VideoModeHighlight | undefined) =>
+ candidate?.fillReveal && !candidate.overlayTransform ? candidate : undefined;
+ const postAction = stabilizable(previousHighlight);
+ const preAction = stabilizable(highlight);
// `trim` chooses whole source frames. Round down so the boundary frame
// belongs to the stabilized piece instead of leaking from the raw gap.
const preActionStart = preAction
@@ -2364,7 +2441,13 @@ const videoPieces = (options: {
let nextCursor = actionEnd;
if (nextHighlight && highlight.end > nextHighlight.start) {
- nextCursor = Math.max(nextCursor, nextHighlight.start);
+ const skipTo = Math.max(nextCursor, nextHighlight.start);
+ const skipCrossesKeptSpan = options.keepSpans.some(
+ (span) => span.start < skipTo && span.end > nextCursor,
+ );
+ if (!skipCrossesKeptSpan) {
+ nextCursor = skipTo;
+ }
}
cursor = Math.min(segment.end, nextCursor);
@@ -2374,7 +2457,10 @@ const videoPieces = (options: {
if (segment.end > cursor) {
pieces.push({
end: segment.end,
- postAction: previousHighlight?.fillReveal ? previousHighlight : undefined,
+ postAction:
+ previousHighlight?.fillReveal && !previousHighlight.overlayTransform
+ ? previousHighlight
+ : undefined,
speed: segment.speed,
start: cursor,
});
@@ -2603,8 +2689,20 @@ const highlightCursorPoint = (
highlight: VideoModeHighlight,
video: { width: number; height: number },
) => {
- const rect = highlight.fillReveal
- ? scaleVideoModeRect(highlight.fillReveal.initialRect, highlight.viewport, video)
+ // fillReveal rects live in child-frame coordinates on projected popup
+ // highlights — map them through the overlay transform first.
+ const transform = highlight.overlayTransform;
+ const projectedInitialRect =
+ highlight.fillReveal && transform
+ ? {
+ height: highlight.fillReveal.initialRect.height * transform.scale,
+ width: highlight.fillReveal.initialRect.width * transform.scale,
+ x: transform.x + highlight.fillReveal.initialRect.x * transform.scale,
+ y: transform.y + highlight.fillReveal.initialRect.y * transform.scale,
+ }
+ : highlight.fillReveal?.initialRect;
+ const rect = projectedInitialRect
+ ? scaleVideoModeRect(projectedInitialRect, highlight.viewport, video)
: scaleHighlight(highlight, video);
return {
@@ -2906,6 +3004,7 @@ const renderedVideoFilter = (options: {
highlightMode: "outline" | "pointer";
highlightInputs: HighlightInput[];
highlights: VideoModeHighlight[];
+ keepSpans: VideoModeSpan[];
preActionStabilizationMs: number;
segments: RenderVideoSegment[];
textPointerInput?: PointerInput;
@@ -2918,10 +3017,23 @@ const renderedVideoFilter = (options: {
addressBars: options.addressBars,
frameDurationMs: options.video.frameDurationMs,
highlights: options.highlights,
+ keepSpans: options.keepSpans,
preActionStabilizationMs: options.preActionStabilizationMs,
segments: options.segments,
});
const renderedPieces = renderedVideoPieces(pieces);
+ if (process.env.MIDDLEWRIGHT_DEBUG_PIECES) {
+ for (const piece of renderedPieces) {
+ console.log(
+ `piece src[${piece.start}-${piece.end}] out[${Math.round(piece.outputStart)}-${Math.round(piece.outputEnd)}] speed=${piece.speed}` +
+ (piece.highlight ? ` highlight=${piece.highlight.method} hstart=${piece.highlight.start}` : "") +
+ (piece.addressBar ? " addressBar" : "") +
+ (piece.highlight?.overlayTransform ? " overlay" : "") +
+ (piece.highlight?.fillReveal ? " fillReveal" : "") +
+ (piece.highlight?.image ? ` image=${piece.highlight.image}` : ""),
+ );
+ }
+ }
const targets = cursorTargets({
highlights: options.highlights,
pieces: renderedPieces,
@@ -3073,6 +3185,188 @@ const renderedVideoFilter = (options: {
continue;
}
+ // Fill reveal inside a popup overlay: the base is a frozen composite
+ // frame from just before the fill (popup risen, field empty, dim and
+ // parent intact), and the typed reveal is the child screenshot's content
+ // rect scaled and positioned through the overlay transform.
+ if (piece.highlight && fillReveal && postFillInput && piece.highlight.overlayTransform) {
+ const transform = piece.highlight.overlayTransform;
+ const projectLength = (value: number) => Math.round(value * transform.scale);
+ const scaledImage = {
+ height: Math.max(2, projectLength(transform.viewport.height)),
+ width: Math.max(2, projectLength(transform.viewport.width)),
+ };
+ const contentLocal = {
+ height: Math.max(1, Math.min(scaledImage.height, projectLength(fillReveal.contentRect.height))),
+ width: Math.max(1, Math.min(scaledImage.width, projectLength(fillReveal.contentRect.width))),
+ x: Math.max(0, projectLength(fillReveal.contentRect.x)),
+ y: Math.max(0, projectLength(fillReveal.contentRect.y)),
+ };
+ const contentAbsolute = {
+ x: Math.round(transform.x + fillReveal.contentRect.x * transform.scale),
+ y: Math.round(transform.y + fillReveal.contentRect.y * transform.scale),
+ };
+ const duration = renderedPieceDuration(piece);
+ const durationSeconds = formatSeconds(duration);
+ const revealStops = fillReveal.revealStops
+ .map((stop) => Math.max(1, Math.min(contentLocal.width, projectLength(stop))))
+ .filter((stop, stopIndex, stops) => stopIndex === 0 || stop !== stops[stopIndex - 1]);
+ const revealSteps = fillReveal.revealBands.flatMap((band) => {
+ const y = Math.max(0, Math.min(contentLocal.height - 1, projectLength(band.y)));
+ const height = Math.max(1, Math.min(contentLocal.height - y, projectLength(band.height)));
+ return revealStops.map((width) => ({ height, width, y }));
+ });
+ const target = plan.targets.find(
+ (candidate) => candidate.highlight === piece.highlight,
+ );
+ const revealEnd =
+ options.highlightMode === "pointer"
+ ? Math.max(0, duration - TEXT_CURSOR_POINTER_TAIL_MS)
+ : duration;
+ const pointerArrival = target
+ ? Math.max(0, target.arriveAt - renderedPiece.outputStart)
+ : 0;
+ const availableAfterArrival = Math.max(0, revealEnd - pointerArrival);
+ const preRevealHold = Math.min(
+ TEXT_CURSOR_HOLD_IDEAL_MS,
+ availableAfterArrival / 2,
+ );
+ const revealStart = Math.max(
+ 0,
+ Math.min(revealEnd, pointerArrival + preRevealHold),
+ );
+ // The base predates the fill, so it shows the field unfocused. The
+ // post-fill screenshot has the focus ring: overlay the field's ring
+ // region from it at reveal start, immediately cover its text with the
+ // pre-fill screenshot's empty content box, and let the reveal bands
+ // type over that — the ring appears when the cursor lands and the
+ // letters arrive inside it, continuous with the live footage after.
+ const ringPaddingPx = 4;
+ const ringSource = {
+ height: fillReveal.initialRect.height + 2 * ringPaddingPx,
+ width: fillReveal.initialRect.width + 2 * ringPaddingPx,
+ x: fillReveal.initialRect.x - ringPaddingPx,
+ y: fillReveal.initialRect.y - ringPaddingPx,
+ };
+ const ringLocal = {
+ height: Math.max(1, Math.min(scaledImage.height, projectLength(ringSource.height))),
+ width: Math.max(1, Math.min(scaledImage.width, projectLength(ringSource.width))),
+ x: Math.max(0, projectLength(ringSource.x)),
+ y: Math.max(0, projectLength(ringSource.y)),
+ };
+ const ringAbsolute = {
+ x: Math.round(transform.x + ringSource.x * transform.scale),
+ y: Math.round(transform.y + ringSource.y * transform.scale),
+ };
+ const baseLabel = `fillbase${index}`;
+ // One frame back only: rewinding further can cross the previous fill's
+ // completion (wiping its value from the frozen base). The content box
+ // is covered with the pre-fill empty state from t=0 below, so anchor
+ // imprecision inside this field can't leak early-typed text either.
+ const freezeStart = Math.max(0, piece.start - options.video.frameDurationMs);
+ filters.push(
+ [
+ `[0:v]trim=start=${formatSeconds(freezeStart)}:end=${formatSeconds(
+ freezeStart + options.video.frameDurationMs,
+ )}`,
+ "setpts=PTS-STARTPTS",
+ `tpad=stop_mode=clone:stop_duration=${formatSeconds(
+ Math.max(0, duration - options.video.frameDurationMs),
+ )}`,
+ `trim=start=0:end=${durationSeconds}`,
+ `setpts=PTS-STARTPTS[${baseLabel}]`,
+ ].join(","),
+ );
+
+ if (revealSteps.length === 0) {
+ filters.push(`[${baseLabel}]null[${label}]`);
+ continue;
+ }
+
+ const splitLabels = revealSteps.map((_, stepIndex) => `fillpost${index}x${stepIndex}`);
+ filters.push(
+ [
+ `[${postFillInput.inputIndex}:v]scale=w=${scaledImage.width}:h=${scaledImage.height}`,
+ `crop=w=${contentLocal.width}:h=${contentLocal.height}:x=${contentLocal.x}:y=${contentLocal.y}`,
+ `trim=start=0:end=${durationSeconds}`,
+ "setpts=PTS-STARTPTS",
+ `split=${revealSteps.length}${splitLabels.map((splitLabel) => `[${splitLabel}]`).join("")}`,
+ ].join(","),
+ );
+
+ let composedLabel = baseLabel;
+ if (preFillInput) {
+ 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}`,
+ `crop=w=${ringLocal.width}:h=${ringLocal.height}:x=${ringLocal.x}:y=${ringLocal.y}`,
+ `trim=start=0:end=${durationSeconds}`,
+ `setpts=PTS-STARTPTS[${ringLabel}]`,
+ ].join(","),
+ );
+ filters.push(
+ [
+ `[${preFillInput.inputIndex}:v]scale=w=${scaledImage.width}:h=${scaledImage.height}`,
+ `crop=w=${contentLocal.width}:h=${contentLocal.height}:x=${contentLocal.x}:y=${contentLocal.y}`,
+ `trim=start=0:end=${durationSeconds}`,
+ `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}`,
+ `y=${contentAbsolute.y}`,
+ `shortest=1[fillemptycomposed${index}]`,
+ ].join(":"),
+ );
+ composedLabel = `fillemptycomposed${index}`;
+ }
+ for (let stepIndex = 0; stepIndex < revealSteps.length; stepIndex += 1) {
+ const step = revealSteps[stepIndex];
+ const cropLabel = `fillcrop${index}x${stepIndex}`;
+ const nextLabel = `fillcomposed${index}x${stepIndex}`;
+ const showAt =
+ revealStart +
+ ((revealEnd - revealStart) * (stepIndex + 1)) /
+ (revealSteps.length + 1);
+ filters.push(
+ `${[
+ `[${splitLabels[stepIndex]}]crop=w=${step.width}`,
+ `h=${step.height}`,
+ "x=0",
+ `y=${step.y}`,
+ ].join(":")}[${cropLabel}]`,
+ );
+ filters.push(
+ [
+ `[${composedLabel}][${cropLabel}]overlay=x=${contentAbsolute.x}`,
+ `y=${contentAbsolute.y + step.y}`,
+ `enable='gte(t\\,${formatSeconds(showAt)})'`,
+ `shortest=1[${nextLabel}]`,
+ ].join(":"),
+ );
+ composedLabel = nextLabel;
+ }
+
+ filters.push(
+ options.highlightMode === "outline"
+ ? `[${composedLabel}]${drawboxFilter(piece.highlight, options.video)}[${label}]`
+ : `[${composedLabel}]null[${label}]`,
+ );
+ continue;
+ }
+
if (piece.highlight && fillReveal && preFillInput && postFillInput) {
const scaledViewport = scaledViewportSize(piece.highlight.viewport, options.video);
const contentRect = scaleVideoModeRect(
@@ -3580,7 +3874,7 @@ const playwrightReportAttachmentName = async (path: string) => {
return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`;
};
-const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
+const videoModePlayerHtml = (options: { metadata: string; raw: string; rendered?: string }) => {
const primary = options.rendered || options.raw;
const primaryLabel = options.rendered ? "Rendered video" : "Raw video";
const primaryActiveKey = options.rendered ? "rendered" : "raw";
@@ -3724,7 +4018,7 @@ const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
frame: 0
duration: ?s
Left/right steps one frame. Shift+left/right steps ten. Space toggles play.
-
+