vid-com 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vid-com",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Video bitrate scanner & batch compressor with ARW→JPEG conversion (macOS, FFmpeg, hevc_videotoolbox)",
5
5
  "main": "video_bitrate_tool.js",
6
6
  "bin": {
@@ -42,6 +42,9 @@ do_convert() {
42
42
 
43
43
  # 执行转换
44
44
  sips -s format jpeg -s formatOptions 90 "$src_file" --out "$target_file" > /dev/null 2>&1
45
+
46
+ # 从原始 ARW 拷贝所有元数据到输出 JPG
47
+ exiftool -TagsFromFile "$src_file" -All:All -overwrite_original "$target_file" > /dev/null 2>&1
45
48
  }
46
49
 
47
50
  export -f do_convert
@@ -6,10 +6,12 @@
6
6
  * 用法:
7
7
  * node scripts/batch_delete.js [--input <json>] # 视频模式(默认)
8
8
  * node scripts/batch_delete.js --mode arw --dir <目录> # ARW 模式
9
+ * 加 -y # yolo 模式:符合条件自动删除,不询问
9
10
  */
10
11
 
11
12
  const fs = require('fs');
12
13
  const path = require('path');
14
+ const os = require('os');
13
15
  const readline = require('readline');
14
16
  const { execSync } = require('child_process');
15
17
 
@@ -18,7 +20,9 @@ const { execSync } = require('child_process');
18
20
  function parseArgs(argv) {
19
21
  const args = {};
20
22
  for (let i = 0; i < argv.length; i++) {
21
- if (argv[i].startsWith('--')) {
23
+ if (argv[i] === '-y') {
24
+ args.y = true;
25
+ } else if (argv[i].startsWith('--')) {
22
26
  const key = argv[i].slice(2);
23
27
  args[key] = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
24
28
  }
@@ -28,6 +32,7 @@ function parseArgs(argv) {
28
32
 
29
33
  const args = parseArgs(process.argv.slice(2));
30
34
  const MODE = (args.mode || 'video').toLowerCase();
35
+ const YOLO = !!args.y;
31
36
 
32
37
  // ─── 共用工具 ─────────────────────────────────────────────────────────────────
33
38
 
@@ -188,18 +193,22 @@ async function runVideoMode(jsonFile) {
188
193
  continue;
189
194
  }
190
195
 
191
- const key = await askKey(' Delete original? [y = yes / other = skip]: ');
192
- if (key.toLowerCase() === 'y') {
193
- try {
194
- fs.unlinkSync(origPath);
195
- console.log(' -> DELETED\n');
196
- deleted++;
197
- } catch (err) {
198
- console.error(` -> FAILED: ${err.message}\n`);
196
+ if (!YOLO) {
197
+ const key = await askKey(' Delete original? [y = yes / other = skip]: ');
198
+ if (key.toLowerCase() !== 'y') {
199
+ console.log(' -> Skipped\n');
200
+ skipped++;
201
+ continue;
199
202
  }
200
203
  } else {
201
- console.log(' -> Skipped\n');
202
- skipped++;
204
+ console.log(' [yolo] 自动删除');
205
+ }
206
+ try {
207
+ fs.unlinkSync(origPath);
208
+ console.log(' -> DELETED\n');
209
+ deleted++;
210
+ } catch (err) {
211
+ console.error(` -> FAILED: ${err.message}\n`);
203
212
  }
204
213
  }
205
214
 
@@ -225,6 +234,113 @@ function getImageDimensions(filePath) {
225
234
  }
226
235
  }
227
236
 
237
+ // ─── pHash 工具 ───────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * 计算图片 dHash(差值哈希)。
241
+ * 用 sips 缩到 9×8 → BMP,直接解析像素,无需 npm 依赖。
242
+ * @returns {bigint}
243
+ */
244
+ function computeDHash(imgPath) {
245
+ const tmpBmp = path.join(
246
+ os.tmpdir(),
247
+ `dhash_${Date.now()}_${Math.random().toString(36).slice(2)}.bmp`
248
+ );
249
+ try {
250
+ // sips -z pixelsH pixelsW → height=8, width=9
251
+ execSync(
252
+ `sips -z 8 9 -s format bmp "${imgPath}" --out "${tmpBmp}"`,
253
+ { stdio: ['pipe', 'pipe', 'pipe'] }
254
+ );
255
+ const buf = fs.readFileSync(tmpBmp);
256
+ const dataOffset = buf.readUInt32LE(10);
257
+ const width = buf.readInt32LE(18);
258
+ const height = Math.abs(buf.readInt32LE(22));
259
+ const bpp = buf.readUInt16LE(28);
260
+ const bytesPerPx = bpp / 8;
261
+ const rowStride = Math.ceil(width * bytesPerPx / 4) * 4; // 4 字节对齐
262
+
263
+ // BMP 行从下到上存储,转为灰度值数组(行从上到下)
264
+ const pixels = [];
265
+ for (let row = 0; row < height; row++) {
266
+ const rowOffset = dataOffset + (height - 1 - row) * rowStride;
267
+ for (let col = 0; col < width; col++) {
268
+ const o = rowOffset + col * bytesPerPx;
269
+ const b = buf[o], g = buf[o + 1], r = buf[o + 2];
270
+ pixels.push(Math.round(0.299 * r + 0.587 * g + 0.114 * b));
271
+ }
272
+ }
273
+
274
+ // dHash:每行相邻像素对比,左 < 右 → 1,否则 → 0
275
+ let hash = 0n;
276
+ for (let row = 0; row < 8; row++) {
277
+ for (let col = 0; col < 8; col++) {
278
+ hash = (hash << 1n) | (pixels[row * 9 + col] < pixels[row * 9 + col + 1] ? 1n : 0n);
279
+ }
280
+ }
281
+ return hash;
282
+ } finally {
283
+ try { fs.unlinkSync(tmpBmp); } catch {}
284
+ }
285
+ }
286
+
287
+ /** 计算两个 64-bit 哈希的汉明距离 */
288
+ function hammingDistance(h1, h2) {
289
+ let xor = h1 ^ h2, dist = 0;
290
+ while (xor > 0n) { dist += Number(xor & 1n); xor >>= 1n; }
291
+ return dist;
292
+ }
293
+
294
+ /**
295
+ * 从 ARW 提取内嵌 JPEG(需要 exiftool)。
296
+ * 优先取 JpgFromRaw(全分辨率),回退到 PreviewImage。
297
+ * @returns {string|null} 临时文件路径,调用方负责删除
298
+ */
299
+ function extractArwPreview(arwPath) {
300
+ const tmpJpg = path.join(
301
+ os.tmpdir(),
302
+ `arw_preview_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
303
+ );
304
+ for (const tag of ['-JpgFromRaw', '-PreviewImage']) {
305
+ try {
306
+ execSync(
307
+ `exiftool -b ${tag} "${arwPath}" > "${tmpJpg}"`,
308
+ { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }
309
+ );
310
+ if (fs.existsSync(tmpJpg) && fs.statSync(tmpJpg).size > 5000) return tmpJpg;
311
+ } catch {}
312
+ }
313
+ try { fs.unlinkSync(tmpJpg); } catch {}
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * 对比 ARW 内嵌预览与 JPG 的视觉哈希。
319
+ * @returns {{ match: boolean|null, dist: number|null, line: string }}
320
+ */
321
+ function checkVisualMatch(arwPath, jpgPath) {
322
+ let previewPath = null;
323
+ try {
324
+ previewPath = extractArwPreview(arwPath);
325
+ if (!previewPath) {
326
+ return { match: null, dist: null, line: ' pHash : ARW 无内嵌预览,跳过视觉验证' };
327
+ }
328
+ const h1 = computeDHash(previewPath);
329
+ const h2 = computeDHash(jpgPath);
330
+ const dist = hammingDistance(h1, h2);
331
+ const match = dist <= 10;
332
+ return {
333
+ match,
334
+ dist,
335
+ line: ` pHash : 汉明距离 ${dist}/64 ${match ? '✓ 视觉一致' : '✗ 视觉不一致'}`,
336
+ };
337
+ } catch (err) {
338
+ return { match: null, dist: null, line: ` pHash : 计算失败 (${err.message})` };
339
+ } finally {
340
+ if (previewPath) try { fs.unlinkSync(previewPath); } catch {}
341
+ }
342
+ }
343
+
228
344
  /** 在同目录下查找同名的 .jpg / .jpeg(大小写不敏感) */
229
345
  function findJpgForArw(arwPath) {
230
346
  const dir = path.dirname(arwPath);
@@ -307,18 +423,31 @@ async function runArwMode(dir) {
307
423
  continue;
308
424
  }
309
425
 
310
- const key = await askKey(' Delete ARW? [y = yes / other = skip]: ');
311
- if (key.toLowerCase() === 'y') {
312
- try {
313
- fs.unlinkSync(arwPath);
314
- console.log(' -> DELETED\n');
315
- deleted++;
316
- } catch (err) {
317
- console.error(` -> FAILED: ${err.message}\n`);
426
+ // 视觉 pHash 验证(ARW 内嵌预览 vs JPG)
427
+ const { match: visualMatch, line: visualLine } = checkVisualMatch(arwPath, jpgPath);
428
+ console.log(visualLine);
429
+ if (visualMatch === false) {
430
+ console.log(` -> 视觉内容不一致,自动跳过\n`);
431
+ mismatch++;
432
+ continue;
433
+ }
434
+
435
+ if (!YOLO) {
436
+ const key = await askKey(' Delete ARW? [y = yes / other = skip]: ');
437
+ if (key.toLowerCase() !== 'y') {
438
+ console.log(' -> Skipped\n');
439
+ skipped++;
440
+ continue;
318
441
  }
319
442
  } else {
320
- console.log(' -> Skipped\n');
321
- skipped++;
443
+ console.log(' [yolo] 自动删除');
444
+ }
445
+ try {
446
+ fs.unlinkSync(arwPath);
447
+ console.log(' -> DELETED\n');
448
+ deleted++;
449
+ } catch (err) {
450
+ console.error(` -> FAILED: ${err.message}\n`);
322
451
  }
323
452
  }
324
453