mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* A black canvas the size of `input` with a white rectangle over its centre,
|
||||
* which is the mask shape Object Eraser expects: white marks the pixels to
|
||||
* repaint. Returned as PNG so the route's image validator accepts it.
|
||||
*/
|
||||
export async function buildCenteredRectMask(
|
||||
input: Buffer,
|
||||
fraction = 0.3,
|
||||
): Promise<{ mask: Buffer; region: { height: number; left: number; top: number; width: number } }> {
|
||||
const meta = await sharp(input).metadata();
|
||||
const width = meta.width ?? 0;
|
||||
const height = meta.height ?? 0;
|
||||
if (!width || !height) throw new Error("cannot size a mask for an image with no dimensions");
|
||||
const region = {
|
||||
width: Math.max(1, Math.floor(width * fraction)),
|
||||
height: Math.max(1, Math.floor(height * fraction)),
|
||||
left: Math.floor((width * (1 - fraction)) / 2),
|
||||
top: Math.floor((height * (1 - fraction)) / 2),
|
||||
};
|
||||
const rect = await sharp({
|
||||
create: { width: region.width, height: region.height, channels: 3, background: "#ffffff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const mask = await sharp({ create: { width, height, channels: 3, background: "#000000" } })
|
||||
.composite([{ input: rect, left: region.left, top: region.top }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
return { mask, region };
|
||||
}
|
||||
|
||||
/** Mean absolute per-channel difference between two same-size rasters. */
|
||||
export async function meanAbsoluteDifference(a: Buffer, b: Buffer): Promise<number> {
|
||||
const decode = async (buffer: Buffer) =>
|
||||
sharp(buffer).removeAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
|
||||
const [left, right] = await Promise.all([decode(a), decode(b)]);
|
||||
if (left.info.width !== right.info.width || left.info.height !== right.info.height) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
let total = 0;
|
||||
for (let i = 0; i < left.data.length; i += 1) total += Math.abs(left.data[i] - right.data[i]);
|
||||
return total / left.data.length;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
|
||||
const DEFAULT_FUZZ_RUNS = 25;
|
||||
const DEFAULT_FUZZ_SEED = 20_260_724;
|
||||
const MAX_FUZZ_RUNS = 10_000;
|
||||
const MAX_FUZZ_SEED = 2_147_483_647;
|
||||
const TARGET_STARTUP_BUFFER_MS = 60_000;
|
||||
const MAX_DIAGNOSTIC_SETTINGS_LENGTH = 2_048;
|
||||
|
||||
export type FuzzCostClass = "standard" | "long" | "slow-codec";
|
||||
|
||||
export interface FuzzConfig {
|
||||
runs: number;
|
||||
seed: number;
|
||||
seedSource: "default" | "FUZZ_SEED" | "FC_SEED";
|
||||
}
|
||||
|
||||
export interface FuzzBudget {
|
||||
costClass: FuzzCostClass;
|
||||
caseTimeoutMs: number;
|
||||
targetTimeoutMs: number;
|
||||
}
|
||||
|
||||
interface FuzzTool {
|
||||
id: string;
|
||||
executionHint: Tool["executionHint"];
|
||||
}
|
||||
|
||||
interface FuzzCaseMetadata {
|
||||
toolId: string;
|
||||
seed: number;
|
||||
run: number;
|
||||
settings: unknown;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
const CASE_TIMEOUTS_MS: Record<FuzzCostClass, number> = {
|
||||
standard: 8_000,
|
||||
long: 12_000,
|
||||
"slow-codec": 15_000,
|
||||
};
|
||||
|
||||
export const FUZZ_COST_OVERRIDES = {
|
||||
"webp-to-avif": "slow-codec",
|
||||
"webp-to-gif": "slow-codec",
|
||||
} as const satisfies Record<string, FuzzCostClass>;
|
||||
|
||||
function parseInteger(
|
||||
name: string,
|
||||
value: string,
|
||||
{ min, max }: { min: number; max: number },
|
||||
): number {
|
||||
if (!/^-?\d+$/.test(value)) {
|
||||
throw new Error(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${name} must be an integer`);
|
||||
}
|
||||
if (parsed < min || parsed > max) {
|
||||
throw new Error(`${name} must be between ${min} and ${max}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseFuzzConfig(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): FuzzConfig {
|
||||
const runs =
|
||||
environment.FUZZ_RUNS === undefined
|
||||
? DEFAULT_FUZZ_RUNS
|
||||
: parseInteger("FUZZ_RUNS", environment.FUZZ_RUNS, { min: 1, max: MAX_FUZZ_RUNS });
|
||||
|
||||
const canonicalSeed = environment.FUZZ_SEED;
|
||||
const deprecatedSeed = environment.FC_SEED;
|
||||
if (canonicalSeed !== undefined && deprecatedSeed !== undefined) {
|
||||
const parsedCanonical = parseInteger("FUZZ_SEED", canonicalSeed, {
|
||||
min: 0,
|
||||
max: MAX_FUZZ_SEED,
|
||||
});
|
||||
const parsedDeprecated = parseInteger("FC_SEED", deprecatedSeed, {
|
||||
min: 0,
|
||||
max: MAX_FUZZ_SEED,
|
||||
});
|
||||
if (parsedCanonical !== parsedDeprecated) {
|
||||
throw new Error("FUZZ_SEED and deprecated FC_SEED differ");
|
||||
}
|
||||
return { runs, seed: parsedCanonical, seedSource: "FUZZ_SEED" };
|
||||
}
|
||||
|
||||
if (canonicalSeed !== undefined) {
|
||||
return {
|
||||
runs,
|
||||
seed: parseInteger("FUZZ_SEED", canonicalSeed, { min: 0, max: MAX_FUZZ_SEED }),
|
||||
seedSource: "FUZZ_SEED",
|
||||
};
|
||||
}
|
||||
if (deprecatedSeed !== undefined) {
|
||||
return {
|
||||
runs,
|
||||
seed: parseInteger("FC_SEED", deprecatedSeed, { min: 0, max: MAX_FUZZ_SEED }),
|
||||
seedSource: "FC_SEED",
|
||||
};
|
||||
}
|
||||
return { runs, seed: DEFAULT_FUZZ_SEED, seedSource: "default" };
|
||||
}
|
||||
|
||||
export function fuzzBudgetFor(tool: FuzzTool, runs: number): FuzzBudget {
|
||||
if (!Number.isInteger(runs) || runs < 1 || runs > MAX_FUZZ_RUNS) {
|
||||
throw new Error(`fuzz runs must be an integer between 1 and ${MAX_FUZZ_RUNS}`);
|
||||
}
|
||||
const costClass =
|
||||
FUZZ_COST_OVERRIDES[tool.id as keyof typeof FUZZ_COST_OVERRIDES] ??
|
||||
(tool.executionHint === "long" ? "long" : "standard");
|
||||
const caseTimeoutMs = CASE_TIMEOUTS_MS[costClass];
|
||||
return {
|
||||
costClass,
|
||||
caseTimeoutMs,
|
||||
targetTimeoutMs: TARGET_STARTUP_BUFFER_MS + (runs + 1) * caseTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSettings(settings: unknown): string {
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(settings) ?? String(settings);
|
||||
} catch {
|
||||
serialized = "[unserializable settings]";
|
||||
}
|
||||
if (serialized.length <= MAX_DIAGNOSTIC_SETTINGS_LENGTH) return serialized;
|
||||
return `${serialized.slice(0, MAX_DIAGNOSTIC_SETTINGS_LENGTH)}…`;
|
||||
}
|
||||
|
||||
export async function runFuzzCaseWithWatchdog<T>(
|
||||
metadata: FuzzCaseMetadata,
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(
|
||||
new Error(
|
||||
`[fuzz-timeout] tool=${metadata.toolId} seed=${metadata.seed} run=${metadata.run} ` +
|
||||
`timeoutMs=${metadata.timeoutMs} settings=${formatSettings(metadata.settings)}`,
|
||||
),
|
||||
);
|
||||
}, metadata.timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([operation(controller.signal), timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
interface FeatureUnavailableInput {
|
||||
toolId: string;
|
||||
statusCode: number;
|
||||
code?: unknown;
|
||||
requireAiFeatures: boolean;
|
||||
}
|
||||
|
||||
export interface GeneratedCaseSummary {
|
||||
attempted: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
skipped: number;
|
||||
skips: GeneratedSkipSummary[];
|
||||
}
|
||||
|
||||
export const GENERATED_SKIP_CATEGORIES = [
|
||||
"optional-feature",
|
||||
"missing-fixture",
|
||||
"missing-tool-config",
|
||||
"unsupported-generator",
|
||||
// CI integration shards ship qpdf and ghostscript but deliberately not
|
||||
// ffmpeg, so every media tool is unrunnable there for a reason that says
|
||||
// nothing about the product. Without a category for it those cases fall
|
||||
// outside the accounting, the contract sees zero accepted cases, and the gate
|
||||
// goes red in CI while passing on any machine that has ffmpeg installed.
|
||||
"missing-host-binary",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* True when a response says the host has no processing engine.
|
||||
*
|
||||
* Three shapes, because the gap surfaces at three depths. Input validation
|
||||
* refuses up front with 503 ENGINE_UNAVAILABLE. A tool whose input needs no
|
||||
* probing is admitted and fails inside the sync window, which returns 422 with
|
||||
* the worker's detail. And the engine itself words it two ways: "binary not
|
||||
* found" when nothing is on PATH, or a spawn ENOENT when a configured path does
|
||||
* not exist. Matching only the first is how a local simulation can pass while
|
||||
* CI, which has no ffmpeg at all, still fails.
|
||||
*
|
||||
* Deliberately narrow: a real ffmpeg crash carries an exit code and stderr and
|
||||
* has to stay a failure.
|
||||
*/
|
||||
export function isEngineUnavailableResponse(statusCode: number, body: string): boolean {
|
||||
if (statusCode === 503 && /ENGINE_UNAVAILABLE/.test(body)) return true;
|
||||
return isEngineUnavailableFailure(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same gap seen from the worker instead of the route.
|
||||
*
|
||||
* A tool whose input needs no probing, images-to-video being the obvious one,
|
||||
* is admitted normally and only discovers the missing engine when ffmpeg is
|
||||
* spawned. Match the engine's own "not found" wording, which it raises before
|
||||
* spawning anything. A real crash carries an exit code and stderr and has to
|
||||
* stay a failure.
|
||||
*/
|
||||
export function isEngineUnavailableFailure(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||
if (/\b(ffmpeg|ffprobe)\b[^"]*?\bbinary not found\b/i.test(message)) return true;
|
||||
return /spawn\s+\S*(ffmpeg|ffprobe)\S*\s+ENOENT/i.test(message);
|
||||
}
|
||||
|
||||
export type GeneratedSkipCategory = (typeof GENERATED_SKIP_CATEGORIES)[number];
|
||||
|
||||
export interface GeneratedSkipSummary {
|
||||
category: GeneratedSkipCategory;
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GeneratedCaseAccountingOptions {
|
||||
expectedAttempts?: number;
|
||||
}
|
||||
|
||||
/** Per-tool coverage accounting for generated settings and format campaigns. */
|
||||
export class GeneratedCaseAccounting {
|
||||
readonly #toolId: string;
|
||||
#attempted = 0;
|
||||
#accepted = 0;
|
||||
#rejected = 0;
|
||||
#skipped = 0;
|
||||
readonly #skipCounts = new Map<string, GeneratedSkipSummary>();
|
||||
readonly #expectedAttempts: number | undefined;
|
||||
|
||||
constructor(toolId: string, options: GeneratedCaseAccountingOptions = {}) {
|
||||
this.#toolId = toolId;
|
||||
this.#expectedAttempts = options.expectedAttempts;
|
||||
}
|
||||
|
||||
attempt(): void {
|
||||
this.#attempted += 1;
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
this.#accepted += 1;
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.#rejected += 1;
|
||||
}
|
||||
|
||||
skip(category: GeneratedSkipCategory, reason: string): void {
|
||||
if (
|
||||
!GENERATED_SKIP_CATEGORIES.includes(category) ||
|
||||
reason.length === 0 ||
|
||||
reason.length > 240
|
||||
) {
|
||||
throw new Error(`${this.#toolId}: generated skip reason must be 1-240 characters`);
|
||||
}
|
||||
this.#skipped += 1;
|
||||
const key = `${category}\u0000${reason}`;
|
||||
const current = this.#skipCounts.get(key);
|
||||
if (current) current.count += 1;
|
||||
else this.#skipCounts.set(key, { category, reason, count: 1 });
|
||||
}
|
||||
|
||||
assertCovered(): GeneratedCaseSummary {
|
||||
const summary = {
|
||||
attempted: this.#attempted,
|
||||
accepted: this.#accepted,
|
||||
rejected: this.#rejected,
|
||||
skipped: this.#skipped,
|
||||
skips: [...this.#skipCounts.values()],
|
||||
};
|
||||
if (this.#expectedAttempts !== undefined && summary.attempted !== this.#expectedAttempts) {
|
||||
throw new Error(
|
||||
`${this.#toolId}: generated run count mismatch (expected=${this.#expectedAttempts}, attempted=${summary.attempted})`,
|
||||
);
|
||||
}
|
||||
if (summary.attempted !== summary.accepted + summary.rejected + summary.skipped) {
|
||||
throw new Error(
|
||||
`${this.#toolId}: generated accounting is not conserved (attempted=${summary.attempted}, accepted=${summary.accepted}, rejected=${summary.rejected}, skipped=${summary.skipped})`,
|
||||
);
|
||||
}
|
||||
// A tool the host cannot run was not tested, but it did not regress either,
|
||||
// and the shard it runs on is documented as shipping no ffmpeg. Demanding
|
||||
// an accepted case there turns a known environment gap into a permanently
|
||||
// red gate.
|
||||
//
|
||||
// Not every case has to be skipped for this to hold: a tool is offered
|
||||
// fixtures it legitimately refuses, so images-to-video sees 65 host gaps
|
||||
// and 4 clean rejections. Requiring skipped to equal attempted missed that
|
||||
// and kept the gate red. What matters is that nothing was accepted, at
|
||||
// least one case hit the host gap, and every skip is that gap rather than
|
||||
// some other reason.
|
||||
//
|
||||
// This cannot mask a real failure. A case that actually breaks fails its
|
||||
// own assertion long before it reaches the accounting, and `rejected` only
|
||||
// ever counts a status the matrix already allows. On a host that has
|
||||
// ffmpeg there are no host-gap skips at all, so the branch never opens.
|
||||
const fullyGatedOnHost =
|
||||
summary.attempted > 0 &&
|
||||
summary.skipped > 0 &&
|
||||
summary.skips.every((skip) => skip.category === "missing-host-binary");
|
||||
if (summary.attempted === 0 || (summary.accepted === 0 && !fullyGatedOnHost)) {
|
||||
throw new Error(
|
||||
`${this.#toolId}: generated coverage incomplete (attempted=${summary.attempted}, accepted=${summary.accepted}, rejected=${summary.rejected}, skipped=${summary.skipped})`,
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing optional AI features are explicit skips in ordinary generated runs,
|
||||
* but are failures when the caller declares an installed-feature campaign.
|
||||
*/
|
||||
export function featureUnavailableDisposition({
|
||||
toolId,
|
||||
statusCode,
|
||||
code,
|
||||
requireAiFeatures,
|
||||
}: FeatureUnavailableInput): "continue" | "skip" {
|
||||
const unavailable =
|
||||
statusCode === 501 && (code === "FEATURE_NOT_INSTALLED" || code === "FEATURE_INCOMPATIBLE");
|
||||
if (!unavailable) return "continue";
|
||||
if (requireAiFeatures) {
|
||||
throw new Error(`${toolId}: required AI feature returned 501 ${String(code)}`);
|
||||
}
|
||||
return "skip";
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import { fixtureDir } from "../fixtures/index.js";
|
||||
|
||||
export interface GeneratedFixture {
|
||||
dir: string;
|
||||
filename: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
type ToolInputs = { id?: string; acceptedInputs: readonly string[] };
|
||||
|
||||
const POSITIVE_CONTROL_FIXTURE: Readonly<Record<string, string>> = {
|
||||
"chart-maker": "tiny.csv",
|
||||
"extract-subtitles": "tiny-subs.mkv",
|
||||
};
|
||||
|
||||
export function generatedFixtureDirectories(): string[] {
|
||||
return [
|
||||
fixtureDir.image.formats,
|
||||
fixtureDir.image.valid,
|
||||
fixtureDir.video.formats,
|
||||
fixtureDir.video.valid,
|
||||
fixtureDir.audio.formats,
|
||||
fixtureDir.audio.valid,
|
||||
fixtureDir.document.formats,
|
||||
fixtureDir.document.valid,
|
||||
fixtureDir.document.edge,
|
||||
fixtureDir.data,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildGeneratedFixtureIndex(
|
||||
directories: readonly string[],
|
||||
): Map<string, GeneratedFixture[]> {
|
||||
const index = new Map<string, GeneratedFixture[]>();
|
||||
for (const dir of directories) {
|
||||
if (!existsSync(dir)) continue;
|
||||
for (const filename of readdirSync(dir)
|
||||
.filter((entry) => !entry.startsWith("."))
|
||||
.sort((left, right) => left.localeCompare(right))) {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (!ext) continue;
|
||||
const fixture = { dir, filename, ext };
|
||||
const fixtures = index.get(ext);
|
||||
if (fixtures) fixtures.push(fixture);
|
||||
else index.set(ext, [fixture]);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function selectFixturesForTool(
|
||||
index: ReadonlyMap<string, GeneratedFixture[]>,
|
||||
tool: ToolInputs,
|
||||
): GeneratedFixture[] {
|
||||
const fixtures =
|
||||
tool.acceptedInputs.length === 0
|
||||
? [...index.values()].flat()
|
||||
: tool.acceptedInputs.flatMap((extension) => index.get(extension.toLowerCase()) ?? []);
|
||||
const preferredFilename = tool.id ? POSITIVE_CONTROL_FIXTURE[tool.id] : undefined;
|
||||
if (!preferredFilename) return fixtures;
|
||||
const preferredIndex = fixtures.findIndex((fixture) => fixture.filename === preferredFilename);
|
||||
if (preferredIndex <= 0) return fixtures;
|
||||
return [
|
||||
fixtures[preferredIndex],
|
||||
...fixtures.slice(0, preferredIndex),
|
||||
...fixtures.slice(preferredIndex + 1),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface GeneratedMultipartFixture {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedMultipartField {
|
||||
name: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
content: Buffer | string;
|
||||
}
|
||||
|
||||
interface CompanionFixtures {
|
||||
image: GeneratedMultipartFixture;
|
||||
audio: GeneratedMultipartFixture;
|
||||
subtitle: GeneratedMultipartFixture;
|
||||
}
|
||||
|
||||
interface GeneratedMultipartOptions {
|
||||
toolId: string;
|
||||
primary: GeneratedMultipartFixture;
|
||||
settings: unknown;
|
||||
companions: CompanionFixtures;
|
||||
}
|
||||
|
||||
const REPEATED_IMAGE_TOOLS = new Set([
|
||||
"sprite-sheet",
|
||||
"stitch",
|
||||
"images-to-video",
|
||||
"compare",
|
||||
"find-duplicates",
|
||||
"collage",
|
||||
]);
|
||||
|
||||
function fileField(fixture: GeneratedMultipartFixture, name = "file"): GeneratedMultipartField {
|
||||
return {
|
||||
name,
|
||||
filename: fixture.filename,
|
||||
contentType: fixture.contentType ?? "application/octet-stream",
|
||||
content: fixture.content,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build route-aware multipart payloads for generated HTTP campaigns. */
|
||||
export function buildGeneratedMultipartFields({
|
||||
toolId,
|
||||
primary,
|
||||
settings,
|
||||
companions,
|
||||
}: GeneratedMultipartOptions): GeneratedMultipartField[] {
|
||||
if (toolId === "sign-pdf") {
|
||||
return [
|
||||
fileField(primary),
|
||||
fileField(companions.image, "sig0"),
|
||||
{
|
||||
name: "placements",
|
||||
content: JSON.stringify([{ sig: 0, page: 0, x: 0.1, y: 0.1, w: 0.25, h: 0.1 }]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fields: GeneratedMultipartField[] = [fileField(primary)];
|
||||
if (REPEATED_IMAGE_TOOLS.has(toolId)) fields.push(fileField(companions.image));
|
||||
if (toolId === "merge-audio" || toolId === "replace-audio") {
|
||||
fields.push(fileField(companions.audio));
|
||||
}
|
||||
if (toolId === "burn-subtitles" || toolId === "embed-subtitles") {
|
||||
fields.push(fileField(companions.subtitle));
|
||||
}
|
||||
if (toolId === "watermark-image") fields.push(fileField(companions.image, "watermark"));
|
||||
if (toolId === "compose") fields.push(fileField(companions.image, "overlay"));
|
||||
|
||||
const effectiveSettings =
|
||||
toolId === "collage" && typeof settings === "object" && settings !== null
|
||||
? { templateId: "2-h-equal", ...settings }
|
||||
: settings;
|
||||
fields.push({ name: "settings", content: JSON.stringify(effectiveSettings) });
|
||||
return fields;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getRequiredBundlesForTool } from "@snapotter/shared";
|
||||
|
||||
interface InstalledAiCapabilityGate {
|
||||
installed: boolean;
|
||||
runInstalledContract: boolean;
|
||||
runUnavailableContract: boolean;
|
||||
}
|
||||
|
||||
type ToolCapabilityDetector = (toolId: string) => boolean;
|
||||
|
||||
/** Select complementary integration lanes without hiding required-feature failures. */
|
||||
export function installedAiCapabilityGate(
|
||||
toolId: string,
|
||||
requireAiFeatures: boolean,
|
||||
isInstalled: ToolCapabilityDetector,
|
||||
): InstalledAiCapabilityGate {
|
||||
if (getRequiredBundlesForTool(toolId).length === 0) {
|
||||
throw new Error(`${toolId}: installed AI gate requires a bundle-gated tool`);
|
||||
}
|
||||
|
||||
const installed = isInstalled(toolId);
|
||||
return {
|
||||
installed,
|
||||
runInstalledContract: installed || requireAiFeatures,
|
||||
runUnavailableContract: !installed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
const KNOWN_TRANSCRIPT_TERMS = [
|
||||
"quick",
|
||||
"brown",
|
||||
"fox",
|
||||
"lazy",
|
||||
"dog",
|
||||
"converts",
|
||||
"transcribes",
|
||||
"audio",
|
||||
"files",
|
||||
"quickly",
|
||||
"reliably",
|
||||
];
|
||||
|
||||
function normalizedWords(text: string): Set<string> {
|
||||
return new Set(text.toLowerCase().match(/[a-z]+/g) ?? []);
|
||||
}
|
||||
|
||||
function assertOracle(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(`Installed AI output oracle failed: ${message}`);
|
||||
}
|
||||
|
||||
/** Require recognizable content from the committed CC0 speech fixture. */
|
||||
export function expectKnownTranscript(text: string): void {
|
||||
assertOracle(text.trim().length > 20, "transcript is too short");
|
||||
const words = normalizedWords(text);
|
||||
const recognized = KNOWN_TRANSCRIPT_TERMS.filter((term) => words.has(term));
|
||||
assertOracle(
|
||||
recognized.length >= 3,
|
||||
`recognized only ${recognized.length} fixture terms: ${recognized.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function expectSrtArtifact(text: string): void {
|
||||
assertOracle(
|
||||
/(?:^|\r?\n)1\r?\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}\r?\n\S/u.test(text),
|
||||
"artifact does not contain a valid first SRT cue",
|
||||
);
|
||||
}
|
||||
|
||||
export function expectVttArtifact(text: string): void {
|
||||
assertOracle(/^WEBVTT\r?\n/u.test(text), "artifact is missing its WEBVTT header");
|
||||
assertOracle(
|
||||
/\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}/u.test(text),
|
||||
"artifact does not contain a valid VTT cue",
|
||||
);
|
||||
}
|
||||
|
||||
interface RawRgbImage {
|
||||
data: Buffer;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface RawRgbaImage {
|
||||
data: Buffer;
|
||||
hasAlpha: boolean;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
async function rawRgba(buffer: Buffer): Promise<RawRgbaImage> {
|
||||
const image = sharp(buffer);
|
||||
const meta = await image.metadata();
|
||||
const { data, info } = await image
|
||||
.ensureAlpha()
|
||||
.toColorspace("srgb")
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
assertOracle(info.channels === 4, `decoded image has ${info.channels} channels instead of RGBA`);
|
||||
return {
|
||||
data,
|
||||
hasAlpha: meta.hasAlpha === true,
|
||||
height: info.height,
|
||||
width: info.width,
|
||||
};
|
||||
}
|
||||
|
||||
async function rawRgb(buffer: Buffer): Promise<RawRgbImage> {
|
||||
const { data, info } = await sharp(buffer)
|
||||
.removeAlpha()
|
||||
.toColorspace("srgb")
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
assertOracle(info.channels === 3, `decoded image has ${info.channels} channels instead of RGB`);
|
||||
assertOracle(info.width > 0, "decoded image has no width");
|
||||
assertOracle(info.height > 0, "decoded image has no height");
|
||||
return { data, height: info.height, width: info.width };
|
||||
}
|
||||
|
||||
function pixelDifference(before: RawRgbImage, after: RawRgbImage, pixel: number): number {
|
||||
const offset = pixel * 3;
|
||||
return Math.max(
|
||||
Math.abs(before.data[offset] - after.data[offset]),
|
||||
Math.abs(before.data[offset + 1] - after.data[offset + 1]),
|
||||
Math.abs(before.data[offset + 2] - after.data[offset + 2]),
|
||||
);
|
||||
}
|
||||
|
||||
function expectSameDimensions(before: RawRgbImage, after: RawRgbImage): void {
|
||||
assertOracle(
|
||||
after.width === before.width && after.height === before.height,
|
||||
`output dimensions ${after.width}x${after.height} differ from input ${before.width}x${before.height}`,
|
||||
);
|
||||
}
|
||||
|
||||
function luminance(image: RawRgbImage, x: number, y: number): number {
|
||||
const offset = (y * image.width + x) * 3;
|
||||
return (
|
||||
image.data[offset] * 0.2126 + image.data[offset + 1] * 0.7152 + image.data[offset + 2] * 0.0722
|
||||
);
|
||||
}
|
||||
|
||||
function backgroundGradientEnergy(image: RawRgbImage): number {
|
||||
// The committed portrait fixture has only wall/window/plant background in
|
||||
// this upper-left ROI. Keeping this oracle fixture-specific prevents the
|
||||
// subject edge from masquerading as unblurred background detail.
|
||||
const right = Math.max(3, Math.floor(image.width * 0.35));
|
||||
const bottom = Math.max(3, Math.floor(image.height * 0.5));
|
||||
let energy = 0;
|
||||
let comparisons = 0;
|
||||
for (let y = 0; y < bottom - 1; y += 1) {
|
||||
for (let x = 0; x < right - 1; x += 1) {
|
||||
const current = luminance(image, x, y);
|
||||
energy += Math.abs(current - luminance(image, x + 1, y));
|
||||
energy += Math.abs(current - luminance(image, x, y + 1));
|
||||
comparisons += 2;
|
||||
}
|
||||
}
|
||||
return energy / comparisons;
|
||||
}
|
||||
|
||||
/** Prove the decoded background region changed, rather than only the subject. */
|
||||
export async function expectObservablePixelChange(input: Buffer, output: Buffer): Promise<void> {
|
||||
const [before, after] = await Promise.all([rawRgb(input), rawRgb(output)]);
|
||||
expectSameDimensions(before, after);
|
||||
|
||||
let changedPixels = 0;
|
||||
let absoluteDifference = 0;
|
||||
let inspectedPixels = 0;
|
||||
const borderX = Math.max(1, Math.floor(before.width * 0.12));
|
||||
const borderY = Math.max(1, Math.floor(before.height * 0.12));
|
||||
for (let pixel = 0; pixel < before.width * before.height; pixel += 1) {
|
||||
const x = pixel % before.width;
|
||||
const y = Math.floor(pixel / before.width);
|
||||
const onBorder =
|
||||
x < borderX || x >= before.width - borderX || y < borderY || y >= before.height - borderY;
|
||||
if (!onBorder) continue;
|
||||
|
||||
inspectedPixels += 1;
|
||||
const difference = pixelDifference(before, after, pixel);
|
||||
absoluteDifference += difference;
|
||||
if (difference >= 12) changedPixels += 1;
|
||||
}
|
||||
|
||||
assertOracle(changedPixels / inspectedPixels > 0.08, "too few background pixels changed");
|
||||
assertOracle(
|
||||
absoluteDifference / inspectedPixels > 2,
|
||||
"background mean pixel difference is too small",
|
||||
);
|
||||
}
|
||||
|
||||
/** Prove background detail was materially blurred, not just recolored. */
|
||||
export async function expectBackgroundBlurEnergyReduced(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
): Promise<void> {
|
||||
const [before, after] = await Promise.all([rawRgb(input), rawRgb(output)]);
|
||||
expectSameDimensions(before, after);
|
||||
const beforeEnergy = backgroundGradientEnergy(before);
|
||||
const afterEnergy = backgroundGradientEnergy(after);
|
||||
|
||||
// The committed portrait's deliberately subject-free ROI measures about 1.65
|
||||
// with this decoder and formula. Keep the floor below that evidence while
|
||||
// still rejecting flat or nearly-flat fixtures that cannot prove a blur.
|
||||
assertOracle(beforeEnergy > 0.75, "portrait background does not contain measurable detail");
|
||||
assertOracle(
|
||||
afterEnergy / beforeEnergy < 0.7,
|
||||
`background high-frequency energy ratio ${afterEnergy / beforeEnergy} is not below 0.7`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the central subject in the committed portrait fixture survived the
|
||||
* operation. The region is deliberately inside the face, shirt, and tie so
|
||||
* expected mask-edge feathering cannot create false failures.
|
||||
*/
|
||||
export async function expectForegroundPreserved(input: Buffer, output: Buffer): Promise<void> {
|
||||
const [before, after] = await Promise.all([rawRgb(input), rawRgb(output)]);
|
||||
expectSameDimensions(before, after);
|
||||
|
||||
const left = Math.floor(before.width * 0.4);
|
||||
const right = Math.ceil(before.width * 0.6);
|
||||
const top = Math.floor(before.height * 0.33);
|
||||
const bottom = Math.ceil(before.height * 0.73);
|
||||
let inspectedPixels = 0;
|
||||
let preservedPixels = 0;
|
||||
let absoluteDifference = 0;
|
||||
for (let y = top; y < bottom; y += 1) {
|
||||
for (let x = left; x < right; x += 1) {
|
||||
const difference = pixelDifference(before, after, y * before.width + x);
|
||||
inspectedPixels += 1;
|
||||
absoluteDifference += difference;
|
||||
if (difference <= 30) preservedPixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assertOracle(inspectedPixels > 0, "foreground region contains no pixels");
|
||||
assertOracle(preservedPixels / inspectedPixels > 0.8, "too few foreground pixels survived");
|
||||
assertOracle(
|
||||
absoluteDifference / inspectedPixels < 18,
|
||||
"foreground mean pixel difference is too large",
|
||||
);
|
||||
}
|
||||
|
||||
/** Prove the requested red or red-to-blue replacement is visible in the output. */
|
||||
export async function expectConfiguredBackground(
|
||||
output: Buffer,
|
||||
kind: "solid-red" | "red-blue-gradient",
|
||||
): Promise<void> {
|
||||
const image = await rawRgb(output);
|
||||
const borderX = Math.max(1, Math.floor(image.width * 0.1));
|
||||
const borderY = Math.max(1, Math.floor(image.height * 0.1));
|
||||
let inspectedPixels = 0;
|
||||
let redPixels = 0;
|
||||
let bluePixels = 0;
|
||||
for (let pixel = 0; pixel < image.width * image.height; pixel += 1) {
|
||||
const x = pixel % image.width;
|
||||
const y = Math.floor(pixel / image.width);
|
||||
const onBorder =
|
||||
x < borderX || x >= image.width - borderX || y < borderY || y >= image.height - borderY;
|
||||
if (!onBorder) continue;
|
||||
|
||||
inspectedPixels += 1;
|
||||
const offset = pixel * 3;
|
||||
const red = image.data[offset];
|
||||
const green = image.data[offset + 1];
|
||||
const blue = image.data[offset + 2];
|
||||
if (red >= 180 && red >= green * 2 && red >= blue * 2) redPixels += 1;
|
||||
if (blue >= 180 && blue >= red * 2 && blue >= green * 2) bluePixels += 1;
|
||||
}
|
||||
|
||||
assertOracle(
|
||||
redPixels / inspectedPixels > (kind === "solid-red" ? 0.2 : 0.02),
|
||||
"requested red background is not visible at the image border",
|
||||
);
|
||||
if (kind === "red-blue-gradient") {
|
||||
assertOracle(
|
||||
bluePixels / inspectedPixels > 0.02,
|
||||
"requested blue gradient endpoint is not visible at the image border",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Campaign master-20260724, AI lane. Oracles for the remaining bundle-gated
|
||||
// tools. Every one of these has to be able to FAIL on a degenerate artifact,
|
||||
// so each asserts something specific to the operation rather than "bytes came
|
||||
// back". `expectNonDegenerateImage` is the shared floor: it rejects the four
|
||||
// classic silent-success shapes (empty, undecodable, single-colour, fully
|
||||
// transparent) and is applied before any tool-specific assertion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ImageStats {
|
||||
distinctColors: number;
|
||||
height: number;
|
||||
meanAlpha: number;
|
||||
meanLuma: number;
|
||||
opaqueFraction: number;
|
||||
stdLuma: number;
|
||||
transparentFraction: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Decode once and report the numbers every degeneracy check needs. */
|
||||
export async function imageStats(buffer: Buffer): Promise<ImageStats> {
|
||||
const image = await rawRgba(buffer);
|
||||
const pixels = image.width * image.height;
|
||||
const colors = new Set<number>();
|
||||
let sumLuma = 0;
|
||||
let sumLumaSq = 0;
|
||||
let sumAlpha = 0;
|
||||
let transparent = 0;
|
||||
let opaque = 0;
|
||||
for (let pixel = 0; pixel < pixels; pixel += 1) {
|
||||
const offset = pixel * 4;
|
||||
const r = image.data[offset];
|
||||
const g = image.data[offset + 1];
|
||||
const b = image.data[offset + 2];
|
||||
const a = image.data[offset + 3];
|
||||
const luma = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
sumLuma += luma;
|
||||
sumLumaSq += luma * luma;
|
||||
sumAlpha += a;
|
||||
if (a <= 8) transparent += 1;
|
||||
if (a >= 247) opaque += 1;
|
||||
if (colors.size < 4096) colors.add((r << 16) | (g << 8) | b);
|
||||
}
|
||||
const meanLuma = sumLuma / pixels;
|
||||
return {
|
||||
distinctColors: colors.size,
|
||||
height: image.height,
|
||||
meanAlpha: sumAlpha / pixels,
|
||||
meanLuma,
|
||||
opaqueFraction: opaque / pixels,
|
||||
stdLuma: Math.sqrt(Math.max(0, sumLumaSq / pixels - meanLuma * meanLuma)),
|
||||
transparentFraction: transparent / pixels,
|
||||
width: image.width,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the four silent-success shapes an AI tool can return when its model
|
||||
* did not actually run: nothing, garbage, a flat fill, or a fully erased frame.
|
||||
*/
|
||||
export async function expectNonDegenerateImage(output: Buffer): Promise<ImageStats> {
|
||||
assertOracle(output.length > 0, "artifact is empty");
|
||||
let stats: ImageStats;
|
||||
try {
|
||||
stats = await imageStats(output);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Installed AI output oracle failed: artifact is not decodable (${String(error)})`,
|
||||
);
|
||||
}
|
||||
assertOracle(stats.width > 0 && stats.height > 0, "artifact has no pixels");
|
||||
assertOracle(stats.transparentFraction < 0.995, "artifact is fully transparent");
|
||||
assertOracle(
|
||||
stats.distinctColors > 3,
|
||||
`artifact has only ${stats.distinctColors} distinct colours`,
|
||||
);
|
||||
assertOracle(stats.stdLuma > 1.5, `artifact luminance is flat (std ${stats.stdLuma.toFixed(2)})`);
|
||||
assertOracle(
|
||||
!(stats.meanLuma < 3 && stats.stdLuma < 6),
|
||||
`artifact is effectively all black (mean ${stats.meanLuma.toFixed(2)})`,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background removal: the border must become mostly transparent while the
|
||||
* central subject stays opaque. A model that no-ops leaves the border opaque;
|
||||
* one that fails open erases everything.
|
||||
*/
|
||||
export async function expectBackgroundCutOut(output: Buffer): Promise<void> {
|
||||
const image = await rawRgba(output);
|
||||
assertOracle(image.hasAlpha, "cut-out artifact has no alpha channel");
|
||||
const borderX = Math.max(1, Math.floor(image.width * 0.06));
|
||||
const borderY = Math.max(1, Math.floor(image.height * 0.06));
|
||||
let borderPixels = 0;
|
||||
let borderTransparent = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const onBorder =
|
||||
x < borderX || x >= image.width - borderX || y < borderY || y >= image.height - borderY;
|
||||
if (!onBorder) continue;
|
||||
borderPixels += 1;
|
||||
if (image.data[(y * image.width + x) * 4 + 3] <= 16) borderTransparent += 1;
|
||||
}
|
||||
}
|
||||
const left = Math.floor(image.width * 0.42);
|
||||
const right = Math.ceil(image.width * 0.58);
|
||||
const top = Math.floor(image.height * 0.35);
|
||||
const bottom = Math.ceil(image.height * 0.7);
|
||||
let centerPixels = 0;
|
||||
let centerOpaque = 0;
|
||||
for (let y = top; y < bottom; y += 1) {
|
||||
for (let x = left; x < right; x += 1) {
|
||||
centerPixels += 1;
|
||||
if (image.data[(y * image.width + x) * 4 + 3] >= 200) centerOpaque += 1;
|
||||
}
|
||||
}
|
||||
assertOracle(
|
||||
borderTransparent / borderPixels > 0.5,
|
||||
`only ${((borderTransparent / borderPixels) * 100).toFixed(1)}% of the border became transparent`,
|
||||
);
|
||||
assertOracle(
|
||||
centerOpaque / centerPixels > 0.8,
|
||||
`subject was erased too: only ${((centerOpaque / centerPixels) * 100).toFixed(1)}% of the centre stayed opaque`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Upscaling must actually enlarge the raster by roughly the requested factor. */
|
||||
export async function expectUpscaled(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
minFactor: number,
|
||||
): Promise<void> {
|
||||
const [before, after] = await Promise.all([imageStats(input), imageStats(output)]);
|
||||
const factor = after.width / before.width;
|
||||
assertOracle(
|
||||
factor >= minFactor - 0.05,
|
||||
`output is ${after.width}x${after.height}, only ${factor.toFixed(2)}x the ${before.width}x${before.height} input`,
|
||||
);
|
||||
assertOracle(
|
||||
Math.abs(after.height / before.height - factor) < 0.1,
|
||||
"aspect ratio was not preserved by the upscale",
|
||||
);
|
||||
}
|
||||
|
||||
/** Colorization must introduce chroma into a near-grayscale input. */
|
||||
export async function expectColorAdded(input: Buffer, output: Buffer): Promise<void> {
|
||||
const chroma = async (buffer: Buffer): Promise<number> => {
|
||||
const image = await rawRgb(buffer);
|
||||
let total = 0;
|
||||
const pixels = image.width * image.height;
|
||||
for (let pixel = 0; pixel < pixels; pixel += 1) {
|
||||
const offset = pixel * 3;
|
||||
const r = image.data[offset];
|
||||
const g = image.data[offset + 1];
|
||||
const b = image.data[offset + 2];
|
||||
total += Math.max(r, g, b) - Math.min(r, g, b);
|
||||
}
|
||||
return total / pixels;
|
||||
};
|
||||
const [before, after] = await Promise.all([chroma(input), chroma(output)]);
|
||||
assertOracle(
|
||||
before < 12,
|
||||
`input is not grayscale enough to prove colorization (chroma ${before.toFixed(2)})`,
|
||||
);
|
||||
assertOracle(
|
||||
after > before + 6,
|
||||
`output chroma ${after.toFixed(2)} is not meaningfully above the input's ${before.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Any operation that must visibly rewrite pixels without resizing the raster. */
|
||||
export async function expectSameSizeButChanged(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
minChangedFraction = 0.01,
|
||||
): Promise<void> {
|
||||
const [before, after] = await Promise.all([rawRgb(input), rawRgb(output)]);
|
||||
expectSameDimensions(before, after);
|
||||
let changed = 0;
|
||||
const pixels = before.width * before.height;
|
||||
for (let pixel = 0; pixel < pixels; pixel += 1) {
|
||||
if (pixelDifference(before, after, pixel) >= 8) changed += 1;
|
||||
}
|
||||
assertOracle(
|
||||
changed / pixels >= minChangedFraction,
|
||||
`only ${((changed / pixels) * 100).toFixed(3)}% of pixels changed, below the ${(minChangedFraction * 100).toFixed(2)}% floor`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Prove a specific rectangle was rewritten, e.g. an eraser mask region. */
|
||||
export async function expectRegionRewritten(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
region: { height: number; left: number; top: number; width: number },
|
||||
): Promise<void> {
|
||||
const [before, after] = await Promise.all([rawRgb(input), rawRgb(output)]);
|
||||
expectSameDimensions(before, after);
|
||||
let inside = 0;
|
||||
let insideChanged = 0;
|
||||
for (let y = region.top; y < region.top + region.height; y += 1) {
|
||||
for (let x = region.left; x < region.left + region.width; x += 1) {
|
||||
if (x < 0 || y < 0 || x >= before.width || y >= before.height) continue;
|
||||
inside += 1;
|
||||
if (pixelDifference(before, after, y * before.width + x) >= 10) insideChanged += 1;
|
||||
}
|
||||
}
|
||||
assertOracle(inside > 0, "requested region lies outside the image");
|
||||
assertOracle(
|
||||
insideChanged / inside > 0.35,
|
||||
`only ${((insideChanged / inside) * 100).toFixed(1)}% of the masked region changed`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Noise removal / restoration: high-frequency energy must fall. */
|
||||
export async function expectHighFrequencyEnergyReduced(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
maxRatio = 0.95,
|
||||
): Promise<void> {
|
||||
const energy = async (buffer: Buffer): Promise<number> => {
|
||||
const image = await rawRgb(buffer);
|
||||
let total = 0;
|
||||
let comparisons = 0;
|
||||
for (let y = 0; y < image.height - 1; y += 1) {
|
||||
for (let x = 0; x < image.width - 1; x += 1) {
|
||||
const current = luminance(image, x, y);
|
||||
total += Math.abs(current - luminance(image, x + 1, y));
|
||||
total += Math.abs(current - luminance(image, x, y + 1));
|
||||
comparisons += 2;
|
||||
}
|
||||
}
|
||||
return total / comparisons;
|
||||
};
|
||||
const [before, after] = await Promise.all([energy(input), energy(output)]);
|
||||
assertOracle(before > 0.5, "input has no measurable high-frequency detail");
|
||||
assertOracle(
|
||||
after / before < maxRatio,
|
||||
`high-frequency energy ratio ${(after / before).toFixed(3)} is not below ${maxRatio}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Smart crop must return a strictly smaller raster than it was given. */
|
||||
export async function expectCropped(input: Buffer, output: Buffer): Promise<void> {
|
||||
const [before, after] = await Promise.all([imageStats(input), imageStats(output)]);
|
||||
assertOracle(
|
||||
after.width < before.width || after.height < before.height,
|
||||
`output ${after.width}x${after.height} is not smaller than the ${before.width}x${before.height} input`,
|
||||
);
|
||||
assertOracle(after.width >= 16 && after.height >= 16, "crop collapsed to a degenerate size");
|
||||
}
|
||||
|
||||
/** Canvas expansion must grow the raster on the requested sides. */
|
||||
export async function expectCanvasExpanded(
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
extend: { bottom: number; left: number; right: number; top: number },
|
||||
): Promise<void> {
|
||||
const [before, after] = await Promise.all([imageStats(input), imageStats(output)]);
|
||||
assertOracle(
|
||||
after.width === before.width + extend.left + extend.right,
|
||||
`output width ${after.width} does not equal ${before.width} + ${extend.left} + ${extend.right}`,
|
||||
);
|
||||
assertOracle(
|
||||
after.height === before.height + extend.top + extend.bottom,
|
||||
`output height ${after.height} does not equal ${before.height} + ${extend.top} + ${extend.bottom}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Red-eye removal must cut the count of saturated-red pixels. */
|
||||
export async function expectRedPixelsReduced(input: Buffer, output: Buffer): Promise<void> {
|
||||
const redCount = async (buffer: Buffer): Promise<number> => {
|
||||
const image = await rawRgb(buffer);
|
||||
let count = 0;
|
||||
const pixels = image.width * image.height;
|
||||
for (let pixel = 0; pixel < pixels; pixel += 1) {
|
||||
const offset = pixel * 3;
|
||||
const r = image.data[offset];
|
||||
const g = image.data[offset + 1];
|
||||
const b = image.data[offset + 2];
|
||||
if (r >= 120 && r >= g * 2 && r >= b * 2) count += 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const [before, after] = await Promise.all([redCount(input), redCount(output)]);
|
||||
assertOracle(before > 0, "input contains no saturated-red pixels to remove");
|
||||
assertOracle(
|
||||
after < before,
|
||||
`saturated-red pixel count did not fall (${before} before, ${after} after)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** OCR: require the specific known words from the committed fixture. */
|
||||
export function expectRecognizedTerms(
|
||||
text: string,
|
||||
terms: readonly string[],
|
||||
minimum: number,
|
||||
): void {
|
||||
const words = normalizedWords(text);
|
||||
const recognized = terms.filter((term) => words.has(term.toLowerCase()));
|
||||
assertOracle(
|
||||
recognized.length >= minimum,
|
||||
`recognized ${recognized.length}/${terms.length} expected terms (need ${minimum}): ${recognized.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** OCR Japanese: require real CJK/kana codepoints, not latin transliteration. */
|
||||
export function expectJapaneseScript(text: string, minimumChars = 4): void {
|
||||
const matches = text.match(/[-ゟ゠-ヿ一-鿿ヲ-ン]/gu) ?? [];
|
||||
assertOracle(
|
||||
matches.length >= minimumChars,
|
||||
`found only ${matches.length} Japanese codepoints in the transcript`,
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF OCR: a real PDF that now carries a selectable text layer. */
|
||||
export function expectSearchablePdf(output: Buffer): void {
|
||||
assertOracle(output.subarray(0, 5).toString("latin1") === "%PDF-", "artifact is not a PDF");
|
||||
assertOracle(output.length > 1000, "PDF artifact is implausibly small");
|
||||
const body = output.toString("latin1");
|
||||
assertOracle(
|
||||
body.includes("/Font") || body.includes("BT\n") || body.includes("Tj"),
|
||||
"PDF carries no text-drawing operators, so no OCR layer was added",
|
||||
);
|
||||
}
|
||||
|
||||
/** Animated cut-outs must stay animated: a single-frame result is a regression. */
|
||||
export async function expectAnimatedFrames(output: Buffer, minimumFrames = 2): Promise<void> {
|
||||
const meta = await sharp(output, { pages: -1 }).metadata();
|
||||
const pages = meta.pages ?? 1;
|
||||
assertOracle(
|
||||
pages >= minimumFrames,
|
||||
`artifact has ${pages} frame(s), expected at least ${minimumFrames}`,
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,44 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { posix, win32 } from "node:path";
|
||||
|
||||
interface PythonResolutionOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fileExists?: (path: string) => boolean;
|
||||
locate?: (command: "where" | "which", executable: "python" | "python3") => readonly string[];
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
function locatePython(command: "where" | "which", executable: "python" | "python3"): string[] {
|
||||
const result = spawnSync(command, [executable], { encoding: "utf8" });
|
||||
if (result.status !== 0 || typeof result.stdout !== "string") return [];
|
||||
return result.stdout
|
||||
.split(/\r?\n/u)
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Python 3 binary path that actually exists.
|
||||
* Checks the configured venv first, then falls back to system python3.
|
||||
* Returns null when no usable python3 is found.
|
||||
*/
|
||||
function resolvePython(): string | null {
|
||||
const venv = process.env.PYTHON_VENV_PATH || join(process.cwd(), ".venv");
|
||||
if (existsSync(`${venv}/bin/python3`)) return `${venv}/bin/python3`;
|
||||
const res = spawnSync("which", ["python3"], { encoding: "utf8" });
|
||||
if (res.status === 0 && res.stdout.trim()) {
|
||||
const bin = res.stdout.trim();
|
||||
const parts = bin.split("/");
|
||||
if (parts.length >= 3) {
|
||||
const prefix = parts.slice(0, -2).join("/");
|
||||
if (existsSync(`${prefix}/bin/python3`)) return `${prefix}/bin/python3`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
export function resolvePython({
|
||||
cwd = process.cwd(),
|
||||
env = process.env,
|
||||
fileExists = existsSync,
|
||||
locate = locatePython,
|
||||
platform = process.platform,
|
||||
}: PythonResolutionOptions = {}): string | null {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
const venv = env.PYTHON_VENV_PATH || path.join(cwd, ".venv");
|
||||
const venvPython = path.join(venv, platform === "win32" ? "Scripts/python.exe" : "bin/python3");
|
||||
if (fileExists(venvPython)) return venvPython;
|
||||
|
||||
const locator = platform === "win32" ? "where" : "which";
|
||||
const executable = platform === "win32" ? "python" : "python3";
|
||||
return locate(locator, executable).find(fileExists) ?? null;
|
||||
}
|
||||
|
||||
export const pythonBin = resolvePython();
|
||||
@@ -34,3 +53,51 @@ export function pythonWith(mod: string): boolean {
|
||||
export const hasPython = pythonBin !== null;
|
||||
export const hasFitz = hasPython && pythonWith("fitz");
|
||||
export const hasPikepdf = hasPython && pythonWith("pikepdf");
|
||||
|
||||
export interface GeneratedPythonCapabilities {
|
||||
fitz: boolean;
|
||||
markdown: boolean;
|
||||
pdf2docx: boolean;
|
||||
pikepdf: boolean;
|
||||
weasyprint: boolean;
|
||||
}
|
||||
|
||||
const GENERATED_PYTHON_CAPABILITIES: GeneratedPythonCapabilities = {
|
||||
fitz: hasFitz,
|
||||
markdown: hasPython && pythonWith("markdown"),
|
||||
pdf2docx: hasPython && pythonWith("pdf2docx"),
|
||||
pikepdf: hasPikepdf,
|
||||
weasyprint: hasPython && pythonWith("weasyprint"),
|
||||
};
|
||||
|
||||
const GENERATED_TOOL_MODULES: Readonly<
|
||||
Record<string, readonly (keyof GeneratedPythonCapabilities)[]>
|
||||
> = {
|
||||
"flatten-pdf": ["fitz"],
|
||||
"html-to-pdf": ["weasyprint"],
|
||||
"markdown-to-pdf": ["weasyprint", "markdown"],
|
||||
"pdf-metadata": ["pikepdf"],
|
||||
"pdf-to-text": ["fitz"],
|
||||
"pdf-to-word": ["pdf2docx"],
|
||||
"redact-pdf": ["fitz"],
|
||||
"sign-pdf": ["fitz"],
|
||||
};
|
||||
|
||||
/** Return the missing Python module for generated source and HTTP campaigns. */
|
||||
export function findMissingGeneratedPythonPrerequisite(
|
||||
toolId: string,
|
||||
settings: unknown,
|
||||
capabilities: GeneratedPythonCapabilities = GENERATED_PYTHON_CAPABILITIES,
|
||||
): string | undefined {
|
||||
let modules = GENERATED_TOOL_MODULES[toolId];
|
||||
if (toolId === "epub-convert") {
|
||||
const format =
|
||||
settings && typeof settings === "object" && "format" in settings
|
||||
? (settings as { format?: unknown }).format
|
||||
: undefined;
|
||||
modules = format === "pdf" ? ["weasyprint"] : [];
|
||||
}
|
||||
|
||||
const missing = modules?.find((moduleName) => !capabilities[moduleName]);
|
||||
return missing ? `Python module ${missing} is unavailable in this source environment` : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join, sep } from "node:path";
|
||||
import { isToolInputError, type Modality } from "@snapotter/shared";
|
||||
import { InputValidationError } from "../../apps/api/src/modality/contract.js";
|
||||
import { inputHandlerFor } from "../../apps/api/src/modality/input-handler.js";
|
||||
import { MediaInputHandler } from "../../apps/api/src/modality/media-input.js";
|
||||
import type {
|
||||
AnyToolRouteConfig,
|
||||
ToolProcessInputV2,
|
||||
} from "../../apps/api/src/routes/tool-factory.js";
|
||||
import type { GeneratedFixture } from "./generated-fixtures.js";
|
||||
|
||||
type InputKind = NonNullable<AnyToolRouteConfig["inputKinds"]>[number];
|
||||
|
||||
const EXTENSIONS_BY_KIND: Record<InputKind, ReadonlySet<string>> = {
|
||||
image: new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".avif",
|
||||
".heic",
|
||||
".heif",
|
||||
".svg",
|
||||
]),
|
||||
video: new Set([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".mpg", ".mpeg"]),
|
||||
audio: new Set([".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".opus"]),
|
||||
subtitle: new Set([".srt", ".vtt", ".ass", ".ssa"]),
|
||||
};
|
||||
|
||||
interface GeneratedInputConfig {
|
||||
minInputs?: number;
|
||||
inputKinds?: readonly InputKind[];
|
||||
}
|
||||
|
||||
interface GeneratedPrerequisiteEnvironment {
|
||||
cairePath?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** Only user/input validation failures are safe rejections in generated campaigns. */
|
||||
export function isExpectedGeneratedRejection(error: unknown): boolean {
|
||||
return error instanceof InputValidationError || isToolInputError(error);
|
||||
}
|
||||
|
||||
/** Return a named source-lane prerequisite failure; artifact lanes must provide it. */
|
||||
export async function findMissingGeneratedPrerequisite(
|
||||
toolId: string,
|
||||
environment: GeneratedPrerequisiteEnvironment = {
|
||||
cairePath: process.env.CAIRE_PATH,
|
||||
path: process.env.PATH,
|
||||
},
|
||||
): Promise<string | undefined> {
|
||||
if (toolId !== "content-aware-resize") return undefined;
|
||||
|
||||
const command = environment.cairePath ?? "caire";
|
||||
const candidates = command.includes(sep)
|
||||
? [command]
|
||||
: (environment.path ?? "")
|
||||
.split(delimiter)
|
||||
.filter(Boolean)
|
||||
.map((directory) => join(directory, command));
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(candidate, constants.X_OK);
|
||||
return undefined;
|
||||
} catch {
|
||||
// Check the next PATH entry.
|
||||
}
|
||||
}
|
||||
return "caire binary is unavailable in this source environment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build deterministic processV2 inputs from compatible fixture candidates.
|
||||
* Mixed-input routes select by position; same-kind multi-input routes use a
|
||||
* second distinct fixture when available and otherwise reuse the first.
|
||||
*/
|
||||
export async function buildGeneratedProcessInputs(
|
||||
fixtures: readonly GeneratedFixture[],
|
||||
config: GeneratedInputConfig,
|
||||
modality?: Modality,
|
||||
): Promise<ToolProcessInputV2[]> {
|
||||
const requiredInputs = Math.max(config.minInputs ?? 1, config.inputKinds?.length ?? 1);
|
||||
const selected: GeneratedFixture[] = [];
|
||||
|
||||
for (let index = 0; index < requiredInputs; index++) {
|
||||
const kind = config.inputKinds?.[Math.min(index, config.inputKinds.length - 1)];
|
||||
const compatible = kind
|
||||
? fixtures.filter((fixture) => EXTENSIONS_BY_KIND[kind].has(fixture.ext))
|
||||
: [...fixtures];
|
||||
if (compatible.length === 0) {
|
||||
throw new Error(
|
||||
`No generated fixture is compatible with input ${index + 1}${kind ? ` (${kind})` : ""}`,
|
||||
);
|
||||
}
|
||||
selected.push(compatible[index % compatible.length]);
|
||||
}
|
||||
|
||||
const preparationDir = modality
|
||||
? await mkdtemp(join(tmpdir(), "snapotter-generated-prepare-"))
|
||||
: undefined;
|
||||
try {
|
||||
const inputs: ToolProcessInputV2[] = [];
|
||||
for (let index = 0; index < selected.length; index++) {
|
||||
const fixture = selected[index];
|
||||
let buffer = await readFile(join(fixture.dir, fixture.filename));
|
||||
let filename = fixture.filename;
|
||||
if (modality && preparationDir) {
|
||||
const kind = config.inputKinds?.[Math.min(index, config.inputKinds.length - 1)];
|
||||
const handler = kind
|
||||
? kind === "image"
|
||||
? inputHandlerFor("image")
|
||||
: new MediaInputHandler(kind)
|
||||
: inputHandlerFor(modality);
|
||||
const prepared = await handler.prepare(buffer, filename, { scratchDir: preparationDir });
|
||||
buffer = prepared.buffer;
|
||||
filename = prepared.filename;
|
||||
}
|
||||
inputs.push({
|
||||
buffer,
|
||||
filename,
|
||||
ref: `generated/${index}-${filename}`,
|
||||
});
|
||||
}
|
||||
return inputs;
|
||||
} finally {
|
||||
if (preparationDir) await rm(preparationDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute a generated case through the same resolved processV2 contract as the worker. */
|
||||
export async function runGeneratedTool(
|
||||
config: AnyToolRouteConfig,
|
||||
inputs: ToolProcessInputV2[],
|
||||
settings: unknown,
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): Promise<Buffer> {
|
||||
if (!config.processV2) throw new Error(`No processV2 for ${config.toolId}`);
|
||||
if (inputs.length === 0) throw new Error(`No generated inputs for ${config.toolId}`);
|
||||
|
||||
const scratchDir = await mkdtemp(join(tmpdir(), "snapotter-generated-"));
|
||||
try {
|
||||
const result = await config.processV2({
|
||||
inputs,
|
||||
settings,
|
||||
scratchDir,
|
||||
signal,
|
||||
report: () => {},
|
||||
});
|
||||
if (result.buffer) return result.buffer;
|
||||
// Await inside the try block so cleanup cannot remove the scratch tree
|
||||
// before the asynchronous read has opened and consumed the output.
|
||||
if (result.scratchPath) return await readFile(result.scratchPath);
|
||||
throw new Error(`Tool ${config.toolId} returned neither buffer nor scratchPath`);
|
||||
} finally {
|
||||
await rm(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
|
||||
"passport-photo": { countryCode: "US" },
|
||||
"trim-video": { startS: 0, endS: 5 },
|
||||
"trim-audio": { startS: 0, endS: 5 },
|
||||
"split-audio": { mode: "parts", parts: 2 },
|
||||
"split-pdf": { mode: "range", range: "1" },
|
||||
"extract-pages": { range: "1" },
|
||||
"remove-pages": { pages: "2" },
|
||||
|
||||
Reference in New Issue
Block a user