@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/cli.cjs CHANGED
@@ -750,6 +750,293 @@ function unescapeXml(value) {
750
750
  return value.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&apos;/g, "'").replace(/&amp;/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 (XMP with IPTC Digital Source Type).
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 = markImage(bytes, input, {
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 WebP file, skipped`);
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 = readImageMarking(bytes);
1210
+ const info = readMarking(bytes);
923
1211
  const marked = info.aiGenerated || info.c2pa;
924
1212
  if (!marked) unmarked++;
925
1213
  if (values.json) {