// ==UserScript== // @name 百分网自动刷课助手 // @description 自动完成百分网人脸验证与课程播放,支持防切屏检测与人脸图片管理 // @namespace https://greasyfork.org/baifenwang-auto-study // @version 1.3.0 // @author TheSkyC // @license MIT // @homepageURL https://github.com/TheSkyC/baifenwang-auto-study // @supportURL https://github.com/TheSkyC/baifenwang-auto-study/issues // @match *://*.tj.100.wang/* // @run-at document-start // @compatible Tampermonkey // @compatible Greasemonkey // @compatible Violentmonkey // @compatible ScriptCat // @compatible AdGuard // @grant none // ==/UserScript== /*! JSZip v3.10.1 - A JavaScript class for generating and reading zip files (c) 2009-2016 Stuart Knightley Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. JSZip uses the library pako released under the MIT license : https://github.com/nodeca/pako/blob/main/LICENSE */ !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r this) between adjacent pixels → edge. * Typical face features produce step edges of 40–80 luma units; * smooth skin varies by <10. A threshold of 20 reliably separates * facial features from uniform neck skin. */ SKIN_EDGE_LUM_THRESHOLD: 20, /** * Minimum relative drop between consecutive rows to be considered a * face→neck cliff. 0.40 = row below has ≤60% of the skin pixels. */ SKIN_CLIFF_THRESHOLD: 0.40, // ---- Layer 5: Aspect ratio sanity ---- /** * A purely-skin-based face box that's much taller than wide almost * certainly includes neck. Clamp height to this ratio. * 1.4:1 is generous for oval faces — a typical face is 1.0–1.3:1 * (height/width). Anything taller gets bottom-trimmed. */ SKIN_MAX_ASPECT_RATIO: 1.4, // ---- Headroom extension ---- /** * The skin heuristic detects the FACE (roughly hairline to chin). * The hair and crown extend above the hairline by 25–38% of face * height (anthropometric average ~33%). This shift re-positions the * attention point upward by a fraction of the detected face height * so the crop window includes the full head, not just the face. * * General anthropometric data: * Face (hairline→chin) = ~60–65% of total head * Hair/crown above hairline = ~35–40% of face height * User measurement: hair 60px : face 160px → 0.375 ratio * * 0.24 × faceH shifts the centroid from the skin centre (roughly * nose bridge) up toward the hairline/crown so the crop window * includes the full head with comfortable headroom. */ SKIN_HEADROOM_SHIFT: 0.24, // ---- Tier 2: Fixed-bias fallback ---- /** * Vertical bias when no skin regions are detected by the heuristic. * Same value as IMAGE_POOL_CONFIG.CROP_FACE_BIAS — kept here as the * canonical source for the face-detection pipeline. */ CROP_FALLBACK_BIAS: 0.38, /** * Vertical bias used when skin detection succeeds. * 0.40 = attention point at 40% from top → more headroom above than * below. Combined with the headroom shift on the centroid, this * ensures the full head (face + hair) is visible in the 4:3 crop. */ CROP_FACE_BIAS: 0.40, }; // Crop editor configuration const CROP_EDITOR_CONFIG = { /** Maximum displayed width/height in the editor (px) — image is scaled to fit */ MAX_DISPLAY_SIZE: 480, /** Handle radius for interaction hit-testing (px) */ HANDLE_RADIUS: 10, /** Handle visual size in CSS (px) */ HANDLE_SIZE: 12, /** Minimum crop rectangle size in source pixels */ MIN_CROP_PX: 20, /** Target aspect ratio (width / height) */ TARGET_RATIO: 4 / 3, /** Live preview thumbnail size (px, square bounding box) */ PREVIEW_SIZE: 72, }; /** * @file Media configuration — video stream replacement, frame capture, and overlay. * @module config/media */ // Video stream replacement configuration const VIDEO_REPLACE_CONFIG = { /** Default canvas width when constraints don't specify */ DEFAULT_WIDTH: 640, /** Default canvas height when constraints don't specify */ DEFAULT_HEIGHT: 480, /** Canvas stream FPS */ STREAM_FPS: 30, /** Subtle brightness jitter range (±this fraction) to simulate live camera */ BRIGHTNESS_JITTER: 0.02, }; // Video frame capture selectors (for manual "capture" button) const VIDEO_CAPTURE_SELECTORS = ['#video', '.main_content', 'video[autoplay]', 'video']; /** * @file Core configuration constants for baifenwang-auto-study. * * Domain-specific configs are in config/pool.js and config/media.js. */ // Script metadata const SCRIPT_NAME = '百分网自动刷课助手'; const SCRIPT_VERSION = '1.3.0'; const GITHUB_URL = 'https://github.com/TheSkyC/baifenwang-auto-study'; const UPDATE_API_URL = 'https://baifenwang-auto-study.tarxf.com'; // Log level const LOG_LEVEL = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, }; // Current log level (change to LOG_LEVEL.DEBUG for verbose output) const CURRENT_LOG_LEVEL = LOG_LEVEL.INFO; // Settings storage key (shared across GM, localStorage, and in-memory backends) const SETTINGS_KEY = 'bfw_settings'; // Progress tracker storage key const PROGRESS_TRACKER_KEY = 'bfw_progress'; // Progress tracker behavior thresholds const PROGRESS_TRACKER_CONFIG = { /** Sessions shorter than this (seconds) with 0 lessons completed are discarded on end. */ MIN_SESSION_DURATION_S: 30, /** Unfinished sessions older than this (ms) are not resumed — treated as abandoned. */ RESUME_MAX_AGE_MS: 4 * 60 * 60 * 1000, // 4 hours }; // Import / export constants const IMPORT_EXPORT_CONFIG = { /** Current backup format version. Increment when the ZIP layout changes. */ FORMAT_VERSION: 1, /** Estimated average cropped JPEG size for file-size prediction (bytes). */ EST_CROPPED_JPEG_SIZE: 50 * 1024, // 50 KB /** Estimated average original JPEG size for file-size prediction (bytes). */ EST_ORIG_JPEG_SIZE: 100 * 1024, // 100 KB /** Per-image JSON overhead in pool-meta.json + pool-stats.json (bytes). */ EST_PER_IMAGE_JSON: 400, }; // Retry settings for auto-processor const AUTO_CONFIG = { /** Delay before initial sequence kickoff (ms) */ CLICK_DELAY_MS: 300, /** Delay after clicking "open camera" before trying the photo button (ms) */ CAMERA_OPEN_DELAY_MS: 1500, /** Delay after clicking "take photo" before trying the compare button (ms) */ PHOTO_DELAY_MS: 1000, /** Max retries when compare button is not yet in the DOM */ MAX_COMPARE_RETRIES: 5, /** Retry interval for compare button polling (ms) */ COMPARE_RETRY_DELAY_MS: 400, /** Max consecutive retry-button cycles before giving up */ RETRY_MAX_ATTEMPTS: 5, /** Base delay for exponential backoff on retry (ms) */ RETRY_BASE_DELAY_MS: 2000, /** Maximum backoff delay cap (ms) */ RETRY_MAX_DELAY_MS: 30000, /** Minimum wait after clicking "开始对比" before handleCompareFailRecovery * may fire. The server needs time to respond; without this cooldown any * DOM mutation during the server round-trip is misidentified as a failure * because the page still shows both "重新拍照" and "开始对比" buttons. * * Fixed per attempt — server response time doesn't change between retries. * The retry gap (COMPARE_RETRY_GAP_BASE_MS) handles pacing between attempts; * this only covers the ambiguous "still waiting or already rejected?" window. */ COMPARE_COOLDOWN_MS: 8000, /** Base retry gap after a confirmed compare failure, before the retake cycle * begins (ms). Grows exponentially: base × 2^(compareAttempts-1), capped by * COMPARE_RETRY_GAP_MAX_MS. * * Separate from COMPARE_COOLDOWN_MS — the cooldown waits for the server to * respond; the gap paces the cycle after failure is confirmed. Using a * shorter base with a moderate cap keeps the overall pace brisk even when * verification fails several times in a row. */ COMPARE_RETRY_GAP_BASE_MS: 2000, /** Maximum retry gap cap for exponential backoff (ms). */ COMPARE_RETRY_GAP_MAX_MS: 15000, /** Delay after clicking retry button before camera-open click (ms). * Used by onRetry() as a bridge between retry and the normal pipeline. */ RETRY_CAMERA_DELAY_MS: 800, }; // Auto-course (自动刷课) configuration const COURSE_CONFIG = { /** Delay before clicking the play button after page load (ms) */ PLAY_CLICK_DELAY_MS: 1000, /** Max retries for finding and clicking the play button */ PLAY_MAX_RETRIES: 10, /** Retry interval for play button polling (ms) */ PLAY_RETRY_DELAY_MS: 500, /** How often to update the progress display (ms) */ PROGRESS_UPDATE_INTERVAL_MS: 3000, /** Seconds of paused playback before auto-resuming */ STUCK_THRESHOLD_S: 30, /** Max auto-resume attempts before giving up */ MAX_RESUME_ATTEMPTS: 3}; // --------------------------------------------------------------------------- // Delay jitter — prevents bot-detection via fixed-interval timing analysis. // Human reaction times have a coefficient of variation (CV) in the 0.3–0.8 // range; fixed delays are CV=0. Apply jitter at every usage site so the // timing fingerprint becomes unpredictable. // --------------------------------------------------------------------------- /** * Apply symmetric random jitter to a base delay. * Returns `baseMs * (1 ± factor)` — the delay can be SHORTER or LONGER. * Suitable for click delays, polling intervals, and general wait times. * * @param {number} baseMs - base delay in milliseconds * @param {number} [factor=0.3] - jitter range (0.3 = ±30%) * @returns {number} jittered delay (always ≥ 1ms) */ function jitterMs(baseMs, factor = 0.3) { const f = Math.min(Math.max(factor, 0), 1); return Math.max(1, Math.round(baseMs * (1 + (Math.random() - 0.5) * 2 * f))); } /** * Apply asymmetric jitter — delay is ONLY lengthened, never shortened. * Suitable for cooldowns, thresholds, and minimum-wait guards where * going below the base value risks a false positive (e.g. mistaking * a slow server response for a comparison failure). * * @param {number} baseMs - floor delay in milliseconds * @param {number} [factor=0.3] - maximum increase relative to baseMs * @returns {number} jittered delay in [baseMs, baseMs × (1 + factor)] */ function jitterMsFloor(baseMs, factor = 0.3) { const f = Math.min(Math.max(factor, 0), 1); return Math.round(baseMs * (1 + Math.random() * f)); } /** * @file Logger utility with colored console output * Provides leveled logging with script prefix and optional styles. */ const STYLES$1 = { debug: 'color: #888; font-style: italic;', info: 'color: #2196F3; font-weight: bold;', warn: 'color: #FF9800; font-weight: bold;', error: 'color: #F44336; font-weight: bold;', }; const LABELS = { debug: 'DBG', info: 'INF', warn: 'WRN', error: 'ERR', }; function shouldLog(level) { return level >= CURRENT_LOG_LEVEL; } function formatPrefix(level) { return `%c[${SCRIPT_NAME}][${LABELS[level]}]`; } /** * Log a debug-level message (verbose, hidden by default). * @param {...any} args - Values to log */ function debug(...args) { if (!shouldLog(LOG_LEVEL.DEBUG)) return; console.log(formatPrefix(LOG_LEVEL.DEBUG), STYLES$1.debug, ...args); } /** * Log an info-level message. * @param {...any} args - Values to log */ function info(...args) { if (!shouldLog(LOG_LEVEL.INFO)) return; console.log(formatPrefix(LOG_LEVEL.INFO), STYLES$1.info, ...args); } /** * Log a warning-level message. * @param {...any} args - Values to log */ function warn(...args) { if (!shouldLog(LOG_LEVEL.WARN)) return; console.warn(formatPrefix(LOG_LEVEL.WARN), STYLES$1.warn, ...args); } /** * Log an error-level message. * @param {...any} args - Values to log */ function error(...args) { if (!shouldLog(LOG_LEVEL.ERROR)) return; console.error(formatPrefix(LOG_LEVEL.ERROR), STYLES$1.error, ...args); } /** * @file Storage adapter — unified multi-backend persistent storage. * * Backends (tried in order): * 1. localStorage (per-origin, persistent, ~5 MB) * 2. In-memory Map (session-only fallback) * * All public methods return Promises so callers never need to distinguish * between sync and async backends. * * Note: GM sandbox storage (GM_setValue / GM.getValue) is intentionally NOT * used because it would require @grant annotations that sandbox the script * away from page globals — every DOM / navigator access would then need * unsafeWindow.*, which is a disproportionate refactor for the modest quota * gain. localStorage is reliable and sufficient for a compressed face-image * pool. * * @module utils/storage-adapter */ // --------------------------------------------------------------------------- // Adapter type // --------------------------------------------------------------------------- /** * @typedef {Object} StorageAdapter * @property {(key: string) => Promise} get * @property {(key: string, value: string) => Promise} set * @property {(key: string) => Promise} remove * @property {() => Promise} keys */ // --------------------------------------------------------------------------- // Cached singleton // --------------------------------------------------------------------------- /** @type {StorageAdapter|null} */ let _adapter = null; // --------------------------------------------------------------------------- // Backend detectors // --------------------------------------------------------------------------- /** * Try localStorage (per-origin, persistent). * @returns {StorageAdapter|null} */ function detectLocalStorage() { try { const testKey = '__bfw_storage_test__'; localStorage.setItem(testKey, '1'); localStorage.removeItem(testKey); return { get: (key) => Promise.resolve(localStorage.getItem(key)), set: (key, value) => { localStorage.setItem(key, value); return Promise.resolve(); }, remove: (key) => { localStorage.removeItem(key); return Promise.resolve(); }, keys: () => { const out = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k) out.push(k); } return Promise.resolve(out); }, }; } catch (_) { /* unavailable (private browsing, quota, etc.) */ } return null; } /** * Build an in-memory fallback adapter (session-only, always available). * @returns {StorageAdapter} */ function createMemoryAdapter() { const store = new Map(); return { get: (key) => Promise.resolve(store.get(key) ?? null), set: (key, value) => { store.set(key, value); return Promise.resolve(); }, remove: (key) => { store.delete(key); return Promise.resolve(); }, keys: () => Promise.resolve(Array.from(store.keys())), }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Return the best available storage adapter (cached after first call). * * Detection order: localStorage → in-memory. * * @returns {StorageAdapter} */ function getStorageAdapter() { if (_adapter) return _adapter; // 1. localStorage _adapter = detectLocalStorage(); if (_adapter) { debug('Storage: using localStorage adapter'); return _adapter; } // 2. In-memory fallback warn('Storage: no persistent backend available, using in-memory (session-only)'); _adapter = createMemoryAdapter(); return _adapter; } /** * @file Settings state — centralized toggleable configuration for the userscript. * * Settings are persisted across page reloads via utils/storage-adapter.js. * On load, persisted values are merged over defaults; on every change the * full state is flushed to storage. * * Reserved for future expansion: auto-course (自动刷课) settings group. */ // --------------------------------------------------------------------------- // Defaults — the source of truth for all setting keys and their initial values // --------------------------------------------------------------------------- const DEFAULTS = { /** Enable auto-click for face verification UI elements */ faceAutoClick: true, /** Replace the camera video stream with a pool image (getUserMedia interception) */ videoReplace: true, /** Auto-compare after photo — when OFF, pauses after photo for manual confirmation */ autoCompare: true, // ---- Auto-course (自动刷课) ---- /** Enable auto-course processor — auto-plays course videos and monitors progress */ autoCourse: false, /** Prevent the site from detecting tab-switch / window minimization */ disableVisibilityCheck: false, // ---- Image pool ---- /** Enable weighted random selection based on per-image quality stats */ dynamicWeight: true, }; // --------------------------------------------------------------------------- // State & listeners // --------------------------------------------------------------------------- /** @type {{ [key: string]: Array<(val: any) => void> }} */ const listeners = {}; /** Runtime state (defaults merged with persisted values after init). */ const state = { ...DEFAULTS }; /** Whether the persisted state has been loaded. */ let loaded$1 = false; /** * Load persisted settings from storage and merge over defaults. * Called once during initialization; safe to call multiple times (idempotent). */ async function loadSettings() { if (loaded$1) return; try { const raw = await getStorageAdapter().get(SETTINGS_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { // Only merge keys that exist in DEFAULTS — ignore unknown/stale keys for (const key of Object.keys(DEFAULTS)) { if (key in parsed && typeof parsed[key] === typeof DEFAULTS[key]) { state[key] = parsed[key]; } } debug('Settings: loaded from storage'); } } } catch (e) { warn('Settings: failed to load from storage, using defaults:', e); } loaded$1 = true; info(`Settings ready: faceAutoClick=${state.faceAutoClick}, videoReplace=${state.videoReplace}, autoCompare=${state.autoCompare}, autoCourse=${state.autoCourse}, disableVisibilityCheck=${state.disableVisibilityCheck}, dynamicWeight=${state.dynamicWeight}`); } /** * Persist the current state to storage (best-effort). */ async function saveSettings() { try { await getStorageAdapter().set(SETTINGS_KEY, JSON.stringify(state)); } catch (e) { warn('Settings: failed to persist:', e); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Get the value of a setting. * * When settings have been loaded (the normal case after DOMContentLoaded) * the in-memory state is returned. Before that — during the document-start * window when interceptors are already installed but loadSettings() hasn't * run yet — a synchronous localStorage read is attempted so that persisted * preferences are honoured from the very first intercepted call. * * @template T * @param {string} key * @param {T} [fallback] * @returns {T} */ function getSetting(key, fallback) { // Fast path: settings already loaded if (loaded$1) { return key in state ? state[key] : fallback; } // Pre-init path: try a synchronous localStorage peek so interceptors // that fire before DOMContentLoaded (e.g. video-interceptor) see the // user's actual preference instead of the hard-coded default. try { const raw = localStorage.getItem(SETTINGS_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && key in parsed && typeof parsed[key] === typeof DEFAULTS[key]) { return parsed[key]; } } } catch (_) { /* localStorage unavailable or corrupt — use fallback */ } return fallback; } /** * Set a setting value, persist it, and notify listeners. * @param {string} key * @param {*} value */ function setSetting(key, value) { const old = state[key]; if (old === value) return; state[key] = value; // Fire-and-forget persistence — never block the UI saveSettings(); if (listeners[key]) { listeners[key].forEach((fn) => { try { fn(value); } catch (_) { /* noop */ } }); } } /** * Register a listener for changes to a setting. * @param {string} key * @param {(val: any) => void} fn * @returns {() => void} Unsubscribe function */ function onChange(key, fn) { if (!listeners[key]) listeners[key] = []; listeners[key].push(fn); return () => { listeners[key] = listeners[key].filter((f) => f !== fn); }; } /** * Check whether face auto-click is enabled. * Convenience aggregator — used by the auto-processor to decide whether to run. * @returns {boolean} */ function isFaceAutoActive() { return state.faceAutoClick; } /** * @file Progress tracker — persistent learning session history and statistics. * * Responsibilities: * 1. Track learning sessions (start/end time, chapters/lessons completed, duration). * 2. Store per-course statistics (total lessons, completion date, last studied). * 3. Calculate aggregate stats (total study time, completion rate, daily activity). * 4. Provide queryable history for UI display (today, this week, all time). * * Storage schema: * { * sessions: [{ id, courseId, startTime, endTime, chaptersCompleted, lessonsCompleted, duration }], * courses: { [courseId]: { name, totalLessons, completedCount, firstStudy, lastStudy, sessions } } * } */ // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Current schema version for progress data */ const SCHEMA_VERSION = 1; // --------------------------------------------------------------------------- // Utility Functions // --------------------------------------------------------------------------- /** * Format a timestamp as YYYY-MM-DD. * @param {number} timestamp - Unix timestamp in milliseconds * @returns {string} */ function formatDate(timestamp) { const d = new Date(timestamp); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** * Format a timestamp as YYYY-MM-DD HH:MM. * @param {number} timestamp - Unix timestamp in milliseconds * @returns {string} */ function formatDateTime(timestamp) { const d = new Date(timestamp); return `${formatDate(timestamp)} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- /** In-memory cache of progress data */ let progressData = { version: SCHEMA_VERSION, sessions: [], courses: {}, lastSync: 0, }; /** Whether data has been loaded from storage */ let loaded = false; /** Current session tracking */ let currentSession = null; /** Debounce timer for saveProgressData */ let saveTimer = null; // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- /** * Migrate old data format to the current schema version. * @param {Object} data - Raw data from storage * @returns {Object} Migrated data */ function migrateData(data) { const version = data.version || 0; // Version 0 → 1: Add version field if (version === 0) { debug('ProgressTracker: migrating data from v0 to v1'); data.version = 1; // V0 to V1: no schema changes, just add version field } // Future migrations go here: // if (version === 1) { ... migrate to v2 ... } return data; } /** * Validate and sanitize progress data schema. * Ensures all required fields exist and are of the correct type. * @param {Object} data - Data object to validate * @returns {Object} Validated and sanitized data */ function validateSchema(data) { // Ensure top-level structure if (!data || typeof data !== 'object') { warn('ProgressTracker: invalid data object, resetting to empty'); return { version: SCHEMA_VERSION, sessions: [], courses: {}, lastSync: Date.now(), }; } // Validate version if (typeof data.version !== 'number') { data.version = SCHEMA_VERSION; } // Validate sessions array if (!Array.isArray(data.sessions)) { warn('ProgressTracker: sessions is not an array, resetting'); data.sessions = []; } else { // Filter out invalid session entries data.sessions = data.sessions.filter(s => { if (!s || typeof s !== 'object') return false; if (typeof s.id !== 'string') return false; if (typeof s.courseId !== 'string') return false; if (typeof s.startTime !== 'number') return false; // endTime can be null for unfinished sessions if (s.endTime !== null && typeof s.endTime !== 'number') return false; return true; }); // Fill in lessonsAtStart for sessions saved before this field existed. // Old sessions stored lessonsCompleted as the total (not delta). We // default lessonsAtStart to 0 so the existing lessonsCompleted value // is treated as the delta — correct for single-session courses, but // may over-count for courses with multiple old sessions. Users in // that situation should clear stats ("清空统计") to get accurate // numbers going forward. for (const s of data.sessions) { if (typeof s.lessonsAtStart !== 'number') { s.lessonsAtStart = 0; } } } // Validate courses object if (typeof data.courses !== 'object' || data.courses === null || Array.isArray(data.courses)) { warn('ProgressTracker: courses is not an object, resetting'); data.courses = {}; } else { // Sanitize course entries for (const [courseId, course] of Object.entries(data.courses)) { if (!course || typeof course !== 'object') { delete data.courses[courseId]; continue; } // Ensure required fields exist with defaults if (typeof course.name !== 'string') course.name = 'Unknown Course'; if (typeof course.totalLessons !== 'number') course.totalLessons = 0; if (typeof course.completedCount !== 'number') course.completedCount = 0; if (typeof course.firstStudy !== 'number') course.firstStudy = Date.now(); if (!Array.isArray(course.sessions)) course.sessions = []; } } // Validate lastSync if (typeof data.lastSync !== 'number') { data.lastSync = Date.now(); } return data; } /** * Force a full reload of progress data from storage. * Used by import-export after writing directly to storage. * @returns {Promise} */ async function reloadProgress() { loaded = false; await loadProgressTracker(); } /** * Load progress history from storage. * Called once during boot; idempotent. */ async function loadProgressTracker() { if (loaded) return; try { const raw = await getStorageAdapter().get(PROGRESS_TRACKER_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { const migrated = migrateData(parsed); const validated = validateSchema(migrated); progressData = validated; // Seal any unfinished sessions that are too old to resume. // These are leftovers from a hard crash or a browser kill — they were // pre-persisted by startSession() but never reached endSession(). const resumeAge = PROGRESS_TRACKER_CONFIG.RESUME_MAX_AGE_MS; let sealedCount = 0; for (const s of progressData.sessions) { if (s.endTime === null && (Date.now() - s.startTime) >= resumeAge) { s.endTime = s.startTime; // zero-duration; will be skipped by stats s.duration = 0; sealedCount++; } } if (sealedCount > 0) { debug(`ProgressTracker: sealed ${sealedCount} stale unfinished session(s)`); } debug(`ProgressTracker: loaded ${progressData.sessions.length} sessions, ${Object.keys(progressData.courses).length} courses (schema v${progressData.version})`); } } } catch (e) { warn('ProgressTracker: failed to load from storage:', e); } loaded = true; } /** * Persist the current progress data to storage (best-effort). */ async function saveProgressData() { try { progressData.lastSync = Date.now(); await getStorageAdapter().set(PROGRESS_TRACKER_KEY, JSON.stringify(progressData)); } catch (e) { warn('ProgressTracker: failed to persist:', e); } } // --------------------------------------------------------------------------- // Session Management // --------------------------------------------------------------------------- /** * Start a new learning session. * Returns a session ID that should be passed to endSession(). * * @param {string} courseId - Unique course identifier * @param {string} courseName - Human-readable course name * @returns {string} Session ID */ function startSession(courseId, courseName) { // Fast path: in-memory session for the same course is still live if (currentSession && currentSession.courseId === courseId) { debug(`ProgressTracker: resuming in-memory session ${currentSession.id}`); return currentSession.id; } // If there's a different in-memory session, end it first if (currentSession) { warn(`ProgressTracker: ending previous session ${currentSession.id} before starting new one`); endSession().catch(e => warn('Failed to end previous session:', e)); } // Cross-refresh recovery: look for an unfinished persisted session for this // course that was abandoned (e.g. tab reload, SPA navigation). Only resume // if it started recently enough to still be meaningful. const resumeAge = PROGRESS_TRACKER_CONFIG.RESUME_MAX_AGE_MS; const unfinished = progressData.sessions.findLast( s => s.courseId === courseId && s.endTime === null && (Date.now() - s.startTime) < resumeAge, ); if (unfinished) { currentSession = { ...unfinished }; debug(`ProgressTracker: recovered unfinished session ${unfinished.id} after page reload`); return unfinished.id; } const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; currentSession = { id: sessionId, courseId, courseName, startTime: Date.now(), endTime: null, chaptersCompleted: 0, lessonsCompleted: 0, /** Number of lessons already completed when this session started. * -1 = baseline not yet captured (React state not available). * Used to compute the delta of lessons completed *during* this session. */ lessonsAtStart: -1, duration: 0, }; // Initialize course record if not exists if (!(courseId in progressData.courses)) { progressData.courses[courseId] = { name: courseName, totalLessons: 0, completedCount: 0, firstStudy: Date.now(), lastStudy: null, sessions: [], }; } // Persist the new session immediately so it can be recovered on page reload // before endSession() is ever called. progressData.sessions.push(currentSession); saveProgressData().catch(e => warn('ProgressTracker: failed to persist new session:', e)); debug(`ProgressTracker: started session ${sessionId} for course "${courseName}"`); return sessionId; } /** * Update the current session with progress data. * Should be called periodically (e.g., on every course-processor tick). * Changes are debounced and saved after 5 seconds of inactivity. * * @param {number} chaptersCompleted * @param {number} lessonsCompleted * @param {number} totalLessons */ function updateSession(chaptersCompleted, lessonsCompleted, totalLessons) { if (!currentSession) return; currentSession.chaptersCompleted = chaptersCompleted; currentSession.lessonsCompleted = lessonsCompleted; // Lazily capture the baseline of already-completed lessons on the first // React state read (totalLessons > 0 in the caller already guarantees // React has rendered). Capturing even when lessonsCompleted is 0 is // essential — if we wait for a non-zero value, the baseline ends up > 0 // and the delta at session-end is zero for every lesson completed. if (currentSession.lessonsAtStart === -1) { currentSession.lessonsAtStart = lessonsCompleted; } // Update course record from React ground-truth const course = progressData.courses[currentSession.courseId]; if (course) { if (totalLessons > course.totalLessons) { course.totalLessons = totalLessons; } // Keep completedCount in sync with the current React-reported total. // Using Math.max prevents transient zeros from overwriting real data // when React briefly unmounts during SPA navigation. course.completedCount = Math.max(course.completedCount || 0, lessonsCompleted); } // Debounced save (avoid excessive writes during active learning) clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveProgressData().catch(e => warn('ProgressTracker: debounced save failed:', e)); }, 5000); } /** * End the current session and save it to history. * Should be called when leaving a course or stopping learning. * Clears any pending debounced saves and immediately persists. */ async function endSession() { if (!currentSession) return; // Clear pending debounced save since we're doing an immediate save clearTimeout(saveTimer); saveTimer = null; currentSession.endTime = Date.now(); currentSession.duration = Math.round((currentSession.endTime - currentSession.startTime) / 1000); // ---- Compute the delta of lessons completed *during* this session ---- // currentSession.lessonsCompleted holds the TOTAL from the last React state read. // lessonsAtStart is the TOTAL when we first saw non-zero React data. // The delta is what actually changed — this is what stats should sum. const totalCompleted = currentSession.lessonsCompleted; const baseline = currentSession.lessonsAtStart >= 0 ? currentSession.lessonsAtStart : totalCompleted; const delta = Math.max(0, totalCompleted - baseline); // Overwrite with delta so stats aggregation (sum of session.lessonsCompleted) // produces the correct total instead of inflating it. currentSession.lessonsCompleted = delta; // Discard sessions that are too short and produced nothing — these are // almost always page reloads or SPA navigations that fired stopCourseMonitor // before any real learning happened. const { MIN_SESSION_DURATION_S } = PROGRESS_TRACKER_CONFIG; if (currentSession.duration < MIN_SESSION_DURATION_S && delta === 0 && totalCompleted === 0) { // Remove the pre-persisted placeholder written in startSession const idx = progressData.sessions.findIndex(s => s.id === currentSession.id); if (idx !== -1) progressData.sessions.splice(idx, 1); debug(`ProgressTracker: discarded trivial session ${currentSession.id} (${currentSession.duration}s, 0 lessons)`); currentSession = null; await saveProgressData(); return; } // Update the in-place record that was persisted during startSession const existing = progressData.sessions.find(s => s.id === currentSession.id); if (existing) { Object.assign(existing, currentSession); } else { // Fallback: session was not pre-persisted (e.g. recovered via findLast path // after a reload where the old entry already had an endTime). progressData.sessions.push(currentSession); } // Update course record const course = progressData.courses[currentSession.courseId]; if (course) { course.lastStudy = currentSession.endTime; // Use Math.max to avoid transient decreases (e.g. from React unmount/remount // during SPA navigation). completedCount is also kept in sync by updateSession // during active learning, so this is a safety net. course.completedCount = Math.max(course.completedCount || 0, totalCompleted); course.sessions = (course.sessions || []).includes(currentSession.id) ? course.sessions : (course.sessions || []).concat(currentSession.id); } debug(`ProgressTracker: ended session ${currentSession.id}, duration ${currentSession.duration}s, delta ${delta} lessons (${totalCompleted} total)`); currentSession = null; await saveProgressData(); } /** * Synchronously flush the current session end to localStorage. * * Called from beforeunload handlers where async work may not complete. * Writes directly to localStorage (skips the async storage adapter) so the * session is saved even if the browser is about to tear down the page. * * Falls back to the async path if localStorage is unavailable. */ function flushSessionSync() { if (!currentSession) return; // Clear pending debounced save clearTimeout(saveTimer); saveTimer = null; currentSession.endTime = Date.now(); currentSession.duration = Math.round((currentSession.endTime - currentSession.startTime) / 1000); // ---- Compute delta (same logic as endSession) ---- const totalCompleted = currentSession.lessonsCompleted; const baseline = currentSession.lessonsAtStart >= 0 ? currentSession.lessonsAtStart : totalCompleted; const delta = Math.max(0, totalCompleted - baseline); currentSession.lessonsCompleted = delta; // Skip trivial sessions const { MIN_SESSION_DURATION_S } = PROGRESS_TRACKER_CONFIG; if (currentSession.duration < MIN_SESSION_DURATION_S && delta === 0 && totalCompleted === 0) { const idx = progressData.sessions.findIndex(s => s.id === currentSession.id); if (idx !== -1) progressData.sessions.splice(idx, 1); currentSession = null; // Synchronous persistence try { progressData.lastSync = Date.now(); localStorage.setItem(PROGRESS_TRACKER_KEY, JSON.stringify(progressData)); } catch (e) { /* best-effort */ } return; } // Update the in-place record const existing = progressData.sessions.find(s => s.id === currentSession.id); if (existing) { Object.assign(existing, currentSession); } else { progressData.sessions.push(currentSession); } // Update course record const course = progressData.courses[currentSession.courseId]; if (course) { course.lastStudy = currentSession.endTime; course.completedCount = Math.max(course.completedCount || 0, totalCompleted); if (!(course.sessions || []).includes(currentSession.id)) { course.sessions = (course.sessions || []).concat(currentSession.id); } } // Synchronous persistence — write directly to localStorage progressData.lastSync = Date.now(); try { localStorage.setItem(PROGRESS_TRACKER_KEY, JSON.stringify(progressData)); debug(`ProgressTracker: flushed session ${currentSession.id} synchronously`); } catch (e) { // Fallback to async adapter saveProgressData().catch(() => {}); } currentSession = null; } // --------------------------------------------------------------------------- // Statistics & Queries // --------------------------------------------------------------------------- /** * Get today's statistics. * @returns {{ sessionsCount: number, totalDuration: number, lessonsCompleted: number, coursesStudied: Set }} */ function getTodayStats() { const now = Date.now(); const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0); const todaySessions = progressData.sessions.filter(s => s.endTime && s.endTime >= todayStart.getTime()); return { sessionsCount: todaySessions.length, totalDuration: Math.round(todaySessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60), lessonsCompleted: todaySessions.reduce((sum, s) => sum + s.lessonsCompleted, 0), coursesStudied: new Set(todaySessions.map(s => s.courseId)), }; } /** * Get this week's statistics (last 7 days). * @returns {{ sessionsCount: number, totalDuration: number, lessonsCompleted: number, daysActive: number }} */ function getWeekStats() { const now = Date.now(); const weekMs = 7 * 24 * 60 * 60 * 1000; const weekStart = now - weekMs; const weekSessions = progressData.sessions.filter(s => s.endTime && s.endTime >= weekStart); const daysActive = new Set( weekSessions.map(s => formatDate(s.endTime)) ).size; return { sessionsCount: weekSessions.length, totalDuration: Math.round(weekSessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60), lessonsCompleted: weekSessions.reduce((sum, s) => sum + s.lessonsCompleted, 0), daysActive, }; } /** * Get daily study data for the last N days (for chart visualization). * Returns an array of { date, duration, lessons, label } ordered from oldest to newest. * * @param {number} [days=7] - Number of days to include * @returns {Array<{date: string, duration: number, lessons: number, label: string}>} */ function getDailyTrendData(days = 7) { const now = Date.now(); const dayMs = 24 * 60 * 60 * 1000; // Build maps of date -> total duration (minutes) and lessons completed const dailyMap = new Map(); const lessonsMap = new Map(); // Initialize all days with 0 for (let i = days - 1; i >= 0; i--) { const d = new Date(now - i * dayMs); d.setHours(0, 0, 0, 0); const key = formatDate(d.getTime()); dailyMap.set(key, 0); lessonsMap.set(key, 0); } // Aggregate session data by date progressData.sessions.forEach(s => { if (!s.endTime) return; const sessionDate = new Date(s.endTime); sessionDate.setHours(0, 0, 0, 0); const key = formatDate(sessionDate.getTime()); if (dailyMap.has(key)) { dailyMap.set(key, dailyMap.get(key) + Math.round((s.duration || 0) / 60)); lessonsMap.set(key, lessonsMap.get(key) + (s.lessonsCompleted || 0)); } }); // Convert to array with short labels (e.g., "周一", "周二", or "1/15") const result = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(now - i * dayMs); d.setHours(0, 0, 0, 0); const key = formatDate(d.getTime()); const dayOfWeek = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]; const monthDay = `${d.getMonth() + 1}/${d.getDate()}`; result.push({ date: key, duration: dailyMap.get(key) || 0, lessons: lessonsMap.get(key) || 0, label: days <= 7 ? dayOfWeek : monthDay, }); } return result; } /** * Get all-time statistics. * @returns {{ sessionsCount: number, totalDuration: number, lessonsCompleted: number, coursesCount: number, totalLessons: number }} */ function getAllTimeStats() { const totalDuration = Math.round( progressData.sessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60 ); const totalLessons = Object.values(progressData.courses).reduce( (sum, course) => sum + (course.totalLessons || 0), 0 ); return { sessionsCount: progressData.sessions.length, totalDuration, lessonsCompleted: progressData.sessions.reduce((sum, s) => sum + s.lessonsCompleted, 0), coursesCount: Object.keys(progressData.courses).length, totalLessons, }; } /** * Get recent sessions for display. * @param {number} [limit=10] * @returns {Array} */ function getRecentSessions(limit = 10) { return progressData.sessions .slice(-limit) .reverse() .map(s => ({ ...s, durationMin: Math.round(s.duration / 60), startDate: formatDateTime(s.startTime), })); } /** * Get all courses sorted by last study time (most recent first). * @returns {Array} */ function getCoursesList() { return Object.entries(progressData.courses) .map(([id, data]) => { // Calculate total study time from all sessions for this course const courseSessions = progressData.sessions.filter(s => s.courseId === id); const totalStudyTime = Math.round( courseSessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60 ); return { id, ...data, completionRate: data.totalLessons > 0 ? Math.round((data.completedCount / data.totalLessons) * 100) : 0, totalStudyTime, }; }) .sort((a, b) => (b.lastStudy || 0) - (a.lastStudy || 0)); } /** * Clear all progress history (destructive). * Use with caution. */ async function clearAllProgress() { progressData = { version: SCHEMA_VERSION, sessions: [], courses: {}, lastSync: Date.now(), }; currentSession = null; await saveProgressData(); debug('ProgressTracker: cleared all progress history'); } /** * Get cached progress data reference (read-only). * @returns {Object} */ function getProgressData() { return progressData; } /** * @file Face-detection-aware smart cropping. * * Two-tier pipeline for positioning the crop window: * * Tier 1: Skin-color heuristic (YCbCr on downscaled canvas) * → density-cluster centroid of skin-tone pixels * * Tier 2: Fixed vertical bias (CROP_FALLBACK_BIAS = 0.38) * → identical to the previous hard-coded behaviour * * Each tier falls through silently on failure so the pipeline degrades * gracefully on any browser. */ // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Smart-crop an image to the standard output dimensions (400×300). * * Attempts face detection to position the crop window around the most * visually important region. Falls back to a fixed vertical bias when * no faces or skin regions can be found. * * @param {HTMLImageElement} img - decoded image (onload already fired) * @returns {Promise<{ dataUrl: string, width: number, height: number }>} */ async function smartCropToStandard(img) { const srcW = img.naturalWidth; const srcH = img.naturalHeight; // Run the face-detection pipeline const { faces } = await detectFaces(img); let attentionX, attentionY, cropBias; if (faces && faces.length > 0) { // Area-weighted centroid of all detected faces. // Prefer the density-weighted centroid (cx/cy) when the heuristic // provides it — it is more precise than the geometric box centre. let totalWeight = 0; attentionX = 0; attentionY = 0; for (const f of faces) { const fx = f.cx != null ? f.cx : (f.x + f.width / 2); const fy = f.cy != null ? f.cy : (f.y + f.height / 2); const area = f.width * f.height; attentionX += fx * area; attentionY += fy * area; totalWeight += area; } attentionX /= totalWeight; attentionY /= totalWeight; // Faces detected → use the face-aware crop bias (tighter framing) cropBias = FACE_DETECT_CONFIG.CROP_FACE_BIAS; debug(`Smart crop: ${faces.length} face(s) detected, attention at (${attentionX.toFixed(0)}, ${attentionY.toFixed(0)})`); } else { // Tier 2 fallback: no faces — use heuristic upper bias attentionX = srcW / 2; attentionY = srcH * FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; cropBias = FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; debug('Smart crop: no faces detected, using fixed bias fallback'); } const cropRect = computeCropRect(srcW, srcH, attentionX, attentionY, cropBias); const result = renderCrop(img, cropRect); return { ...result, cropRect }; } /** * Run the full face-detection pipeline and return debug information * for visualization in the face preview modal. * * @param {HTMLImageElement} img - decoded image (onload already fired) * @returns {Promise<{faces: Array<{x:number,y:number,width:number,height:number}>|null, tier: 'skin'|'fallback', attentionPoint: {x:number,y:number}|null, cropRect: {sx:number,sy:number,sw:number,sh:number}|null}>} */ async function detectFacesDebug(img) { const srcW = img.naturalWidth; const srcH = img.naturalHeight; const { faces, tier } = await detectFaces(img); let attentionX, attentionY, cropBias; if (faces && faces.length > 0) { let totalWeight = 0; attentionX = 0; attentionY = 0; for (const f of faces) { const fx = f.cx != null ? f.cx : (f.x + f.width / 2); const fy = f.cy != null ? f.cy : (f.y + f.height / 2); const area = f.width * f.height; attentionX += fx * area; attentionY += fy * area; totalWeight += area; } attentionX /= totalWeight; attentionY /= totalWeight; cropBias = FACE_DETECT_CONFIG.CROP_FACE_BIAS; } else { attentionX = srcW / 2; attentionY = srcH * FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; cropBias = FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; } const cropRect = computeCropRect(srcW, srcH, attentionX, attentionY, cropBias); return { faces, tier, attentionPoint: { x: attentionX, y: attentionY }, cropRect, }; } // --------------------------------------------------------------------------- // Detection orchestrator // --------------------------------------------------------------------------- /** * Run the two-tier detection pipeline. * * @param {HTMLImageElement} img * @returns {Promise<{faces: Array<{x:number,y:number,width:number,height:number}>|null, tier: 'skin'|'fallback'}>} */ async function detectFaces(img) { // Tier 1: Skin-color heuristic const skinFaces = detectFacesSkinHeuristic(img); if (skinFaces && skinFaces.length > 0) return { faces: skinFaces, tier: 'skin' }; // Tier 2: nothing found — caller uses fixed bias return { faces: null, tier: 'fallback' }; } // --------------------------------------------------------------------------- // Tier 1: Skin-color heuristic (YCbCr on downscaled canvas) // --------------------------------------------------------------------------- /** * Estimate face position by detecting connected skin-tone regions. * * P0 pipeline: * 1. Classify skin pixels on a downscaled canvas * 2. Build connected components on the binary skin mask * 3. Score each component by size, vertical prior, edge density, * aspect ratio, dominance, and top/bottom taper * 4. Fall back when the best component is too weak or ambiguous * 5. Reuse the existing density-cliff and aspect-ratio safeguards to * trim neck bleed from the selected component * * @param {HTMLImageElement} img * @returns {Array<{x:number,y:number,width:number,height:number,cx?:number,cy?:number}>|null} */ function detectFacesSkinHeuristic(img) { const srcW = img.naturalWidth; const srcH = img.naturalHeight; const cfg = FACE_DETECT_CONFIG; const scale = Math.min(cfg.SKIN_SAMPLE_SIZE / srcW, cfg.SKIN_SAMPLE_SIZE / srcH); const cw = Math.max(1, Math.round(srcW * scale)); const ch = Math.max(1, Math.round(srcH * scale)); const canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(img, 0, 0, cw, ch); let imageData; try { imageData = ctx.getImageData(0, 0, cw, ch); } catch (_) { debug('Skin heuristic: canvas tainted, cannot read pixels'); return null; } const { data } = imageData; const pixelCount = cw * ch; const skinMask = new Uint8Array(pixelCount); const edgeMask = new Uint8Array(pixelCount); let totalSkin = 0; for (let py = 0; py < ch; py++) { for (let px = 0; px < cw; px++) { const pixelIndex = py * cw + px; const i = pixelIndex * 4; const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; const cb = 128 - 0.169 * r - 0.331 * g + 0.500 * b; const cr = 128 + 0.500 * r - 0.419 * g - 0.081 * b; if (cb >= cfg.SKIN_CB_MIN && cb <= cfg.SKIN_CB_MAX && cr >= cfg.SKIN_CR_MIN && cr <= cfg.SKIN_CR_MAX) { skinMask[pixelIndex] = 1; totalSkin++; if (px < cw - 1) { const lum = 0.299 * r + 0.587 * g + 0.114 * b; const i2 = i + 4; const lum2 = 0.299 * data[i2] + 0.587 * data[i2 + 1] + 0.114 * data[i2 + 2]; if (Math.abs(lum - lum2) > cfg.SKIN_EDGE_LUM_THRESHOLD) { edgeMask[pixelIndex] = 1; } } } } } if (totalSkin < cfg.SKIN_MIN_PIXELS) { debug(`Skin heuristic: only ${totalSkin} skin pixels (need ${cfg.SKIN_MIN_PIXELS})`); return null; } const minComponentPixels = Math.max( cfg.SKIN_COMPONENT_MIN_PIXELS, Math.round(pixelCount * cfg.SKIN_COMPONENT_MIN_AREA_RATIO), ); const components = collectSkinComponents(skinMask, edgeMask, cw, ch, cfg, minComponentPixels); if (components.length === 0) { debug(`Skin heuristic: ${totalSkin} skin px but no component survived min area ${minComponentPixels}`); return null; } const candidates = components .map((component) => scoreSkinComponent(component, totalSkin, cw, ch, cfg)) .filter(Boolean) .sort((a, b) => b.score - a.score); if (candidates.length === 0) { debug(`Skin heuristic: ${components.length} component(s) found, none passed candidate scoring`); return null; } const best = candidates[0]; const second = candidates[1] || null; const margin = second ? best.score - second.score : best.score; debug( `Skin heuristic: ${totalSkin} skin px, ${components.length} component(s), ` + `best score=${best.score.toFixed(2)} ` + `(area=${best.component.area}, edge=${best.edgeRatio.toFixed(3)}, aspect=${best.aspect.toFixed(2)}, cy=${best.centerY.toFixed(1)})` + (second ? `, second=${second.score.toFixed(2)}, margin=${margin.toFixed(2)}` : ''), ); if (best.score < cfg.SKIN_COMPONENT_SCORE_THRESHOLD || (second && margin < cfg.SKIN_COMPONENT_MIN_SCORE_MARGIN)) { debug( `Skin heuristic: low-confidence candidate ` + `(best=${best.score.toFixed(2)}, threshold=${cfg.SKIN_COMPONENT_SCORE_THRESHOLD.toFixed(2)}, margin=${margin.toFixed(2)})`, ); return null; } const result = finalizeComponentFace(best.component, srcW, srcH, cw, ch, edgeMask, cfg); if (!result) { debug('Skin heuristic: selected component failed finalization'); return null; } return [result]; } function collectSkinComponents(skinMask, edgeMask, cw, ch, cfg, minComponentPixels) { const visited = new Uint8Array(cw * ch); const components = []; for (let start = 0; start < skinMask.length; start++) { if (!skinMask[start] || visited[start]) continue; const stack = [start]; visited[start] = 1; const component = { area: 0, edgeCount: 0, minX: cw, maxX: -1, minY: ch, maxY: -1, weightedX: 0, weightedY: 0, weightSum: 0, pixels: [], }; while (stack.length > 0) { const idx = stack.pop(); const x = idx % cw; const y = Math.floor(idx / cw); component.area++; component.pixels.push(idx); if (edgeMask[idx]) component.edgeCount++; if (x < component.minX) component.minX = x; if (x > component.maxX) component.maxX = x; if (y < component.minY) component.minY = y; if (y > component.maxY) component.maxY = y; const weight = getSkinPixelWeight(y, ch, edgeMask[idx], cfg); component.weightedX += (x + 0.5) * weight; component.weightedY += (y + 0.5) * weight; component.weightSum += weight; for (let ny = Math.max(0, y - 1); ny <= Math.min(ch - 1, y + 1); ny++) { for (let nx = Math.max(0, x - 1); nx <= Math.min(cw - 1, x + 1); nx++) { if (nx === x && ny === y) continue; const nidx = ny * cw + nx; if (!skinMask[nidx] || visited[nidx]) continue; visited[nidx] = 1; stack.push(nidx); } } } if (component.area >= minComponentPixels) { components.push(component); } } return components; } function scoreSkinComponent(component, totalSkin, cw, ch, cfg) { const width = component.maxX - component.minX + 1; const height = component.maxY - component.minY + 1; if (width <= 0 || height <= 0) return null; const aspect = height / width; if (aspect < cfg.SKIN_COMPONENT_MIN_ASPECT_RATIO || aspect > cfg.SKIN_COMPONENT_MAX_CANDIDATE_ASPECT_RATIO) { return null; } const areaRatio = component.area / (cw * ch); const edgeRatio = component.edgeCount / Math.max(component.area, 1); if (component.area >= Math.round(cw * ch * 0.03) && edgeRatio < cfg.SKIN_COMPONENT_HARD_MIN_EDGE_RATIO) { return null; } const centerY = component.weightSum > 0 ? component.weightedY / component.weightSum : (component.minY + component.maxY + 1) / 2; if (centerY > ch * 0.84) return null; const areaScore = clamp01( (areaRatio - cfg.SKIN_COMPONENT_MIN_AREA_RATIO) / Math.max(cfg.SKIN_COMPONENT_TARGET_AREA_RATIO - cfg.SKIN_COMPONENT_MIN_AREA_RATIO, 0.0001), ); const verticalScore = clamp01(1 - centerY / Math.max(ch * 0.85, 1)); const edgeScore = clamp01( (edgeRatio - cfg.SKIN_COMPONENT_MIN_EDGE_RATIO) / Math.max(0.06 - cfg.SKIN_COMPONENT_MIN_EDGE_RATIO, 0.0001), ); const aspectScore = scoreAspect(aspect, cfg.SKIN_COMPONENT_IDEAL_ASPECT_RATIO); const shareScore = clamp01((component.area / Math.max(totalSkin, 1)) / 0.55); const taperScore = measureComponentTaper(component, cw); const score = areaScore * cfg.SKIN_SCORE_WEIGHT_AREA + verticalScore * cfg.SKIN_SCORE_WEIGHT_VERTICAL + edgeScore * cfg.SKIN_SCORE_WEIGHT_EDGE + aspectScore * cfg.SKIN_SCORE_WEIGHT_ASPECT + shareScore * cfg.SKIN_SCORE_WEIGHT_SHARE + taperScore * cfg.SKIN_SCORE_WEIGHT_TAPER; return { component, score, aspect, edgeRatio, centerY, }; } function finalizeComponentFace(component, srcW, srcH, cw, ch, edgeMask, cfg) { const rowData = buildComponentRowData(component, cw); let minY = component.minY; let maxY = component.maxY; { const clippedMaxY = findDensityCliffCutoff(rowData, minY, maxY, cfg); if (clippedMaxY < maxY) { debug(`Skin heuristic: density cliff at row ${clippedMaxY}, face bottom clipped`); maxY = clippedMaxY; } } let bounds = boundsForRows(rowData, minY, maxY); if (!bounds) return null; let regionW = bounds.maxX - bounds.minX + 1; let regionH = bounds.maxY - bounds.minY + 1; if (regionH / regionW > cfg.SKIN_MAX_ASPECT_RATIO) { const maxRows = Math.max(1, Math.ceil(regionW * cfg.SKIN_MAX_ASPECT_RATIO)); const trimmed = regionH - maxRows; if (trimmed > 0) { maxY = bounds.minY + maxRows - 1; debug(`Skin heuristic: region too tall (${(regionH / regionW).toFixed(1)}:1), trimmed ${trimmed} row(s) from bottom`); bounds = boundsForRows(rowData, bounds.minY, maxY); if (!bounds) return null; regionW = bounds.maxX - bounds.minX + 1; regionH = bounds.maxY - bounds.minY + 1; } } let weightedX = 0; let weightedY = 0; let weightSum = 0; for (const idx of component.pixels) { const x = idx % cw; const y = Math.floor(idx / cw); if (x < bounds.minX || x > bounds.maxX || y < bounds.minY || y > bounds.maxY) continue; const weight = getSkinPixelWeight(y, ch, edgeMask[idx], cfg); weightedX += (x + 0.5) * weight; weightedY += (y + 0.5) * weight; weightSum += weight; } const scaleX = srcW / cw; const scaleY = srcH / ch; const faceX = bounds.minX * scaleX; const faceY = bounds.minY * scaleY; const faceW = regionW * scaleX; const faceH = regionH * scaleY; const result = { x: faceX, y: faceY, width: faceW, height: faceH, }; if (weightSum > 0) { result.cx = (weightedX / weightSum) * scaleX; const skinCy = (weightedY / weightSum) * scaleY; result.cy = skinCy - faceH * cfg.SKIN_HEADROOM_SHIFT; } return result; } function buildComponentRowData(component, cw) { const height = component.maxY - component.minY + 1; const rowCounts = new Array(height).fill(0); const rowMinX = new Array(height).fill(Infinity); const rowMaxX = new Array(height).fill(-Infinity); for (const idx of component.pixels) { const x = idx % cw; const y = Math.floor(idx / cw); const row = y - component.minY; rowCounts[row]++; if (x < rowMinX[row]) rowMinX[row] = x; if (x > rowMaxX[row]) rowMaxX[row] = x; } return { rowCounts, rowMinX, rowMaxX, baseY: component.minY }; } function boundsForRows(rowData, minY, maxY) { let minX = Infinity; let maxX = -Infinity; let found = false; for (let y = minY; y <= maxY; y++) { const row = y - rowData.baseY; if (row < 0 || row >= rowData.rowCounts.length || rowData.rowCounts[row] === 0) continue; if (rowData.rowMinX[row] < minX) minX = rowData.rowMinX[row]; if (rowData.rowMaxX[row] > maxX) maxX = rowData.rowMaxX[row]; found = true; } if (!found) return null; return { minX, maxX, minY, maxY }; } function findDensityCliffCutoff(rowData, minY, maxY, cfg) { const density = []; for (let y = minY; y <= maxY; y++) { const row = y - rowData.baseY; density.push({ row: y, value: rowData.rowCounts[row] || 0 }); } const startIdx = Math.floor(density.length / 2); let bestDrop = 0; let dropRow = maxY; for (let i = startIdx; i < density.length - 1; i++) { if (density[i].value <= 0) continue; const drop = (density[i].value - density[i + 1].value) / density[i].value; if (drop > bestDrop && drop >= cfg.SKIN_CLIFF_THRESHOLD) { bestDrop = drop; dropRow = density[i].row; } } return dropRow; } function measureComponentTaper(component, cw) { const rowData = buildComponentRowData(component, cw); const height = rowData.rowCounts.length; if (height <= 2) return 0.5; const band = Math.max(1, Math.floor(height * 0.35)); let topWidth = 0; let topRows = 0; let bottomWidth = 0; let bottomRows = 0; for (let i = 0; i < band; i++) { if (rowData.rowCounts[i] > 0) { topWidth += rowData.rowMaxX[i] - rowData.rowMinX[i] + 1; topRows++; } } for (let i = height - band; i < height; i++) { if (i >= 0 && rowData.rowCounts[i] > 0) { bottomWidth += rowData.rowMaxX[i] - rowData.rowMinX[i] + 1; bottomRows++; } } if (topRows === 0 || bottomRows === 0) return 0.5; const ratio = (bottomWidth / bottomRows) / Math.max(topWidth / topRows, 1); return clamp01(1 - Math.abs(ratio - 0.85) / 0.55); } function getSkinPixelWeight(y, ch, hasEdge, cfg) { let weight = 1.0; if (cfg.SKIN_VERTICAL_WEIGHT_ENABLED) { const vertFactor = 1.0 - cfg.SKIN_VERTICAL_WEIGHT_DECAY * (y / Math.max(ch - 1, 1)); weight *= Math.max(0.1, vertFactor); } if (cfg.SKIN_EDGE_BONUS_ENABLED && hasEdge) { weight += cfg.SKIN_EDGE_BONUS_WEIGHT; } return weight; } function scoreAspect(aspect, idealAspect) { const diff = Math.abs(Math.log(aspect / idealAspect)); return clamp01(1 - diff / Math.log(1.9)); } function clamp01(value) { return Math.max(0, Math.min(1, value)); } // --------------------------------------------------------------------------- // Crop geometry // --------------------------------------------------------------------------- /** * Compute the source crop rectangle that places the attention point at * the given vertical bias from the top of the output, while clamping to * image bounds so the crop window never extends outside the source. * * @param {number} srcW - Source image width * @param {number} srcH - Source image height * @param {number} attentionX - Horizontal attention point (px) * @param {number} attentionY - Vertical attention point (px) * @param {number} bias - Where the attention point should land vertically * in the output: 0 = top edge, 0.5 = center, 1 = bottom edge. * @returns {{ sx: number, sy: number, sw: number, sh: number }} */ function computeCropRect(srcW, srcH, attentionX, attentionY, bias) { const targetW = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; const targetH = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; const targetRatio = targetW / targetH; const srcRatio = srcW / srcH; let sx, sy, sw, sh; if (Math.abs(srcRatio - targetRatio) < 0.01) { // Already the correct ratio — no crop needed sx = 0; sy = 0; sw = srcW; sh = srcH; } else if (srcRatio > targetRatio) { // Too wide — crop sides, center horizontally around the attention point sh = srcH; sw = sh * targetRatio; sx = attentionX - sw / 2; sy = 0; } else { // Too tall — crop top/bottom, position attention point at bias from top sw = srcW; sh = sw / targetRatio; sx = 0; sy = attentionY - sh * bias; } // Clamp to image bounds sx = Math.max(0, Math.min(sx, srcW - sw)); sy = Math.max(0, Math.min(sy, srcH - sh)); return { sx, sy, sw, sh }; } /** * Render the crop rectangle to a canvas and export as JPEG. * * @param {HTMLImageElement} img * @param {{ sx: number, sy: number, sw: number, sh: number }} cropRect * @returns {{ dataUrl: string, width: number, height: number }} */ function renderCrop(img, cropRect) { const { sx, sy, sw, sh } = cropRect; const targetW = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; const targetH = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; const canvas = document.createElement('canvas'); canvas.width = targetW; canvas.height = targetH; const ctx = canvas.getContext('2d'); ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH); const dataUrl = canvas.toDataURL('image/jpeg', IMAGE_POOL_CONFIG.JPEG_QUALITY); return { dataUrl, width: targetW, height: targetH }; } /** * @file Image pool — persistent, uploadable, deduplicated face-image pool. * * Storage is provided by utils/storage-adapter.js (GM → localStorage → memory). * * Upload pipeline: * File → FileReader → validate MIME / size → decode * → smart-crop to 400×300 (face-biased) → JPEG compress * → dHash perceptual fingerprint → dedup check → store * * Each image stores TWO keys: * bfw_img_{id} — cropped 400×300 JPEG (what the system uses) * bfw_img_orig_{id} — original un-cropped data URI (for manual crop editing) * * Pick pipeline: * pickImage() → load metadata → pool empty? → fallback canvas face * → random index → load & validate stored image * → load fails? → evict entry, retry (up to 3×) * → apply random mutations (brightness/contrast/saturation/hue * flip/rotate/scale-jitter + JPEG quality jitter) * → return mutated image (bytes differ every call) */ // --------------------------------------------------------------------------- // Image validation & processing // --------------------------------------------------------------------------- /** * Validate that a string looks like a base64 data URI we can use. * @param {string} str * @returns {boolean} */ function isValidDataURI(str) { return typeof str === 'string' && str.startsWith('data:image/') && str.length > 50 && str.length < IMAGE_POOL_CONFIG.MAX_FILE_SIZE * 1.5; // base64 ~33% larger } /** * Fisher-Yates shuffle (in-place). * @template T * @param {T[]} arr * @returns {T[]} the same array */ function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * Compress an image data URI to a smaller size suitable for long-term storage. * Resizes to max 1200px on the longest side, exports as JPEG at quality 0.65. * A 12MP phone photo (~5 MB base64) compresses to ~80–150 KB. * * @param {string} dataUrl - source data URI * @returns {Promise} compressed JPEG data URI */ function compressOriginal(dataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onerror = () => reject(new Error('Failed to decode for compression')); img.onload = () => { try { const maxDim = IMAGE_POOL_CONFIG.ORIG_MAX_DIMENSION; let w = img.naturalWidth; let h = img.naturalHeight; if (w <= maxDim && h <= maxDim && dataUrl.length < 200 * 1024) { // Already small enough — return as-is resolve(dataUrl); return; } const scale = Math.min(maxDim / w, maxDim / h); w = Math.round(w * scale); h = Math.round(h * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); resolve(canvas.toDataURL('image/jpeg', IMAGE_POOL_CONFIG.ORIG_JPEG_QUALITY)); } catch (e) { reject(e); } }; img.src = dataUrl; }); } // --------------------------------------------------------------------------- // Mutation engine — applied at pick-time to make every output byte-unique // --------------------------------------------------------------------------- /** Cached offscreen canvas for mutateImage (400×300, constant size). * Reusing a single canvas avoids repeated createElement + GC pressure on the * hot path — pickImage() is called every time the camera interceptor * activates (face verification modal opens). */ let _mutationCanvas = null; /** @returns {{ canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }} */ function getMutationCanvas() { if (!_mutationCanvas) { _mutationCanvas = document.createElement('canvas'); _mutationCanvas.width = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; _mutationCanvas.height = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; } const ctx = _mutationCanvas.getContext('2d'); return { canvas: _mutationCanvas, ctx }; } /** * Apply a random set of low-level mutations to a 400×300 image. * Each mutation has an independent activation chance → on average 2–4 fire. * * Transforms are composed into a SINGLE draw call (canvas filter + transform) * to avoid quality degradation from multi-pass rendering. * * Mutation catalogue (applied in composited order): * brightness : ×0.85–1.15 * contrast : ×0.88–1.12 * saturation : ×0.85–1.15 * hue : ±4° * flip : horizontal mirror (50% dice) * rotate : ±2.5° (background filled black, draw 8% oversize) * scale-jitter: ×1.00–1.06 per axis (independent, floor=1.0 prevents black borders) * JPEG quality: 0.72–0.85 random * * Reuses a single offscreen canvas for all mutation calls to reduce GC pressure. * * @param {string} sourceDataUrl - the stored clean image * @returns {Promise} mutated JPEG data URI */ function mutateImage(sourceDataUrl) { return mutateImageWithMeta(sourceDataUrl).then((r) => r.dataUrl); } /** * Apply mutations to an image and return both the result and metadata * about which mutations were applied. * * @param {string} sourceDataUrl - the stored clean image * @returns {Promise<{dataUrl: string, mutations: Array<{type: string, label: string, value: string}>}>} */ function mutateImageWithMeta(sourceDataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onerror = () => reject(new Error('Mutation: failed to load source image')); img.onload = () => { try { const cfg = IMAGE_POOL_CONFIG; const targetW = cfg.OUTPUT_WIDTH; const targetH = cfg.OUTPUT_HEIGHT; const { canvas, ctx } = getMutationCanvas(); // Background fill (handles rotation corners / scale shrink) ctx.fillStyle = '#000'; ctx.fillRect(0, 0, targetW, targetH); // ---- Gather mutations and track their values ---- /** @type {string[]} */ const active = []; /** @type {Array<{type: string, label: string, value: string}>} */ const mutations = []; if (Math.random() < cfg.MUTATION_CHANCE_BRIGHTNESS) active.push('brightness'); if (Math.random() < cfg.MUTATION_CHANCE_CONTRAST) active.push('contrast'); if (Math.random() < cfg.MUTATION_CHANCE_SATURATION) active.push('saturation'); if (Math.random() < cfg.MUTATION_CHANCE_HUE) active.push('hue'); // Shuffle so the same string doesn't repeat often shuffle(active); // ---- Build CSS filter ---- /** @type {string[]} */ const filters = []; for (const m of active) { switch (m) { case 'brightness': { const v = lerp(cfg.MUTATION_BRIGHTNESS_RANGE, Math.random()).toFixed(2); filters.push(`brightness(${v})`); mutations.push({ type: 'brightness', label: '亮度', value: `×${v}` }); break; } case 'contrast': { const v = lerp(cfg.MUTATION_CONTRAST_RANGE, Math.random()).toFixed(2); filters.push(`contrast(${v})`); mutations.push({ type: 'contrast', label: '对比度', value: `×${v}` }); break; } case 'saturation': { const v = lerp(cfg.MUTATION_SATURATION_RANGE, Math.random()).toFixed(2); filters.push(`saturate(${v})`); mutations.push({ type: 'saturation', label: '饱和度', value: `×${v}` }); break; } case 'hue': { const v = lerp(cfg.MUTATION_HUE_RANGE, Math.random()).toFixed(1); filters.push(`hue-rotate(${v}deg)`); mutations.push({ type: 'hue', label: '色相', value: `${v}°` }); break; } } } if (filters.length > 0) { ctx.filter = filters.join(' '); } // ---- Build transform ---- ctx.save(); ctx.translate(targetW / 2, targetH / 2); const flip = Math.random() < cfg.MUTATION_CHANCE_FLIP ? -1 : 1; const jitter = Math.random() < cfg.MUTATION_CHANCE_SCALE_JITTER; const sx = flip * (jitter ? lerp(cfg.MUTATION_SCALE_RANGE, Math.random()) : 1); const sy = jitter ? lerp(cfg.MUTATION_SCALE_RANGE, Math.random()) : 1; const doRotate = Math.random() < cfg.MUTATION_CHANCE_ROTATE; const angle = doRotate ? lerp(cfg.MUTATION_ROTATE_RANGE, Math.random()) * Math.PI / 180 : 0; ctx.transform(sx, 0, 0, sy, 0, 0); if (angle) ctx.rotate(angle); // Draw slightly larger when rotating so corners stay filled const margin = doRotate ? 1.08 : 1.0; ctx.drawImage( img, -targetW * margin / 2, -targetH * margin / 2, targetW * margin, targetH * margin, ); ctx.restore(); ctx.filter = 'none'; // ---- Export with quality jitter ---- const q = lerp(cfg.MUTATION_QUALITY_RANGE, Math.random()).toFixed(3); const dataUrl = canvas.toDataURL('image/jpeg', Number(q)); // ---- Record transform mutations ---- if (flip === -1) { mutations.push({ type: 'flip', label: '水平翻转', value: '是' }); } if (doRotate) { mutations.push({ type: 'rotate', label: '旋转', value: `${angle.toFixed(1)}°` }); } if (jitter) { mutations.push({ type: 'scale', label: '缩放', value: `×${sx.toFixed(2)} / ×${sy.toFixed(2)}` }); } mutations.push({ type: 'quality', label: 'JPEG质量', value: `${Math.round(Number(q) * 100)}%` }); resolve({ dataUrl, mutations }); } catch (e) { reject(e); } }; img.src = sourceDataUrl; }); } /** * Linear interpolation in [range[0], range[1]]. * @param {[number,number]} range * @param {number} t 0–1 * @returns {number} */ function lerp(range, t) { return range[0] + (range[1] - range[0]) * t; } /** Cached 9×8 offscreen canvas for dHash computation. Reusing a single * canvas avoids repeated createElement + GC pressure during batch uploads * where computeDHash() is called for every file. */ let _dHashCanvas = null; /** * Compute a 64-bit dHash (difference hash) from an Image element. * Returns a BigInt (0n – 0xFFFFFFFFFFFFFFFFn) or null on failure. * * Algorithm: shrink to 9×8, compare each pixel's luminance with its * right neighbour → 64 bits. * * @param {HTMLImageElement} img * @returns {bigint|null} */ function computeDHash(img) { try { if (!_dHashCanvas) { _dHashCanvas = document.createElement('canvas'); _dHashCanvas.width = 9; _dHashCanvas.height = 8; } const ctx = _dHashCanvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(img, 0, 0, 9, 8); const { data } = ctx.getImageData(0, 0, 9, 8); let hash = 0n; for (let y = 0; y < 8; y++) { for (let x = 0; x < 8; x++) { const idx = (y * 9 + x) * 4; // Perceived luminance const lumA = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114; const lumB = data[(y * 9 + x + 1) * 4] * 0.299 + data[(y * 9 + x + 1) * 4 + 1] * 0.587 + data[(y * 9 + x + 1) * 4 + 2] * 0.114; hash = (hash << 1n) | (lumA > lumB ? 1n : 0n); } } return hash; } catch (e) { debug('dHash computation failed:', e); return null; } } /** * Hamming distance between two BigInts (popcount of XOR). * @param {bigint} a * @param {bigint} b * @returns {number} */ function hammingDistance(a, b) { let xor = a ^ b; let dist = 0; while (xor) { dist++; xor &= xor - 1n; } return dist; } /** * Process a File object into a pool-ready entry. * Returns the entry or null if the file is invalid / unreadable. * * @param {File} file * @param {bigint[]} existingHashes - hashes already in the pool * @returns {Promise} */ function processUploadedFile(file, existingHashes) { return new Promise((resolve) => { // 1. Size guard if (file.size > IMAGE_POOL_CONFIG.MAX_FILE_SIZE) { warn(`File "${file.name}" exceeds max size (${(file.size / 1e6).toFixed(1)}MB > ${IMAGE_POOL_CONFIG.MAX_FILE_SIZE / 1e6}MB), skipping`); return resolve(null); } // 2. MIME guard if (!IMAGE_POOL_CONFIG.ACCEPTED_TYPES.includes(file.type) && file.type !== '') { // Allow empty MIME (some systems don't set it) but reject known non-images if (file.type.startsWith('video/') || file.type.startsWith('audio/') || file.type.startsWith('application/') || file.type.startsWith('text/')) { warn(`File "${file.name}" is not an image (${file.type}), skipping`); return resolve(null); } } // 3. Read as data URL const reader = new FileReader(); reader.onerror = () => { warn(`Failed to read file "${file.name}"`); resolve(null); }; reader.onload = () => { const dataUrl = /** @type {string} */ (reader.result); // 4. Decode via Image to validate const img = new Image(); img.onerror = () => { warn(`File "${file.name}" could not be decoded as an image, skipping`); resolve(null); }; img.onload = async () => { // 5. Smart-crop to standard size (async: face-detection-aware) const { dataUrl: cropped, width, height, cropRect } = await smartCropToStandard(img); // 6. Compress original for quota-friendly storage let origDataUrl; try { origDataUrl = await compressOriginal(dataUrl); } catch (e) { warn(`Failed to compress original for "${file.name}":`, e); origDataUrl = null; } // 7. Perceptual hash for dedup const hash = computeDHash(img); if (hash !== null) { const threshold = IMAGE_POOL_CONFIG.DEDUP_HAMMING_THRESHOLD; const duplicate = existingHashes.some((h) => hammingDistance(h, hash) <= threshold); if (duplicate) { debug(`File "${file.name}" is a perceptual duplicate, skipping`); return resolve(null); } } debug(`Processed "${file.name}": ${file.size} → ${cropped.length} bytes, ${width}×${height}`); resolve({ name: file.name, dataUrl: cropped, origDataUrl: origDataUrl, origWidth: img.naturalWidth, origHeight: img.naturalHeight, hash: hash ? hash.toString(16) : null, size: cropped.length, width, height, cropParams: origDataUrl ? cropRect : null, }); }; img.src = dataUrl; }; reader.readAsDataURL(file); }); } // --------------------------------------------------------------------------- // Pool state & metadata // --------------------------------------------------------------------------- /** * @typedef {Object} PoolEntry * @property {number} id - stable index * @property {string} name - original filename * @property {string} hash - hex dHash string (or null) * @property {number} size - data URI length in bytes (cropped image) * @property {number} width - pixels (cropped, always 400) * @property {number} height - pixels (cropped, always 300) * @property {number} addedAt - epoch ms * @property {{sx:number,sy:number,sw:number,sh:number}|null} cropParams - source crop rectangle, null if unavailable * @property {number} origWidth - original (un-cropped) image width in px (after compression) * @property {number} origHeight - original (un-cropped) image height in px (after compression) */ /** @typedef {'high'|'neutral'|'low'} QualityTier */ /** * @typedef {Object} ImageStats * @property {number} totalUses - number of times this image was picked * @property {number} successes - number of times it led to a passed verification * @property {number} failures - number of times it led to a failed verification * @property {number} lastUsedAt - epoch ms of last pick * @property {'success'|'fail'|null} lastResult - outcome of last verification attempt */ /** * @typedef {Object} PoolMeta * @property {number} version * @property {number} nextId * @property {PoolEntry[]} entries */ /** In-memory metadata cache */ let _meta = { version: 1, nextId: 0, entries: [] }; /** In-memory stats cache — { [id: number]: ImageStats } */ let _stats = {}; /** ID of the most recently picked image (set by pickImage, read by recordLastPickResult). */ let _lastPickedId = null; /** * ID of the most recently successful image — excluded from the next pick to * avoid back-to-back reuse of the same face. Controlled by NO_REPEAT_ENABLED * and NO_REPEAT_MODE config. */ let _lastSuccessId = null; /** * Set of all image IDs that passed verification in the current page session. * When NO_REPEAT_MODE is 'session', these IDs are excluded from all future * picks. In-memory only — resets on page reload. * @type {Set} */ let _sessionSuccessIds = new Set(); /** Whether init() has completed */ let _ready = false; function imgKey(id) { return IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX + id; } function imgOrigKey(id) { return IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX + 'orig_' + id; } /** * Load metadata from storage. If metadata is missing but image keys exist, * rebuild metadata from surviving keys (self-healing). */ async function loadMeta() { const adapter = getStorageAdapter(); const raw = await adapter.get(IMAGE_POOL_CONFIG.META_KEY); if (raw) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed.version === 'number' && typeof parsed.nextId === 'number' && Array.isArray(parsed.entries)) { _meta = parsed; debug(`Image pool: loaded ${_meta.entries.length} entries (nextId=${_meta.nextId})`); return; } } catch (e) { warn('Image pool: metadata corrupted, attempting rebuild'); } } // Self-heal: scan for orphaned image keys const allKeys = await adapter.keys(); const prefix = IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX; const orphanIds = allKeys .filter((k) => k.startsWith(prefix) && k !== IMAGE_POOL_CONFIG.META_KEY && !k.includes('orig_')) .map((k) => { const idStr = k.slice(prefix.length); const id = parseInt(idStr, 10); return Number.isFinite(id) ? id : -1; }) .filter((id) => id >= 0) .sort((a, b) => a - b); if (orphanIds.length > 0) { info(`Image pool: found ${orphanIds.length} orphaned images, rebuilding metadata`); const entries = []; for (const id of orphanIds) { const data = await adapter.get(imgKey(id)); if (data && isValidDataURI(data)) { entries.push({ id, name: `recovered_${id}`, hash: null, size: data.length, width: 0, height: 0, addedAt: Date.now(), cropParams: null, origWidth: 0, origHeight: 0, }); } else { // Dead key — clean up await adapter.remove(imgKey(id)); } } _meta = { version: 1, nextId: orphanIds.length > 0 ? Math.max(...orphanIds) + 1 : 0, entries, }; await persistMeta(); } else { _meta = { version: 1, nextId: 0, entries: [] }; } } /** * Write metadata to storage (best-effort). */ async function persistMeta() { try { await getStorageAdapter().set(IMAGE_POOL_CONFIG.META_KEY, JSON.stringify(_meta)); } catch (e) { warn('Image pool: failed to persist metadata:', e); } } /** * Remove an image key from storage (best-effort). */ async function removeImageData(id) { try { await getStorageAdapter().remove(imgKey(id)); } catch (e) { warn(`Image pool: failed to remove image ${id}:`, e); } } // --------------------------------------------------------------------------- // Stats tracking — per-image quality scoring // --------------------------------------------------------------------------- /** * Create a fresh (zeroed) stats object for a new image. * @returns {ImageStats} */ function createDefaultStats() { return { totalUses: 0, successes: 0, failures: 0, lastUsedAt: 0, lastResult: null }; } /** * Load per-image stats from storage. Self-heals: removes entries for * images that no longer exist in metadata. */ async function loadStats() { const adapter = getStorageAdapter(); const raw = await adapter.get(IMAGE_POOL_CONFIG.STATS_KEY); if (raw) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { // Keep only stats for images that still exist const validIds = new Set(_meta.entries.map((e) => e.id)); const cleaned = {}; let removed = 0; for (const [idStr, s] of Object.entries(parsed)) { const id = Number(idStr); if (validIds.has(id) && s && typeof s.totalUses === 'number') { cleaned[id] = s; } else { removed++; } } _stats = cleaned; if (removed > 0) { debug(`Image pool stats: removed ${removed} orphaned entries`); await persistStats(); } else { debug(`Image pool stats: loaded ${Object.keys(_stats).length} entries`); } return; } } catch (e) { warn('Image pool stats: corrupted, resetting'); } } _stats = {}; } /** * Write stats to storage (best-effort). */ async function persistStats() { try { await getStorageAdapter().set(IMAGE_POOL_CONFIG.STATS_KEY, JSON.stringify(_stats)); } catch (e) { warn('Image pool: failed to persist stats:', e); } } /** * Get or create stats for an image ID. * @param {number} id * @returns {ImageStats} */ function getOrCreateStats(id) { if (!_stats[id]) { _stats[id] = createDefaultStats(); } return _stats[id]; } /** * Determine the quality tier for given stats. * @param {ImageStats} stats * @returns {QualityTier} */ function getQualityTier(stats) { const cfg = IMAGE_POOL_CONFIG.QUALITY_SCORING; if (!stats || stats.totalUses < cfg.MIN_USES_FOR_ASSESSMENT) return 'neutral'; const failRate = stats.failures / stats.totalUses; if (stats.failures >= cfg.LOW_QUALITY_FAILURE_THRESHOLD && failRate >= cfg.LOW_QUALITY_FAIL_RATE) { return 'low'; } if (stats.totalUses >= cfg.HIGH_QUALITY_MIN_USES) { const successRate = stats.successes / stats.totalUses; if (successRate >= cfg.HIGH_QUALITY_SUCCESS_RATE) return 'high'; } return 'neutral'; } /** * Compute selection weights for all pool entries based on their quality tier. * Returns an array aligned with `_meta.entries`. Every weight is strictly > 0. * @returns {number[]} */ function computeWeights() { const cfg = IMAGE_POOL_CONFIG.QUALITY_SCORING; return _meta.entries.map((entry) => { const stats = _stats[entry.id] || createDefaultStats(); const tier = getQualityTier(stats); switch (tier) { case 'high': return cfg.HIGH_QUALITY_WEIGHT; case 'low': return cfg.LOW_QUALITY_WEIGHT; default: return cfg.NEUTRAL_WEIGHT; } }); } /** * Weighted random index into `_meta.entries`. * Falls back to uniform random if weights sum to zero (should not happen). * @param {number[]} weights * @returns {number} index into _meta.entries */ function weightedRandomIndex(weights) { const total = weights.reduce((a, b) => a + b, 0); if (total <= 0) return Math.floor(Math.random() * weights.length); let target = Math.random() * total; for (let i = 0; i < weights.length; i++) { target -= weights[i]; if (target <= 0) return i; } return weights.length - 1; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Force a full reload of the pool from storage. * Resets the ready flag so initPool() re-reads metadata and stats. * Used by import-export after writing directly to storage. * * @returns {Promise} */ async function reloadPool() { _ready = false; await initPool(); } /** * Initialize the image pool. Must be called once before any other operations. * Idempotent — subsequent calls are no-ops. * * @returns {Promise} */ async function initPool() { if (_ready) return; await loadMeta(); await loadStats(); _ready = true; info(`Image pool ready: ${_meta.entries.length}/${IMAGE_POOL_CONFIG.MAX_IMAGES} images`); } /** * How many images are currently in the pool? * @returns {number} */ function poolSize() { return _meta.entries.length; } /** * Maximum capacity. * @returns {number} */ function poolCapacity() { return IMAGE_POOL_CONFIG.MAX_IMAGES; } /** * Return a shallow copy of all pool entries (for UI rendering). * @returns {PoolEntry[]} */ function listEntries() { return _meta.entries.slice(); } /** * Pick a random image from the pool using weighted random selection. * High-quality images are boosted; low-quality images are down-weighted * but never fully excluded. Falls back to uniform random if the pool * has no stats data yet. * * On every call the chosen image is run through mutateImage() so that * no two requests ever return the same bytes — brightness, contrast, * saturation, hue, flip, rotation, scale, and JPEG quality are all * randomized per invocation. * * Also records usage stats: increments totalUses and sets lastUsedAt * so that the quality scoring system can track per-image performance. * * @returns {Promise} base64 JPEG data URI */ async function pickImage() { if (_meta.entries.length === 0) { const err = new Error('Image pool is empty — cannot pick an image for replacement'); err.code = 'POOL_EMPTY'; throw err; } // ── No-repeat exclusion ────────────────────────────────────────────── // Avoid reusing faces that already passed verification this session. // Build a list of eligible entry indices; fall back to the full pool // if every image is excluded (tiny pool edge case). const excludeIds = new Set(); { if (_lastSuccessId != null) excludeIds.add(_lastSuccessId); { for (const id of _sessionSuccessIds) excludeIds.add(id); } } /** @type {number[]} — indices into _meta.entries that are eligible for selection */ let eligibleIndices = []; for (let i = 0; i < _meta.entries.length; i++) { if (!excludeIds.has(_meta.entries[i].id)) { eligibleIndices.push(i); } } const usingFallback = eligibleIndices.length === 0; if (usingFallback) { eligibleIndices = _meta.entries.map((_, i) => i); } const maxRetries = 3; const tried = new Set(); // Compute weights once — they don't change during retries const useWeighted = getSetting('dynamicWeight', true); const weights = useWeighted ? computeWeights() : null; for (let attempt = 0; attempt < maxRetries && tried.size < eligibleIndices.length; attempt++) { // Weighted or uniform random selection over eligible indices let eligiblePos; do { if (useWeighted) { const eligibleWeights = eligibleIndices.map((i) => weights[i]); eligiblePos = weightedRandomIndex(eligibleWeights); } else { eligiblePos = Math.floor(Math.random() * eligibleIndices.length); } } while (tried.has(eligiblePos)); tried.add(eligiblePos); const idx = eligibleIndices[eligiblePos]; const entry = _meta.entries[idx]; const adapter = getStorageAdapter(); const raw = await adapter.get(imgKey(entry.id)); if (raw && isValidDataURI(raw)) { // Record usage stats _lastPickedId = entry.id; const stats = getOrCreateStats(entry.id); const excludedNote = (usingFallback && excludeIds.has(entry.id)) ? ' (fallback — was excluded)' : ''; info(`Picked image #${entry.id} "${entry.name}" (tier=${getQualityTier(stats)}, prevUses=${stats.totalUses}${excludedNote})`); stats.totalUses++; stats.lastUsedAt = Date.now(); persistStats(); // fire-and-forget // Apply random mutations if enabled { try { const mutated = await mutateImage(raw); return mutated; } catch (e) { // Mutation failed — return the clean copy as fallback warn(`Mutation failed for "${entry.name}", returning clean copy:`, e); return raw; } } return raw; } // Stale/dead entry — evict warn(`Image ${entry.id} ("${entry.name}") data missing or invalid, evicting`); await removeImageData(entry.id); _meta.entries.splice(idx, 1); // Also remove stale stats delete _stats[entry.id]; await persistMeta(); await persistStats(); } // All retries exhausted — every entry failed to load or validate warn(`All ${_meta.entries.length} pool images failed to load from storage — cannot pick an image`); const err = new Error('All pool images failed to load — storage may be corrupted or inaccessible'); err.code = 'POOL_EMPTY'; throw err; } /** * Add images from File objects to the pool. * * @param {File[]} files * @returns {Promise<{ added: PoolEntry[], skipped: number, duplicates: number }>} */ async function addImages(files) { if (!_ready) await initPool(); const existingHashes = _meta.entries .map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return null; } } return null; }) .filter(Boolean); const results = { added: [], skipped: 0, duplicates: 0 }; for (const file of files) { // Capacity check if (_meta.entries.length + results.added.length >= IMAGE_POOL_CONFIG.MAX_IMAGES) { warn(`Image pool full (${IMAGE_POOL_CONFIG.MAX_IMAGES}), skipping "${file.name}"`); results.skipped++; continue; } const processed = await processUploadedFile(file, [...existingHashes, ...results.added.map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return 0n; } } return 0n; }).filter((h) => h !== 0n)]); if (!processed) { results.skipped++; continue; } const id = _meta.nextId++; const entry = { id, name: processed.name, hash: processed.hash, size: processed.size, width: processed.width, height: processed.height, addedAt: Date.now(), cropParams: processed.cropParams || null, origWidth: processed.origWidth || 0, origHeight: processed.origHeight || 0, }; // Persist cropped image (must succeed); original is best-effort try { await getStorageAdapter().set(imgKey(id), processed.dataUrl); } catch (e) { warn(`Failed to store cropped image "${file.name}":`, e); results.skipped++; continue; } // Store original — non-blocking: if it fails we keep the cropped image // but disable crop editing (cropParams stays null) if (processed.origDataUrl) { try { await getStorageAdapter().set(imgOrigKey(id), processed.origDataUrl); } catch (e) { warn(`Failed to store original for "${file.name}" (quota), crop editing disabled`); entry.cropParams = null; } } else { entry.cropParams = null; } _meta.entries.push(entry); results.added.push(entry); info(`Image "${file.name}" added to pool (id=${id}, ${processed.width}×${processed.height})`); } if (results.added.length > 0 || results.skipped > 0) { await persistMeta(); } return results; } /** * Add a single image from a data URI (canvas capture, screenshot, etc.). * Skips the File/FileReader path — goes directly to decode → compress → * dHash dedup → storage. Designed for programmatic frame capture from * <video> elements. * * @param {string} dataUrl - A base64 data URI (data:image/…) * @param {string} name - Human-readable label (e.g. "captured_1712345678901") * @returns {Promise} The added entry, or null if rejected */ async function addImageFromDataURI(dataUrl, name) { if (!_ready) await initPool(); // 1. Quick sanity — must look like a data URI if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { warn('addImageFromDataURI: not a valid image data URI'); return null; } if (dataUrl.length > IMAGE_POOL_CONFIG.MAX_FILE_SIZE * 2) { warn('addImageFromDataURI: data URI too large before compression, rejecting'); return null; } // 2. Capacity check if (_meta.entries.length >= IMAGE_POOL_CONFIG.MAX_IMAGES) { warn('Image pool full, rejecting captured frame'); return null; } // 3. Build existing hashes for dedup const existingHashes = _meta.entries .map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return null; } } return null; }) .filter(Boolean); // 4. Decode, compress, dHash return new Promise((resolve) => { const img = new Image(); img.onerror = () => { warn('addImageFromDataURI: failed to decode image'); resolve(null); }; img.onload = async () => { // Compress via smart-crop to standard dimensions (async: face-detection-aware) const { dataUrl: compressed, width, height, cropRect } = await smartCropToStandard(img); // Compress original for quota-friendly storage let origDataUrl; try { origDataUrl = await compressOriginal(dataUrl); } catch (e) { warn(`Failed to compress original for "${name}":`, e); origDataUrl = null; } // Perceptual dedup const hash = computeDHash(img); if (hash !== null) { const threshold = IMAGE_POOL_CONFIG.DEDUP_HAMMING_THRESHOLD; const duplicate = existingHashes.some((h) => hammingDistance(h, hash) <= threshold); if (duplicate) { debug(`Captured frame "${name}" is a perceptual duplicate, skipping`); return resolve(null); } } // 5. Allocate id & persist const id = _meta.nextId++; const entry = { id, name, hash: hash ? hash.toString(16) : null, size: compressed.length, width, height, addedAt: Date.now(), cropParams: (cropRect && origDataUrl) ? cropRect : null, origWidth: img.naturalWidth, origHeight: img.naturalHeight, }; // Persist cropped image (must succeed) try { await getStorageAdapter().set(imgKey(id), compressed); } catch (e) { warn(`Failed to store captured image "${name}":`, e); return resolve(null); } // Store original — non-blocking: if it fails we keep the cropped image if (origDataUrl) { try { await getStorageAdapter().set(imgOrigKey(id), origDataUrl); } catch (e) { warn(`Failed to store original for "${name}" (quota), crop editing disabled`); entry.cropParams = null; } } _meta.entries.push(entry); await persistMeta(); info(`Captured image "${name}" added to pool (id=${id}, ${width}×${height})`); resolve(entry); }; img.src = dataUrl; }); } /** * Remove a single image by its stable id. * @param {number} id * @returns {Promise} true if removed */ async function removeImage(id) { const idx = _meta.entries.findIndex((e) => e.id === id); if (idx === -1) return false; const entry = _meta.entries[idx]; await removeImageData(entry.id); try { await getStorageAdapter().remove(imgOrigKey(entry.id)); } catch (_) { /* ignore */ } _meta.entries.splice(idx, 1); // Clean up stats and no-repeat state for the removed image delete _stats[entry.id]; if (_lastSuccessId === entry.id) _lastSuccessId = null; _sessionSuccessIds.delete(entry.id); await persistMeta(); await persistStats(); info(`Removed image "${entry.name}" (id=${id})`); return true; } /** * Remove all images from the pool. * @returns {Promise} */ async function clearPool() { const adapter = getStorageAdapter(); for (const entry of _meta.entries) { await adapter.remove(imgKey(entry.id)); try { await adapter.remove(imgOrigKey(entry.id)); } catch (_) { /* ignore */ } } _meta.entries = []; _meta.nextId = 0; _stats = {}; _lastPickedId = null; _lastSuccessId = null; _sessionSuccessIds.clear(); await persistMeta(); await persistStats(); info('Image pool cleared'); } /** * Return the data URI for a specific entry (for thumbnail display). * @param {number} id * @returns {Promise} */ async function getImageData(id) { const raw = await getStorageAdapter().get(imgKey(id)); return raw && isValidDataURI(raw) ? raw : null; } /** * Return the ORIGINAL (un-cropped) data URI for a specific entry. * @param {number} id * @returns {Promise} */ async function getOriginalImageData(id) { const raw = await getStorageAdapter().get(imgOrigKey(id)); return raw && typeof raw === 'string' && raw.startsWith('data:image/') ? raw : null; } /** * Update the crop rectangle for an existing image, re-render the cropped * output, and persist everything back to storage. * * @param {number} id * @param {{sx:number, sy:number, sw:number, sh:number}} cropParams — new crop rect in source-pixel coordinates * @returns {Promise} true if successful */ async function updateCrop(id, cropParams) { const idx = _meta.entries.findIndex((e) => e.id === id); if (idx === -1) { warn(`updateCrop: image ${id} not found`); return false; } const origDataUrl = await getOriginalImageData(id); if (!origDataUrl) { warn(`updateCrop: original image data not found for ${id}`); return false; } return new Promise((resolve) => { const img = new Image(); img.onerror = () => { warn(`updateCrop: failed to decode original image for ${id}`); resolve(false); }; img.onload = async () => { try { const { dataUrl, width, height } = renderCrop(img, cropParams); // Persist new cropped image await getStorageAdapter().set(imgKey(id), dataUrl); // Update metadata — sync origWidth/origHeight to the loaded image // dimensions so that on re-open the coordinate system matches. _meta.entries[idx].cropParams = { ...cropParams }; _meta.entries[idx].size = dataUrl.length; _meta.entries[idx].width = width; _meta.entries[idx].height = height; _meta.entries[idx].origWidth = img.naturalWidth; _meta.entries[idx].origHeight = img.naturalHeight; await persistMeta(); debug(`Crop updated for image ${id}: source (${cropParams.sx},${cropParams.sy}) ${cropParams.sw}×${cropParams.sh}`); resolve(true); } catch (e) { warn(`updateCrop: render failed for ${id}:`, e); resolve(false); } }; img.src = origDataUrl; }); } // --------------------------------------------------------------------------- // Stats public API — per-image quality tracking // --------------------------------------------------------------------------- /** * Record a verification result for a specific image. * Updates successes/failures/lastResult and persists stats. * * @param {number} id - Image ID * @param {boolean} success - Whether verification succeeded */ function recordImageResult(id, success) { const stats = getOrCreateStats(id); if (success) { stats.successes++; stats.lastResult = 'success'; // Track for no-repeat exclusion so this face won't be reused immediately _lastSuccessId = id; _sessionSuccessIds.add(id); } else { stats.failures++; stats.lastResult = 'fail'; } // If lastUsedAt is 0 (never picked directly but result recorded), set now if (!stats.lastUsedAt) stats.lastUsedAt = Date.now(); persistStats(); // fire-and-forget const tier = getQualityTier(stats); info(`Image ${id} verification: ${success ? '✓ SUCCESS' : '✗ FAIL'} (totalUses=${stats.totalUses}, success=${stats.successes}/${stats.totalUses}, tier=${tier})`); } /** * Record a verification result for the most recently picked image. * Convenience wrapper — the processor doesn't know the exact image ID; * it just knows the current verification attempt succeeded or failed. * * @param {boolean} success */ function recordLastPickResult(success) { if (_lastPickedId == null) { info('recordLastPickResult: no image was picked yet — cannot record result'); return; } recordImageResult(_lastPickedId, success); } /** * Get the stats for a specific image. * @param {number} id * @returns {ImageStats|null} */ function getImageStats(id) { return _stats[id] || null; } /** * Get stats for all images in the pool, keyed by ID. * Returns a fresh object — safe for UI rendering. * @returns {{ [id: number]: ImageStats }} */ function getAllStats() { return { ..._stats }; } /** * Reset the no-repeat exclusion state so all images are eligible for * picking again. Call when starting a new course or when the user * explicitly requests a fresh verification cycle. * * Does NOT affect quality scores — only clears the in-memory exclusion * set (_lastSuccessId and _sessionSuccessIds). */ function resetNoRepeatState() { const hadState = _lastSuccessId != null || _sessionSuccessIds.size > 0; _lastSuccessId = null; _sessionSuccessIds.clear(); if (hadState) { debug('No-repeat state reset — all images are eligible again'); } } /** * Get the quality tier for a specific image. * @param {number} id * @returns {QualityTier} */ function getImageQualityTier(id) { const stats = _stats[id] || createDefaultStats(); return getQualityTier(stats); } /** * @file CSS styles for the edge-drawer panel UI * All styles are injected as a