export type DomainId = "data" | "image" | "av" | "subtitle"; export type Preservation = "expected-preserved" | "conditional" | "expected-lost" | "unknown"; export interface FormatDefinition { id: string; label: string; domain: DomainId; note: string; } export interface PropertyDefinition { id: string; label: string; } export interface ConversionEdge { from: string; to: string; availability: "lab" | "toolbox" | "browser" | "catalog"; statuses: Record; note: string; } export interface ConversionRoute { formats: string[]; edges: ConversionEdge[]; score: number; summary: Record; } export const domains: Array<{ id: DomainId; label: string }> = [ { id: "data", label: "Structured data" }, { id: "image", label: "Images" }, { id: "av", label: "Audio & video" }, { id: "subtitle", label: "Subtitles" }, ]; export const properties: Record = { data: [ { id: "record-order", label: "Record order" }, { id: "field-names", label: "Field names" }, { id: "field-order", label: "Field order" }, { id: "scalar-types", label: "Scalar types" }, { id: "nulls", label: "Null values" }, { id: "nested", label: "Nested values" }, { id: "numbers", label: "Numeric values" }, { id: "unicode", label: "Unicode text" }, { id: "metadata", label: "Document metadata" }, ], image: [ { id: "dimensions", label: "Dimensions" }, { id: "pixels", label: "Pixel values" }, { id: "alpha", label: "Alpha channel" }, { id: "colour", label: "Colour profile / depth" }, { id: "metadata", label: "EXIF / metadata" }, { id: "animation", label: "Animation" }, { id: "vectors", label: "Vector geometry" }, { id: "editable-text", label: "Editable text" }, ], av: [ { id: "duration", label: "Duration" }, { id: "timestamps", label: "Timestamps" }, { id: "video", label: "Video samples" }, { id: "audio", label: "Audio samples" }, { id: "channels", label: "Channel layout" }, { id: "subtitles", label: "Subtitle tracks" }, { id: "chapters", label: "Chapters" }, { id: "metadata", label: "Container metadata" }, ], subtitle: [ { id: "timing", label: "Cue timing" }, { id: "text", label: "Cue text" }, { id: "ids", label: "Cue identifiers" }, { id: "position", label: "Position / regions" }, { id: "styles", label: "Styling" }, { id: "speakers", label: "Speaker semantics" }, { id: "metadata", label: "Document metadata" }, ], }; export const formats: FormatDefinition[] = [ { id: "json", label: "JSON", domain: "data", note: "Nested typed values; object order is conventional, not semantic.", }, { id: "ndjson", label: "NDJSON", domain: "data", note: "One JSON record per line; no document wrapper.", }, { id: "csv", label: "CSV", domain: "data", note: "Rectangular text cells; dialect and type schema are external.", }, { id: "xml", label: "XML", domain: "data", note: "Flexible tree with namespaces, attributes and mixed content.", }, { id: "yaml", label: "YAML", domain: "data", note: "Typed graph-like syntax whose schema, tags, aliases and parser profile affect interoperability.", }, { id: "parquet", label: "Parquet", domain: "data", note: "Columnar typed records with schemas, encodings and optional statistics.", }, { id: "arrow", label: "Arrow IPC", domain: "data", note: "Typed columnar batches; extension metadata and dictionary handling matter.", }, { id: "png", label: "PNG", domain: "image", note: "Raster, lossless compression, alpha and optional metadata.", }, { id: "jpeg", label: "JPEG", domain: "image", note: "Lossy raster without alpha; broad photo support.", }, { id: "webp", label: "WebP", domain: "image", note: "Lossy or lossless raster, alpha and animation.", }, { id: "svg", label: "SVG", domain: "image", note: "Vector/XML document with text and external-resource risks.", }, { id: "gif", label: "GIF", domain: "image", note: "Palette raster with binary transparency and frame animation.", }, { id: "avif", label: "AVIF", domain: "image", note: "AV1 image sequences in ISO BMFF with HDR/alpha profile variation.", }, { id: "tiff", label: "TIFF", domain: "image", note: "Flexible tagged raster container with many compression and colour variants.", }, { id: "heic", label: "HEIC", domain: "image", note: "HEVC image collections in ISO BMFF; browser decode support varies.", }, { id: "wav", label: "WAV", domain: "av", note: "Audio container, commonly uncompressed PCM.", }, { id: "flac", label: "FLAC", domain: "av", note: "Lossless compressed audio and metadata.", }, { id: "mp3", label: "MP3", domain: "av", note: "Lossy audio with limited channel/metadata models.", }, { id: "opus", label: "Opus", domain: "av", note: "Lossy speech/music codec, commonly in Ogg or WebM.", }, { id: "mp4", label: "MP4", domain: "av", note: "Timed media container; actual preservation depends on streams.", }, { id: "webm", label: "WebM", domain: "av", note: "Web-oriented Matroska profile with constrained codecs.", }, { id: "mkv", label: "Matroska", domain: "av", note: "Flexible multi-track container with attachments and chapters.", }, { id: "ogg", label: "Ogg", domain: "av", note: "Page-oriented container; actual preservation depends on codec streams and chaining.", }, { id: "m4a", label: "M4A", domain: "av", note: "ISO BMFF audio convention, commonly AAC or ALAC with metadata.", }, { id: "mov", label: "QuickTime MOV", domain: "av", note: "QuickTime/ISO BMFF family with broad codec and metadata options.", }, { id: "aac", label: "AAC (ADTS)", domain: "av", note: "Raw framed lossy audio with limited document-level metadata.", }, { id: "srt", label: "SRT", domain: "subtitle", note: "Simple numbered cues with de-facto inline formatting.", }, { id: "vtt", label: "WebVTT", domain: "subtitle", note: "Cue identifiers, settings, regions and web timing.", }, { id: "ass", label: "ASS", domain: "subtitle", note: "Rich scripted styling, positioning and events.", }, { id: "ttml", label: "TTML", domain: "subtitle", note: "XML timing and styling profiles vary by ecosystem.", }, { id: "sbv", label: "SBV", domain: "subtitle", note: "Simple timestamp pairs and text blocks with minimal styling.", }, { id: "sami", label: "SAMI", domain: "subtitle", note: "HTML-like timed captions with CSS and legacy parser variation.", }, ]; function statuses( domain: DomainId, values: Partial>, ): Record { return Object.fromEntries( properties[domain].map(({ id }) => [id, values[id] ?? "unknown"]), ); } const dataTyped = statuses("data", { "record-order": "expected-preserved", "field-names": "expected-preserved", "field-order": "conditional", "scalar-types": "expected-preserved", nulls: "expected-preserved", nested: "expected-preserved", numbers: "conditional", unicode: "expected-preserved", metadata: "conditional", }); const dataCsv = statuses("data", { "record-order": "expected-preserved", "field-names": "expected-preserved", "field-order": "conditional", "scalar-types": "expected-lost", nulls: "expected-lost", nested: "conditional", numbers: "conditional", unicode: "conditional", metadata: "expected-lost", }); const rasterLossy = statuses("image", { dimensions: "expected-preserved", pixels: "expected-lost", alpha: "expected-lost", colour: "conditional", metadata: "expected-lost", animation: "expected-lost", vectors: "expected-lost", "editable-text": "expected-lost", }); const rasterConditional = statuses("image", { dimensions: "expected-preserved", pixels: "conditional", alpha: "conditional", colour: "conditional", metadata: "expected-lost", animation: "conditional", vectors: "expected-lost", "editable-text": "expected-lost", }); const audioLossy = statuses("av", { duration: "conditional", timestamps: "conditional", video: "unknown", audio: "expected-lost", channels: "conditional", subtitles: "expected-lost", chapters: "expected-lost", metadata: "conditional", }); const audioLossless = statuses("av", { duration: "expected-preserved", timestamps: "conditional", video: "unknown", audio: "expected-preserved", channels: "expected-preserved", subtitles: "expected-lost", chapters: "expected-lost", metadata: "conditional", }); const remux = statuses("av", { duration: "conditional", timestamps: "conditional", video: "conditional", audio: "conditional", channels: "conditional", subtitles: "conditional", chapters: "conditional", metadata: "conditional", }); const subtitleSimple = statuses("subtitle", { timing: "conditional", text: "expected-preserved", ids: "conditional", position: "expected-lost", styles: "expected-lost", speakers: "conditional", metadata: "expected-lost", }); const subtitleRich = statuses("subtitle", { timing: "conditional", text: "expected-preserved", ids: "conditional", position: "conditional", styles: "conditional", speakers: "conditional", metadata: "conditional", }); function edge( from: string, to: string, availability: ConversionEdge["availability"], values: Record, note: string, ): ConversionEdge { return { from, to, availability, statuses: { ...values }, note }; } export const edges: ConversionEdge[] = [ edge( "json", "ndjson", "lab", dataTyped, "Wrapper-level values and metadata need an explicit record mapping.", ), edge( "ndjson", "json", "lab", dataTyped, "The lab writes an array; an original wrapper is not reconstructed.", ), edge( "json", "xml", "lab", dataTyped, "Measured lab XML uses typed field elements; arbitrary XML mappings differ.", ), edge( "xml", "json", "lab", dataTyped, "Generic XML attributes/mixed content are outside the lab record dialect.", ), edge( "ndjson", "xml", "lab", dataTyped, "Records pass through the bounded canonical model.", ), edge( "xml", "ndjson", "lab", dataTyped, "Document-level XML information is not part of NDJSON.", ), ...["json", "ndjson", "xml"].flatMap((format) => [ edge( format, "csv", "lab", dataCsv, "CSV has text cells and no intrinsic null/type/nesting schema.", ), edge( "csv", format, "lab", dataCsv, "CSV cells enter the canonical model as strings.", ), ]), edge( "png", "jpeg", "browser", rasterLossy, "Canvas/JPEG encoding is lossy and flattens alpha.", ), edge( "webp", "jpeg", "browser", rasterLossy, "Decode/re-encode can lose alpha, animation and metadata.", ), edge( "svg", "png", "toolbox", rasterConditional, "Rasterization fixes a resolution and loses editability.", ), edge( "svg", "webp", "toolbox", rasterConditional, "Rasterization and WebP mode/settings determine pixel loss.", ), edge( "jpeg", "png", "browser", rasterConditional, "PNG cannot restore JPEG-discarded samples or metadata.", ), edge( "png", "webp", "browser", rasterConditional, "WebP lossless/lossy mode and animation handling must be measured.", ), edge( "webp", "png", "browser", rasterConditional, "A still PNG cannot retain WebP animation; metadata is commonly stripped.", ), edge( "wav", "flac", "toolbox", audioLossless, "PCM compatibility and metadata mapping still need inspection.", ), edge( "flac", "wav", "toolbox", audioLossless, "Decoded samples can match while metadata/container timing changes.", ), ...["wav", "flac"].flatMap((from) => ["mp3", "opus"].map((to) => edge( from, to, "toolbox", audioLossy, "A lossy audio encode discards sample information.", ), ), ), ...["mp3", "opus"].flatMap((from) => ["wav", "flac"].map((to) => edge( from, to, "toolbox", audioLossy, "Decoding does not restore information discarded by the source codec.", ), ), ), edge( "mp4", "mkv", "toolbox", remux, "Stream copy is conditional on codec and attachment/metadata mapping.", ), edge( "mkv", "mp4", "toolbox", remux, "MP4 may not represent every Matroska track, attachment or chapter.", ), edge( "mkv", "webm", "toolbox", remux, "WebM accepts a constrained codec/track subset.", ), edge( "webm", "mkv", "toolbox", remux, "The streams may copy while container metadata changes.", ), edge( "mp4", "webm", "toolbox", statuses("av", { ...remux, video: "expected-lost", audio: "expected-lost", }), "Typical codecs require transcoding; inspect each stream.", ), edge( "webm", "mp4", "toolbox", statuses("av", { ...remux, video: "expected-lost", audio: "expected-lost", }), "Typical codecs require transcoding; inspect each stream.", ), ...["srt", "vtt", "ass", "ttml"].flatMap((from) => ["srt", "vtt", "ass", "ttml"] .filter((to) => to !== from) .map((to) => edge( from, to, "toolbox", from === "srt" || to === "srt" ? subtitleSimple : subtitleRich, "Timing precision, profiles and styling constructs require cue-level verification.", ), ), ), ]; const rank: Record = { "expected-preserved": 0, conditional: 2, unknown: 4, "expected-lost": 8, }; function summarize( route: ConversionEdge[], relevant: string[], ): Record { return Object.fromEntries( relevant.map((property) => { const worst = route.reduce( (current, item) => rank[item.statuses[property] ?? "unknown"] > rank[current] ? (item.statuses[property] ?? "unknown") : current, "expected-preserved", ); return [property, worst]; }), ); } export function findRoutes( domain: DomainId, from: string, to: string, relevant: string[], maxHops = 4, ): ConversionRoute[] { const allowed = new Set( formats .filter((format) => format.domain === domain) .map((format) => format.id), ); if (!allowed.has(from) || !allowed.has(to)) return []; if (from === to) return [ { formats: [from], edges: [], score: 0, summary: Object.fromEntries( relevant.map((property) => [property, "expected-preserved"]), ), }, ]; const routes: ConversionRoute[] = []; const visit = ( current: string, path: string[], pathEdges: ConversionEdge[], ) => { if (pathEdges.length >= maxHops) return; for (const item of edges.filter( (candidate) => candidate.from === current && allowed.has(candidate.to), )) { if (path.includes(item.to)) continue; const nextPath = [...path, item.to]; const nextEdges = [...pathEdges, item]; if (item.to === to) { const summary = summarize(nextEdges, relevant); routes.push({ formats: nextPath, edges: nextEdges, summary, score: Object.values(summary).reduce( (sum, status) => sum + rank[status], 0, ) + nextEdges.length * 0.1, }); } else visit(item.to, nextPath, nextEdges); } }; visit(from, [from], []); return routes.sort( (left, right) => left.score - right.score || left.edges.length - right.edges.length || left.formats.join(">").localeCompare(right.formats.join(">")), ); } export function labelFor(format: string): string { return formats.find((item) => item.id === format)?.label ?? format; }