/*
RFC-0891: nachweis.screenshot.process command handler — transforms a raw full-page screenshot into a 16:9 display variant (1280x720, WebP) and uploads to R2 public.
- Does not ingest raw screenshots — use nachweis.screenshot.ingest (RFC-0890).
- Does not upload pre-processed files — use nachweis.screenshot.upload (RFC-0886).
- Does not validate screenshot content quality or visual correctness.
- RFC-0891: initial nachweis.screenshot.process command handler.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10).
- RFC-1254: drop unused io-migration leftovers
*/
import path from "node:path";
import { createHash } from "node:crypto";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
parseMarkdownFrontmatter,
stringifyMarkdownFrontmatter,
} from "@warpgogol/werkstatt-shared/content";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts";
import { writeFileIfChanged } from "@warpgogol/werkstatt-engine/kernel";
import {
isNachweisEntitled,
makeSkipResult,
resolveNachweisCachePath,
resolvePbpEntityDir,
resolveDefaultLang,
resolveNachweisScreenshotDisplayR2Path,
resolveNachweisRawScreenshotLocalPath,
uploadToR2,
downloadFromR2,
type NachweisScreenshotProcessResult,
} from "./nachweis-io.ts";
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
function flagNumber(input: KernelCommandInput, key: string): number | undefined {
const v = input.flags[key];
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return undefined;
}
export async function runNachweisScreenshotProcess(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagString(input, "system") ?? context.site?.name;
const slug = flagString(input, "slug");
const dryRun = context.dryRun === true || flagBool(input, "dry-run");
const cropOffset = flagNumber(input, "crop-offset") ?? 0;
if (!systemId) throw new Error("[nachweis.screenshot.process] --system is required");
if (!slug) throw new Error("[nachweis.screenshot.process] --slug is required");
const entitled = await isNachweisEntitled(workspaceRoot, systemId);
if (!entitled) {
return makeSkipResult(
"nachweis.screenshot.process",
systemId,
) as unknown as KernelCommandResult;
}
const cachePath = await resolveNachweisCachePath(workspaceRoot, systemId);
const lang = await resolveDefaultLang(cachePath);
const evidenceDir = resolvePbpEntityDir(cachePath, lang, "evidence-source");
const evidenceFile = path.join(evidenceDir, `${slug}.md`);
if (!(await context.io.exists(evidenceFile))) {
throw new Error(
`[nachweis.screenshot.process] NOT_FOUND: evidence-source '${slug}' not found at ${evidenceFile}`,
);
}
const rawEvidence = await context.io.readFile(evidenceFile);
const { data: evidenceData } = parseMarkdownFrontmatter(rawEvidence);
const websiteScreenshot = evidenceData.websiteScreenshot as
| {
rawArtifact?: {
sha256?: string;
mediaType?: string;
originalFilename?: string;
width?: number;
height?: number;
r2Key?: string;
localPath?: string;
capturedAt?: string;
};
}
| undefined;
const rawArtifact = websiteScreenshot?.rawArtifact;
if (!rawArtifact) {
throw new Error(
`[nachweis.screenshot.process] no rawArtifact found on websiteScreenshot for '${slug}'. Run nachweis.screenshot.ingest first.`,
);
}
const originalFilename = rawArtifact.originalFilename;
if (!originalFilename) {
throw new Error(
`[nachweis.screenshot.process] rawArtifact.originalFilename is missing for '${slug}'`,
);
}
// Resolve raw file: check cache clone local copy first, fallback to R2 private
const localRawPath = resolveNachweisRawScreenshotLocalPath(cachePath, slug, originalFilename);
let rawFilePath: string;
let rawBuffer: Uint8Array;
if (await context.io.exists(localRawPath)) {
rawFilePath = localRawPath;
rawBuffer = new Uint8Array(await context.io.readFileBytes(localRawPath));
} else {
// Fallback: download from R2 private
const r2Key = rawArtifact.r2Key;
if (!r2Key) {
throw new Error(
`[nachweis.screenshot.process] rawArtifact not found locally and no r2Key for fallback. Local path: ${localRawPath}`,
);
}
logger.info(
`[nachweis.screenshot.process] raw file not in cache clone, downloading from R2: ${r2Key}`,
);
rawBuffer = await downloadFromR2(r2Key);
// Write to temp file for sharp to read
rawFilePath = path.join(
cachePath,
"trust",
"evidence",
"screenshots",
slug,
"raw",
originalFilename,
);
await context.io.mkdir(path.dirname(rawFilePath));
await context.io.writeFile(rawFilePath, rawBuffer);
}
// Compute SHA-256 of raw file
const rawSha256 = createHash("sha256").update(rawBuffer).digest("hex");
// Read raw image metadata via sharp
const sharp = (await import("sharp")).default;
const metadata = await sharp(rawFilePath).metadata();
const rawWidth = metadata.width;
const rawHeight = metadata.height;
if (!rawWidth || !rawHeight) {
throw new Error(
`[nachweis.screenshot.process] could not read image dimensions from ${rawFilePath} (format: ${metadata.format ?? "unknown"})`,
);
}
// Compute 16:9 crop region
const cropHeight = Math.min(Math.round((rawWidth * 9) / 16), rawHeight);
const cropWidth = cropHeight === rawHeight ? Math.round((rawHeight * 16) / 9) : rawWidth;
const cropLeft = cropWidth < rawWidth ? Math.round((rawWidth - cropWidth) / 2) : 0;
const cropTop = cropOffset;
// Validate crop offset boundary
if (cropTop + cropHeight > rawHeight) {
const maxOffset = rawHeight - cropHeight;
throw new Error(
`[nachweis.screenshot.process] --crop-offset ${cropTop} places crop region beyond raw image boundary (cropOffset + cropHeight = ${cropTop + cropHeight} > rawHeight = ${rawHeight}). Maximum allowed offset: ${maxOffset}`,
);
}
const r2Key = resolveNachweisScreenshotDisplayR2Path(systemId, slug);
const capturedAt = rawArtifact.capturedAt ?? null;
if (dryRun) {
return {
data: {
slug,
systemId,
rawSha256,
rawDimensions: { width: rawWidth, height: rawHeight },
cropRegion: { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight },
displaySha256: "",
displayMediaType: "image/webp",
displayWidth: 1280,
displayHeight: 720,
r2Key,
capturedAt,
bordbuchEventId: "",
},
exitCode: 0,
summary: `[nachweis.screenshot.process] ${systemId}: DRY RUN — would process screenshot for '${slug}' (crop: ${cropWidth}x${cropHeight} at offset ${cropTop})`,
};
}
// Crop + resize + convert to WebP
const displayBuffer = await sharp(rawFilePath)
.extract({ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight })
.resize(1280, 720, { fit: "cover" })
.webp({ quality: 100 })
.toBuffer();
const displaySha256 = createHash("sha256").update(displayBuffer).digest("hex");
// Upload to R2 public (backup/archive)
await uploadToR2(displayBuffer, r2Key, "image/webp");
// Copy to public/ for serving from the site
const publicScreenshotDir = path.join(cachePath, "public", "nachweis-screenshots");
const publicScreenshotPath = path.join(publicScreenshotDir, `${slug}.webp`);
const publicUrl = `/nachweis-screenshots/${slug}.webp`;
await context.io.mkdir(publicScreenshotDir);
await writeFileIfChanged(publicScreenshotPath, Buffer.from(displayBuffer));
// Update EvidenceSource.websiteScreenshot, preserving rawArtifact
const { data: currentEvidence, content: evidenceContent } = parseMarkdownFrontmatter(
await context.io.readFile(evidenceFile),
);
const currentScreenshot = (currentEvidence.websiteScreenshot ?? {}) as Record;
currentEvidence.websiteScreenshot = {
...currentScreenshot,
sha256: displaySha256,
mediaType: "image/webp",
storage: "public",
url: publicUrl,
r2Key,
...(capturedAt ? { capturedAt } : {}),
};
const updatedContent = stringifyMarkdownFrontmatter(evidenceContent, currentEvidence);
await context.io.writeFile(evidenceFile, updatedContent);
logger.info(
`[nachweis.screenshot.process] processed display variant for '${slug}' to ${publicUrl}`,
);
// Append Bordbuch entry
const operationId = generateOperationId();
await acquireLock(
workspaceRoot,
`system:${systemId}`,
operationId,
"nachweis.screenshot.process",
"agent",
);
await acquireLock(
workspaceRoot,
`bordbuch:${systemId}`,
operationId,
"nachweis.screenshot.process",
"agent",
);
let bordbuchEventId: string;
try {
const { entry } = await appendAndCommitBordbuch(
workspaceRoot,
systemId,
"nachweis-record",
`Display screenshot processed for '${slug}'`,
"agent",
{
writerRole: "nachweis",
metadata: {
slug,
displaySha256,
displayMediaType: "image/webp",
displayWidth: 1280,
displayHeight: 720,
rawSha256,
r2Key,
},
},
`Bordbuch: nachweis-record ${systemId} ${slug} display-screenshot`,
);
bordbuchEventId = entry.id;
} finally {
await releaseLock(workspaceRoot, `bordbuch:${systemId}`);
await releaseLock(workspaceRoot, `system:${systemId}`);
}
return {
data: {
slug,
systemId,
rawSha256,
rawDimensions: { width: rawWidth, height: rawHeight },
cropRegion: { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight },
displaySha256,
displayMediaType: "image/webp",
displayWidth: 1280,
displayHeight: 720,
r2Key,
capturedAt,
bordbuchEventId,
},
exitCode: 0,
summary: `[nachweis.screenshot.process] ${systemId}: display variant for '${slug}' processed (bordbuch: ${bordbuchEventId})`,
nextSteps: [
{
action: `Upload the public derivative: pnpm exec werkstatt run nachweis.public-derivative --site ${systemId} --slug ${slug}`,
kind: "optional",
},
],
};
}