@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/bin.cjs
CHANGED
|
@@ -729,6 +729,293 @@ function unescapeXml(value) {
|
|
|
729
729
|
return value.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/&/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
|
|
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 =
|
|
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
|
|
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 =
|
|
1189
|
+
const info = readMarking(bytes);
|
|
902
1190
|
const marked = info.aiGenerated || info.c2pa;
|
|
903
1191
|
if (!marked) unmarked++;
|
|
904
1192
|
if (values.json) {
|