diff --git a/supplementary-media-accessibility-guard/README.md b/supplementary-media-accessibility-guard/README.md
new file mode 100644
index 00000000..87184d63
--- /dev/null
+++ b/supplementary-media-accessibility-guard/README.md
@@ -0,0 +1,38 @@
+# Supplementary Media Accessibility Guard
+
+This module is a focused Scientific/Engineering Data & Code Hosting slice for SCIBASE issue #14. It validates hosted supplementary media before public previews, API access, archive exports, or reviewer packets are released.
+
+The guard checks:
+
+- alt text for images, figure panels, and thumbnails
+- captions for figures, microscopy clips, and protocol videos
+- transcript and timecoded segment coverage for videos
+- thumbnail provenance against the source artifact checksum
+- checksum and metadata parity between source media and previews
+- embargo and restricted-access state before public release
+- DataCite and schema.org accessibility metadata readiness
+- deterministic reviewer actions to release, revise, or hold previews
+
+It is intentionally separate from broad FAIR manifests, artifact package integrity, preview cache/version drift, raw-instrument previews, notebook previews, retention/tombstones, model-card lineage, license metadata, sensitive redaction, schema evolution, data dictionaries, persistent identifiers, SBOM/advisory checks, upload checkpoints, replica consistency, column sensitivity, malware/archive quarantine, and sandbox egress. This slice focuses on accessibility and provenance readiness for supplementary scientific media.
+
+## Reviewer Path
+
+```bash
+npm run check
+npm test
+npm run demo
+npm run verify-video
+```
+
+Generated reviewer artifacts:
+
+- `reports/clean-media-packet.json`
+- `reports/risky-media-packet.json`
+- `reports/media-accessibility-report.md`
+- `reports/summary.svg`
+- `reports/demo-script.txt`
+- `reports/demo.mp4`
+
+## Safety
+
+All fixtures are synthetic. The module does not call live media stores, private projects, uploaded datasets, customer portals, payment systems, credential stores, external APIs, or financial accounts.
diff --git a/supplementary-media-accessibility-guard/demo.js b/supplementary-media-accessibility-guard/demo.js
new file mode 100644
index 00000000..52cbc5ab
--- /dev/null
+++ b/supplementary-media-accessibility-guard/demo.js
@@ -0,0 +1,50 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { evaluateMediaPacket, renderMarkdownReport, renderSvgSummary } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const cleanEvaluation = evaluateMediaPacket(cleanPacket);
+const riskyEvaluation = evaluateMediaPacket(riskyPacket);
+
+fs.writeFileSync(
+ path.join(reportsDir, "clean-media-packet.json"),
+ `${JSON.stringify({ input: cleanPacket, evaluation: cleanEvaluation }, null, 2)}\n`
+);
+fs.writeFileSync(
+ path.join(reportsDir, "risky-media-packet.json"),
+ `${JSON.stringify({ input: riskyPacket, evaluation: riskyEvaluation }, null, 2)}\n`
+);
+fs.writeFileSync(
+ path.join(reportsDir, "media-accessibility-report.md"),
+ renderMarkdownReport(riskyPacket, riskyEvaluation)
+);
+fs.writeFileSync(
+ path.join(reportsDir, "summary.svg"),
+ renderSvgSummary(riskyEvaluation)
+);
+fs.writeFileSync(
+ path.join(reportsDir, "demo-script.txt"),
+ [
+ "Supplementary media accessibility preview guard demo",
+ "",
+ `Clean packet decision: ${cleanEvaluation.summary.decision}`,
+ `Clean audit digest: ${cleanEvaluation.summary.auditDigest}`,
+ "",
+ `Risky packet decision: ${riskyEvaluation.summary.decision}`,
+ `Risky finding count: ${riskyEvaluation.summary.findingCount}`,
+ `Risky audit digest: ${riskyEvaluation.summary.auditDigest}`,
+ "",
+ "The risky packet demonstrates an embargoed restricted video exposed for public preview, incomplete transcript coverage, unlabeled segments, thumbnail/preview checksum drift, missing alt text, missing captions, and incomplete accessibility metadata.",
+ ""
+ ].join("\n")
+);
+
+console.log(JSON.stringify({
+ cleanDecision: cleanEvaluation.summary.decision,
+ riskyDecision: riskyEvaluation.summary.decision,
+ riskyFindings: riskyEvaluation.summary.findingCount,
+ report: "reports/media-accessibility-report.md"
+}, null, 2));
diff --git a/supplementary-media-accessibility-guard/index.js b/supplementary-media-accessibility-guard/index.js
new file mode 100644
index 00000000..ecef112f
--- /dev/null
+++ b/supplementary-media-accessibility-guard/index.js
@@ -0,0 +1,334 @@
+const crypto = require("node:crypto");
+
+function asArray(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function stableJson(value) {
+ if (Array.isArray(value)) {
+ return `[${value.map(stableJson).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function sha256(value) {
+ return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
+}
+
+function toDate(value) {
+ if (!value) {
+ return null;
+ }
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function daysBetween(laterValue, earlierValue) {
+ const later = toDate(laterValue);
+ const earlier = toDate(earlierValue);
+ if (!later || !earlier) {
+ return null;
+ }
+ return Math.floor((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000));
+}
+
+function severityRank(severity) {
+ return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0;
+}
+
+function addFinding(findings, severity, code, message, refs, action) {
+ findings.push({
+ severity,
+ code,
+ message,
+ refs: asArray(refs),
+ action
+ });
+}
+
+function evaluateMediaPacket(packet) {
+ const findings = [];
+ const releaseDate = packet.releaseDate || new Date().toISOString().slice(0, 10);
+ const artifacts = asArray(packet.mediaArtifacts);
+ const coverage = [];
+
+ for (const artifact of artifacts) {
+ const refs = [artifact.id || "media-artifact"];
+ const type = artifact.type || "unknown";
+ const requiresAlt = ["image", "figure_panel", "thumbnail", "microscopy_image"].includes(type);
+ const requiresTranscript = ["video", "microscopy_clip", "protocol_video"].includes(type);
+ const altText = String(artifact.altText || "").trim();
+ const caption = String(artifact.caption || "").trim();
+ const transcriptSegments = asArray(artifact.transcriptSegments);
+ const metadata = artifact.accessibilityMetadata || {};
+
+ if (requiresAlt && altText.length < 24) {
+ addFinding(
+ findings,
+ "high",
+ "ALT_TEXT_MISSING_OR_TOO_SHORT",
+ `${artifact.id || "media"} needs descriptive alt text before public preview.`,
+ refs,
+ "add_descriptive_alt_text"
+ );
+ }
+
+ if (caption.length < 24) {
+ addFinding(
+ findings,
+ "medium",
+ "CAPTION_MISSING_OR_TOO_SHORT",
+ `${artifact.id || "media"} needs a reviewer-facing caption for preview and export packets.`,
+ refs,
+ "add_reviewer_caption"
+ );
+ }
+
+ if (requiresTranscript) {
+ const transcriptCoverage = coveredSeconds(transcriptSegments, Number(artifact.durationSeconds || 0));
+ if (transcriptCoverage < 0.9) {
+ addFinding(
+ findings,
+ "high",
+ "TRANSCRIPT_COVERAGE_INCOMPLETE",
+ `${artifact.id || "video"} has ${(transcriptCoverage * 100).toFixed(0)}% transcript coverage.`,
+ refs,
+ "complete_timecoded_transcript"
+ );
+ }
+ const unlabeledSegments = transcriptSegments.filter((segment) => !String(segment.label || "").trim());
+ if (unlabeledSegments.length > 0) {
+ addFinding(
+ findings,
+ "medium",
+ "TIMECODE_LABEL_MISSING",
+ `${artifact.id || "video"} has ${unlabeledSegments.length} transcript segments without scientific labels.`,
+ refs,
+ "label_transcript_segments"
+ );
+ }
+ }
+
+ if (artifact.publicPreview === true && artifact.embargoUntil) {
+ const daysRemaining = daysBetween(artifact.embargoUntil, releaseDate);
+ if (daysRemaining !== null && daysRemaining > 0) {
+ addFinding(
+ findings,
+ "critical",
+ "EMBARGOED_MEDIA_PUBLIC_PREVIEW",
+ `${artifact.id || "media"} is previewable while embargoed for ${daysRemaining} more days.`,
+ refs,
+ "hold_embargoed_media_preview"
+ );
+ }
+ }
+
+ if (artifact.accessState === "restricted" && artifact.publicPreview === true) {
+ addFinding(
+ findings,
+ "critical",
+ "RESTRICTED_MEDIA_PUBLIC_PREVIEW",
+ `${artifact.id || "media"} is restricted but marked for public preview.`,
+ refs,
+ "disable_public_preview_for_restricted_media"
+ );
+ }
+
+ const thumbnail = artifact.thumbnail || {};
+ if (thumbnail.sourceChecksum && artifact.checksum && thumbnail.sourceChecksum !== artifact.checksum) {
+ addFinding(
+ findings,
+ "high",
+ "THUMBNAIL_PROVENANCE_DRIFT",
+ `${artifact.id || "media"} thumbnail was generated from a checksum that differs from the hosted source artifact.`,
+ refs,
+ "regenerate_thumbnail_from_current_artifact"
+ );
+ }
+
+ if (artifact.previewChecksum && artifact.checksum && artifact.previewSourceChecksum && artifact.previewSourceChecksum !== artifact.checksum) {
+ addFinding(
+ findings,
+ "high",
+ "PREVIEW_SOURCE_CHECKSUM_DRIFT",
+ `${artifact.id || "media"} preview source checksum does not match the current artifact checksum.`,
+ refs,
+ "regenerate_preview_from_current_artifact"
+ );
+ }
+
+ const requiredMetadata = [
+ ["accessMode", metadata.accessMode],
+ ["accessibilityFeature", metadata.accessibilityFeature],
+ ["encodingFormat", metadata.encodingFormat],
+ ["schemaOrgType", metadata.schemaOrgType]
+ ];
+ const missingMetadata = requiredMetadata.filter(([, value]) => !String(value || "").trim()).map(([key]) => key);
+ if (missingMetadata.length > 0) {
+ addFinding(
+ findings,
+ "medium",
+ "ACCESSIBILITY_METADATA_INCOMPLETE",
+ `${artifact.id || "media"} is missing accessibility metadata: ${missingMetadata.join(", ")}.`,
+ refs,
+ "complete_datacite_schema_org_accessibility_metadata"
+ );
+ }
+
+ coverage.push({
+ id: artifact.id,
+ type,
+ publicPreview: Boolean(artifact.publicPreview),
+ hasAltText: altText.length >= 24,
+ hasCaption: caption.length >= 24,
+ transcriptCoverage: requiresTranscript ? coveredSeconds(transcriptSegments, Number(artifact.durationSeconds || 0)) : null,
+ metadataComplete: missingMetadata.length === 0
+ });
+ }
+
+ if (artifacts.length === 0) {
+ addFinding(
+ findings,
+ "high",
+ "MEDIA_PACKET_EMPTY",
+ "No supplementary media artifacts were supplied for review.",
+ [packet.projectId || "project"],
+ "attach_media_artifacts_before_export"
+ );
+ }
+
+ findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code));
+ const decision = findings.some((finding) => severityRank(finding.severity) >= 3)
+ ? "hold_media_preview_release"
+ : findings.some((finding) => finding.severity === "medium")
+ ? "revise_media_accessibility_packet"
+ : "release_media_preview_packet";
+
+ const summary = {
+ projectId: packet.projectId,
+ releaseDate,
+ decision,
+ mediaReviewed: artifacts.length,
+ findingCount: findings.length,
+ highOrCriticalFindings: findings.filter((finding) => severityRank(finding.severity) >= 3).length
+ };
+ const auditDigest = `sha256:${sha256({ summary, findings, coverage }).slice(0, 16)}`;
+
+ return {
+ summary: {
+ ...summary,
+ auditDigest
+ },
+ coverage,
+ findings,
+ actions: buildActions(findings)
+ };
+}
+
+function coveredSeconds(segments, durationSeconds) {
+ if (!durationSeconds || durationSeconds < 1) {
+ return segments.length > 0 ? 1 : 0;
+ }
+ const covered = asArray(segments).reduce((sum, segment) => {
+ const start = Number(segment.startSeconds || 0);
+ const end = Number(segment.endSeconds || 0);
+ return sum + Math.max(0, Math.min(durationSeconds, end) - Math.max(0, start));
+ }, 0);
+ return Math.max(0, Math.min(1, covered / durationSeconds));
+}
+
+function buildActions(findings) {
+ const seen = new Set();
+ const actions = [];
+ for (const finding of findings) {
+ if (!finding.action || seen.has(finding.action)) {
+ continue;
+ }
+ seen.add(finding.action);
+ actions.push({
+ id: finding.action,
+ severity: finding.severity,
+ refs: finding.refs
+ });
+ }
+ return actions;
+}
+
+function renderMarkdownReport(packet, evaluation) {
+ const lines = [];
+ lines.push(`# Supplementary Media Accessibility Review: ${packet.projectId}`);
+ lines.push("");
+ lines.push(`Decision: **${evaluation.summary.decision}**`);
+ lines.push(`Audit digest: \`${evaluation.summary.auditDigest}\``);
+ lines.push("");
+ lines.push("## Findings");
+ lines.push("");
+ if (evaluation.findings.length === 0) {
+ lines.push("No supplementary media accessibility blockers were detected.");
+ } else {
+ lines.push("| Severity | Code | Message | Action |");
+ lines.push("| --- | --- | --- | --- |");
+ for (const finding of evaluation.findings) {
+ lines.push(`| ${finding.severity} | \`${finding.code}\` | ${escapeMarkdown(finding.message)} | \`${finding.action}\` |`);
+ }
+ }
+ lines.push("");
+ lines.push("## Media Coverage");
+ lines.push("");
+ lines.push("| Artifact | Type | Public preview | Alt text | Caption | Transcript coverage | Metadata |");
+ lines.push("| --- | --- | --- | --- | --- | --- | --- |");
+ for (const item of evaluation.coverage) {
+ const transcript = item.transcriptCoverage === null ? "n/a" : `${Math.round(item.transcriptCoverage * 100)}%`;
+ lines.push(`| ${item.id || ""} | ${item.type} | ${item.publicPreview ? "yes" : "no"} | ${item.hasAltText ? "yes" : "no"} | ${item.hasCaption ? "yes" : "no"} | ${transcript} | ${item.metadataComplete ? "complete" : "incomplete"} |`);
+ }
+ lines.push("");
+ lines.push("Synthetic data only. No live media stores, private projects, uploaded datasets, credential stores, payment systems, financial accounts, or external APIs are used.");
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvgSummary(evaluation) {
+ const color = evaluation.summary.decision === "hold_media_preview_release"
+ ? "#b91c1c"
+ : evaluation.summary.decision === "revise_media_accessibility_packet"
+ ? "#b45309"
+ : "#047857";
+ const rows = evaluation.findings.slice(0, 5).map((finding, index) => {
+ const y = 304 + index * 42;
+ return `${escapeXml(finding.severity.toUpperCase())} ${escapeXml(finding.code)}`;
+ }).join("\n");
+ return `
+
+`;
+}
+
+function escapeMarkdown(value) {
+ return String(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+module.exports = {
+ evaluateMediaPacket,
+ renderMarkdownReport,
+ renderSvgSummary,
+ sha256
+};
diff --git a/supplementary-media-accessibility-guard/make-demo-video.js b/supplementary-media-accessibility-guard/make-demo-video.js
new file mode 100644
index 00000000..a4b1e56e
--- /dev/null
+++ b/supplementary-media-accessibility-guard/make-demo-video.js
@@ -0,0 +1,91 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+const { evaluateMediaPacket } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+const framesDir = path.join(reportsDir, "frames");
+fs.mkdirSync(framesDir, { recursive: true });
+
+const clean = evaluateMediaPacket(cleanPacket);
+const risky = evaluateMediaPacket(riskyPacket);
+const width = 960;
+const height = 540;
+const frames = 72;
+const fps = 18;
+
+function setPixel(buffer, x, y, r, g, b) {
+ if (x < 0 || y < 0 || x >= width || y >= height) {
+ return;
+ }
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+}
+
+function fillRect(buffer, x, y, w, h, r, g, b) {
+ for (let row = y; row < y + h; row += 1) {
+ for (let col = x; col < x + w; col += 1) {
+ setPixel(buffer, col, row, r, g, b);
+ }
+ }
+}
+
+function writeFrame(index, progress) {
+ const buffer = Buffer.alloc(width * height * 3, 248);
+ fillRect(buffer, 0, 0, width, height, 248, 250, 252);
+ fillRect(buffer, 54, 48, 852, 444, 255, 255, 255);
+ fillRect(buffer, 54, 48, 852, 8, 15, 23, 42);
+
+ const cleanWidth = Math.floor(320 * Math.min(1, progress * 1.7));
+ const riskyWidth = Math.floor(320 * Math.max(0, (progress - 0.25) * 1.5));
+ fillRect(buffer, 102, 108, 320, 68, 229, 231, 235);
+ fillRect(buffer, 102, 108, cleanWidth, 68, 5, 150, 105);
+ fillRect(buffer, 538, 108, 320, 68, 229, 231, 235);
+ fillRect(buffer, 538, 108, riskyWidth, 68, 185, 28, 28);
+
+ for (let i = 0; i < clean.summary.mediaReviewed; i += 1) {
+ fillRect(buffer, 118 + i * 70, 238, 44, 116, 16, 185, 129);
+ fillRect(buffer, 124 + i * 70, 248, 32, 24, 255, 255, 255);
+ }
+
+ for (let i = 0; i < Math.min(9, risky.summary.findingCount); i += 1) {
+ const barHeight = 34 + (i % 5) * 22;
+ fillRect(buffer, 548 + i * 30, 372 - barHeight, 22, barHeight, 220, 38, 38);
+ }
+
+ fillRect(buffer, 104, 430, Math.floor(752 * progress), 18, 37, 99, 235);
+ fillRect(buffer, 104, 458, Math.floor(520 * progress), 18, 180, 83, 9);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii");
+ fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer]));
+}
+
+for (let index = 0; index < frames; index += 1) {
+ writeFrame(index, index / (frames - 1));
+}
+
+const output = path.join(reportsDir, "demo.mp4");
+const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
+const result = spawnSync(ffmpeg, [
+ "-y",
+ "-framerate",
+ String(fps),
+ "-i",
+ path.join(framesDir, "frame-%03d.ppm"),
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ output
+], { stdio: "inherit" });
+
+fs.rmSync(framesDir, { recursive: true, force: true });
+
+if (result.status !== 0) {
+ process.exit(result.status || 1);
+}
+
+console.log(`Wrote ${output}`);
diff --git a/supplementary-media-accessibility-guard/package.json b/supplementary-media-accessibility-guard/package.json
new file mode 100644
index 00000000..5e7eb9a3
--- /dev/null
+++ b/supplementary-media-accessibility-guard/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "supplementary-media-accessibility-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Synthetic supplementary media accessibility preview guard for SCIBASE scientific data and code hosting.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js",
+ "test": "node test.js",
+ "demo": "node demo.js && node make-demo-video.js",
+ "verify-video": "ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,duration,avg_frame_rate -show_entries format=duration,size -of default=noprint_wrappers=1 reports/demo.mp4"
+ },
+ "keywords": [
+ "scibase",
+ "data-code-hosting",
+ "supplementary-media",
+ "accessibility",
+ "metadata-preview"
+ ],
+ "license": "MIT"
+}
diff --git a/supplementary-media-accessibility-guard/reports/clean-media-packet.json b/supplementary-media-accessibility-guard/reports/clean-media-packet.json
new file mode 100644
index 00000000..a0bf0ca9
--- /dev/null
+++ b/supplementary-media-accessibility-guard/reports/clean-media-packet.json
@@ -0,0 +1,103 @@
+{
+ "input": {
+ "projectId": "synthetic-microscopy-atlas-v2",
+ "releaseDate": "2026-06-01",
+ "mediaArtifacts": [
+ {
+ "id": "figure-panel-astrocyte-calcium",
+ "type": "figure_panel",
+ "checksum": "sha256:figure-panel-001",
+ "previewChecksum": "sha256:figure-preview-001",
+ "previewSourceChecksum": "sha256:figure-panel-001",
+ "publicPreview": true,
+ "accessState": "public",
+ "altText": "Line plot showing astrocyte calcium signal rising after stimulation and returning to baseline.",
+ "caption": "Figure panel summarizing calcium response traces for the synthetic astrocyte cohort.",
+ "thumbnail": {
+ "id": "thumb-figure-panel-001",
+ "sourceChecksum": "sha256:figure-panel-001",
+ "generatedAt": "2026-05-29"
+ },
+ "accessibilityMetadata": {
+ "accessMode": "visual",
+ "accessibilityFeature": "alternativeText",
+ "encodingFormat": "image/png",
+ "schemaOrgType": "ImageObject"
+ }
+ },
+ {
+ "id": "protocol-video-imaging-setup",
+ "type": "protocol_video",
+ "checksum": "sha256:protocol-video-002",
+ "previewChecksum": "sha256:protocol-preview-002",
+ "previewSourceChecksum": "sha256:protocol-video-002",
+ "publicPreview": true,
+ "accessState": "public",
+ "durationSeconds": 120,
+ "altText": "",
+ "caption": "Protocol video demonstrating the synthetic imaging setup and calibration sequence.",
+ "transcriptSegments": [
+ {
+ "startSeconds": 0,
+ "endSeconds": 45,
+ "label": "Microscope alignment"
+ },
+ {
+ "startSeconds": 45,
+ "endSeconds": 92,
+ "label": "Stage calibration"
+ },
+ {
+ "startSeconds": 92,
+ "endSeconds": 120,
+ "label": "Acquisition settings"
+ }
+ ],
+ "thumbnail": {
+ "id": "thumb-video-002",
+ "sourceChecksum": "sha256:protocol-video-002",
+ "generatedAt": "2026-05-29"
+ },
+ "accessibilityMetadata": {
+ "accessMode": "visual",
+ "accessibilityFeature": "transcript",
+ "encodingFormat": "video/mp4",
+ "schemaOrgType": "VideoObject"
+ }
+ }
+ ]
+ },
+ "evaluation": {
+ "summary": {
+ "projectId": "synthetic-microscopy-atlas-v2",
+ "releaseDate": "2026-06-01",
+ "decision": "release_media_preview_packet",
+ "mediaReviewed": 2,
+ "findingCount": 0,
+ "highOrCriticalFindings": 0,
+ "auditDigest": "sha256:e9e87184f71e42ee"
+ },
+ "coverage": [
+ {
+ "id": "figure-panel-astrocyte-calcium",
+ "type": "figure_panel",
+ "publicPreview": true,
+ "hasAltText": true,
+ "hasCaption": true,
+ "transcriptCoverage": null,
+ "metadataComplete": true
+ },
+ {
+ "id": "protocol-video-imaging-setup",
+ "type": "protocol_video",
+ "publicPreview": true,
+ "hasAltText": false,
+ "hasCaption": true,
+ "transcriptCoverage": 1,
+ "metadataComplete": true
+ }
+ ],
+ "findings": [],
+ "actions": []
+ }
+}
diff --git a/supplementary-media-accessibility-guard/reports/demo-script.txt b/supplementary-media-accessibility-guard/reports/demo-script.txt
new file mode 100644
index 00000000..7732f836
--- /dev/null
+++ b/supplementary-media-accessibility-guard/reports/demo-script.txt
@@ -0,0 +1,10 @@
+Supplementary media accessibility preview guard demo
+
+Clean packet decision: release_media_preview_packet
+Clean audit digest: sha256:e9e87184f71e42ee
+
+Risky packet decision: hold_media_preview_release
+Risky finding count: 11
+Risky audit digest: sha256:9a556f472cf3e16c
+
+The risky packet demonstrates an embargoed restricted video exposed for public preview, incomplete transcript coverage, unlabeled segments, thumbnail/preview checksum drift, missing alt text, missing captions, and incomplete accessibility metadata.
diff --git a/supplementary-media-accessibility-guard/reports/demo.mp4 b/supplementary-media-accessibility-guard/reports/demo.mp4
new file mode 100644
index 00000000..49761b56
Binary files /dev/null and b/supplementary-media-accessibility-guard/reports/demo.mp4 differ
diff --git a/supplementary-media-accessibility-guard/reports/media-accessibility-report.md b/supplementary-media-accessibility-guard/reports/media-accessibility-report.md
new file mode 100644
index 00000000..41026f75
--- /dev/null
+++ b/supplementary-media-accessibility-guard/reports/media-accessibility-report.md
@@ -0,0 +1,29 @@
+# Supplementary Media Accessibility Review: synthetic-embargoed-behavior-video-v1
+
+Decision: **hold_media_preview_release**
+Audit digest: `sha256:9a556f472cf3e16c`
+
+## Findings
+
+| Severity | Code | Message | Action |
+| --- | --- | --- | --- |
+| critical | `EMBARGOED_MEDIA_PUBLIC_PREVIEW` | embargoed-protocol-video is previewable while embargoed for 24 more days. | `hold_embargoed_media_preview` |
+| critical | `RESTRICTED_MEDIA_PUBLIC_PREVIEW` | embargoed-protocol-video is restricted but marked for public preview. | `disable_public_preview_for_restricted_media` |
+| high | `ALT_TEXT_MISSING_OR_TOO_SHORT` | microscopy-panel-no-alt needs descriptive alt text before public preview. | `add_descriptive_alt_text` |
+| high | `PREVIEW_SOURCE_CHECKSUM_DRIFT` | embargoed-protocol-video preview source checksum does not match the current artifact checksum. | `regenerate_preview_from_current_artifact` |
+| high | `THUMBNAIL_PROVENANCE_DRIFT` | embargoed-protocol-video thumbnail was generated from a checksum that differs from the hosted source artifact. | `regenerate_thumbnail_from_current_artifact` |
+| high | `TRANSCRIPT_COVERAGE_INCOMPLETE` | embargoed-protocol-video has 42% transcript coverage. | `complete_timecoded_transcript` |
+| medium | `ACCESSIBILITY_METADATA_INCOMPLETE` | embargoed-protocol-video is missing accessibility metadata: accessMode, accessibilityFeature, schemaOrgType. | `complete_datacite_schema_org_accessibility_metadata` |
+| medium | `ACCESSIBILITY_METADATA_INCOMPLETE` | microscopy-panel-no-alt is missing accessibility metadata: accessibilityFeature. | `complete_datacite_schema_org_accessibility_metadata` |
+| medium | `CAPTION_MISSING_OR_TOO_SHORT` | embargoed-protocol-video needs a reviewer-facing caption for preview and export packets. | `add_reviewer_caption` |
+| medium | `CAPTION_MISSING_OR_TOO_SHORT` | microscopy-panel-no-alt needs a reviewer-facing caption for preview and export packets. | `add_reviewer_caption` |
+| medium | `TIMECODE_LABEL_MISSING` | embargoed-protocol-video has 1 transcript segments without scientific labels. | `label_transcript_segments` |
+
+## Media Coverage
+
+| Artifact | Type | Public preview | Alt text | Caption | Transcript coverage | Metadata |
+| --- | --- | --- | --- | --- | --- | --- |
+| embargoed-protocol-video | protocol_video | yes | no | no | 42% | incomplete |
+| microscopy-panel-no-alt | microscopy_image | yes | no | no | n/a | incomplete |
+
+Synthetic data only. No live media stores, private projects, uploaded datasets, credential stores, payment systems, financial accounts, or external APIs are used.
diff --git a/supplementary-media-accessibility-guard/reports/risky-media-packet.json b/supplementary-media-accessibility-guard/reports/risky-media-packet.json
new file mode 100644
index 00000000..ad475f8d
--- /dev/null
+++ b/supplementary-media-accessibility-guard/reports/risky-media-packet.json
@@ -0,0 +1,263 @@
+{
+ "input": {
+ "projectId": "synthetic-embargoed-behavior-video-v1",
+ "releaseDate": "2026-06-01",
+ "mediaArtifacts": [
+ {
+ "id": "embargoed-protocol-video",
+ "type": "protocol_video",
+ "checksum": "sha256:protocol-video-current",
+ "previewChecksum": "sha256:protocol-video-preview-old",
+ "previewSourceChecksum": "sha256:protocol-video-previous",
+ "publicPreview": true,
+ "accessState": "restricted",
+ "embargoUntil": "2026-06-25",
+ "durationSeconds": 180,
+ "altText": "",
+ "caption": "Short clip.",
+ "transcriptSegments": [
+ {
+ "startSeconds": 0,
+ "endSeconds": 38,
+ "label": "Task setup"
+ },
+ {
+ "startSeconds": 38,
+ "endSeconds": 75,
+ "label": ""
+ }
+ ],
+ "thumbnail": {
+ "id": "thumb-video-risky",
+ "sourceChecksum": "sha256:protocol-video-previous",
+ "generatedAt": "2026-05-01"
+ },
+ "accessibilityMetadata": {
+ "accessMode": "",
+ "accessibilityFeature": "",
+ "encodingFormat": "video/mp4",
+ "schemaOrgType": ""
+ }
+ },
+ {
+ "id": "microscopy-panel-no-alt",
+ "type": "microscopy_image",
+ "checksum": "sha256:microscopy-image-current",
+ "previewChecksum": "sha256:microscopy-image-preview",
+ "previewSourceChecksum": "sha256:microscopy-image-current",
+ "publicPreview": true,
+ "accessState": "public",
+ "altText": "cells",
+ "caption": "",
+ "thumbnail": {
+ "id": "thumb-image-risky",
+ "sourceChecksum": "sha256:microscopy-image-current",
+ "generatedAt": "2026-05-30"
+ },
+ "accessibilityMetadata": {
+ "accessMode": "visual",
+ "accessibilityFeature": "",
+ "encodingFormat": "image/png",
+ "schemaOrgType": "ImageObject"
+ }
+ }
+ ]
+ },
+ "evaluation": {
+ "summary": {
+ "projectId": "synthetic-embargoed-behavior-video-v1",
+ "releaseDate": "2026-06-01",
+ "decision": "hold_media_preview_release",
+ "mediaReviewed": 2,
+ "findingCount": 11,
+ "highOrCriticalFindings": 6,
+ "auditDigest": "sha256:9a556f472cf3e16c"
+ },
+ "coverage": [
+ {
+ "id": "embargoed-protocol-video",
+ "type": "protocol_video",
+ "publicPreview": true,
+ "hasAltText": false,
+ "hasCaption": false,
+ "transcriptCoverage": 0.4166666666666667,
+ "metadataComplete": false
+ },
+ {
+ "id": "microscopy-panel-no-alt",
+ "type": "microscopy_image",
+ "publicPreview": true,
+ "hasAltText": false,
+ "hasCaption": false,
+ "transcriptCoverage": null,
+ "metadataComplete": false
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "EMBARGOED_MEDIA_PUBLIC_PREVIEW",
+ "message": "embargoed-protocol-video is previewable while embargoed for 24 more days.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "hold_embargoed_media_preview"
+ },
+ {
+ "severity": "critical",
+ "code": "RESTRICTED_MEDIA_PUBLIC_PREVIEW",
+ "message": "embargoed-protocol-video is restricted but marked for public preview.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "disable_public_preview_for_restricted_media"
+ },
+ {
+ "severity": "high",
+ "code": "ALT_TEXT_MISSING_OR_TOO_SHORT",
+ "message": "microscopy-panel-no-alt needs descriptive alt text before public preview.",
+ "refs": [
+ "microscopy-panel-no-alt"
+ ],
+ "action": "add_descriptive_alt_text"
+ },
+ {
+ "severity": "high",
+ "code": "PREVIEW_SOURCE_CHECKSUM_DRIFT",
+ "message": "embargoed-protocol-video preview source checksum does not match the current artifact checksum.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "regenerate_preview_from_current_artifact"
+ },
+ {
+ "severity": "high",
+ "code": "THUMBNAIL_PROVENANCE_DRIFT",
+ "message": "embargoed-protocol-video thumbnail was generated from a checksum that differs from the hosted source artifact.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "regenerate_thumbnail_from_current_artifact"
+ },
+ {
+ "severity": "high",
+ "code": "TRANSCRIPT_COVERAGE_INCOMPLETE",
+ "message": "embargoed-protocol-video has 42% transcript coverage.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "complete_timecoded_transcript"
+ },
+ {
+ "severity": "medium",
+ "code": "ACCESSIBILITY_METADATA_INCOMPLETE",
+ "message": "embargoed-protocol-video is missing accessibility metadata: accessMode, accessibilityFeature, schemaOrgType.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "complete_datacite_schema_org_accessibility_metadata"
+ },
+ {
+ "severity": "medium",
+ "code": "ACCESSIBILITY_METADATA_INCOMPLETE",
+ "message": "microscopy-panel-no-alt is missing accessibility metadata: accessibilityFeature.",
+ "refs": [
+ "microscopy-panel-no-alt"
+ ],
+ "action": "complete_datacite_schema_org_accessibility_metadata"
+ },
+ {
+ "severity": "medium",
+ "code": "CAPTION_MISSING_OR_TOO_SHORT",
+ "message": "embargoed-protocol-video needs a reviewer-facing caption for preview and export packets.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "add_reviewer_caption"
+ },
+ {
+ "severity": "medium",
+ "code": "CAPTION_MISSING_OR_TOO_SHORT",
+ "message": "microscopy-panel-no-alt needs a reviewer-facing caption for preview and export packets.",
+ "refs": [
+ "microscopy-panel-no-alt"
+ ],
+ "action": "add_reviewer_caption"
+ },
+ {
+ "severity": "medium",
+ "code": "TIMECODE_LABEL_MISSING",
+ "message": "embargoed-protocol-video has 1 transcript segments without scientific labels.",
+ "refs": [
+ "embargoed-protocol-video"
+ ],
+ "action": "label_transcript_segments"
+ }
+ ],
+ "actions": [
+ {
+ "id": "hold_embargoed_media_preview",
+ "severity": "critical",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "disable_public_preview_for_restricted_media",
+ "severity": "critical",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "add_descriptive_alt_text",
+ "severity": "high",
+ "refs": [
+ "microscopy-panel-no-alt"
+ ]
+ },
+ {
+ "id": "regenerate_preview_from_current_artifact",
+ "severity": "high",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "regenerate_thumbnail_from_current_artifact",
+ "severity": "high",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "complete_timecoded_transcript",
+ "severity": "high",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "complete_datacite_schema_org_accessibility_metadata",
+ "severity": "medium",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "add_reviewer_caption",
+ "severity": "medium",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ },
+ {
+ "id": "label_transcript_segments",
+ "severity": "medium",
+ "refs": [
+ "embargoed-protocol-video"
+ ]
+ }
+ ]
+ }
+}
diff --git a/supplementary-media-accessibility-guard/reports/summary.svg b/supplementary-media-accessibility-guard/reports/summary.svg
new file mode 100644
index 00000000..53d89f4b
--- /dev/null
+++ b/supplementary-media-accessibility-guard/reports/summary.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/supplementary-media-accessibility-guard/sample-data.js b/supplementary-media-accessibility-guard/sample-data.js
new file mode 100644
index 00000000..ef2c1a32
--- /dev/null
+++ b/supplementary-media-accessibility-guard/sample-data.js
@@ -0,0 +1,138 @@
+const cleanPacket = {
+ projectId: "synthetic-microscopy-atlas-v2",
+ releaseDate: "2026-06-01",
+ mediaArtifacts: [
+ {
+ id: "figure-panel-astrocyte-calcium",
+ type: "figure_panel",
+ checksum: "sha256:figure-panel-001",
+ previewChecksum: "sha256:figure-preview-001",
+ previewSourceChecksum: "sha256:figure-panel-001",
+ publicPreview: true,
+ accessState: "public",
+ altText: "Line plot showing astrocyte calcium signal rising after stimulation and returning to baseline.",
+ caption: "Figure panel summarizing calcium response traces for the synthetic astrocyte cohort.",
+ thumbnail: {
+ id: "thumb-figure-panel-001",
+ sourceChecksum: "sha256:figure-panel-001",
+ generatedAt: "2026-05-29"
+ },
+ accessibilityMetadata: {
+ accessMode: "visual",
+ accessibilityFeature: "alternativeText",
+ encodingFormat: "image/png",
+ schemaOrgType: "ImageObject"
+ }
+ },
+ {
+ id: "protocol-video-imaging-setup",
+ type: "protocol_video",
+ checksum: "sha256:protocol-video-002",
+ previewChecksum: "sha256:protocol-preview-002",
+ previewSourceChecksum: "sha256:protocol-video-002",
+ publicPreview: true,
+ accessState: "public",
+ durationSeconds: 120,
+ altText: "",
+ caption: "Protocol video demonstrating the synthetic imaging setup and calibration sequence.",
+ transcriptSegments: [
+ {
+ startSeconds: 0,
+ endSeconds: 45,
+ label: "Microscope alignment"
+ },
+ {
+ startSeconds: 45,
+ endSeconds: 92,
+ label: "Stage calibration"
+ },
+ {
+ startSeconds: 92,
+ endSeconds: 120,
+ label: "Acquisition settings"
+ }
+ ],
+ thumbnail: {
+ id: "thumb-video-002",
+ sourceChecksum: "sha256:protocol-video-002",
+ generatedAt: "2026-05-29"
+ },
+ accessibilityMetadata: {
+ accessMode: "visual",
+ accessibilityFeature: "transcript",
+ encodingFormat: "video/mp4",
+ schemaOrgType: "VideoObject"
+ }
+ }
+ ]
+};
+
+const riskyPacket = {
+ projectId: "synthetic-embargoed-behavior-video-v1",
+ releaseDate: "2026-06-01",
+ mediaArtifacts: [
+ {
+ id: "embargoed-protocol-video",
+ type: "protocol_video",
+ checksum: "sha256:protocol-video-current",
+ previewChecksum: "sha256:protocol-video-preview-old",
+ previewSourceChecksum: "sha256:protocol-video-previous",
+ publicPreview: true,
+ accessState: "restricted",
+ embargoUntil: "2026-06-25",
+ durationSeconds: 180,
+ altText: "",
+ caption: "Short clip.",
+ transcriptSegments: [
+ {
+ startSeconds: 0,
+ endSeconds: 38,
+ label: "Task setup"
+ },
+ {
+ startSeconds: 38,
+ endSeconds: 75,
+ label: ""
+ }
+ ],
+ thumbnail: {
+ id: "thumb-video-risky",
+ sourceChecksum: "sha256:protocol-video-previous",
+ generatedAt: "2026-05-01"
+ },
+ accessibilityMetadata: {
+ accessMode: "",
+ accessibilityFeature: "",
+ encodingFormat: "video/mp4",
+ schemaOrgType: ""
+ }
+ },
+ {
+ id: "microscopy-panel-no-alt",
+ type: "microscopy_image",
+ checksum: "sha256:microscopy-image-current",
+ previewChecksum: "sha256:microscopy-image-preview",
+ previewSourceChecksum: "sha256:microscopy-image-current",
+ publicPreview: true,
+ accessState: "public",
+ altText: "cells",
+ caption: "",
+ thumbnail: {
+ id: "thumb-image-risky",
+ sourceChecksum: "sha256:microscopy-image-current",
+ generatedAt: "2026-05-30"
+ },
+ accessibilityMetadata: {
+ accessMode: "visual",
+ accessibilityFeature: "",
+ encodingFormat: "image/png",
+ schemaOrgType: "ImageObject"
+ }
+ }
+ ]
+};
+
+module.exports = {
+ cleanPacket,
+ riskyPacket
+};
diff --git a/supplementary-media-accessibility-guard/test.js b/supplementary-media-accessibility-guard/test.js
new file mode 100644
index 00000000..7fb9d845
--- /dev/null
+++ b/supplementary-media-accessibility-guard/test.js
@@ -0,0 +1,43 @@
+const assert = require("node:assert/strict");
+const { evaluateMediaPacket, renderMarkdownReport, renderSvgSummary } = require("./index");
+const { cleanPacket, riskyPacket } = require("./sample-data");
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function codes(evaluation) {
+ return new Set(evaluation.findings.map((finding) => finding.code));
+}
+
+const clean = evaluateMediaPacket(cleanPacket);
+assert.equal(clean.summary.decision, "release_media_preview_packet");
+assert.equal(clean.findings.length, 0);
+
+const risky = evaluateMediaPacket(riskyPacket);
+assert.equal(risky.summary.decision, "hold_media_preview_release");
+assert.equal(codes(risky).has("EMBARGOED_MEDIA_PUBLIC_PREVIEW"), true);
+assert.equal(codes(risky).has("RESTRICTED_MEDIA_PUBLIC_PREVIEW"), true);
+assert.equal(codes(risky).has("TRANSCRIPT_COVERAGE_INCOMPLETE"), true);
+assert.equal(codes(risky).has("TIMECODE_LABEL_MISSING"), true);
+assert.equal(codes(risky).has("THUMBNAIL_PROVENANCE_DRIFT"), true);
+assert.equal(codes(risky).has("PREVIEW_SOURCE_CHECKSUM_DRIFT"), true);
+assert.equal(codes(risky).has("ALT_TEXT_MISSING_OR_TOO_SHORT"), true);
+assert.equal(codes(risky).has("CAPTION_MISSING_OR_TOO_SHORT"), true);
+assert.equal(codes(risky).has("ACCESSIBILITY_METADATA_INCOMPLETE"), true);
+
+const reviseOnly = clone(cleanPacket);
+reviseOnly.mediaArtifacts[0].caption = "";
+const revise = evaluateMediaPacket(reviseOnly);
+assert.equal(revise.summary.decision, "revise_media_accessibility_packet");
+assert.equal(codes(revise).has("CAPTION_MISSING_OR_TOO_SHORT"), true);
+
+const markdown = renderMarkdownReport(riskyPacket, risky);
+assert.match(markdown, /Supplementary Media Accessibility Review/);
+assert.match(markdown, /hold_media_preview_release/);
+
+const svg = renderSvgSummary(risky);
+assert.match(svg, /