686 lines
16 KiB
TypeScript
686 lines
16 KiB
TypeScript
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<string, Preservation>;
|
|
note: string;
|
|
}
|
|
|
|
export interface ConversionRoute {
|
|
formats: string[];
|
|
edges: ConversionEdge[];
|
|
score: number;
|
|
summary: Record<string, Preservation>;
|
|
}
|
|
|
|
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<DomainId, PropertyDefinition[]> = {
|
|
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<string, Preservation>>,
|
|
): Record<string, Preservation> {
|
|
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<string, Preservation>,
|
|
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<Preservation, number> = {
|
|
"expected-preserved": 0,
|
|
conditional: 2,
|
|
unknown: 4,
|
|
"expected-lost": 8,
|
|
};
|
|
|
|
function summarize(
|
|
route: ConversionEdge[],
|
|
relevant: string[],
|
|
): Record<string, Preservation> {
|
|
return Object.fromEntries(
|
|
relevant.map((property) => {
|
|
const worst = route.reduce<Preservation>(
|
|
(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;
|
|
}
|