Release Format Lab 0.1.0

This commit is contained in:
2026-09-01 13:39:38 +02:00
commit cfc525a88e
59 changed files with 9840 additions and 0 deletions
+607
View File
@@ -0,0 +1,607 @@
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: "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: "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: "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.",
},
];
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;
}