@sweberdev/witness 0.1.0 → 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-DHX7SNWE.js → chunk-O6TADCND.js} +10 -3
- package/dist/chunk-O6TADCND.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/elements-auto.cjs +9 -2
- package/dist/elements-auto.cjs.map +1 -1
- package/dist/elements-auto.js +1 -1
- package/dist/elements.cjs +9 -2
- package/dist/elements.cjs.map +1 -1
- package/dist/elements.d.cts +1 -1
- package/dist/elements.d.ts +1 -1
- package/dist/elements.js +1 -1
- 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/elements.ts +9 -2
- 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-DHX7SNWE.js.map +0 -1
- package/dist/chunk-UBRK4CTM.js.map +0 -1
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectMediaFormat,
|
|
3
|
+
markFile,
|
|
4
|
+
readMarking
|
|
5
|
+
} from "./chunk-F7OWVTCS.js";
|
|
1
6
|
import {
|
|
2
7
|
detectImageFormat,
|
|
3
8
|
iptcSourceType,
|
|
4
|
-
markImage,
|
|
5
|
-
readImageMarking,
|
|
6
9
|
readTextWatermark
|
|
7
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-GXMGYIOK.js";
|
|
8
11
|
|
|
9
12
|
// src/cli.ts
|
|
10
13
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
11
14
|
import { basename, extname, join } from "path";
|
|
12
15
|
import { parseArgs } from "util";
|
|
13
|
-
var HELP = `witness: AI Act Article 50 marking for images and text
|
|
16
|
+
var HELP = `witness: AI Act Article 50 marking for images, audio, video and text
|
|
14
17
|
|
|
15
18
|
Usage
|
|
16
19
|
witness mark <files...> (--out <dir> | --in-place) [options]
|
|
@@ -29,7 +32,8 @@ inspect options
|
|
|
29
32
|
--json one JSON object per file
|
|
30
33
|
--require exit 1 if a file carries no AI marking
|
|
31
34
|
|
|
32
|
-
Images: PNG, JPEG, WebP
|
|
35
|
+
Images: PNG, JPEG, WebP. Audio and video: MP3, WAV, MP4, MOV, M4A.
|
|
36
|
+
All get XMP with the IPTC Digital Source Type.
|
|
33
37
|
Text files are checked for the Witness text watermark.`;
|
|
34
38
|
var KINDS = ["generated", "edited", "deepfake"];
|
|
35
39
|
async function runCli(argv, io = defaultIo) {
|
|
@@ -81,11 +85,11 @@ async function mark(argv, io) {
|
|
|
81
85
|
let failed = 0;
|
|
82
86
|
for (const file of positionals) {
|
|
83
87
|
const bytes = new Uint8Array(await readFile(file));
|
|
84
|
-
const result =
|
|
88
|
+
const result = markFile(bytes, input, {
|
|
85
89
|
c2pa: values["overwrite-c2pa"] ? "overwrite" : "skip"
|
|
86
90
|
});
|
|
87
91
|
if (result.status === "unsupported") {
|
|
88
|
-
io.err(`${file}: not a PNG, JPEG or
|
|
92
|
+
io.err(`${file}: not a PNG, JPEG, WebP, MP3, WAV or MP4 file, skipped`);
|
|
89
93
|
failed++;
|
|
90
94
|
continue;
|
|
91
95
|
}
|
|
@@ -109,9 +113,9 @@ async function inspect(argv, io) {
|
|
|
109
113
|
let unmarked = 0;
|
|
110
114
|
for (const file of positionals) {
|
|
111
115
|
const bytes = new Uint8Array(await readFile(file));
|
|
112
|
-
const format = detectImageFormat(bytes);
|
|
116
|
+
const format = detectImageFormat(bytes) ?? detectMediaFormat(bytes);
|
|
113
117
|
if (format) {
|
|
114
|
-
const info =
|
|
118
|
+
const info = readMarking(bytes);
|
|
115
119
|
const marked = info.aiGenerated || info.c2pa;
|
|
116
120
|
if (!marked) unmarked++;
|
|
117
121
|
if (values.json) {
|
|
@@ -153,4 +157,4 @@ var defaultIo = {
|
|
|
153
157
|
export {
|
|
154
158
|
runCli
|
|
155
159
|
};
|
|
156
|
-
//# sourceMappingURL=chunk-
|
|
160
|
+
//# sourceMappingURL=chunk-PT57EYD7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["import { mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { basename, extname, join } from \"node:path\";\nimport { parseArgs } from \"node:util\";\nimport { detectImageFormat } from \"./image.js\";\nimport { iptcSourceType } from \"./marking.js\";\nimport { detectMediaFormat, markFile, readMarking } from \"./media.js\";\nimport type { ContentKind, MarkingInput } from \"./types.js\";\nimport { readTextWatermark } from \"./watermark.js\";\n\nexport interface CliIo {\n out: (line: string) => void;\n err: (line: string) => void;\n}\n\nconst HELP = `witness: AI Act Article 50 marking for images, audio, video and text\n\nUsage\n witness mark <files...> (--out <dir> | --in-place) [options]\n witness inspect <files...> [--json] [--require]\n\nmark options\n --generator <name> AI system, e.g. \"gpt-image-2\"\n --generator-version <v> its version\n --kind <kind> generated (default), edited or deepfake\n --provider <name> your company or product\n --url <url> page that explains how you use AI\n --description <text> free text written to dc:description\n --overwrite-c2pa also rewrite files that carry a C2PA manifest\n\ninspect options\n --json one JSON object per file\n --require exit 1 if a file carries no AI marking\n\nImages: PNG, JPEG, WebP. Audio and video: MP3, WAV, MP4, MOV, M4A.\nAll get XMP with the IPTC Digital Source Type.\nText files are checked for the Witness text watermark.`;\n\nconst KINDS: ContentKind[] = [\"generated\", \"edited\", \"deepfake\"];\n\nexport async function runCli(argv: string[], io: CliIo = defaultIo): Promise<number> {\n const [command, ...rest] = argv;\n if (!command || command === \"--help\" || command === \"-h\" || command === \"help\") {\n io.out(HELP);\n return command ? 0 : 1;\n }\n try {\n if (command === \"mark\") return await mark(rest, io);\n if (command === \"inspect\") return await inspect(rest, io);\n io.err(`Unknown command \"${command}\". Run \"witness --help\".`);\n return 1;\n } catch (error) {\n io.err(error instanceof Error ? error.message : String(error));\n return 1;\n }\n}\n\nasync function mark(argv: string[], io: CliIo): Promise<number> {\n const { values, positionals } = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n out: { type: \"string\" },\n \"in-place\": { type: \"boolean\" },\n generator: { type: \"string\" },\n \"generator-version\": { type: \"string\" },\n kind: { type: \"string\" },\n provider: { type: \"string\" },\n url: { type: \"string\" },\n description: { type: \"string\" },\n \"overwrite-c2pa\": { type: \"boolean\" },\n },\n });\n if (positionals.length === 0) throw new Error(\"witness mark: no files given.\");\n if (!values.out && !values[\"in-place\"]) {\n throw new Error(\"witness mark: pass --out <dir> or --in-place.\");\n }\n const kind = (values.kind ?? \"generated\") as ContentKind;\n if (!KINDS.includes(kind))\n throw new Error(`witness mark: --kind must be one of ${KINDS.join(\", \")}.`);\n const input: MarkingInput = { kind };\n if (values.generator) input.generator = values.generator;\n if (values[\"generator-version\"]) input.generatorVersion = values[\"generator-version\"];\n if (values.provider) input.provider = values.provider;\n if (values.url) input.disclosureUrl = values.url;\n if (values.description) input.description = values.description;\n if (values.out) await mkdir(values.out, { recursive: true });\n\n let failed = 0;\n for (const file of positionals) {\n const bytes = new Uint8Array(await readFile(file));\n const result = markFile(bytes, input, {\n c2pa: values[\"overwrite-c2pa\"] ? \"overwrite\" : \"skip\",\n });\n if (result.status === \"unsupported\") {\n io.err(`${file}: not a PNG, JPEG, WebP, MP3, WAV or MP4 file, skipped`);\n failed++;\n continue;\n }\n if (result.status === \"skipped-c2pa\") {\n io.err(`${file}: carries a C2PA manifest, left unchanged (use --overwrite-c2pa to force)`);\n continue;\n }\n const target = values.out ? join(values.out, basename(file)) : file;\n await writeFile(target, result.bytes);\n io.out(`${file}: marked (${result.format}) -> ${target}`);\n }\n return failed > 0 ? 1 : 0;\n}\n\nasync function inspect(argv: string[], io: CliIo): Promise<number> {\n const { values, positionals } = parseArgs({\n args: argv,\n allowPositionals: true,\n options: { json: { type: \"boolean\" }, require: { type: \"boolean\" } },\n });\n if (positionals.length === 0) throw new Error(\"witness inspect: no files given.\");\n let unmarked = 0;\n for (const file of positionals) {\n const bytes = new Uint8Array(await readFile(file));\n const format = detectImageFormat(bytes) ?? detectMediaFormat(bytes);\n if (format) {\n const info = readMarking(bytes);\n const marked = info.aiGenerated || info.c2pa;\n if (!marked) unmarked++;\n if (values.json) {\n const { xmp: _xmp, ...rest } = info;\n io.out(JSON.stringify({ file, ...rest }));\n } else {\n const parts = [\n `${file}: ${format}`,\n info.sourceType\n ? `source type ${iptcSourceType(info.sourceType)}`\n : \"no IPTC source type\",\n info.generator ? `generator ${info.generator}` : null,\n info.c2pa ? \"C2PA manifest present (not verified)\" : null,\n marked ? \"AI-marked\" : \"NOT AI-marked\",\n ];\n io.out(parts.filter(Boolean).join(\", \"));\n }\n continue;\n }\n const ext = extname(file).toLowerCase();\n const text = new TextDecoder().decode(bytes);\n const watermark = readTextWatermark(text);\n if (!watermark) unmarked++;\n if (values.json) {\n io.out(JSON.stringify({ file, format: ext.slice(1) || \"text\", watermark }));\n } else {\n io.out(\n watermark\n ? `${file}: text watermark found${watermark.generator ? `, generator ${watermark.generator}` : \"\"}${watermark.createdAt ? `, created ${watermark.createdAt}` : \"\"}`\n : `${file}: no Witness text watermark`,\n );\n }\n }\n return values.require && unmarked > 0 ? 1 : 0;\n}\n\nconst defaultIo: CliIo = {\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n};\n"],"mappings":"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","names":[]}
|
package/dist/cli.cjs
CHANGED
|
@@ -750,6 +750,293 @@ function unescapeXml(value) {
|
|
|
750
750
|
return value.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/&/g, "&");
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
// src/media.ts
|
|
754
|
+
var XMP_UUID = hex("be7acfcb97a942e89c71999491e3afac");
|
|
755
|
+
var C2PA_UUID = hex("d8fec3d61b0e483c92975828877ec481");
|
|
756
|
+
function detectMediaFormat(bytes) {
|
|
757
|
+
if (bytes.length >= 10 && ascii2(bytes, 0, 3) === "ID3") return "mp3";
|
|
758
|
+
if (isMpegAudioFrame(bytes, 0)) return "mp3";
|
|
759
|
+
if (bytes.length >= 12 && ascii2(bytes, 0, 4) === "RIFF" && ascii2(bytes, 8, 4) === "WAVE") {
|
|
760
|
+
return "wav";
|
|
761
|
+
}
|
|
762
|
+
if (bytes.length >= 12 && ascii2(bytes, 4, 4) === "ftyp") return "mp4";
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
function markMedia(bytes, input = {}, options = {}) {
|
|
766
|
+
const format = detectMediaFormat(bytes);
|
|
767
|
+
if (!format) return { bytes, status: "unsupported", format };
|
|
768
|
+
const parsed = parse(bytes, format);
|
|
769
|
+
if (!parsed) return { bytes, status: "unsupported", format };
|
|
770
|
+
if (options.c2pa !== "overwrite" && parsed.c2pa) {
|
|
771
|
+
return { bytes, status: "skipped-c2pa", format };
|
|
772
|
+
}
|
|
773
|
+
const marking = "sourceType" in input && "humanReviewed" in input ? input : createMarking(input);
|
|
774
|
+
const xmp = buildXmp(marking, parsed.xmp);
|
|
775
|
+
const out = format === "mp3" ? writeMp3(bytes, xmp) : format === "wav" ? writeWav(bytes, xmp) : writeMp4(bytes, xmp);
|
|
776
|
+
if (!out) return { bytes, status: "unsupported", format };
|
|
777
|
+
return { bytes: out, status: "marked", format };
|
|
778
|
+
}
|
|
779
|
+
function readMediaMarking(bytes) {
|
|
780
|
+
const format = detectMediaFormat(bytes);
|
|
781
|
+
const parsed = format ? parse(bytes, format) : null;
|
|
782
|
+
if (!format || !parsed) {
|
|
783
|
+
return { format, xmp: null, aiGenerated: false, witness: false, c2pa: false };
|
|
784
|
+
}
|
|
785
|
+
return describe(format, parsed.xmp, parsed.c2pa);
|
|
786
|
+
}
|
|
787
|
+
function markFile(bytes, input = {}, options = {}) {
|
|
788
|
+
if (detectImageFormat(bytes)) return markImage(bytes, input, options);
|
|
789
|
+
return markMedia(bytes, input, options);
|
|
790
|
+
}
|
|
791
|
+
function readMarking(bytes) {
|
|
792
|
+
if (detectImageFormat(bytes)) return readImageMarking(bytes);
|
|
793
|
+
return readMediaMarking(bytes);
|
|
794
|
+
}
|
|
795
|
+
function describe(format, xmp, c2pa) {
|
|
796
|
+
const sourceType = xmp ? parseSourceType(xmpProperty(xmp, "Iptc4xmpExt:DigitalSourceType")) : void 0;
|
|
797
|
+
const generator = xmp ? xmpProperty(xmp, "Iptc4xmpExt:AISystemUsed") ?? xmpProperty(xmp, "xmp:CreatorTool") : void 0;
|
|
798
|
+
const info = {
|
|
799
|
+
format,
|
|
800
|
+
xmp,
|
|
801
|
+
aiGenerated: isAiSourceType(sourceType),
|
|
802
|
+
witness: xmp?.includes(WITNESS_NS) ?? false,
|
|
803
|
+
c2pa
|
|
804
|
+
};
|
|
805
|
+
if (sourceType) info.sourceType = sourceType;
|
|
806
|
+
if (generator) info.generator = generator;
|
|
807
|
+
return info;
|
|
808
|
+
}
|
|
809
|
+
function parse(bytes, format) {
|
|
810
|
+
if (format === "mp3") {
|
|
811
|
+
const tag = readId3(bytes);
|
|
812
|
+
if (tag === "unsupported") return null;
|
|
813
|
+
if (!tag) return { xmp: null, c2pa: false };
|
|
814
|
+
const xmpFrame = tag.frames.find((f) => f.id === "PRIV" && isXmpPriv(bytes, f));
|
|
815
|
+
return {
|
|
816
|
+
xmp: xmpFrame ? decode(bytes.subarray(xmpFrame.dataStart + 4, xmpFrame.end)) : null,
|
|
817
|
+
c2pa: tag.frames.some(
|
|
818
|
+
(f) => f.id === "GEOB" && ascii2(bytes, f.dataStart, Math.min(64, f.end - f.dataStart)).toLowerCase().includes("c2pa")
|
|
819
|
+
)
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
if (format === "wav") {
|
|
823
|
+
const chunks = riffChunks(bytes);
|
|
824
|
+
if (!chunks) return null;
|
|
825
|
+
const xmp2 = chunks.find((c) => c.fourcc === "_PMX");
|
|
826
|
+
return {
|
|
827
|
+
xmp: xmp2 ? decode(bytes.subarray(xmp2.dataStart, xmp2.dataEnd)) : null,
|
|
828
|
+
c2pa: chunks.some((c) => c.fourcc === "C2PA")
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
const boxes = mp4Boxes(bytes);
|
|
832
|
+
if (!boxes) return null;
|
|
833
|
+
const xmp = boxes.find((b) => isUuidBox(bytes, b, XMP_UUID));
|
|
834
|
+
return {
|
|
835
|
+
xmp: xmp ? decode(bytes.subarray(xmp.dataStart + 16, xmp.end)) : null,
|
|
836
|
+
c2pa: boxes.some((b) => isUuidBox(bytes, b, C2PA_UUID))
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
function readId3(bytes) {
|
|
840
|
+
if (ascii2(bytes, 0, 3) !== "ID3") return null;
|
|
841
|
+
const version = bytes[3];
|
|
842
|
+
const flags = bytes[5] ?? 0;
|
|
843
|
+
if (version !== 3 && version !== 4) return "unsupported";
|
|
844
|
+
if (flags & 128 || flags & 16) return "unsupported";
|
|
845
|
+
const length = 10 + syncsafe(bytes, 6);
|
|
846
|
+
if (length > bytes.length) return "unsupported";
|
|
847
|
+
let offset = 10;
|
|
848
|
+
if (flags & 64) {
|
|
849
|
+
offset += version === 4 ? syncsafe(bytes, 10) : 4 + readU32BE2(bytes, 10);
|
|
850
|
+
}
|
|
851
|
+
const frames = [];
|
|
852
|
+
while (offset + 10 <= length) {
|
|
853
|
+
const id = ascii2(bytes, offset, 4);
|
|
854
|
+
if (!/^[A-Z0-9]{4}$/.test(id)) break;
|
|
855
|
+
const size = version === 4 ? syncsafe(bytes, offset + 4) : readU32BE2(bytes, offset + 4);
|
|
856
|
+
const end = offset + 10 + size;
|
|
857
|
+
if (end > length) return "unsupported";
|
|
858
|
+
frames.push({ id, start: offset, dataStart: offset + 10, end });
|
|
859
|
+
offset = end;
|
|
860
|
+
}
|
|
861
|
+
return { version, length, frames };
|
|
862
|
+
}
|
|
863
|
+
function isXmpPriv(bytes, frame) {
|
|
864
|
+
return ascii2(bytes, frame.dataStart, 4) === "XMP\0";
|
|
865
|
+
}
|
|
866
|
+
function writeMp3(bytes, xmp) {
|
|
867
|
+
const tag = readId3(bytes);
|
|
868
|
+
if (tag === "unsupported") return null;
|
|
869
|
+
const version = tag ? tag.version : 4;
|
|
870
|
+
const kept = tag ? tag.frames.filter((f) => !(f.id === "PRIV" && isXmpPriv(bytes, f))).map((f) => bytes.subarray(f.start, f.end)) : [];
|
|
871
|
+
const data = concat2(latin12("XMP\0"), utf82(xmp));
|
|
872
|
+
const frame = new Uint8Array(10 + data.length);
|
|
873
|
+
frame.set(latin12("PRIV"), 0);
|
|
874
|
+
if (version === 4) writeSyncsafe(frame, 4, data.length);
|
|
875
|
+
else writeU32BE2(frame, 4, data.length);
|
|
876
|
+
frame.set(data, 10);
|
|
877
|
+
const body = concat2(...kept, frame, new Uint8Array(64));
|
|
878
|
+
const header = new Uint8Array(10);
|
|
879
|
+
header.set(latin12("ID3"), 0);
|
|
880
|
+
header[3] = version;
|
|
881
|
+
writeSyncsafe(header, 6, body.length);
|
|
882
|
+
return concat2(header, body, bytes.subarray(tag ? tag.length : 0));
|
|
883
|
+
}
|
|
884
|
+
function isMpegAudioFrame(bytes, offset) {
|
|
885
|
+
const b1 = bytes[offset + 1] ?? 0;
|
|
886
|
+
return bytes[offset] === 255 && (b1 & 224) === 224 && (b1 & 24) !== 8 && (b1 & 6) !== 0;
|
|
887
|
+
}
|
|
888
|
+
function syncsafe(b, o) {
|
|
889
|
+
return ((b[o] ?? 0) & 127) << 21 | ((b[o + 1] ?? 0) & 127) << 14 | ((b[o + 2] ?? 0) & 127) << 7 | (b[o + 3] ?? 0) & 127;
|
|
890
|
+
}
|
|
891
|
+
function writeSyncsafe(b, o, v) {
|
|
892
|
+
b[o] = v >>> 21 & 127;
|
|
893
|
+
b[o + 1] = v >>> 14 & 127;
|
|
894
|
+
b[o + 2] = v >>> 7 & 127;
|
|
895
|
+
b[o + 3] = v & 127;
|
|
896
|
+
}
|
|
897
|
+
function riffChunks(bytes) {
|
|
898
|
+
const chunks = [];
|
|
899
|
+
const limit = Math.min(bytes.length, 8 + readU32LE2(bytes, 4));
|
|
900
|
+
let offset = 12;
|
|
901
|
+
while (offset + 8 <= limit) {
|
|
902
|
+
const size = readU32LE2(bytes, offset + 4);
|
|
903
|
+
const dataEnd = offset + 8 + size;
|
|
904
|
+
if (dataEnd > bytes.length) return null;
|
|
905
|
+
const end = Math.min(dataEnd + size % 2, bytes.length);
|
|
906
|
+
chunks.push({
|
|
907
|
+
fourcc: ascii2(bytes, offset, 4),
|
|
908
|
+
start: offset,
|
|
909
|
+
dataStart: offset + 8,
|
|
910
|
+
dataEnd,
|
|
911
|
+
end
|
|
912
|
+
});
|
|
913
|
+
offset = end;
|
|
914
|
+
}
|
|
915
|
+
return chunks;
|
|
916
|
+
}
|
|
917
|
+
function writeWav(bytes, xmp) {
|
|
918
|
+
const chunks = riffChunks(bytes);
|
|
919
|
+
if (!chunks) return null;
|
|
920
|
+
const data = utf82(xmp);
|
|
921
|
+
const chunk = new Uint8Array(8 + data.length + data.length % 2);
|
|
922
|
+
chunk.set(latin12("_PMX"), 0);
|
|
923
|
+
writeU32LE2(chunk, 4, data.length);
|
|
924
|
+
chunk.set(data, 8);
|
|
925
|
+
const body = concat2(
|
|
926
|
+
...chunks.filter((c) => c.fourcc !== "_PMX").map((c) => bytes.subarray(c.start, c.end)),
|
|
927
|
+
chunk
|
|
928
|
+
);
|
|
929
|
+
const out = concat2(bytes.subarray(0, 12), body);
|
|
930
|
+
writeU32LE2(out, 4, out.length - 8);
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
function mp4Boxes(bytes) {
|
|
934
|
+
const boxes = [];
|
|
935
|
+
let offset = 0;
|
|
936
|
+
while (offset + 8 <= bytes.length) {
|
|
937
|
+
const size = readU32BE2(bytes, offset);
|
|
938
|
+
const type = ascii2(bytes, offset + 4, 4);
|
|
939
|
+
let header = 8;
|
|
940
|
+
let end;
|
|
941
|
+
if (size === 1) {
|
|
942
|
+
if (offset + 16 > bytes.length) return null;
|
|
943
|
+
const high = readU32BE2(bytes, offset + 8);
|
|
944
|
+
end = offset + high * 2 ** 32 + readU32BE2(bytes, offset + 12);
|
|
945
|
+
header = 16;
|
|
946
|
+
} else if (size === 0) {
|
|
947
|
+
end = bytes.length;
|
|
948
|
+
} else {
|
|
949
|
+
end = offset + size;
|
|
950
|
+
}
|
|
951
|
+
if (end > bytes.length || end < offset + header) return null;
|
|
952
|
+
boxes.push({ type, start: offset, dataStart: offset + header, end, open: size === 0 });
|
|
953
|
+
offset = end;
|
|
954
|
+
}
|
|
955
|
+
return boxes;
|
|
956
|
+
}
|
|
957
|
+
function isUuidBox(bytes, box, uuid) {
|
|
958
|
+
if (box.type !== "uuid" || box.end - box.dataStart < 16) return false;
|
|
959
|
+
for (let i = 0; i < 16; i++) if (bytes[box.dataStart + i] !== uuid[i]) return false;
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
function writeMp4(bytes, xmp) {
|
|
963
|
+
const boxes = mp4Boxes(bytes);
|
|
964
|
+
if (!boxes) return null;
|
|
965
|
+
const last = boxes[boxes.length - 1];
|
|
966
|
+
let head = bytes;
|
|
967
|
+
let length = bytes.length;
|
|
968
|
+
if (last && isUuidBox(bytes, last, XMP_UUID)) length = last.start;
|
|
969
|
+
head = bytes.slice(0, length);
|
|
970
|
+
for (const box2 of boxes) {
|
|
971
|
+
if (box2.start < length && isUuidBox(bytes, box2, XMP_UUID)) {
|
|
972
|
+
head.set(latin12("free"), box2.start + 4);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const open = boxes.find((b) => b.open && b.start < length);
|
|
976
|
+
if (open) {
|
|
977
|
+
const size = length - open.start;
|
|
978
|
+
if (size > 4294967295) return null;
|
|
979
|
+
writeU32BE2(head, open.start, size);
|
|
980
|
+
}
|
|
981
|
+
const data = utf82(xmp);
|
|
982
|
+
const box = new Uint8Array(8 + 16 + data.length);
|
|
983
|
+
writeU32BE2(box, 0, box.length);
|
|
984
|
+
box.set(latin12("uuid"), 4);
|
|
985
|
+
box.set(XMP_UUID, 8);
|
|
986
|
+
box.set(data, 24);
|
|
987
|
+
return concat2(head, box);
|
|
988
|
+
}
|
|
989
|
+
function hex(value) {
|
|
990
|
+
const out = new Uint8Array(value.length / 2);
|
|
991
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
|
|
992
|
+
return out;
|
|
993
|
+
}
|
|
994
|
+
function decode(bytes) {
|
|
995
|
+
return new TextDecoder().decode(bytes).replace(/\0+$/, "");
|
|
996
|
+
}
|
|
997
|
+
function ascii2(bytes, offset, length) {
|
|
998
|
+
let out = "";
|
|
999
|
+
for (let i = offset; i < offset + length && i < bytes.length; i++) {
|
|
1000
|
+
out += String.fromCharCode(bytes[i] ?? 0);
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
function latin12(text) {
|
|
1005
|
+
const out = new Uint8Array(text.length);
|
|
1006
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 255;
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
function utf82(text) {
|
|
1010
|
+
return new TextEncoder().encode(text);
|
|
1011
|
+
}
|
|
1012
|
+
function concat2(...parts) {
|
|
1013
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
1014
|
+
let offset = 0;
|
|
1015
|
+
for (const part of parts) {
|
|
1016
|
+
out.set(part, offset);
|
|
1017
|
+
offset += part.length;
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
function readU32BE2(b, o) {
|
|
1022
|
+
return ((b[o] ?? 0) << 24 | (b[o + 1] ?? 0) << 16 | (b[o + 2] ?? 0) << 8 | (b[o + 3] ?? 0)) >>> 0;
|
|
1023
|
+
}
|
|
1024
|
+
function readU32LE2(b, o) {
|
|
1025
|
+
return ((b[o] ?? 0) | (b[o + 1] ?? 0) << 8 | (b[o + 2] ?? 0) << 16 | (b[o + 3] ?? 0) << 24) >>> 0;
|
|
1026
|
+
}
|
|
1027
|
+
function writeU32BE2(b, o, v) {
|
|
1028
|
+
b[o] = v >>> 24 & 255;
|
|
1029
|
+
b[o + 1] = v >>> 16 & 255;
|
|
1030
|
+
b[o + 2] = v >>> 8 & 255;
|
|
1031
|
+
b[o + 3] = v & 255;
|
|
1032
|
+
}
|
|
1033
|
+
function writeU32LE2(b, o, v) {
|
|
1034
|
+
b[o] = v & 255;
|
|
1035
|
+
b[o + 1] = v >>> 8 & 255;
|
|
1036
|
+
b[o + 2] = v >>> 16 & 255;
|
|
1037
|
+
b[o + 3] = v >>> 24 & 255;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
753
1040
|
// src/watermark.ts
|
|
754
1041
|
var MAGIC = [87, 49];
|
|
755
1042
|
function readTextWatermark(text) {
|
|
@@ -818,7 +1105,7 @@ function findInRun(bytes) {
|
|
|
818
1105
|
}
|
|
819
1106
|
|
|
820
1107
|
// src/cli.ts
|
|
821
|
-
var HELP = `witness: AI Act Article 50 marking for images and text
|
|
1108
|
+
var HELP = `witness: AI Act Article 50 marking for images, audio, video and text
|
|
822
1109
|
|
|
823
1110
|
Usage
|
|
824
1111
|
witness mark <files...> (--out <dir> | --in-place) [options]
|
|
@@ -837,7 +1124,8 @@ inspect options
|
|
|
837
1124
|
--json one JSON object per file
|
|
838
1125
|
--require exit 1 if a file carries no AI marking
|
|
839
1126
|
|
|
840
|
-
Images: PNG, JPEG, WebP
|
|
1127
|
+
Images: PNG, JPEG, WebP. Audio and video: MP3, WAV, MP4, MOV, M4A.
|
|
1128
|
+
All get XMP with the IPTC Digital Source Type.
|
|
841
1129
|
Text files are checked for the Witness text watermark.`;
|
|
842
1130
|
var KINDS = ["generated", "edited", "deepfake"];
|
|
843
1131
|
async function runCli(argv, io = defaultIo) {
|
|
@@ -889,11 +1177,11 @@ async function mark(argv, io) {
|
|
|
889
1177
|
let failed = 0;
|
|
890
1178
|
for (const file of positionals) {
|
|
891
1179
|
const bytes = new Uint8Array(await (0, import_promises.readFile)(file));
|
|
892
|
-
const result =
|
|
1180
|
+
const result = markFile(bytes, input, {
|
|
893
1181
|
c2pa: values["overwrite-c2pa"] ? "overwrite" : "skip"
|
|
894
1182
|
});
|
|
895
1183
|
if (result.status === "unsupported") {
|
|
896
|
-
io.err(`${file}: not a PNG, JPEG or
|
|
1184
|
+
io.err(`${file}: not a PNG, JPEG, WebP, MP3, WAV or MP4 file, skipped`);
|
|
897
1185
|
failed++;
|
|
898
1186
|
continue;
|
|
899
1187
|
}
|
|
@@ -917,9 +1205,9 @@ async function inspect(argv, io) {
|
|
|
917
1205
|
let unmarked = 0;
|
|
918
1206
|
for (const file of positionals) {
|
|
919
1207
|
const bytes = new Uint8Array(await (0, import_promises.readFile)(file));
|
|
920
|
-
const format = detectImageFormat(bytes);
|
|
1208
|
+
const format = detectImageFormat(bytes) ?? detectMediaFormat(bytes);
|
|
921
1209
|
if (format) {
|
|
922
|
-
const info =
|
|
1210
|
+
const info = readMarking(bytes);
|
|
923
1211
|
const marked = info.aiGenerated || info.c2pa;
|
|
924
1212
|
if (!marked) unmarked++;
|
|
925
1213
|
if (values.json) {
|