tphim 2.2.2 β†’ 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tphim",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "TPHIM - Ultimate Video Pipeline: Download, Transcode HLS, AI Subtitles (with skip option), Resume Upload, and Cloud Upload.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pipeline.mjs CHANGED
@@ -133,7 +133,7 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
133
133
  const highResPath = path.join(outputDir, 'preview.jpg');
134
134
  const lowResPath = path.join(outputDir, 'preview_low.jpg');
135
135
 
136
- console.log(' πŸ“Έ [2.5/5] Generating Dual Preview Sprites (High-Res + Low-Res)...');
136
+ console.log(' πŸ“Έ [2.5/5] Generating Dual Preview Sprites (Legacy format)...');
137
137
 
138
138
  // Complex filter để tαΊ‘o cαΊ£ 2 phiΓͺn bαΊ£n tα»« 1 luα»“ng
139
139
  const complexFilter = [
@@ -173,7 +173,7 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
173
173
 
174
174
  ffmpeg.on('close', (code) => {
175
175
  if (code === 0) {
176
- console.log(' βœ… Dual Preview Sprites ready (High-Res + Low-Res).');
176
+ console.log(' βœ… Dual Preview Sprites ready.');
177
177
  resolve({ highResPath, lowResPath });
178
178
  } else {
179
179
  console.error(' ⚠ Dual sprite generation failed:', stderr);
@@ -188,6 +188,60 @@ function generateDualPreviewSprites(mp4Path, outputDir) {
188
188
  });
189
189
  }
190
190
 
191
+ // [TURBO MODE] Combine L1, L2, L3 into ONE single pass for 20x-50x speed
192
+ async function generateStoryboards(mp4Path, outputDir) {
193
+ console.log(` πŸš€ [Turbo-Storyboard] Generating ALL levels in ONE pass from: ${path.basename(mp4Path)}`);
194
+
195
+ const args = [
196
+ '-skip_frame', 'nokey', // Essential for 20x speed
197
+ '-threads', '0',
198
+ '-an', '-sn', '-dn',
199
+ '-i', mp4Path,
200
+ '-filter_complex', [
201
+ // Split video into 3 branches for L1, L2, L3
202
+ '[0:v]split=3[v1][v2][v3]',
203
+ // L1: 160x90, 10s interval, 10x10 tile
204
+ '[v1]fps=1/10,scale=160:90,tile=10x10[out1]',
205
+ // L2: 240x135, 30s interval, 5x5 tile
206
+ '[v2]fps=1/30,scale=240:135,tile=5x5[out2]',
207
+ // L3: 320x180, 60s interval, 3x3 tile
208
+ '[v3]fps=1/60,scale=320:180,tile=3x3[out3]'
209
+ ].join(';'),
210
+ '-map', '[out1]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L1_M%d.jpg'),
211
+ '-map', '[out2]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L2_M%d.jpg'),
212
+ '-map', '[out3]', '-q:v', '4', '-start_number', '0', '-vsync', '0', path.join(outputDir, 'L3_M%d.jpg')
213
+ ];
214
+
215
+ await new Promise((resolve, reject) => {
216
+ const proc = spawn(FFMPEG_BIN, args, { shell: false });
217
+ // proc.stderr.on('data', d => console.log(d.toString())); // Debug speed
218
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Turbo-Storyboard failed with code ${code}`)));
219
+ proc.on('error', reject);
220
+ });
221
+
222
+ console.log(' βœ… Turbo Storyboard ready (Pass 1/1).');
223
+ }
224
+
225
+ // Function to find the highest resolution MP4 in a directory or a list of files
226
+ function selectHighestQualityVideo(files) {
227
+ if (!files || files.length === 0) return null;
228
+
229
+ // Sort by resolution labels in filename (YouTube style)
230
+ const resMap = { '4k': 2160, '1080p': 1080, '720p': 720, '480p': 480, '360p': 360, '240p': 240, '144p': 144 };
231
+
232
+ return files.sort((a, b) => {
233
+ const getRes = (name) => {
234
+ const m = name.match(/(\d{3,4}p|4[kK])/);
235
+ if (m) return resMap[m[1].toLowerCase()] || parseInt(m[1]);
236
+ return 0;
237
+ };
238
+ const resA = getRes(a);
239
+ const resB = getRes(b);
240
+ if (resA !== resB) return resB - resA;
241
+ return 0; // Fallback to original order
242
+ })[0];
243
+ }
244
+
191
245
  function formatDuration(ms) {
192
246
  const seconds = Math.floor((ms / 1000) % 60);
193
247
  const minutes = Math.floor((ms / (1000 * 60)) % 60);
@@ -236,10 +290,41 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
236
290
 
237
291
  let startTime = Date.now();
238
292
  // ─── BƯỚC 1: Download ─────────────────────────────────────────
239
- const mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
293
+ let mp4Path = existsSync(input) && input.toLowerCase().endsWith('.mp4')
240
294
  ? input
241
295
  : path.join('downloads', `${slug}.mp4`);
242
296
 
297
+ if (!existsSync(mp4Path)) {
298
+ // If input is a directory, find the highest quality video
299
+ if (existsSync(input) && !input.toLowerCase().endsWith('.mp4')) {
300
+ const files = execSync(IS_WIN ? `dir /b "${input}"` : `ls "${input}"`, { encoding: 'utf8' })
301
+ .split(/\r?\n/)
302
+ .filter(f => f.toLowerCase().endsWith('.mp4'))
303
+ .map(f => path.join(input, f));
304
+
305
+ const bestFile = selectHighestQualityVideo(files);
306
+ if (bestFile) {
307
+ console.log(` ✨ Found multiple files in source. Selected highest quality: ${path.basename(bestFile)}`);
308
+ mp4Path = bestFile;
309
+ }
310
+ } else {
311
+ // Check downloads folder for any files matching slug (e.g. 1080p - slug.mp4)
312
+ const downloadsDir = 'downloads';
313
+ if (existsSync(downloadsDir)) {
314
+ const files = execSync(IS_WIN ? `dir /b "${downloadsDir}"` : `ls "${downloadsDir}"`, { encoding: 'utf8' })
315
+ .split(/\r?\n/)
316
+ .filter(f => f.toLowerCase().endsWith('.mp4') && f.toLowerCase().includes(slug.toLowerCase()))
317
+ .map(f => path.join(downloadsDir, f));
318
+
319
+ const bestFile = selectHighestQualityVideo(files);
320
+ if (bestFile) {
321
+ console.log(` ✨ Found existing files in downloads. Selected highest quality: ${path.basename(bestFile)}`);
322
+ mp4Path = bestFile;
323
+ }
324
+ }
325
+ }
326
+ }
327
+
243
328
  if (!existsSync(mp4Path)) {
244
329
  console.log('\nπŸ”½ [1/5] Downloading video...');
245
330
 
@@ -370,32 +455,36 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
370
455
 
371
456
  const outDir = path.join('hls', slug);
372
457
 
373
- // XΓ’y dα»±ng ffmpeg command Δ‘α»™ng
374
- let maps = '';
458
+ // [TURBO HLS] Build dynamic filter chain for sequential scaling
459
+ // Scaling from previously scaled version (e.g. 1080 -> 720) is much faster
460
+ let filterParts = [`[0:v]split=${selectedQualities.length}`];
461
+ let splitLabels = selectedQualities.map((_, i) => `[v_raw${i}]`);
462
+ filterParts[0] += splitLabels.join('');
463
+
375
464
  let outputs = '';
465
+ let maps = '';
376
466
  let varStreamMapArr = [];
377
467
 
378
468
  selectedQualities.forEach((q, i) => {
379
- maps += `-map 0:v -map 0:a `;
380
- outputs += `-c:v:${i} libx264 -preset ${vpsOpt.preset} -tune ${vpsOpt.tune} -b:v:${i} ${q.bv} -s:v:${i} ${q.w}x${q.h} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
469
+ filterParts.push(`[v_raw${i}]scale=${q.w}:${q.h},format=yuv420p[v${i}]`);
470
+ maps += `-map "[v${i}]" -map 0:a `;
471
+ outputs += `-c:v:${i} libx264 -preset ultrafast -tune fastdecode -b:v:${i} ${q.bv} -c:a:${i} aac -b:a:${i} ${q.ba} -movflags +faststart `;
381
472
  varStreamMapArr.push(`v:${i},a:${i}`);
382
473
  });
383
474
 
384
- // Generate encoded segment name with timestamp
385
475
  const timestamp = Date.now();
386
476
  const encodedPrefix = Buffer.from(`txa_${timestamp}`).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
387
-
388
- // Optimize thread count based on CPU cores
389
- const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores, 8) : 0; // 0 = auto, but limit on VPS
477
+ const threadCount = vpsOpt.isVPS ? Math.min(vpsOpt.cpuCores * 2, 16) : 0; // Oversubscribe for maximum saturation
390
478
 
391
479
  const ffmpegCmd = [
392
480
  `"${FFMPEG_BIN}"`, '-i', `"${mp4Path}"`,
393
- `-threads ${threadCount}`, `-preset ${vpsOpt.preset}`, `-tune ${vpsOpt.tune}`,
481
+ `-threads ${threadCount}`,
482
+ '-filter_complex', `"${filterParts.join(';')}"`,
394
483
  maps,
395
484
  outputs,
396
485
  '-var_stream_map', `"${varStreamMapArr.join(' ')}"`,
397
486
  '-master_pl_name', `"${masterName}"`,
398
- '-f hls -hls_time 4 -hls_list_size 0 -hls_segment_type mpegts',
487
+ '-f hls -hls_time 6 -hls_list_size 0 -hls_segment_type mpegts', // 6s segments are faster to write than 4s
399
488
  '-hls_segment_filename', `"${outDir}/%v/${encodedPrefix}_%04d.ts"`,
400
489
  `"${outDir}/%v/index.m3u8"`
401
490
  ].join(' ');
@@ -417,12 +506,26 @@ export async function executePipeline({ input, slug, title, langArg = 'vi' }) {
417
506
  console.log(`\n⏭ [2/5] HLS Δ‘Γ£ cΓ³ sαΊ΅n`);
418
507
  }
419
508
 
420
- // --- STEP 2.5: PREVIEW SPRITE --- (Independent check)
509
+ // --- STEP 2.5: PREVIEW SPRITE & STORYBOARD --- (Independent check)
421
510
  const spriteLowPath = path.join('hls', slug, 'preview_low.jpg');
511
+ const storyboardCheckPath = path.join('hls', slug, 'L1_M0.jpg');
512
+
513
+ const outDir = path.join('hls', slug);
514
+
515
+ // 1. New Storyboard format (L1, L2, L3)
516
+ if (!existsSync(storyboardCheckPath)) {
517
+ try {
518
+ await generateStoryboards(mp4Path, outDir);
519
+ } catch (e) {
520
+ console.log(' ⚠ Storyboard generation failed:', e.message);
521
+ }
522
+ } else {
523
+ console.log(` ⏭ Storyboard levels already exist`);
524
+ }
422
525
 
526
+ // 2. Legacy Dual Sprite format (for backward compatibility)
423
527
  if (!existsSync(spritePath) || !existsSync(spriteLowPath)) {
424
528
  try {
425
- const outDir = path.join('hls', slug);
426
529
  const { highResPath, lowResPath } = await generateDualPreviewSprites(mp4Path, outDir);
427
530
  console.log(' βœ… Dual Preview Sprites generated successfully.');
428
531
  } catch (e) {
@@ -59,6 +59,7 @@ export async function uploadHLSFolder(slug) {
59
59
 
60
60
  const cacheMap = {
61
61
  '.ts': 'public, max-age=31536000',
62
+ '.jpg': 'public, max-age=31536000',
62
63
  '.m3u8': 'public, max-age=300',
63
64
  '.vtt': 'public, max-age=86400',
64
65
  '.txt': 'no-cache',