@sweberdev/witness 0.1.1 → 0.2.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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { D as Disclosure, a as DisclosureEvent, b as DisclosureOptions, c as DisclosureStorage, d as createDisclosure, m as memoryStorage } from './disclosure-C7Y_uY73.js';
2
2
  import { D as DisclosureKind, L as LocaleOverride, b as DisclosureText, c as LocaleMessages, a as MarkingInput, M as Marking, S as SourceType } from './types-WWHzrNlc.js';
3
3
  export { C as ContentKind, d as DISCLOSURE_KINDS, U as UiText } from './types-WWHzrNlc.js';
4
- export { I as ImageFormat, a as ImageMarkingInfo, M as MarkImageOptions, b as MarkImageResult, c as buildXmp, d as detectImageFormat, m as markImage, r as readImageMarking } from './image-B7YvdITx.js';
4
+ import { I as ImageFormat, M as MarkImageOptions, a as ImageMarkingInfo } from './image-B7YvdITx.js';
5
+ export { b as MarkImageResult, c as buildXmp, d as detectImageFormat, m as markImage, r as readImageMarking } from './image-B7YvdITx.js';
5
6
 
6
7
  /** The locales bundled with the free package. */
7
8
  declare const BUILT_IN_LOCALES: readonly ["en", "de", "fr", "it"];
@@ -80,6 +81,48 @@ declare function labelText(input: Marking | MarkingInput, options?: Pick<LabelHt
80
81
  declare function generatorName(m: Pick<Marking, "generator" | "generatorVersion">): string;
81
82
  declare function escapeHtml(value: string): string;
82
83
 
84
+ /**
85
+ * Machine-readable AI marking for audio and video: the same XMP packet as for images, with the
86
+ * IPTC Digital Source Type, written where the XMP specification puts it for each container.
87
+ *
88
+ * - MP3: an ID3v2 `PRIV` frame with the owner `XMP`.
89
+ * - WAV: a RIFF `_PMX` chunk.
90
+ * - MP4, MOV and M4A: a top-level `uuid` box with the XMP UUID, appended at the end of the file
91
+ * so no sample offsets move.
92
+ *
93
+ * The audio and video data is not touched. Files that carry a C2PA manifest are left unchanged
94
+ * by default, as for images.
95
+ */
96
+
97
+ type MediaFormat = "mp3" | "wav" | "mp4";
98
+ type MarkMediaOptions = MarkImageOptions;
99
+ interface MarkMediaResult {
100
+ bytes: Uint8Array;
101
+ status: "marked" | "skipped-c2pa" | "unsupported";
102
+ format: MediaFormat | null;
103
+ }
104
+ interface MediaMarkingInfo extends Omit<ImageMarkingInfo, "format"> {
105
+ format: MediaFormat | null;
106
+ }
107
+ interface MarkFileResult {
108
+ bytes: Uint8Array;
109
+ status: "marked" | "skipped-c2pa" | "unsupported";
110
+ format: ImageFormat | MediaFormat | null;
111
+ }
112
+ interface MarkingInfo extends Omit<ImageMarkingInfo, "format"> {
113
+ format: ImageFormat | MediaFormat | null;
114
+ }
115
+ /** Detects MP3, WAV or an ISO media file (MP4, MOV, M4A) from the first bytes. */
116
+ declare function detectMediaFormat(bytes: Uint8Array): MediaFormat | null;
117
+ /** Writes the marking as XMP into an MP3, WAV, MP4, MOV or M4A file. */
118
+ declare function markMedia(bytes: Uint8Array, input?: Marking | MarkingInput, options?: MarkMediaOptions): MarkMediaResult;
119
+ /** Reads the AI marking of an audio or video file, whoever wrote it. */
120
+ declare function readMediaMarking(bytes: Uint8Array): MediaMarkingInfo;
121
+ /** Marks an image, audio or video file, whichever it is. */
122
+ declare function markFile(bytes: Uint8Array, input?: Marking | MarkingInput, options?: MarkMediaOptions): MarkFileResult;
123
+ /** Reads the AI marking of an image, audio or video file. */
124
+ declare function readMarking(bytes: Uint8Array): MarkingInfo;
125
+
83
126
  /**
84
127
  * An invisible, machine-readable marker for AI-generated text.
85
128
  *
@@ -121,4 +164,4 @@ declare function hasTextWatermark(text: string): boolean;
121
164
  */
122
165
  declare function stripTextWatermark(text: string): string;
123
166
 
124
- export { BUILT_IN_LOCALES, DisclosureKind, DisclosureText, type LabelHtmlOptions, LocaleMessages, LocaleOverride, Marking, MarkingInput, type ReadTextWatermark, SourceType, type TextWatermark, createMarking, disclosureText, escapeHtml, format, generatorName, getMessages, hasTextWatermark, iptcSourceType, isAiSourceType, labelHtml, labelText, markingAttributes, markingJsonLd, markingMetaTags, nextMetadata, parseSourceType, readTextWatermark, registerLocale, registeredLocales, renderJsonLd, renderMetaTags, resolveLocale, schemaOrgSourceType, stripTextWatermark, watermarkSuffix, watermarkText };
167
+ export { BUILT_IN_LOCALES, DisclosureKind, DisclosureText, ImageFormat, ImageMarkingInfo, type LabelHtmlOptions, LocaleMessages, LocaleOverride, type MarkFileResult, MarkImageOptions, type MarkMediaOptions, type MarkMediaResult, Marking, type MarkingInfo, MarkingInput, type MediaFormat, type MediaMarkingInfo, type ReadTextWatermark, SourceType, type TextWatermark, createMarking, detectMediaFormat, disclosureText, escapeHtml, format, generatorName, getMessages, hasTextWatermark, iptcSourceType, isAiSourceType, labelHtml, labelText, markFile, markMedia, markingAttributes, markingJsonLd, markingMetaTags, nextMetadata, parseSourceType, readMarking, readMediaMarking, readTextWatermark, registerLocale, registeredLocales, renderJsonLd, renderMetaTags, resolveLocale, schemaOrgSourceType, stripTextWatermark, watermarkSuffix, watermarkText };
package/dist/index.js CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ detectMediaFormat,
3
+ markFile,
4
+ markMedia,
5
+ readMarking,
6
+ readMediaMarking
7
+ } from "./chunk-F7OWVTCS.js";
1
8
  import {
2
9
  buildXmp,
3
10
  createMarking,
@@ -23,7 +30,7 @@ import {
23
30
  stripTextWatermark,
24
31
  watermarkSuffix,
25
32
  watermarkText
26
- } from "./chunk-APXU3MTX.js";
33
+ } from "./chunk-GXMGYIOK.js";
27
34
  import {
28
35
  DISCLOSURE_KINDS,
29
36
  createDisclosure,
@@ -45,6 +52,7 @@ export {
45
52
  createDisclosure,
46
53
  createMarking,
47
54
  detectImageFormat,
55
+ detectMediaFormat,
48
56
  disclosureText,
49
57
  escapeHtml,
50
58
  format,
@@ -55,7 +63,9 @@ export {
55
63
  isAiSourceType,
56
64
  labelHtml,
57
65
  labelText,
66
+ markFile,
58
67
  markImage,
68
+ markMedia,
59
69
  markingAttributes,
60
70
  markingJsonLd,
61
71
  markingMetaTags,
@@ -63,6 +73,8 @@ export {
63
73
  nextMetadata,
64
74
  parseSourceType,
65
75
  readImageMarking,
76
+ readMarking,
77
+ readMediaMarking,
66
78
  readTextWatermark,
67
79
  registerLocale,
68
80
  registeredLocales,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sweberdev/witness",
3
- "version": "0.1.1",
4
- "description": "AI transparency kit for Article 50 of the EU AI Act: chatbot notices, AI labels in DE/EN/FR/IT, IPTC/XMP marking for images, an invisible text watermark and Vercel AI SDK middleware.",
3
+ "version": "0.2.0",
4
+ "description": "AI transparency kit for Article 50 of the EU AI Act: chatbot notices, AI labels in DE/EN/FR/IT, IPTC/XMP marking for images, audio and video, an invisible text watermark and Vercel AI SDK middleware.",
5
5
  "license": "MIT",
6
6
  "author": "Seya Weber (https://github.com/sxwxbxr)",
7
7
  "homepage": "https://packages.sweber.dev/witness/docs",
package/src/cli.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { detectImageFormat, markImage, readImageMarking } from "./image.js";
4
+ import { detectImageFormat } from "./image.js";
5
5
  import { iptcSourceType } from "./marking.js";
6
+ import { detectMediaFormat, markFile, readMarking } from "./media.js";
6
7
  import type { ContentKind, MarkingInput } from "./types.js";
7
8
  import { readTextWatermark } from "./watermark.js";
8
9
 
@@ -11,7 +12,7 @@ export interface CliIo {
11
12
  err: (line: string) => void;
12
13
  }
13
14
 
14
- const HELP = `witness: AI Act Article 50 marking for images and text
15
+ const HELP = `witness: AI Act Article 50 marking for images, audio, video and text
15
16
 
16
17
  Usage
17
18
  witness mark <files...> (--out <dir> | --in-place) [options]
@@ -30,7 +31,8 @@ inspect options
30
31
  --json one JSON object per file
31
32
  --require exit 1 if a file carries no AI marking
32
33
 
33
- Images: PNG, JPEG, WebP (XMP with IPTC Digital Source Type).
34
+ Images: PNG, JPEG, WebP. Audio and video: MP3, WAV, MP4, MOV, M4A.
35
+ All get XMP with the IPTC Digital Source Type.
34
36
  Text files are checked for the Witness text watermark.`;
35
37
 
36
38
  const KINDS: ContentKind[] = ["generated", "edited", "deepfake"];
@@ -86,11 +88,11 @@ async function mark(argv: string[], io: CliIo): Promise<number> {
86
88
  let failed = 0;
87
89
  for (const file of positionals) {
88
90
  const bytes = new Uint8Array(await readFile(file));
89
- const result = markImage(bytes, input, {
91
+ const result = markFile(bytes, input, {
90
92
  c2pa: values["overwrite-c2pa"] ? "overwrite" : "skip",
91
93
  });
92
94
  if (result.status === "unsupported") {
93
- io.err(`${file}: not a PNG, JPEG or WebP file, skipped`);
95
+ io.err(`${file}: not a PNG, JPEG, WebP, MP3, WAV or MP4 file, skipped`);
94
96
  failed++;
95
97
  continue;
96
98
  }
@@ -115,9 +117,9 @@ async function inspect(argv: string[], io: CliIo): Promise<number> {
115
117
  let unmarked = 0;
116
118
  for (const file of positionals) {
117
119
  const bytes = new Uint8Array(await readFile(file));
118
- const format = detectImageFormat(bytes);
120
+ const format = detectImageFormat(bytes) ?? detectMediaFormat(bytes);
119
121
  if (format) {
120
- const info = readImageMarking(bytes);
122
+ const info = readMarking(bytes);
121
123
  const marked = info.aiGenerated || info.c2pa;
122
124
  if (!marked) unmarked++;
123
125
  if (values.json) {
package/src/image.ts CHANGED
@@ -50,7 +50,8 @@ export interface ImageMarkingInfo {
50
50
  }
51
51
 
52
52
  const XMP_NS = "http://ns.adobe.com/xap/1.0/\0";
53
- const WITNESS_NS = "https://packages.sweber.dev/witness/ns/1.0/";
53
+ /** @internal */
54
+ export const WITNESS_NS = "https://packages.sweber.dev/witness/ns/1.0/";
54
55
 
55
56
  /** Detects PNG, JPEG or WebP from the first bytes. */
56
57
  export function detectImageFormat(bytes: Uint8Array): ImageFormat | null {
@@ -165,8 +166,8 @@ function buildDescription(m: Marking): string {
165
166
  : `<rdf:Description${attrs}/>`;
166
167
  }
167
168
 
168
- /** Reads a simple property in attribute or element form. */
169
- function xmpProperty(xmp: string, name: string): string | undefined {
169
+ /** Reads a simple property in attribute or element form. @internal */
170
+ export function xmpProperty(xmp: string, name: string): string | undefined {
170
171
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
171
172
  const attribute = new RegExp(`${escaped}="([^"]*)"`).exec(xmp);
172
173
  if (attribute?.[1] !== undefined) return unescapeXml(attribute[1]);
package/src/index.ts CHANGED
@@ -43,6 +43,19 @@ export {
43
43
  renderMetaTags,
44
44
  schemaOrgSourceType,
45
45
  } from "./marking.js";
46
+ export {
47
+ detectMediaFormat,
48
+ type MarkFileResult,
49
+ type MarkingInfo,
50
+ type MarkMediaOptions,
51
+ type MarkMediaResult,
52
+ type MediaFormat,
53
+ type MediaMarkingInfo,
54
+ markFile,
55
+ markMedia,
56
+ readMarking,
57
+ readMediaMarking,
58
+ } from "./media.js";
46
59
  export {
47
60
  type ContentKind,
48
61
  DISCLOSURE_KINDS,
package/src/media.ts ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Machine-readable AI marking for audio and video: the same XMP packet as for images, with the
3
+ * IPTC Digital Source Type, written where the XMP specification puts it for each container.
4
+ *
5
+ * - MP3: an ID3v2 `PRIV` frame with the owner `XMP`.
6
+ * - WAV: a RIFF `_PMX` chunk.
7
+ * - MP4, MOV and M4A: a top-level `uuid` box with the XMP UUID, appended at the end of the file
8
+ * so no sample offsets move.
9
+ *
10
+ * The audio and video data is not touched. Files that carry a C2PA manifest are left unchanged
11
+ * by default, as for images.
12
+ */
13
+
14
+ import {
15
+ buildXmp,
16
+ detectImageFormat,
17
+ type ImageFormat,
18
+ type ImageMarkingInfo,
19
+ type MarkImageOptions,
20
+ markImage,
21
+ readImageMarking,
22
+ WITNESS_NS,
23
+ xmpProperty,
24
+ } from "./image.js";
25
+ import { createMarking, isAiSourceType, parseSourceType } from "./marking.js";
26
+ import type { Marking, MarkingInput } from "./types.js";
27
+
28
+ export type MediaFormat = "mp3" | "wav" | "mp4";
29
+
30
+ export type MarkMediaOptions = MarkImageOptions;
31
+
32
+ export interface MarkMediaResult {
33
+ bytes: Uint8Array;
34
+ status: "marked" | "skipped-c2pa" | "unsupported";
35
+ format: MediaFormat | null;
36
+ }
37
+
38
+ export interface MediaMarkingInfo extends Omit<ImageMarkingInfo, "format"> {
39
+ format: MediaFormat | null;
40
+ }
41
+
42
+ export interface MarkFileResult {
43
+ bytes: Uint8Array;
44
+ status: "marked" | "skipped-c2pa" | "unsupported";
45
+ format: ImageFormat | MediaFormat | null;
46
+ }
47
+
48
+ export interface MarkingInfo extends Omit<ImageMarkingInfo, "format"> {
49
+ format: ImageFormat | MediaFormat | null;
50
+ }
51
+
52
+ const XMP_UUID = hex("be7acfcb97a942e89c71999491e3afac");
53
+ const C2PA_UUID = hex("d8fec3d61b0e483c92975828877ec481");
54
+
55
+ /** Detects MP3, WAV or an ISO media file (MP4, MOV, M4A) from the first bytes. */
56
+ export function detectMediaFormat(bytes: Uint8Array): MediaFormat | null {
57
+ if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") return "mp3";
58
+ if (isMpegAudioFrame(bytes, 0)) return "mp3";
59
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE") {
60
+ return "wav";
61
+ }
62
+ if (bytes.length >= 12 && ascii(bytes, 4, 4) === "ftyp") return "mp4";
63
+ return null;
64
+ }
65
+
66
+ /** Writes the marking as XMP into an MP3, WAV, MP4, MOV or M4A file. */
67
+ export function markMedia(
68
+ bytes: Uint8Array,
69
+ input: Marking | MarkingInput = {},
70
+ options: MarkMediaOptions = {},
71
+ ): MarkMediaResult {
72
+ const format = detectMediaFormat(bytes);
73
+ if (!format) return { bytes, status: "unsupported", format };
74
+ const parsed = parse(bytes, format);
75
+ if (!parsed) return { bytes, status: "unsupported", format };
76
+ if (options.c2pa !== "overwrite" && parsed.c2pa) {
77
+ return { bytes, status: "skipped-c2pa", format };
78
+ }
79
+ const marking =
80
+ "sourceType" in input && "humanReviewed" in input ? (input as Marking) : createMarking(input);
81
+ const xmp = buildXmp(marking, parsed.xmp);
82
+ const out =
83
+ format === "mp3"
84
+ ? writeMp3(bytes, xmp)
85
+ : format === "wav"
86
+ ? writeWav(bytes, xmp)
87
+ : writeMp4(bytes, xmp);
88
+ if (!out) return { bytes, status: "unsupported", format };
89
+ return { bytes: out, status: "marked", format };
90
+ }
91
+
92
+ /** Reads the AI marking of an audio or video file, whoever wrote it. */
93
+ export function readMediaMarking(bytes: Uint8Array): MediaMarkingInfo {
94
+ const format = detectMediaFormat(bytes);
95
+ const parsed = format ? parse(bytes, format) : null;
96
+ if (!format || !parsed) {
97
+ return { format, xmp: null, aiGenerated: false, witness: false, c2pa: false };
98
+ }
99
+ return describe(format, parsed.xmp, parsed.c2pa);
100
+ }
101
+
102
+ /** Marks an image, audio or video file, whichever it is. */
103
+ export function markFile(
104
+ bytes: Uint8Array,
105
+ input: Marking | MarkingInput = {},
106
+ options: MarkMediaOptions = {},
107
+ ): MarkFileResult {
108
+ if (detectImageFormat(bytes)) return markImage(bytes, input, options);
109
+ return markMedia(bytes, input, options);
110
+ }
111
+
112
+ /** Reads the AI marking of an image, audio or video file. */
113
+ export function readMarking(bytes: Uint8Array): MarkingInfo {
114
+ if (detectImageFormat(bytes)) return readImageMarking(bytes);
115
+ return readMediaMarking(bytes);
116
+ }
117
+
118
+ function describe(format: MediaFormat, xmp: string | null, c2pa: boolean): MediaMarkingInfo {
119
+ const sourceType = xmp
120
+ ? parseSourceType(xmpProperty(xmp, "Iptc4xmpExt:DigitalSourceType"))
121
+ : undefined;
122
+ const generator = xmp
123
+ ? (xmpProperty(xmp, "Iptc4xmpExt:AISystemUsed") ?? xmpProperty(xmp, "xmp:CreatorTool"))
124
+ : undefined;
125
+ const info: MediaMarkingInfo = {
126
+ format,
127
+ xmp,
128
+ aiGenerated: isAiSourceType(sourceType),
129
+ witness: xmp?.includes(WITNESS_NS) ?? false,
130
+ c2pa,
131
+ };
132
+ if (sourceType) info.sourceType = sourceType;
133
+ if (generator) info.generator = generator;
134
+ return info;
135
+ }
136
+
137
+ interface Parsed {
138
+ xmp: string | null;
139
+ c2pa: boolean;
140
+ }
141
+
142
+ /** Null when the file is truncated or uses a variant Witness does not write. */
143
+ function parse(bytes: Uint8Array, format: MediaFormat): Parsed | null {
144
+ if (format === "mp3") {
145
+ const tag = readId3(bytes);
146
+ if (tag === "unsupported") return null;
147
+ if (!tag) return { xmp: null, c2pa: false };
148
+ const xmpFrame = tag.frames.find((f) => f.id === "PRIV" && isXmpPriv(bytes, f));
149
+ return {
150
+ xmp: xmpFrame ? decode(bytes.subarray(xmpFrame.dataStart + 4, xmpFrame.end)) : null,
151
+ c2pa: tag.frames.some(
152
+ (f) =>
153
+ f.id === "GEOB" &&
154
+ ascii(bytes, f.dataStart, Math.min(64, f.end - f.dataStart))
155
+ .toLowerCase()
156
+ .includes("c2pa"),
157
+ ),
158
+ };
159
+ }
160
+ if (format === "wav") {
161
+ const chunks = riffChunks(bytes);
162
+ if (!chunks) return null;
163
+ const xmp = chunks.find((c) => c.fourcc === "_PMX");
164
+ return {
165
+ xmp: xmp ? decode(bytes.subarray(xmp.dataStart, xmp.dataEnd)) : null,
166
+ c2pa: chunks.some((c) => c.fourcc === "C2PA"),
167
+ };
168
+ }
169
+ const boxes = mp4Boxes(bytes);
170
+ if (!boxes) return null;
171
+ const xmp = boxes.find((b) => isUuidBox(bytes, b, XMP_UUID));
172
+ return {
173
+ xmp: xmp ? decode(bytes.subarray(xmp.dataStart + 16, xmp.end)) : null,
174
+ c2pa: boxes.some((b) => isUuidBox(bytes, b, C2PA_UUID)),
175
+ };
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------- MP3 (ID3v2)
179
+
180
+ interface Id3Frame {
181
+ id: string;
182
+ start: number;
183
+ dataStart: number;
184
+ end: number;
185
+ }
186
+
187
+ interface Id3Tag {
188
+ version: 3 | 4;
189
+ /** Bytes of the whole tag, header included. */
190
+ length: number;
191
+ frames: Id3Frame[];
192
+ }
193
+
194
+ /** Null when there is no tag; `unsupported` for ID3v2.2, unsynchronised tags or footers. */
195
+ function readId3(bytes: Uint8Array): Id3Tag | null | "unsupported" {
196
+ if (ascii(bytes, 0, 3) !== "ID3") return null;
197
+ const version = bytes[3];
198
+ const flags = bytes[5] ?? 0;
199
+ if (version !== 3 && version !== 4) return "unsupported";
200
+ if (flags & 0x80 || flags & 0x10) return "unsupported";
201
+ const length = 10 + syncsafe(bytes, 6);
202
+ if (length > bytes.length) return "unsupported";
203
+ let offset = 10;
204
+ if (flags & 0x40) {
205
+ offset += version === 4 ? syncsafe(bytes, 10) : 4 + readU32BE(bytes, 10);
206
+ }
207
+ const frames: Id3Frame[] = [];
208
+ while (offset + 10 <= length) {
209
+ const id = ascii(bytes, offset, 4);
210
+ if (!/^[A-Z0-9]{4}$/.test(id)) break; // padding
211
+ const size = version === 4 ? syncsafe(bytes, offset + 4) : readU32BE(bytes, offset + 4);
212
+ const end = offset + 10 + size;
213
+ if (end > length) return "unsupported";
214
+ frames.push({ id, start: offset, dataStart: offset + 10, end });
215
+ offset = end;
216
+ }
217
+ return { version, length, frames };
218
+ }
219
+
220
+ function isXmpPriv(bytes: Uint8Array, frame: Id3Frame): boolean {
221
+ return ascii(bytes, frame.dataStart, 4) === "XMP\0";
222
+ }
223
+
224
+ function writeMp3(bytes: Uint8Array, xmp: string): Uint8Array | null {
225
+ const tag = readId3(bytes);
226
+ if (tag === "unsupported") return null;
227
+ const version = tag ? tag.version : 4;
228
+ const kept = tag
229
+ ? tag.frames
230
+ .filter((f) => !(f.id === "PRIV" && isXmpPriv(bytes, f)))
231
+ .map((f) => bytes.subarray(f.start, f.end))
232
+ : [];
233
+ const data = concat(latin1("XMP\0"), utf8(xmp));
234
+ const frame = new Uint8Array(10 + data.length);
235
+ frame.set(latin1("PRIV"), 0);
236
+ if (version === 4) writeSyncsafe(frame, 4, data.length);
237
+ else writeU32BE(frame, 4, data.length);
238
+ frame.set(data, 10);
239
+ // Zero padding ends the frame list; some readers warn without it.
240
+ const body = concat(...kept, frame, new Uint8Array(64));
241
+ const header = new Uint8Array(10);
242
+ header.set(latin1("ID3"), 0);
243
+ header[3] = version;
244
+ writeSyncsafe(header, 6, body.length);
245
+ return concat(header, body, bytes.subarray(tag ? tag.length : 0));
246
+ }
247
+
248
+ function isMpegAudioFrame(bytes: Uint8Array, offset: number): boolean {
249
+ const b1 = bytes[offset + 1] ?? 0;
250
+ // Frame sync, a valid MPEG version and layer (ADTS AAC has layer 0 and is not MP3).
251
+ return (
252
+ bytes[offset] === 0xff && (b1 & 0xe0) === 0xe0 && (b1 & 0x18) !== 0x08 && (b1 & 0x06) !== 0
253
+ );
254
+ }
255
+
256
+ function syncsafe(b: Uint8Array, o: number): number {
257
+ return (
258
+ (((b[o] ?? 0) & 0x7f) << 21) |
259
+ (((b[o + 1] ?? 0) & 0x7f) << 14) |
260
+ (((b[o + 2] ?? 0) & 0x7f) << 7) |
261
+ ((b[o + 3] ?? 0) & 0x7f)
262
+ );
263
+ }
264
+
265
+ function writeSyncsafe(b: Uint8Array, o: number, v: number): void {
266
+ b[o] = (v >>> 21) & 0x7f;
267
+ b[o + 1] = (v >>> 14) & 0x7f;
268
+ b[o + 2] = (v >>> 7) & 0x7f;
269
+ b[o + 3] = v & 0x7f;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------- WAV (RIFF)
273
+
274
+ interface RiffChunk {
275
+ fourcc: string;
276
+ start: number;
277
+ dataStart: number;
278
+ dataEnd: number;
279
+ /** Including the pad byte. */
280
+ end: number;
281
+ }
282
+
283
+ function riffChunks(bytes: Uint8Array): RiffChunk[] | null {
284
+ const chunks: RiffChunk[] = [];
285
+ const limit = Math.min(bytes.length, 8 + readU32LE(bytes, 4));
286
+ let offset = 12;
287
+ while (offset + 8 <= limit) {
288
+ const size = readU32LE(bytes, offset + 4);
289
+ const dataEnd = offset + 8 + size;
290
+ if (dataEnd > bytes.length) return null;
291
+ const end = Math.min(dataEnd + (size % 2), bytes.length);
292
+ chunks.push({
293
+ fourcc: ascii(bytes, offset, 4),
294
+ start: offset,
295
+ dataStart: offset + 8,
296
+ dataEnd,
297
+ end,
298
+ });
299
+ offset = end;
300
+ }
301
+ return chunks;
302
+ }
303
+
304
+ function writeWav(bytes: Uint8Array, xmp: string): Uint8Array | null {
305
+ const chunks = riffChunks(bytes);
306
+ if (!chunks) return null;
307
+ const data = utf8(xmp);
308
+ const chunk = new Uint8Array(8 + data.length + (data.length % 2));
309
+ chunk.set(latin1("_PMX"), 0);
310
+ writeU32LE(chunk, 4, data.length);
311
+ chunk.set(data, 8);
312
+ const body = concat(
313
+ ...chunks.filter((c) => c.fourcc !== "_PMX").map((c) => bytes.subarray(c.start, c.end)),
314
+ chunk,
315
+ );
316
+ const out = concat(bytes.subarray(0, 12), body);
317
+ writeU32LE(out, 4, out.length - 8);
318
+ return out;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------- MP4 (ISO BMFF)
322
+
323
+ interface Box {
324
+ type: string;
325
+ start: number;
326
+ /** After the size, type and any 64-bit size. */
327
+ dataStart: number;
328
+ end: number;
329
+ /** The box runs to the end of the file (size 0). */
330
+ open: boolean;
331
+ }
332
+
333
+ function mp4Boxes(bytes: Uint8Array): Box[] | null {
334
+ const boxes: Box[] = [];
335
+ let offset = 0;
336
+ while (offset + 8 <= bytes.length) {
337
+ const size = readU32BE(bytes, offset);
338
+ const type = ascii(bytes, offset + 4, 4);
339
+ let header = 8;
340
+ let end: number;
341
+ if (size === 1) {
342
+ if (offset + 16 > bytes.length) return null;
343
+ const high = readU32BE(bytes, offset + 8);
344
+ end = offset + high * 2 ** 32 + readU32BE(bytes, offset + 12);
345
+ header = 16;
346
+ } else if (size === 0) {
347
+ end = bytes.length;
348
+ } else {
349
+ end = offset + size;
350
+ }
351
+ if (end > bytes.length || end < offset + header) return null;
352
+ boxes.push({ type, start: offset, dataStart: offset + header, end, open: size === 0 });
353
+ offset = end;
354
+ }
355
+ return boxes;
356
+ }
357
+
358
+ function isUuidBox(bytes: Uint8Array, box: Box, uuid: Uint8Array): boolean {
359
+ if (box.type !== "uuid" || box.end - box.dataStart < 16) return false;
360
+ for (let i = 0; i < 16; i++) if (bytes[box.dataStart + i] !== uuid[i]) return false;
361
+ return true;
362
+ }
363
+
364
+ /**
365
+ * Appends the XMP box at the end so chunk offsets in `moov` stay valid. An earlier XMP box at
366
+ * the end is replaced; one elsewhere is turned into a `free` box of the same size.
367
+ */
368
+ function writeMp4(bytes: Uint8Array, xmp: string): Uint8Array | null {
369
+ const boxes = mp4Boxes(bytes);
370
+ if (!boxes) return null;
371
+ const last = boxes[boxes.length - 1];
372
+ let head = bytes;
373
+ let length = bytes.length;
374
+ if (last && isUuidBox(bytes, last, XMP_UUID)) length = last.start;
375
+ head = bytes.slice(0, length);
376
+ for (const box of boxes) {
377
+ if (box.start < length && isUuidBox(bytes, box, XMP_UUID)) {
378
+ head.set(latin1("free"), box.start + 4);
379
+ }
380
+ }
381
+ const open = boxes.find((b) => b.open && b.start < length);
382
+ if (open) {
383
+ const size = length - open.start;
384
+ if (size > 0xffffffff) return null;
385
+ writeU32BE(head, open.start, size);
386
+ }
387
+ const data = utf8(xmp);
388
+ const box = new Uint8Array(8 + 16 + data.length);
389
+ writeU32BE(box, 0, box.length);
390
+ box.set(latin1("uuid"), 4);
391
+ box.set(XMP_UUID, 8);
392
+ box.set(data, 24);
393
+ return concat(head, box);
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------- shared
397
+
398
+ function hex(value: string): Uint8Array {
399
+ const out = new Uint8Array(value.length / 2);
400
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
401
+ return out;
402
+ }
403
+
404
+ function decode(bytes: Uint8Array): string {
405
+ return new TextDecoder().decode(bytes).replace(/\0+$/, "");
406
+ }
407
+
408
+ function ascii(bytes: Uint8Array, offset: number, length: number): string {
409
+ let out = "";
410
+ for (let i = offset; i < offset + length && i < bytes.length; i++) {
411
+ out += String.fromCharCode(bytes[i] ?? 0);
412
+ }
413
+ return out;
414
+ }
415
+
416
+ function latin1(text: string): Uint8Array {
417
+ const out = new Uint8Array(text.length);
418
+ for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
419
+ return out;
420
+ }
421
+
422
+ function utf8(text: string): Uint8Array {
423
+ return new TextEncoder().encode(text);
424
+ }
425
+
426
+ function concat(...parts: Uint8Array[]): Uint8Array {
427
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
428
+ let offset = 0;
429
+ for (const part of parts) {
430
+ out.set(part, offset);
431
+ offset += part.length;
432
+ }
433
+ return out;
434
+ }
435
+
436
+ function readU32BE(b: Uint8Array, o: number): number {
437
+ return (
438
+ (((b[o] ?? 0) << 24) | ((b[o + 1] ?? 0) << 16) | ((b[o + 2] ?? 0) << 8) | (b[o + 3] ?? 0)) >>> 0
439
+ );
440
+ }
441
+
442
+ function readU32LE(b: Uint8Array, o: number): number {
443
+ return (
444
+ ((b[o] ?? 0) | ((b[o + 1] ?? 0) << 8) | ((b[o + 2] ?? 0) << 16) | ((b[o + 3] ?? 0) << 24)) >>> 0
445
+ );
446
+ }
447
+
448
+ function writeU32BE(b: Uint8Array, o: number, v: number): void {
449
+ b[o] = (v >>> 24) & 0xff;
450
+ b[o + 1] = (v >>> 16) & 0xff;
451
+ b[o + 2] = (v >>> 8) & 0xff;
452
+ b[o + 3] = v & 0xff;
453
+ }
454
+
455
+ function writeU32LE(b: Uint8Array, o: number, v: number): void {
456
+ b[o] = v & 0xff;
457
+ b[o + 1] = (v >>> 8) & 0xff;
458
+ b[o + 2] = (v >>> 16) & 0xff;
459
+ b[o + 3] = (v >>> 24) & 0xff;
460
+ }