From 9d9e17474ead84ddc192c473b733a26b098f05c1 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:51:23 -0400 Subject: [PATCH] Add supplementary media accessibility guard --- .../README.md | 38 ++ .../demo.js | 50 +++ .../index.js | 334 ++++++++++++++++++ .../make-demo-video.js | 91 +++++ .../package.json | 21 ++ .../reports/clean-media-packet.json | 103 ++++++ .../reports/demo-script.txt | 10 + .../reports/demo.mp4 | Bin 0 -> 9843 bytes .../reports/media-accessibility-report.md | 29 ++ .../reports/risky-media-packet.json | 263 ++++++++++++++ .../reports/summary.svg | 16 + .../sample-data.js | 138 ++++++++ .../test.js | 43 +++ 13 files changed, 1136 insertions(+) create mode 100644 supplementary-media-accessibility-guard/README.md create mode 100644 supplementary-media-accessibility-guard/demo.js create mode 100644 supplementary-media-accessibility-guard/index.js create mode 100644 supplementary-media-accessibility-guard/make-demo-video.js create mode 100644 supplementary-media-accessibility-guard/package.json create mode 100644 supplementary-media-accessibility-guard/reports/clean-media-packet.json create mode 100644 supplementary-media-accessibility-guard/reports/demo-script.txt create mode 100644 supplementary-media-accessibility-guard/reports/demo.mp4 create mode 100644 supplementary-media-accessibility-guard/reports/media-accessibility-report.md create mode 100644 supplementary-media-accessibility-guard/reports/risky-media-packet.json create mode 100644 supplementary-media-accessibility-guard/reports/summary.svg create mode 100644 supplementary-media-accessibility-guard/sample-data.js create mode 100644 supplementary-media-accessibility-guard/test.js 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 ` + + + + Supplementary Media Accessibility Guard + + ${escapeXml(evaluation.summary.decision)} + Media reviewed: ${evaluation.summary.mediaReviewed} + Findings: ${evaluation.summary.findingCount} +${rows || 'No findings detected.'} + ${escapeXml(evaluation.summary.auditDigest)} + +`; +} + +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 0000000000000000000000000000000000000000..49761b56d3f70d987ed1604b59e58fd52e7d217c GIT binary patch literal 9843 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 + + + + Supplementary Media Accessibility Guard + + hold_media_preview_release + Media reviewed: 2 + Findings: 11 +CRITICAL EMBARGOED_MEDIA_PUBLIC_PREVIEW +CRITICAL RESTRICTED_MEDIA_PUBLIC_PREVIEW +HIGH ALT_TEXT_MISSING_OR_TOO_SHORT +HIGH PREVIEW_SOURCE_CHECKSUM_DRIFT +HIGH THUMBNAIL_PROVENANCE_DRIFT + sha256:9a556f472cf3e16c + 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, /