@crewhaus/tool-media 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Box-and-arrow diagrams from a node and edge list, laid out in layers.
3
+ *
4
+ * ## The layout, and why it is this one
5
+ *
6
+ * Each node is assigned to a layer one past its deepest predecessor, and
7
+ * layers are drawn in order — downward for `TB`, rightward for `LR`. Within
8
+ * a layer, nodes keep the order they were declared in. That is the whole
9
+ * algorithm: no force simulation, no crossing minimisation, no iteration.
10
+ * It is reproducible to the byte, it fits in a sentence, and it is right
11
+ * for the pipelines, state machines and architecture sketches this is for.
12
+ *
13
+ * It is NOT general graph layout, and does not pretend to be. A dense graph
14
+ * will have crossing edges and this will not untangle them. A cycle is
15
+ * broken by ignoring the edge that closes it when layers are assigned; that
16
+ * edge is still drawn — dashed, curving back — and is reported in
17
+ * `backEdges`, so a caller can see what happened rather than wonder why the
18
+ * picture looks the way it does.
19
+ */
20
+ import { MediaFormatError } from "./bytes";
21
+ import { escapeXml, estimateTextWidth, num, rect, svgDocument, text } from "./svg";
22
+ const FONT = 13;
23
+ const PADDING_X = 14;
24
+ const BOX_HEIGHT = 40;
25
+ const LAYER_GAP = 60;
26
+ const SIBLING_GAP = 24;
27
+ const MARGIN = 24;
28
+ const TITLE_SIZE = 16;
29
+ const INK = "#1f2329";
30
+ const EDGE = "#5a6069";
31
+ const key = (from, to) => `${from} -> ${to}`;
32
+ /**
33
+ * Layer assignment. A depth-first pass in declaration order finds the edges
34
+ * that close a cycle; the rest form a DAG, and each node's layer is one
35
+ * past its deepest remaining predecessor.
36
+ */
37
+ export function assignLayers(nodes, edges) {
38
+ const ids = nodes.map((n) => n.id);
39
+ const known = new Set(ids);
40
+ const outgoing = new Map();
41
+ for (const id of ids)
42
+ outgoing.set(id, []);
43
+ for (const edge of edges) {
44
+ if (known.has(edge.from) && known.has(edge.to)) {
45
+ outgoing.get(edge.from).push(edge.to);
46
+ }
47
+ }
48
+ const backEdges = [];
49
+ const backSet = new Set();
50
+ const state = new Map();
51
+ for (const id of ids)
52
+ state.set(id, 0);
53
+ // Iterative depth-first search: a deep chain must not overflow the stack.
54
+ for (const root of ids) {
55
+ if (state.get(root) !== 0)
56
+ continue;
57
+ const stack = [{ id: root, next: 0 }];
58
+ state.set(root, 1);
59
+ while (stack.length > 0) {
60
+ const frame = stack[stack.length - 1];
61
+ const children = outgoing.get(frame.id);
62
+ if (frame.next >= children.length) {
63
+ state.set(frame.id, 2);
64
+ stack.pop();
65
+ continue;
66
+ }
67
+ const child = children[frame.next];
68
+ frame.next++;
69
+ const seen = state.get(child);
70
+ if (seen === 1) {
71
+ const k = key(frame.id, child);
72
+ if (!backSet.has(k)) {
73
+ backSet.add(k);
74
+ backEdges.push({ from: frame.id, to: child });
75
+ }
76
+ }
77
+ else if (seen === 0) {
78
+ state.set(child, 1);
79
+ stack.push({ id: child, next: 0 });
80
+ }
81
+ }
82
+ }
83
+ const incoming = new Map();
84
+ for (const id of ids)
85
+ incoming.set(id, []);
86
+ for (const edge of edges) {
87
+ if (!known.has(edge.from) || !known.has(edge.to))
88
+ continue;
89
+ if (backSet.has(key(edge.from, edge.to)))
90
+ continue;
91
+ incoming.get(edge.to).push(edge.from);
92
+ }
93
+ // Longest path to each node, memoised. The graph is acyclic now, so the
94
+ // recursion terminates; `resolving` is a belt-and-braces guard.
95
+ const layers = new Map();
96
+ const resolving = new Set();
97
+ const depth = (id) => {
98
+ const cached = layers.get(id);
99
+ if (cached !== undefined)
100
+ return cached;
101
+ if (resolving.has(id))
102
+ return 0;
103
+ resolving.add(id);
104
+ let best = 0;
105
+ for (const parent of incoming.get(id))
106
+ best = Math.max(best, depth(parent) + 1);
107
+ resolving.delete(id);
108
+ layers.set(id, best);
109
+ return best;
110
+ };
111
+ for (const id of ids)
112
+ depth(id);
113
+ return { layers, backEdges };
114
+ }
115
+ /** Render a node and edge list to SVG with a layered layout. */
116
+ export function renderDiagram(spec) {
117
+ if (spec.nodes.length === 0)
118
+ throw new MediaFormatError("a diagram needs at least one node");
119
+ if (spec.nodes.length > 200) {
120
+ throw new MediaFormatError(`${spec.nodes.length} nodes is past what a layered layout renders readably; the cap is 200`);
121
+ }
122
+ const seen = new Set();
123
+ for (const node of spec.nodes) {
124
+ if (node.id === "")
125
+ throw new MediaFormatError("a node id cannot be empty");
126
+ if (seen.has(node.id))
127
+ throw new MediaFormatError(`node id "${node.id}" appears twice`);
128
+ seen.add(node.id);
129
+ }
130
+ for (const edge of spec.edges) {
131
+ if (!seen.has(edge.from))
132
+ throw new MediaFormatError(`edge from unknown node "${edge.from}"`);
133
+ if (!seen.has(edge.to))
134
+ throw new MediaFormatError(`edge to unknown node "${edge.to}"`);
135
+ }
136
+ const direction = spec.direction ?? "TB";
137
+ const { layers, backEdges } = assignLayers(spec.nodes, spec.edges);
138
+ const labelOf = (node) => node.label ?? node.id;
139
+ // One box width for every node, so layers line up and edges stay short.
140
+ const boxWidth = Math.ceil(Math.max(64, ...spec.nodes.map((n) => estimateTextWidth(labelOf(n), FONT) + PADDING_X * 2)));
141
+ const maxLayer = Math.max(...[...layers.values()]);
142
+ const byLayer = Array.from({ length: maxLayer + 1 }, () => []);
143
+ for (const node of spec.nodes) {
144
+ byLayer[layers.get(node.id)].push(node);
145
+ }
146
+ const widest = Math.max(...byLayer.map((row) => row.length));
147
+ const titleOffset = spec.title === undefined ? 0 : TITLE_SIZE + 14;
148
+ // A back edge loops outside the boxes; reserve the room up front.
149
+ const loopRoom = backEdges.length > 0 ? 34 : 0;
150
+ const boxes = new Map();
151
+ let width;
152
+ let height;
153
+ if (direction === "TB") {
154
+ const rowSpan = widest * boxWidth + (widest - 1) * SIBLING_GAP;
155
+ width = rowSpan + MARGIN * 2 + loopRoom;
156
+ height = titleOffset + (maxLayer + 1) * BOX_HEIGHT + maxLayer * LAYER_GAP + MARGIN * 2;
157
+ const centre = MARGIN + rowSpan / 2;
158
+ for (let layer = 0; layer <= maxLayer; layer++) {
159
+ const row = byLayer[layer];
160
+ const span = row.length * boxWidth + (row.length - 1) * SIBLING_GAP;
161
+ let x = centre - span / 2;
162
+ const y = MARGIN + titleOffset + layer * (BOX_HEIGHT + LAYER_GAP);
163
+ for (const node of row) {
164
+ boxes.set(node.id, { x, y, w: boxWidth, h: BOX_HEIGHT });
165
+ x += boxWidth + SIBLING_GAP;
166
+ }
167
+ }
168
+ }
169
+ else {
170
+ width = (maxLayer + 1) * boxWidth + maxLayer * LAYER_GAP + MARGIN * 2;
171
+ const columnSpan = widest * BOX_HEIGHT + (widest - 1) * SIBLING_GAP;
172
+ height = columnSpan + MARGIN * 2 + titleOffset + loopRoom;
173
+ const centre = MARGIN + titleOffset + loopRoom + columnSpan / 2;
174
+ for (let layer = 0; layer <= maxLayer; layer++) {
175
+ const column = byLayer[layer];
176
+ const span = column.length * BOX_HEIGHT + (column.length - 1) * SIBLING_GAP;
177
+ let y = centre - span / 2;
178
+ const x = MARGIN + layer * (boxWidth + LAYER_GAP);
179
+ for (const node of column) {
180
+ boxes.set(node.id, { x, y, w: boxWidth, h: BOX_HEIGHT });
181
+ y += BOX_HEIGHT + SIBLING_GAP;
182
+ }
183
+ }
184
+ }
185
+ const background = spec.background ?? "#ffffff";
186
+ const fill = spec.nodeFill ?? "#eef2fb";
187
+ const stroke = spec.nodeStroke ?? "#3b6fd4";
188
+ const body = [
189
+ `<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${escapeXml(EDGE)}"/></marker></defs>`,
190
+ ];
191
+ if (spec.title !== undefined) {
192
+ body.push(text(width / 2, MARGIN + TITLE_SIZE - 4, spec.title, TITLE_SIZE, {
193
+ fill: INK,
194
+ anchor: "middle",
195
+ weight: "bold",
196
+ }));
197
+ }
198
+ const backSet = new Set(backEdges.map((e) => key(e.from, e.to)));
199
+ // Edges first, so a box always sits on top of the line that reaches it.
200
+ for (const edge of spec.edges) {
201
+ const from = boxes.get(edge.from);
202
+ const to = boxes.get(edge.to);
203
+ const isBack = backSet.has(key(edge.from, edge.to));
204
+ const forward = layers.get(edge.to) > layers.get(edge.from);
205
+ let path;
206
+ let midX;
207
+ let midY;
208
+ if (forward) {
209
+ const [x1, y1] = direction === "TB"
210
+ ? [from.x + from.w / 2, from.y + from.h]
211
+ : [from.x + from.w, from.y + from.h / 2];
212
+ const [x2, y2] = direction === "TB" ? [to.x + to.w / 2, to.y] : [to.x, to.y + to.h / 2];
213
+ path = `M ${num(x1)} ${num(y1)} L ${num(x2)} ${num(y2)}`;
214
+ midX = (x1 + x2) / 2;
215
+ midY = (y1 + y2) / 2;
216
+ }
217
+ else {
218
+ // Back or sideways: leave and enter on the outside edge, bowing away
219
+ // from the boxes so the line is followable.
220
+ const [x1, y1] = direction === "TB"
221
+ ? [from.x + from.w, from.y + from.h / 2]
222
+ : [from.x + from.w / 2, from.y + from.h];
223
+ const [x2, y2] = direction === "TB" ? [to.x + to.w, to.y + to.h / 2] : [to.x + to.w / 2, to.y + to.h];
224
+ const bow = 28;
225
+ const [cx, cy] = direction === "TB"
226
+ ? [Math.max(x1, x2) + bow, (y1 + y2) / 2]
227
+ : [(x1 + x2) / 2, Math.max(y1, y2) + bow];
228
+ path = `M ${num(x1)} ${num(y1)} Q ${num(cx)} ${num(cy)} ${num(x2)} ${num(y2)}`;
229
+ // The midpoint of a quadratic at t = 0.5.
230
+ midX = 0.25 * x1 + 0.5 * cx + 0.25 * x2;
231
+ midY = 0.25 * y1 + 0.5 * cy + 0.25 * y2;
232
+ }
233
+ body.push(`<path d="${path}" fill="none" stroke="${escapeXml(EDGE)}" stroke-width="1.5"${isBack ? ' stroke-dasharray="5 3"' : ""} marker-end="url(#arrow)"/>`);
234
+ if (edge.label !== undefined && edge.label !== "") {
235
+ const w = estimateTextWidth(edge.label, 11) + 8;
236
+ body.push(rect(midX - w / 2, midY - 8, w, 16, background, 'rx="3"'));
237
+ body.push(text(midX, midY, edge.label, 11, { fill: EDGE, anchor: "middle", baseline: "middle" }));
238
+ }
239
+ }
240
+ for (const node of spec.nodes) {
241
+ const box = boxes.get(node.id);
242
+ body.push(`<rect x="${num(box.x)}" y="${num(box.y)}" width="${num(box.w)}" height="${num(box.h)}" rx="6" fill="${escapeXml(fill)}" stroke="${escapeXml(stroke)}" stroke-width="1.5"/>`);
243
+ body.push(text(box.x + box.w / 2, box.y + box.h / 2, labelOf(node), FONT, {
244
+ fill: INK,
245
+ anchor: "middle",
246
+ baseline: "middle",
247
+ }));
248
+ }
249
+ return {
250
+ svg: svgDocument(width, height, background, body, spec.title),
251
+ width,
252
+ height,
253
+ layers: spec.nodes.map((n) => ({ id: n.id, layer: layers.get(n.id) })),
254
+ backEdges,
255
+ };
256
+ }
@@ -0,0 +1,36 @@
1
+ export type ProbeStream = {
2
+ readonly index: number;
3
+ readonly type: string;
4
+ readonly codec?: string;
5
+ readonly codecDescription?: string;
6
+ readonly profile?: string;
7
+ readonly width?: number;
8
+ readonly height?: number;
9
+ readonly frameRate?: number;
10
+ readonly pixelFormat?: string;
11
+ readonly sampleRateHz?: number;
12
+ readonly channels?: number;
13
+ readonly channelLayout?: string;
14
+ readonly bitRateBps?: number;
15
+ readonly durationSeconds?: number;
16
+ readonly language?: string;
17
+ readonly title?: string;
18
+ };
19
+ export type ProbeResult = {
20
+ readonly formatName?: string;
21
+ readonly formatDescription?: string;
22
+ readonly durationSeconds?: number;
23
+ readonly sizeBytes?: number;
24
+ readonly bitRateBps?: number;
25
+ readonly streamCount: number;
26
+ readonly streams: ReadonlyArray<ProbeStream>;
27
+ /** Container-level tags worth reporting, sorted by key. */
28
+ readonly tags: ReadonlyArray<{
29
+ key: string;
30
+ value: string;
31
+ }>;
32
+ };
33
+ /** `"30000/1001"` to 29.97. A zero denominator means ffprobe does not know. */
34
+ export declare function parseRate(value: unknown): number | undefined;
35
+ /** Parse ffprobe's JSON. Throws `MediaFormatError` on anything unexpected. */
36
+ export declare function parseProbeJson(text: string): ProbeResult;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shaping `ffprobe -print_format json` output into something worth putting
3
+ * in a context window.
4
+ *
5
+ * ffprobe's JSON is wide, deeply optional and full of fields that are
6
+ * strings when they look like numbers. This module is the pure half of
7
+ * `MediaProbe`: it takes the text, treats every field as absent until
8
+ * proven otherwise, and returns a fixed shape. It is also the security
9
+ * boundary — the JSON came out of a process reading a file the caller
10
+ * supplied, so nothing in it is trusted, and nothing is passed through
11
+ * except the fields named below.
12
+ */
13
+ import { MediaFormatError } from "./bytes";
14
+ /** Container tags that are worth carrying; the rest are noise or private. */
15
+ const TAG_ALLOWLIST = [
16
+ "album",
17
+ "artist",
18
+ "comment",
19
+ "composer",
20
+ "date",
21
+ "encoder",
22
+ "genre",
23
+ "major_brand",
24
+ "title",
25
+ "track",
26
+ ];
27
+ function asObject(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value)
29
+ ? value
30
+ : undefined;
31
+ }
32
+ /** A field that ffprobe may write as a number or as a numeric string. */
33
+ function numberField(value) {
34
+ if (typeof value === "number")
35
+ return Number.isFinite(value) ? value : undefined;
36
+ if (typeof value !== "string")
37
+ return undefined;
38
+ const parsed = Number.parseFloat(value);
39
+ return Number.isFinite(parsed) ? parsed : undefined;
40
+ }
41
+ function stringField(value) {
42
+ if (typeof value !== "string")
43
+ return undefined;
44
+ const trimmed = value.trim();
45
+ if (trimmed === "" || trimmed === "unknown" || trimmed === "N/A")
46
+ return undefined;
47
+ // Bound it: a tag is metadata, not a payload.
48
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}…` : trimmed;
49
+ }
50
+ /** `"30000/1001"` to 29.97. A zero denominator means ffprobe does not know. */
51
+ export function parseRate(value) {
52
+ if (typeof value !== "string")
53
+ return undefined;
54
+ const parts = value.split("/");
55
+ if (parts.length !== 2)
56
+ return numberField(value);
57
+ const numerator = Number.parseFloat(parts[0]);
58
+ const denominator = Number.parseFloat(parts[1]);
59
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) {
60
+ return undefined;
61
+ }
62
+ return Math.round((numerator / denominator) * 1000) / 1000;
63
+ }
64
+ function round3(value) {
65
+ return Math.round(value * 1000) / 1000;
66
+ }
67
+ /** Parse ffprobe's JSON. Throws `MediaFormatError` on anything unexpected. */
68
+ export function parseProbeJson(text) {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(text);
72
+ }
73
+ catch (err) {
74
+ throw new MediaFormatError(`ffprobe did not return JSON: ${err.message}`);
75
+ }
76
+ const root = asObject(parsed);
77
+ if (root === undefined)
78
+ throw new MediaFormatError("ffprobe returned JSON that is not an object");
79
+ const format = asObject(root["format"]) ?? {};
80
+ const rawStreams = Array.isArray(root["streams"]) ? root["streams"] : [];
81
+ const streams = [];
82
+ for (const entry of rawStreams) {
83
+ const stream = asObject(entry);
84
+ if (stream === undefined)
85
+ continue;
86
+ const tags = asObject(stream["tags"]) ?? {};
87
+ const width = numberField(stream["width"]);
88
+ const height = numberField(stream["height"]);
89
+ const frameRate = parseRate(stream["avg_frame_rate"]) ?? parseRate(stream["r_frame_rate"]);
90
+ const duration = numberField(stream["duration"]);
91
+ const bitRate = numberField(stream["bit_rate"]);
92
+ streams.push({
93
+ index: numberField(stream["index"]) ?? streams.length,
94
+ type: stringField(stream["codec_type"]) ?? "unknown",
95
+ ...(stringField(stream["codec_name"]) !== undefined
96
+ ? { codec: stringField(stream["codec_name"]) }
97
+ : {}),
98
+ ...(stringField(stream["codec_long_name"]) !== undefined
99
+ ? { codecDescription: stringField(stream["codec_long_name"]) }
100
+ : {}),
101
+ ...(stringField(stream["profile"]) !== undefined
102
+ ? { profile: stringField(stream["profile"]) }
103
+ : {}),
104
+ ...(width !== undefined ? { width } : {}),
105
+ ...(height !== undefined ? { height } : {}),
106
+ ...(frameRate !== undefined && frameRate > 0 ? { frameRate } : {}),
107
+ ...(stringField(stream["pix_fmt"]) !== undefined
108
+ ? { pixelFormat: stringField(stream["pix_fmt"]) }
109
+ : {}),
110
+ ...(numberField(stream["sample_rate"]) !== undefined
111
+ ? { sampleRateHz: numberField(stream["sample_rate"]) }
112
+ : {}),
113
+ ...(numberField(stream["channels"]) !== undefined
114
+ ? { channels: numberField(stream["channels"]) }
115
+ : {}),
116
+ ...(stringField(stream["channel_layout"]) !== undefined
117
+ ? { channelLayout: stringField(stream["channel_layout"]) }
118
+ : {}),
119
+ ...(bitRate !== undefined ? { bitRateBps: Math.round(bitRate) } : {}),
120
+ ...(duration !== undefined ? { durationSeconds: round3(duration) } : {}),
121
+ ...(stringField(tags["language"]) !== undefined
122
+ ? { language: stringField(tags["language"]) }
123
+ : {}),
124
+ ...(stringField(tags["title"]) !== undefined
125
+ ? { title: stringField(tags["title"]) }
126
+ : {}),
127
+ });
128
+ }
129
+ const formatTags = asObject(format["tags"]) ?? {};
130
+ const tags = [];
131
+ for (const key of TAG_ALLOWLIST) {
132
+ const value = stringField(formatTags[key]);
133
+ if (value !== undefined)
134
+ tags.push({ key, value });
135
+ }
136
+ const duration = numberField(format["duration"]);
137
+ const size = numberField(format["size"]);
138
+ const bitRate = numberField(format["bit_rate"]);
139
+ return {
140
+ ...(stringField(format["format_name"]) !== undefined
141
+ ? { formatName: stringField(format["format_name"]) }
142
+ : {}),
143
+ ...(stringField(format["format_long_name"]) !== undefined
144
+ ? { formatDescription: stringField(format["format_long_name"]) }
145
+ : {}),
146
+ ...(duration !== undefined ? { durationSeconds: round3(duration) } : {}),
147
+ ...(size !== undefined ? { sizeBytes: Math.round(size) } : {}),
148
+ ...(bitRate !== undefined ? { bitRateBps: Math.round(bitRate) } : {}),
149
+ streamCount: streams.length,
150
+ // Sorted by index, so two probes of the same file agree on the order.
151
+ streams: [...streams].sort((a, b) => a.index - b.index),
152
+ tags,
153
+ };
154
+ }
@@ -0,0 +1,39 @@
1
+ /** A format this package can name from its magic bytes. */
2
+ export type FileKind = {
3
+ /** Short, stable identifier: `"png"`, `"jpeg"`, `"mp4"`, … */
4
+ readonly kind: string;
5
+ readonly mediaType: string;
6
+ /** Conventional extensions, lowercase, without the dot. First is preferred. */
7
+ readonly extensions: ReadonlyArray<string>;
8
+ /** Broad family, for a caller routing on it. */
9
+ readonly family: "image" | "audio" | "video" | "document" | "archive" | "text" | "font";
10
+ };
11
+ /**
12
+ * Name the format from the bytes. Returns `undefined` when nothing matches,
13
+ * which is the honest answer — "unknown" beats a guess from the extension.
14
+ */
15
+ export declare function detectKind(bytes: Uint8Array): FileKind | undefined;
16
+ /** Every format `detectKind` can name, sorted, for a tool to report. */
17
+ export declare function knownKinds(): ReadonlyArray<string>;
18
+ /** Look up the kinds an extension conventionally names. */
19
+ export declare function kindsForExtension(extension: string): ReadonlyArray<string>;
20
+ export type ImageHeader = {
21
+ readonly format: string;
22
+ readonly width: number;
23
+ readonly height: number;
24
+ /** Bits per sample (per channel), where the format states one. */
25
+ readonly bitDepth?: number;
26
+ /** A human-readable colour model: `"rgb"`, `"palette"`, `"ycbcr"`, … */
27
+ readonly colorType: string;
28
+ readonly hasAlpha: boolean;
29
+ /** Anything worth saying that does not fit the fields above. */
30
+ readonly notes?: ReadonlyArray<string>;
31
+ };
32
+ /** The formats `readImageHeader` understands, in the order it tries them. */
33
+ export declare const HEADER_FORMATS: ReadonlyArray<string>;
34
+ /**
35
+ * Dimensions and colour facts from a file header alone. Throws
36
+ * `MediaFormatError` when the bytes are not one of `HEADER_FORMATS`, or
37
+ * when they are but the header is malformed or truncated.
38
+ */
39
+ export declare function readImageHeader(bytes: Uint8Array): ImageHeader;