Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions apps/extension/src/entrypoints/long-screenshot-page.content.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { createPageCapture, pageError } from "@/long-screenshot/page";
import { LONG_SCREENSHOT, LONG_SCREENSHOT_PAGE, type PageRequest } from "@/long-screenshot/types";
import {
LONG_SCREENSHOT,
LONG_SCREENSHOT_PAGE,
type PageRequest,
ScreenshotError,
} from "@/long-screenshot/types";

export default defineContentScript({
matches: ["http://*/*", "https://*/*"],
runAt: "document_end",
allFrames: false,
main(ctx) {
const capture = createPageCapture((id) => {
const capture = createPageCapture((id, reason) => {
void chrome.runtime
.sendMessage({ type: LONG_SCREENSHOT, action: "cancel", id })
.sendMessage({ type: LONG_SCREENSHOT, action: "cancel", id, reason })
.catch(() => {});
});
const listener = (
Expand All @@ -21,7 +26,12 @@ export default defineContentScript({
if (request.type !== LONG_SCREENSHOT_PAGE || typeof request.id !== "string") return;
void capture.handle(request).then(
(metrics) => respond({ ok: true, metrics }),
(error) => respond({ ok: false, error: pageError(error) }),
(error) =>
respond({
ok: false,
error: pageError(error),
...(error instanceof ScreenshotError && error.reason ? { reason: error.reason } : {}),
}),
);
return true;
};
Expand Down
27 changes: 27 additions & 0 deletions apps/extension/src/long-screenshot/agent.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,33 @@ describe.skipIf(!process.env.BSK_LONG_SCREENSHOT_CHROME || !process.env.BSK_LONG
120_000,
);

it(
"captures the current range when the loading indicator never clears",
() =>
withAgent(async (h) => {
const original = await h.restored();
await h.onPage(
`document.body.insertAdjacentHTML('beforeend', '<span style="position:absolute;top:2500px;left:500px">加载中...</span>')`,
);
const out = path.join(h.directory, "current.png");
const reply = await h.ok([
"screenshot",
"--session",
h.session,
"--full-page",
"--scope",
"current",
"--out",
out,
]);
expect(reply.scope).toBe("current");
verifyPng(await readFile(out), 2634, 1);
expect(await h.restored()).toEqual(original);
expect(await h.scratch()).toEqual([]);
}),
120_000,
);

it(
"restores the page on timeout and Ctrl-C without replacing an existing output",
() =>
Expand Down
92 changes: 91 additions & 1 deletion apps/extension/src/long-screenshot/capture.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { capturePage, sameLayout, sliceForFrame } from "./capture";
import { frameSignature, isStaleFrame } from "./frame-freshness";
import { type PageCommand, type PageMetrics, ScreenshotError } from "./types";

vi.mock("./frame-freshness", () => ({
frameSignature: vi.fn(() => ({})),
isStaleFrame: vi.fn(() => false),
}));

const metrics: PageMetrics = {
x: 0,
y: 0,
Expand All @@ -14,7 +20,11 @@ const metrics: PageMetrics = {
dpr: 1,
};

afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
vi.mocked(isStaleFrame).mockReset();
vi.mocked(frameSignature).mockClear();
});

describe("long screenshot stitching", () => {
it("preserves a complete fixed footer even when the final scroll adds only a few rows", () => {
Expand Down Expand Up @@ -71,6 +81,7 @@ function harness() {
screenshot,
write: draw,
signal: controller.signal,
checkFreshness: true,
progress: vi.fn(),
label: "Capture",
cancelLabel: "Cancel",
Expand Down Expand Up @@ -190,3 +201,82 @@ describe("capture lifecycle", () => {
expect(pixels.slice(2300)).toEqual(Array(700).fill(-1));
});
});

describe("capture recovery", () => {
it("keeps frame validation opt-in for existing callers", async () => {
const h = harness();
vi.mocked(isStaleFrame).mockReturnValue(true);
const { checkFreshness: _, ...deps } = h.deps;
await expect(capturePage(deps)).resolves.toEqual({ width: 1600, height: 5002 });
expect(frameSignature).not.toHaveBeenCalled();
expect(isStaleFrame).not.toHaveBeenCalled();
});
it.each([
{ mismatches: [2, 3], stale: 1 },
{ mismatches: [4], stale: 2 },
])("does not combine layout mismatches $mismatches with $stale stale exposures", async ({
mismatches,
stale,
}) => {
const h = harness();
const page = h.deps.page;
let inspections = 0;
h.deps.page = vi.fn(async (command) => {
const metrics = await page(command);
if (command.action === "inspect" && mismatches.includes(++inspections))
return { ...metrics, x: metrics.x + 1 };
return metrics;
});
for (let i = 0; i < stale; i++) vi.mocked(isStaleFrame).mockReturnValueOnce(true);
vi.mocked(isStaleFrame).mockReturnValue(false);
await expect(capturePage(h.deps)).resolves.toEqual({ width: 1600, height: 5002 });
expect(h.commands.at(-1)).toEqual({ action: "finish" });
});

it("never writes a stale frame and stops after three exposures", async () => {
const h = harness();
vi.mocked(isStaleFrame).mockReturnValue(true);
await expect(capturePage(h.deps)).rejects.toMatchObject({ reason: "stale_frame" });
expect(h.draw).toHaveBeenCalledTimes(1);
expect(h.bitmaps).toHaveLength(4);
expect(h.commands.at(-1)).toEqual({ action: "finish" });
});
it("retries a stale exposure at the same position without adding rows", async () => {
const h = harness();
vi.mocked(isStaleFrame).mockReturnValueOnce(true).mockReturnValue(false);
await expect(capturePage(h.deps)).resolves.toEqual({ width: 1600, height: 5002 });
const moves = h.commands.filter((c) => c.action === "move");
expect(moves[1]).toEqual(moves[2]);
});
it("does not count a quiet wait before the loading indicator appears as stalled loading", async () => {
const h = harness();
const page = h.deps.page;
let waits = 0;
vi.stubGlobal("performance", { now: () => waits * 10_000 });
h.deps.page = vi.fn(async (command) => {
if (command.action === "move" && command.final) waits++;
return {
...(await page(command)),
loading: waits === 3,
bottomReady: waits >= 4,
};
});
await expect(capturePage({ ...h.deps, loadingTimeoutMs: 20_000 })).resolves.toEqual({
width: 1600,
height: 5002,
});
});
it("reports a stalled loading bottom and restores the page", async () => {
const h = harness();
const page = h.deps.page;
h.deps.page = vi.fn(async (command) => ({
...(await page(command)),
loading: true,
bottomReady: false,
}));
await expect(capturePage({ ...h.deps, loadingTimeoutMs: 0 })).rejects.toMatchObject({
reason: "loading_stalled",
});
expect(h.commands.at(-1)).toEqual({ action: "finish" });
});
});
60 changes: 54 additions & 6 deletions apps/extension/src/long-screenshot/capture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { type CapturePhase, type PageCommand, type PageMetrics, ScreenshotError } from "./types";
import { frameSignature, isStaleFrame } from "./frame-freshness";
import { type FrameSignature } from "./manual";
import {
type CapturePhase,
type CaptureScope,
type PageCommand,
type PageMetrics,
ScreenshotError,
} from "./types";

/** Round document boundaries once, so fractional zoom cannot accumulate seams. */
export function sliceForFrame(metrics: PageMetrics, covered: number, scale: number) {
Expand Down Expand Up @@ -48,6 +56,9 @@ export interface CaptureDeps {
checkpoint?(): Promise<void>;
prepared?(): void;
finished?(): boolean;
scope?: CaptureScope;
loadingTimeoutMs?: number;
checkFreshness?: boolean;
label: string;
cancelLabel: string;
}
Expand All @@ -60,6 +71,8 @@ export async function capturePage(deps: CaptureDeps) {
let width = 0;
let scale = 1;
let frames = 0;
let previousFrame: { y: number; pixels: FrameSignature } | undefined;
let bottomWait: { height: number; since: number } | undefined;
let baseline: PageMetrics | undefined;
const checkpoint = async () => {
signal.throwIfAborted();
Expand All @@ -68,12 +81,18 @@ export async function capturePage(deps: CaptureDeps) {
};
try {
await checkpoint();
let metrics = await page({ action: "begin", label: deps.label, cancelLabel: deps.cancelLabel });
let metrics = await page({
action: "begin",
label: deps.label,
cancelLabel: deps.cancelLabel,
...(deps.scope ? { scope: deps.scope } : {}),
});
let previous = metrics;
let repairThrough = 0;
deps.prepared?.();
let y = 0;
let failures = 0;
let layoutFailures = 0;
let staleFailures = 0;
let final = false;
while (true) {
await checkpoint();
Expand All @@ -100,17 +119,31 @@ export async function capturePage(deps: CaptureDeps) {
}
// A loading indicator or recent layout/content changes keeps the bottom
// provisional. Checkpoints still allow pause, Finish and cancellation.
if (final && metrics.bottomReady === false && covered >= metrics.height - 0.5) continue;
if (final && metrics.bottomReady === false && covered >= metrics.height - 0.5) {
if (!metrics.loading) bottomWait = undefined;
else {
if (!bottomWait || bottomWait.height !== metrics.height)
bottomWait = { height: metrics.height, since: performance.now() };
if (
deps.loadingTimeoutMs !== undefined &&
performance.now() - bottomWait.since >= deps.loadingTimeoutMs
)
throw new ScreenshotError("timeout", "loading_stalled");
}
continue;
}
bottomWait = undefined;
const bitmap = await deps.screenshot();
try {
signal.throwIfAborted();
const after = await page({ action: "inspect" });
if (final && after.bottomReady === false) continue;
if (!sameLayout(metrics, after)) {
if (++failures >= 3) throw new ScreenshotError("changed");
staleFailures = 0;
if (++layoutFailures >= 3) throw new ScreenshotError("changed");
continue;
}
failures = 0;
layoutFailures = 0;
if (!frames) {
scale = bitmap.width / metrics.innerWidth;
width = Math.round(metrics.viewportWidth * scale);
Expand All @@ -120,6 +153,20 @@ export async function capturePage(deps: CaptureDeps) {
Math.abs(bitmap.height - metrics.innerHeight * scale) > 1
)
throw new ScreenshotError("changed");
const pixels = deps.checkFreshness ? frameSignature(bitmap) : undefined;
if (
pixels &&
previousFrame &&
isStaleFrame(
previousFrame.pixels,
pixels,
Math.round((metrics.y - previousFrame.y) * scale),
)
) {
if (++staleFailures >= 3) throw new ScreenshotError("captureFailed", "stale_frame");
continue;
}
staleFailures = 0;
const slice = sliceForFrame(metrics, covered, scale);
if (slice.sourceY < 0 || slice.sourceY + slice.height > bitmap.height || slice.height < 0)
throw new ScreenshotError("changed");
Expand All @@ -128,6 +175,7 @@ export async function capturePage(deps: CaptureDeps) {
covered = slice.end;
frames++;
}
if (pixels) previousFrame = { y: metrics.y, pixels };
progress("capturing", Math.min(99, Math.round((covered / metrics.height) * 100)), frames);
} finally {
bitmap.close();
Expand Down
37 changes: 37 additions & 0 deletions apps/extension/src/long-screenshot/frame-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { isStaleFrame } from "./frame-freshness";
import type { FrameSignature } from "./manual";

function frame(offset = 0, blank = false): FrameSignature {
const width = 96,
height = 600,
pixels = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++)
for (let c = 0; c < 3; c++)
pixels[(y * width + x) * 4 + c] = blank ? 240 : ((y + offset) * 31 + x * 17 + c * 57) % 251;
return { width, height, pixels };
}
describe("automatic frame freshness", () => {
it("rejects an unchanged textured image at a different document offset", () => {
expect(isStaleFrame(frame(), frame(), 450)).toBe(true);
});
it("accepts pixels that moved by the measured displacement", () => {
expect(isStaleFrame(frame(), frame(450), 450)).toBe(false);
});
it("does not reject blank, repeated, same-position or rewound exposures", () => {
expect(isStaleFrame(frame(0, true), frame(0, true), 450)).toBe(false);
expect(isStaleFrame(frame(), frame(), 251)).toBe(false);
expect(isStaleFrame(frame(), frame(), 0)).toBe(false);
expect(isStaleFrame(frame(), frame(), -450)).toBe(false);
});
it("tolerates stationary sidebars when article content scrolls", () => {
const a = frame(),
b = frame(450);
for (let y = 0; y < 600; y++)
for (let x = 0; x < 96; x++)
if (x < 32 || x > 63)
b.pixels.set(a.pixels.subarray((y * 96 + x) * 4, (y * 96 + x) * 4 + 4), (y * 96 + x) * 4);
expect(isStaleFrame(a, b, 450)).toBe(false);
});
});
52 changes: 52 additions & 0 deletions apps/extension/src/long-screenshot/frame-freshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { FrameSignature } from "./manual";
import { ScreenshotError } from "./types";

export function frameSignature(bitmap: ImageBitmap): FrameSignature {
const pixels = new Uint8ClampedArray(96 * bitmap.height * 4);
const canvas = new OffscreenCanvas(96, Math.min(512, bitmap.height));
try {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new ScreenshotError("captureFailed");
for (let y = 0; y < bitmap.height; y += canvas.height) {
const height = Math.min(canvas.height, bitmap.height - y);
ctx.drawImage(bitmap, 0, y, bitmap.width, height, 0, 0, 96, height);
pixels.set(ctx.getImageData(0, 0, 96, height).data, y * 96 * 4);
}
return { width: bitmap.width, height: bitmap.height, pixels };
} finally {
canvas.width = canvas.height = 1;
}
}

/** Reject positive evidence of a stale exposure, not ambiguous/blank content.
* Compare the known document displacement against stationary textured patches.
* Keep signatures only; no full-image buffers or offset search are needed. */
export function isStaleFrame(a: FrameSignature, b: FrameSignature, offset: number): boolean {
if (a.width !== b.width || a.height !== b.height || offset < 4 || offset > a.height - 32)
return false;
let stale = 0;
let scrolling = 0;
const bands = new Set<number>();
const error = (ay: number, by: number, x: number) => {
let sum = 0;
for (let dx = 0; dx < 8; dx++)
for (let c = 0; c < 3; c++)
sum += Math.abs(
a.pixels[(ay * 96 + x + dx) * 4 + c] - b.pixels[(by * 96 + x + dx) * 4 + c],
);
return sum / 24;
};
for (let y = 8; y < a.height - offset - 8; y += 4) {
// Ignore edge chrome and tolerate stationary sidebars/local animation.
for (let x = 24; x <= 64; x += 8) {
const stationary = error(y, y, x);
const shifted = error(y + offset, y, x);
if (shifted < 3 && stationary > 12) scrolling++;
else if (stationary < 0.5 && shifted > 12) {
stale++;
bands.add(x);
}
}
}
return stale >= 24 && bands.size >= 2 && scrolling === 0;
}
Loading