@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/bin.cjs CHANGED
@@ -729,6 +729,293 @@ function unescapeXml(value) {
729
729
  return value.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
730
730
  }
731
731
 
732
+ // src/media.ts
733
+ var XMP_UUID = hex("be7acfcb97a942e89c71999491e3afac");
734
+ var C2PA_UUID = hex("d8fec3d61b0e483c92975828877ec481");
735
+ function detectMediaFormat(bytes) {
736
+ if (bytes.length >= 10 && ascii2(bytes, 0, 3) === "ID3") return "mp3";
737
+ if (isMpegAudioFrame(bytes, 0)) return "mp3";
738
+ if (bytes.length >= 12 && ascii2(bytes, 0, 4) === "RIFF" && ascii2(bytes, 8, 4) === "WAVE") {
739
+ return "wav";
740
+ }
741
+ if (bytes.length >= 12 && ascii2(bytes, 4, 4) === "ftyp") return "mp4";
742
+ return null;
743
+ }
744
+ function markMedia(bytes, input = {}, options = {}) {
745
+ const format = detectMediaFormat(bytes);
746
+ if (!format) return { bytes, status: "unsupported", format };
747
+ const parsed = parse(bytes, format);
748
+ if (!parsed) return { bytes, status: "unsupported", format };
749
+ if (options.c2pa !== "overwrite" && parsed.c2pa) {
750
+ return { bytes, status: "skipped-c2pa", format };
751
+ }
752
+ const marking = "sourceType" in input && "humanReviewed" in input ? input : createMarking(input);
753
+ const xmp = buildXmp(marking, parsed.xmp);
754
+ const out = format === "mp3" ? writeMp3(bytes, xmp) : format === "wav" ? writeWav(bytes, xmp) : writeMp4(bytes, xmp);
755
+ if (!out) return { bytes, status: "unsupported", format };
756
+ return { bytes: out, status: "marked", format };
757
+ }
758
+ function readMediaMarking(bytes) {
759
+ const format = detectMediaFormat(bytes);
760
+ const parsed = format ? parse(bytes, format) : null;
761
+ if (!format || !parsed) {
762
+ return { format, xmp: null, aiGenerated: false, witness: false, c2pa: false };
763
+ }
764
+ return describe(format, parsed.xmp, parsed.c2pa);
765
+ }
766
+ function markFile(bytes, input = {}, options = {}) {
767
+ if (detectImageFormat(bytes)) return markImage(bytes, input, options);
768
+ return markMedia(bytes, input, options);
769
+ }
770
+ function readMarking(bytes) {
771
+ if (detectImageFormat(bytes)) return readImageMarking(bytes);
772
+ return readMediaMarking(bytes);
773
+ }
774
+ function describe(format, xmp, c2pa) {
775
+ const sourceType = xmp ? parseSourceType(xmpProperty(xmp, "Iptc4xmpExt:DigitalSourceType")) : void 0;
776
+ const generator = xmp ? xmpProperty(xmp, "Iptc4xmpExt:AISystemUsed") ?? xmpProperty(xmp, "xmp:CreatorTool") : void 0;
777
+ const info = {
778
+ format,
779
+ xmp,
780
+ aiGenerated: isAiSourceType(sourceType),
781
+ witness: xmp?.includes(WITNESS_NS) ?? false,
782
+ c2pa
783
+ };
784
+ if (sourceType) info.sourceType = sourceType;
785
+ if (generator) info.generator = generator;
786
+ return info;
787
+ }
788
+ function parse(bytes, format) {
789
+ if (format === "mp3") {
790
+ const tag = readId3(bytes);
791
+ if (tag === "unsupported") return null;
792
+ if (!tag) return { xmp: null, c2pa: false };
793
+ const xmpFrame = tag.frames.find((f) => f.id === "PRIV" && isXmpPriv(bytes, f));
794
+ return {
795
+ xmp: xmpFrame ? decode(bytes.subarray(xmpFrame.dataStart + 4, xmpFrame.end)) : null,
796
+ c2pa: tag.frames.some(
797
+ (f) => f.id === "GEOB" && ascii2(bytes, f.dataStart, Math.min(64, f.end - f.dataStart)).toLowerCase().includes("c2pa")
798
+ )
799
+ };
800
+ }
801
+ if (format === "wav") {
802
+ const chunks = riffChunks(bytes);
803
+ if (!chunks) return null;
804
+ const xmp2 = chunks.find((c) => c.fourcc === "_PMX");
805
+ return {
806
+ xmp: xmp2 ? decode(bytes.subarray(xmp2.dataStart, xmp2.dataEnd)) : null,
807
+ c2pa: chunks.some((c) => c.fourcc === "C2PA")
808
+ };
809
+ }
810
+ const boxes = mp4Boxes(bytes);
811
+ if (!boxes) return null;
812
+ const xmp = boxes.find((b) => isUuidBox(bytes, b, XMP_UUID));
813
+ return {
814
+ xmp: xmp ? decode(bytes.subarray(xmp.dataStart + 16, xmp.end)) : null,
815
+ c2pa: boxes.some((b) => isUuidBox(bytes, b, C2PA_UUID))
816
+ };
817
+ }
818
+ function readId3(bytes) {
819
+ if (ascii2(bytes, 0, 3) !== "ID3") return null;
820
+ const version = bytes[3];
821
+ const flags = bytes[5] ?? 0;
822
+ if (version !== 3 && version !== 4) return "unsupported";
823
+ if (flags & 128 || flags & 16) return "unsupported";
824
+ const length = 10 + syncsafe(bytes, 6);
825
+ if (length > bytes.length) return "unsupported";
826
+ let offset = 10;
827
+ if (flags & 64) {
828
+ offset += version === 4 ? syncsafe(bytes, 10) : 4 + readU32BE2(bytes, 10);
829
+ }
830
+ const frames = [];
831
+ while (offset + 10 <= length) {
832
+ const id = ascii2(bytes, offset, 4);
833
+ if (!/^[A-Z0-9]{4}$/.test(id)) break;
834
+ const size = version === 4 ? syncsafe(bytes, offset + 4) : readU32BE2(bytes, offset + 4);
835
+ const end = offset + 10 + size;
836
+ if (end > length) return "unsupported";
837
+ frames.push({ id, start: offset, dataStart: offset + 10, end });
838
+ offset = end;
839
+ }
840
+ return { version, length, frames };
841
+ }
842
+ function isXmpPriv(bytes, frame) {
843
+ return ascii2(bytes, frame.dataStart, 4) === "XMP\0";
844
+ }
845
+ function writeMp3(bytes, xmp) {
846
+ const tag = readId3(bytes);
847
+ if (tag === "unsupported") return null;
848
+ const version = tag ? tag.version : 4;
849
+ const kept = tag ? tag.frames.filter((f) => !(f.id === "PRIV" && isXmpPriv(bytes, f))).map((f) => bytes.subarray(f.start, f.end)) : [];
850
+ const data = concat2(latin12("XMP\0"), utf82(xmp));
851
+ const frame = new Uint8Array(10 + data.length);
852
+ frame.set(latin12("PRIV"), 0);
853
+ if (version === 4) writeSyncsafe(frame, 4, data.length);
854
+ else writeU32BE2(frame, 4, data.length);
855
+ frame.set(data, 10);
856
+ const body = concat2(...kept, frame, new Uint8Array(64));
857
+ const header = new Uint8Array(10);
858
+ header.set(latin12("ID3"), 0);
859
+ header[3] = version;
860
+ writeSyncsafe(header, 6, body.length);
861
+ return concat2(header, body, bytes.subarray(tag ? tag.length : 0));
862
+ }
863
+ function isMpegAudioFrame(bytes, offset) {
864
+ const b1 = bytes[offset + 1] ?? 0;
865
+ return bytes[offset] === 255 && (b1 & 224) === 224 && (b1 & 24) !== 8 && (b1 & 6) !== 0;
866
+ }
867
+ function syncsafe(b, o) {
868
+ return ((b[o] ?? 0) & 127) << 21 | ((b[o + 1] ?? 0) & 127) << 14 | ((b[o + 2] ?? 0) & 127) << 7 | (b[o + 3] ?? 0) & 127;
869
+ }
870
+ function writeSyncsafe(b, o, v) {
871
+ b[o] = v >>> 21 & 127;
872
+ b[o + 1] = v >>> 14 & 127;
873
+ b[o + 2] = v >>> 7 & 127;
874
+ b[o + 3] = v & 127;
875
+ }
876
+ function riffChunks(bytes) {
877
+ const chunks = [];
878
+ const limit = Math.min(bytes.length, 8 + readU32LE2(bytes, 4));
879
+ let offset = 12;
880
+ while (offset + 8 <= limit) {
881
+ const size = readU32LE2(bytes, offset + 4);
882
+ const dataEnd = offset + 8 + size;
883
+ if (dataEnd > bytes.length) return null;
884
+ const end = Math.min(dataEnd + size % 2, bytes.length);
885
+ chunks.push({
886
+ fourcc: ascii2(bytes, offset, 4),
887
+ start: offset,
888
+ dataStart: offset + 8,
889
+ dataEnd,
890
+ end
891
+ });
892
+ offset = end;
893
+ }
894
+ return chunks;
895
+ }
896
+ function writeWav(bytes, xmp) {
897
+ const chunks = riffChunks(bytes);
898
+ if (!chunks) return null;
899
+ const data = utf82(xmp);
900
+ const chunk = new Uint8Array(8 + data.length + data.length % 2);
901
+ chunk.set(latin12("_PMX"), 0);
902
+ writeU32LE2(chunk, 4, data.length);
903
+ chunk.set(data, 8);
904
+ const body = concat2(
905
+ ...chunks.filter((c) => c.fourcc !== "_PMX").map((c) => bytes.subarray(c.start, c.end)),
906
+ chunk
907
+ );
908
+ const out = concat2(bytes.subarray(0, 12), body);
909
+ writeU32LE2(out, 4, out.length - 8);
910
+ return out;
911
+ }
912
+ function mp4Boxes(bytes) {
913
+ const boxes = [];
914
+ let offset = 0;
915
+ while (offset + 8 <= bytes.length) {
916
+ const size = readU32BE2(bytes, offset);
917
+ const type = ascii2(bytes, offset + 4, 4);
918
+ let header = 8;
919
+ let end;
920
+ if (size === 1) {
921
+ if (offset + 16 > bytes.length) return null;
922
+ const high = readU32BE2(bytes, offset + 8);
923
+ end = offset + high * 2 ** 32 + readU32BE2(bytes, offset + 12);
924
+ header = 16;
925
+ } else if (size === 0) {
926
+ end = bytes.length;
927
+ } else {
928
+ end = offset + size;
929
+ }
930
+ if (end > bytes.length || end < offset + header) return null;
931
+ boxes.push({ type, start: offset, dataStart: offset + header, end, open: size === 0 });
932
+ offset = end;
933
+ }
934
+ return boxes;
935
+ }
936
+ function isUuidBox(bytes, box, uuid) {
937
+ if (box.type !== "uuid" || box.end - box.dataStart < 16) return false;
938
+ for (let i = 0; i < 16; i++) if (bytes[box.dataStart + i] !== uuid[i]) return false;
939
+ return true;
940
+ }
941
+ function writeMp4(bytes, xmp) {
942
+ const boxes = mp4Boxes(bytes);
943
+ if (!boxes) return null;
944
+ const last = boxes[boxes.length - 1];
945
+ let head = bytes;
946
+ let length = bytes.length;
947
+ if (last && isUuidBox(bytes, last, XMP_UUID)) length = last.start;
948
+ head = bytes.slice(0, length);
949
+ for (const box2 of boxes) {
950
+ if (box2.start < length && isUuidBox(bytes, box2, XMP_UUID)) {
951
+ head.set(latin12("free"), box2.start + 4);
952
+ }
953
+ }
954
+ const open = boxes.find((b) => b.open && b.start < length);
955
+ if (open) {
956
+ const size = length - open.start;
957
+ if (size > 4294967295) return null;
958
+ writeU32BE2(head, open.start, size);
959
+ }
960
+ const data = utf82(xmp);
961
+ const box = new Uint8Array(8 + 16 + data.length);
962
+ writeU32BE2(box, 0, box.length);
963
+ box.set(latin12("uuid"), 4);
964
+ box.set(XMP_UUID, 8);
965
+ box.set(data, 24);
966
+ return concat2(head, box);
967
+ }
968
+ function hex(value) {
969
+ const out = new Uint8Array(value.length / 2);
970
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
971
+ return out;
972
+ }
973
+ function decode(bytes) {
974
+ return new TextDecoder().decode(bytes).replace(/\0+$/, "");
975
+ }
976
+ function ascii2(bytes, offset, length) {
977
+ let out = "";
978
+ for (let i = offset; i < offset + length && i < bytes.length; i++) {
979
+ out += String.fromCharCode(bytes[i] ?? 0);
980
+ }
981
+ return out;
982
+ }
983
+ function latin12(text) {
984
+ const out = new Uint8Array(text.length);
985
+ for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 255;
986
+ return out;
987
+ }
988
+ function utf82(text) {
989
+ return new TextEncoder().encode(text);
990
+ }
991
+ function concat2(...parts) {
992
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
993
+ let offset = 0;
994
+ for (const part of parts) {
995
+ out.set(part, offset);
996
+ offset += part.length;
997
+ }
998
+ return out;
999
+ }
1000
+ function readU32BE2(b, o) {
1001
+ return ((b[o] ?? 0) << 24 | (b[o + 1] ?? 0) << 16 | (b[o + 2] ?? 0) << 8 | (b[o + 3] ?? 0)) >>> 0;
1002
+ }
1003
+ function readU32LE2(b, o) {
1004
+ return ((b[o] ?? 0) | (b[o + 1] ?? 0) << 8 | (b[o + 2] ?? 0) << 16 | (b[o + 3] ?? 0) << 24) >>> 0;
1005
+ }
1006
+ function writeU32BE2(b, o, v) {
1007
+ b[o] = v >>> 24 & 255;
1008
+ b[o + 1] = v >>> 16 & 255;
1009
+ b[o + 2] = v >>> 8 & 255;
1010
+ b[o + 3] = v & 255;
1011
+ }
1012
+ function writeU32LE2(b, o, v) {
1013
+ b[o] = v & 255;
1014
+ b[o + 1] = v >>> 8 & 255;
1015
+ b[o + 2] = v >>> 16 & 255;
1016
+ b[o + 3] = v >>> 24 & 255;
1017
+ }
1018
+
732
1019
  // src/watermark.ts
733
1020
  var MAGIC = [87, 49];
734
1021
  function readTextWatermark(text) {
@@ -797,7 +1084,7 @@ function findInRun(bytes) {
797
1084
  }
798
1085
 
799
1086
  // src/cli.ts
800
- var HELP = `witness: AI Act Article 50 marking for images and text
1087
+ var HELP = `witness: AI Act Article 50 marking for images, audio, video and text
801
1088
 
802
1089
  Usage
803
1090
  witness mark <files...> (--out <dir> | --in-place) [options]
@@ -816,7 +1103,8 @@ inspect options
816
1103
  --json one JSON object per file
817
1104
  --require exit 1 if a file carries no AI marking
818
1105
 
819
- Images: PNG, JPEG, WebP (XMP with IPTC Digital Source Type).
1106
+ Images: PNG, JPEG, WebP. Audio and video: MP3, WAV, MP4, MOV, M4A.
1107
+ All get XMP with the IPTC Digital Source Type.
820
1108
  Text files are checked for the Witness text watermark.`;
821
1109
  var KINDS = ["generated", "edited", "deepfake"];
822
1110
  async function runCli(argv, io = defaultIo) {
@@ -868,11 +1156,11 @@ async function mark(argv, io) {
868
1156
  let failed = 0;
869
1157
  for (const file of positionals) {
870
1158
  const bytes = new Uint8Array(await (0, import_promises.readFile)(file));
871
- const result = markImage(bytes, input, {
1159
+ const result = markFile(bytes, input, {
872
1160
  c2pa: values["overwrite-c2pa"] ? "overwrite" : "skip"
873
1161
  });
874
1162
  if (result.status === "unsupported") {
875
- io.err(`${file}: not a PNG, JPEG or WebP file, skipped`);
1163
+ io.err(`${file}: not a PNG, JPEG, WebP, MP3, WAV or MP4 file, skipped`);
876
1164
  failed++;
877
1165
  continue;
878
1166
  }
@@ -896,9 +1184,9 @@ async function inspect(argv, io) {
896
1184
  let unmarked = 0;
897
1185
  for (const file of positionals) {
898
1186
  const bytes = new Uint8Array(await (0, import_promises.readFile)(file));
899
- const format = detectImageFormat(bytes);
1187
+ const format = detectImageFormat(bytes) ?? detectMediaFormat(bytes);
900
1188
  if (format) {
901
- const info = readImageMarking(bytes);
1189
+ const info = readMarking(bytes);
902
1190
  const marked = info.aiGenerated || info.c2pa;
903
1191
  if (!marked) unmarked++;
904
1192
  if (values.json) {