Release Format Lab 0.1.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user