@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/README.md +1 -1
- package/dist/ai-sdk.cjs.map +1 -1
- package/dist/ai-sdk.js +1 -1
- package/dist/bin.cjs +294 -6
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +3 -2
- package/dist/bin.js.map +1 -1
- package/dist/chunk-F7OWVTCS.js +307 -0
- package/dist/chunk-F7OWVTCS.js.map +1 -0
- package/dist/{chunk-APXU3MTX.js → chunk-GXMGYIOK.js} +3 -1
- package/dist/chunk-GXMGYIOK.js.map +1 -0
- package/dist/{chunk-UBRK4CTM.js → chunk-PT57EYD7.js} +14 -10
- package/dist/chunk-PT57EYD7.js.map +1 -0
- package/dist/cli.cjs +294 -6
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +3 -2
- package/dist/index.cjs +297 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -2
- package/dist/index.d.ts +45 -2
- package/dist/index.js +13 -1
- package/package.json +2 -2
- package/src/cli.ts +9 -7
- package/src/image.ts +4 -3
- package/src/index.ts +13 -0
- package/src/media.ts +460 -0
- package/dist/chunk-APXU3MTX.js.map +0 -1
- package/dist/chunk-UBRK4CTM.js.map +0 -1
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
|
-
|
|
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-
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|