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 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 @@ + + + + + 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, /