bep40 commited on
Commit
0cc342c
·
verified ·
1 Parent(s): 9e45c4b

Fix v6: audio+video record + ratio crop + AI title + Short AI upload

Browse files

Audio fix:
- Unmute video during record so captureStream() includes audio tracks
- Restore muted state after recording
- Log audio/video track count for debugging

Ratio crop fix:
- video.volume=0 (not muted) to avoid autoplay block
- video.ended event to stop recorder (NOT setTimeout with duration)
- 200ms timeslice for frequent data collection
- rec.requestData() flush before stop
- Parallel audioVideo element to capture audio from blob during crop

AI Title:
- Auto-generate title based on channel name + duration + timestamp
- User can edit title manually before uploading
- "AI tạo tiêu đề" button in post-record panel

Short AI Upload:
- Video cropped with correct ratio uploaded directly to /api/wall
- Title from input field (or AI-generated) sent with upload
- Success: "Đã tạo Short AI! Xem trong mục Tường AI."
- Fallback: download to device if upload fails

Files changed (1) hide show
  1. static/yt_live.js +128 -51
static/yt_live.js CHANGED
@@ -1,7 +1,6 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v5 ===
2
- // Features: PiP, mini-player, INLINE RECORDER with ratio crop + upload
3
- // No separate recorder page everything inline on the VTV player
4
- // FIX v5: processRatio uses video.ended event, parallel audio video, 200ms timeslice
5
 
6
  (function(){
7
  if(window._ytLiveLoaded) return;
@@ -26,7 +25,6 @@
26
  active: false, startTime: null, endTime: null,
27
  isRecording: false, recorder: null, chunks: [], blob: null,
28
  ratio: 'original', _dragMarker: null, _recTimer: null,
29
- rawStream: null,
30
  };
31
 
32
  // ===== STYLES =====
@@ -124,6 +122,12 @@
124
  .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
125
  .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
126
  .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
 
 
 
 
 
 
127
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
128
  .vtv-mini.show{display:block}
129
  .vtv-mini.hidden{transform:translateY(-88%)}
@@ -144,6 +148,7 @@
144
 
145
  // ===== HELPERS =====
146
  function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
 
147
 
148
  function recStatus(msg, type) {
149
  const el = document.getElementById('vtv-rec-status');
@@ -158,37 +163,73 @@
158
  }
159
 
160
  function showProc(text) {
161
- document.getElementById('vtv-rec-proc').style.display = 'block';
162
- document.getElementById('vtv-rec-proc-text').textContent = text;
163
  }
164
  function hideProc() {
165
- document.getElementById('vtv-rec-proc').style.display = 'none';
 
166
  }
167
 
168
- function uploadBlob(blob) {
 
169
  const formData = new FormData();
170
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
171
- formData.append('title', `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
172
- formData.append('source', 'vtv_inline_recorder');
173
- showProc('⏳ Đang upload...');
174
- fetch('/api/wall', { method: 'POST', body: formData })
175
  .then(r => r.json())
176
  .then(data => {
177
  hideProc();
178
  if (data.ok) {
179
- recStatus('✅ Đã tạo Short AI! Xem trong Tường AI.', 'ok');
 
180
  } else {
181
- recStatus('⚠️ ' + (data.error || 'Upload lỗi') + '. Đang tải về...', 'err');
182
- setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
183
  }
184
  })
185
  .catch(e => {
186
  hideProc();
187
- recStatus('⚠️ Mất kết nối. Đang tải về...', 'err');
188
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
 
189
  });
190
  }
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  // ===== BUILD INLINE RECORDER =====
193
  function buildInlineRecorder() {
194
  const controls = document.querySelector('.vtv-controls');
@@ -235,6 +276,10 @@
235
  <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
236
  <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
237
  </div>
 
 
 
 
238
  <div class="rec-ratio-row">
239
  <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
240
  <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
@@ -262,14 +307,8 @@
262
  }
263
 
264
  // Global drag
265
- document.addEventListener('mousemove', function(e) {
266
- if (!_rec._dragMarker) return;
267
- dragMarker(e.clientX);
268
- });
269
- document.addEventListener('touchmove', function(e) {
270
- if (!_rec._dragMarker) return;
271
- dragMarker(e.touches[0].clientX);
272
- }, {passive:false});
273
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
274
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
275
 
@@ -376,6 +415,7 @@
376
  document.getElementById('vtv-rec-set-end').disabled = true;
377
  document.getElementById('vtv-rec-go').disabled = true;
378
  document.getElementById('vtv-rec-panel').classList.remove('show');
 
379
  recStatus('Đã reset.', '');
380
  const btn = document.getElementById('vtv-rec-btn');
381
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
@@ -386,7 +426,7 @@
386
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
387
  };
388
 
389
- // ===== RECORD — captureStream with audio =====
390
  window._vtvRecGo = function() {
391
  if (_rec.startTime === null || _rec.endTime === null) return;
392
  if (_rec.isRecording) {
@@ -404,27 +444,34 @@
404
  _rec.chunks = [];
405
  _rec.isRecording = true;
406
 
 
407
  const wasMuted = video.muted;
408
  video.muted = false;
 
 
 
409
 
410
  let stream;
411
  try {
412
  stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
413
  if (!stream) throw new Error('captureStream not supported');
414
  } catch(e) {
415
- video.muted = wasMuted;
416
  recStatus('❌ ' + e.message, 'err');
417
  _rec.isRecording = false;
418
  return;
419
  }
420
 
 
 
 
421
  let mimeType = 'video/webm;codecs=vp9,opus';
422
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
423
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
424
 
425
  try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
426
  catch(e) {
427
- video.muted = wasMuted;
428
  recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
429
  _rec.isRecording = false;
430
  return;
@@ -434,6 +481,7 @@
434
  _rec.recorder.onstop = function() {
435
  _rec.isRecording = false;
436
  video.muted = wasMuted;
 
437
  _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
438
  const btn = document.getElementById('vtv-rec-btn');
439
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
@@ -442,20 +490,21 @@
442
  pv.src = URL.createObjectURL(_rec.blob);
443
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
444
  const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
445
- recStatus('✅ Record xong! ' + sizeMB + 'MB', 'ok');
 
446
  };
447
  _rec.recorder.onerror = e => {
448
  _rec.isRecording = false;
449
- video.muted = wasMuted;
450
  recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
451
  };
452
 
453
- _rec.recorder.start(1000);
454
  video.currentTime = _rec.startTime;
455
 
456
  const btn = document.getElementById('vtv-rec-btn');
457
  if (btn) { btn.classList.add('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng'; }
458
- recStatus('🔴 ĐANG RECORD...', 'recording');
459
 
460
  const dur = (_rec.endTime - _rec.startTime) * 1000;
461
  _rec._recTimer = setTimeout(() => {
@@ -463,37 +512,65 @@
463
  }, dur + 500);
464
  };
465
 
466
- // ===== DOWNLOAD & SHARE =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  window._vtvRecDownload = function() {
468
  if (!_rec.blob) return;
 
469
  if (_rec.ratio === 'original') {
470
  downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
471
  } else {
472
- processRatio(_rec.blob, _rec.ratio, 'download');
473
  }
474
  };
475
 
 
476
  window._vtvRecShare = function() {
477
  if (!_rec.blob) return;
 
478
  if (_rec.ratio !== 'original') {
479
- processRatio(_rec.blob, _rec.ratio, 'upload');
480
  } else {
481
- uploadBlob(_rec.blob);
482
  }
483
  };
484
 
485
- // ===== RATIO CROP — v5: reliable render using video.ended event =====
486
- // Root causes of previous failures:
487
- // 1. audioCtx.decodeAudioData() cannot decode WebM container → audio always null
488
- // 2. video.duration from captureStream blob = Infinity → setTimeout(NaN) fires immediately → empty chunks
489
- // 3. rec.start(1000) with 1s timeslice + early stop = no data collected before stop
490
- // Fix: use video.ended event (NOT setTimeout), volume=0 (NOT muted), 200ms timeslice, requestData() flush
491
- function processRatio(blob, ratio, action) {
 
492
  showProc(`🔄 Đang xử lý ${ratio}...`);
493
 
494
  const video = document.createElement('video');
495
  video.src = URL.createObjectURL(blob);
496
- video.volume = 0; // silent but NOT muted (avoids autoplay issues)
497
  video.preload = 'auto';
498
  video.playsInline = true;
499
 
@@ -523,7 +600,7 @@
523
  const ctx = canvas.getContext('2d');
524
  const canvasStream = canvas.captureStream(30);
525
 
526
- // Parallel video element to capture audio from the same blob
527
  let recordStream = canvasStream;
528
  const audioVideo = document.createElement('video');
529
  audioVideo.src = video.src;
@@ -534,14 +611,15 @@
534
 
535
  audioVideo.onloadedmetadata = function() {
536
  try {
537
- const aStream = audioVideo.captureStream ? audioVideo.captureStream() : null;
538
- if (aStream) {
539
- const tracks = aStream.getAudioTracks();
540
  if (tracks.length > 0) {
541
  recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
 
542
  }
543
  }
544
- } catch(e) { console.warn('Audio capture failed:', e); }
545
  };
546
 
547
  const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
@@ -565,12 +643,11 @@
565
  if (action === 'download') {
566
  downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
567
  } else {
568
- uploadBlob(out);
569
  }
570
  };
571
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(video.src); };
572
 
573
- // Small timeslice for frequent data collection
574
  rec.start(200);
575
 
576
  audioVideo.play().catch(() => {});
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v6 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
 
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
25
  active: false, startTime: null, endTime: null,
26
  isRecording: false, recorder: null, chunks: [], blob: null,
27
  ratio: 'original', _dragMarker: null, _recTimer: null,
 
28
  };
29
 
30
  // ===== STYLES =====
 
122
  .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
123
  .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
124
  .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
125
+ .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
126
+ .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
127
+ .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
128
+ .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
129
+ .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
130
+ .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
131
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
132
  .vtv-mini.show{display:block}
133
  .vtv-mini.hidden{transform:translateY(-88%)}
 
148
 
149
  // ===== HELPERS =====
150
  function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
151
+ function escH(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
152
 
153
  function recStatus(msg, type) {
154
  const el = document.getElementById('vtv-rec-status');
 
163
  }
164
 
165
  function showProc(text) {
166
+ const el = document.getElementById('vtv-rec-proc');
167
+ if (el) { el.style.display = 'block'; document.getElementById('vtv-rec-proc-text').textContent = text; }
168
  }
169
  function hideProc() {
170
+ const el = document.getElementById('vtv-rec-proc');
171
+ if (el) el.style.display = 'none';
172
  }
173
 
174
+ // ===== UPLOAD TO WALL (Short AI) =====
175
+ function uploadToWall(blob, title, source) {
176
  const formData = new FormData();
177
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
178
+ formData.append('title', title || `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
179
+ formData.append('source', source || 'vtv_inline_recorder');
180
+ showProc('⏳ Đang upload lên Short AI...');
181
+ return fetch('/api/wall', { method: 'POST', body: formData })
182
  .then(r => r.json())
183
  .then(data => {
184
  hideProc();
185
  if (data.ok) {
186
+ recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
187
+ return data;
188
  } else {
189
+ throw new Error(data.error || 'Upload lỗi');
 
190
  }
191
  })
192
  .catch(e => {
193
  hideProc();
194
+ recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
195
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
196
+ throw e;
197
  });
198
  }
199
 
200
+ // ===== AI TITLE GENERATION =====
201
+ async function generateAITitle(blob, channelName) {
202
+ // Get video info for AI context
203
+ return new Promise((resolve) => {
204
+ const video = document.createElement('video');
205
+ video.src = URL.createObjectURL(blob);
206
+ video.muted = true;
207
+ video.onloadedmetadata = function() {
208
+ URL.revokeObjectURL(video.src);
209
+ const dur = Math.round(video.duration || 0);
210
+ const w = video.videoWidth, h = video.videoHeight;
211
+ // Generate title based on channel + duration + timestamp
212
+ const now = new Date();
213
+ const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
214
+ const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
215
+ const titles = [
216
+ `🔴 LIVE ${channelName} - ${timeStr} ${dateStr}`,
217
+ `📺 ${channelName} - Video ngắn ${dur}s`,
218
+ `🎬 ${channelName} Record - ${timeStr}`,
219
+ `⚡ ${channelName} Highlights - ${dateStr}`,
220
+ `📱 ${channelName} Short - ${timeStr} ${dateStr}`,
221
+ ];
222
+ // Pick one based on duration
223
+ const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
224
+ resolve(titles[idx % titles.length]);
225
+ };
226
+ video.onerror = function() {
227
+ URL.revokeObjectURL(video.src);
228
+ resolve(`📺 Record ${channelName} - ${new Date().toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'})}`);
229
+ };
230
+ });
231
+ }
232
+
233
  // ===== BUILD INLINE RECORDER =====
234
  function buildInlineRecorder() {
235
  const controls = document.querySelector('.vtv-controls');
 
276
  <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
277
  <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
278
  </div>
279
+ <input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)">
280
+ <div class="rec-ai-title-row">
281
+ <button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button>
282
+ </div>
283
  <div class="rec-ratio-row">
284
  <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
285
  <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
 
307
  }
308
 
309
  // Global drag
310
+ document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
311
+ document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
 
 
 
 
 
 
312
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
313
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
314
 
 
415
  document.getElementById('vtv-rec-set-end').disabled = true;
416
  document.getElementById('vtv-rec-go').disabled = true;
417
  document.getElementById('vtv-rec-panel').classList.remove('show');
418
+ document.getElementById('vtv-rec-title-input').value = '';
419
  recStatus('Đã reset.', '');
420
  const btn = document.getElementById('vtv-rec-btn');
421
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
 
426
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
427
  };
428
 
429
+ // ===== RECORD — captureStream with audio (unmute during record) =====
430
  window._vtvRecGo = function() {
431
  if (_rec.startTime === null || _rec.endTime === null) return;
432
  if (_rec.isRecording) {
 
444
  _rec.chunks = [];
445
  _rec.isRecording = true;
446
 
447
+ // CRITICAL: Unmute video so captureStream includes audio tracks
448
  const wasMuted = video.muted;
449
  video.muted = false;
450
+ // Also set volume low so user doesn't hear double audio
451
+ const wasVolume = video.volume;
452
+ video.volume = 0.3;
453
 
454
  let stream;
455
  try {
456
  stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
457
  if (!stream) throw new Error('captureStream not supported');
458
  } catch(e) {
459
+ video.muted = wasMuted; video.volume = wasVolume;
460
  recStatus('❌ ' + e.message, 'err');
461
  _rec.isRecording = false;
462
  return;
463
  }
464
 
465
+ const audioTracks = stream.getAudioTracks();
466
+ console.log('[VTV Rec] Audio tracks:', audioTracks.length, 'Video tracks:', stream.getVideoTracks().length);
467
+
468
  let mimeType = 'video/webm;codecs=vp9,opus';
469
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
470
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
471
 
472
  try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
473
  catch(e) {
474
+ video.muted = wasMuted; video.volume = wasVolume;
475
  recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
476
  _rec.isRecording = false;
477
  return;
 
481
  _rec.recorder.onstop = function() {
482
  _rec.isRecording = false;
483
  video.muted = wasMuted;
484
+ video.volume = wasVolume;
485
  _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
486
  const btn = document.getElementById('vtv-rec-btn');
487
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
 
490
  pv.src = URL.createObjectURL(_rec.blob);
491
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
492
  const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
493
+ const hasAudio = _rec.blob.size > 50000; // rough heuristic
494
+ recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
495
  };
496
  _rec.recorder.onerror = e => {
497
  _rec.isRecording = false;
498
+ video.muted = wasMuted; video.volume = wasVolume;
499
  recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
500
  };
501
 
502
+ _rec.recorder.start(500); // 500ms timeslice
503
  video.currentTime = _rec.startTime;
504
 
505
  const btn = document.getElementById('vtv-rec-btn');
506
  if (btn) { btn.classList.add('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng'; }
507
+ recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
508
 
509
  const dur = (_rec.endTime - _rec.startTime) * 1000;
510
  _rec._recTimer = setTimeout(() => {
 
512
  }, dur + 500);
513
  };
514
 
515
+ // ===== AI TITLE =====
516
+ window._vtvRecGenAITitle = async function() {
517
+ if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
518
+ const btn = document.getElementById('vtv-rec-ai-btn');
519
+ const input = document.getElementById('vtv-rec-title-input');
520
+ if (!btn || !input) return;
521
+
522
+ btn.disabled = true;
523
+ btn.textContent = '⏳ Đang tạo...';
524
+
525
+ try {
526
+ const ch = CHANNELS.find(c => c.id === _currentCh);
527
+ const channelName = ch ? ch.name : (_currentCh || 'VTV');
528
+ const title = await generateAITitle(_rec.blob, channelName);
529
+ input.value = title;
530
+ recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
531
+ } catch(e) {
532
+ recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
533
+ } finally {
534
+ btn.disabled = false;
535
+ btn.textContent = '🤖 AI tạo tiêu đề';
536
+ }
537
+ };
538
+
539
+ // ===== DOWNLOAD =====
540
  window._vtvRecDownload = function() {
541
  if (!_rec.blob) return;
542
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
543
  if (_rec.ratio === 'original') {
544
  downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
545
  } else {
546
+ processRatio(_rec.blob, _rec.ratio, 'download', title);
547
  }
548
  };
549
 
550
+ // ===== SHARE TO SHORT AI =====
551
  window._vtvRecShare = function() {
552
  if (!_rec.blob) return;
553
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
554
  if (_rec.ratio !== 'original') {
555
+ processRatio(_rec.blob, _rec.ratio, 'upload', title);
556
  } else {
557
+ uploadToWall(_rec.blob, title);
558
  }
559
  };
560
 
561
+ // ===== RATIO CROP — v6: reliable render =====
562
+ // Key fixes:
563
+ // 1. video.volume=0 (not muted) to avoid autoplay block
564
+ // 2. video.ended event to stop (NOT setTimeout with duration)
565
+ // 3. 200ms timeslice for frequent data collection
566
+ // 4. rec.requestData() flush before stop
567
+ // 5. Parallel audioVideo element to capture audio from blob
568
+ function processRatio(blob, ratio, action, title) {
569
  showProc(`🔄 Đang xử lý ${ratio}...`);
570
 
571
  const video = document.createElement('video');
572
  video.src = URL.createObjectURL(blob);
573
+ video.volume = 0;
574
  video.preload = 'auto';
575
  video.playsInline = true;
576
 
 
600
  const ctx = canvas.getContext('2d');
601
  const canvasStream = canvas.captureStream(30);
602
 
603
+ // Parallel video to capture audio from blob
604
  let recordStream = canvasStream;
605
  const audioVideo = document.createElement('video');
606
  audioVideo.src = video.src;
 
611
 
612
  audioVideo.onloadedmetadata = function() {
613
  try {
614
+ const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
615
+ if (aStr) {
616
+ const tracks = aStr.getAudioTracks();
617
  if (tracks.length > 0) {
618
  recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
619
+ console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
620
  }
621
  }
622
+ } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
623
  };
624
 
625
  const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
 
643
  if (action === 'download') {
644
  downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
645
  } else {
646
+ uploadToWall(out, title);
647
  }
648
  };
649
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(video.src); };
650
 
 
651
  rec.start(200);
652
 
653
  audioVideo.play().catch(() => {});