@officexapp/vidfarm-devcli 0.21.15 → 0.21.17

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.
Files changed (29) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +3 -3
  2. package/.agents/skills/{vidfarm-director → vidfarm}/SKILL.md +40 -7
  3. package/.agents/skills/{vidfarm-director → vidfarm}/references/assets-and-sourcing.md +6 -0
  4. package/.agents/skills/{vidfarm-director → vidfarm}/references/automation-and-local-dev.md +38 -6
  5. package/.agents/skills/{vidfarm-director → vidfarm}/references/core-workflows.md +2 -2
  6. package/.agents/skills/{vidfarm-director → vidfarm}/references/editor-workflows.md +29 -2
  7. package/.agents/skills/{vidfarm-director → vidfarm}/references/rest-api.md +1 -1
  8. package/SKILL.director.md +115 -17
  9. package/SKILL.md +10 -5
  10. package/demo/dist/app.js +207 -207
  11. package/dist/src/cli.js +333 -46
  12. package/dist/src/devcli/cost-mode.js +22 -7
  13. package/dist/src/devcli/doctor.js +12 -2
  14. package/dist/src/devcli/greenscreen-local.js +191 -0
  15. package/dist/src/services/clip-curation/hunt.js +5 -4
  16. package/dist/src/services/clip-curation/media-select.js +76 -30
  17. package/package.json +2 -1
  18. package/public/serve-shells/editor.html +17 -7
  19. package/public/serve-shells/library-files.html +89 -5
  20. package/public/serve-shells/library-raws.html +224 -18
  21. package/public/serve-shells/tools-clipper.html +75 -6
  22. package/public/serve-shells/tools-image.html +174 -7
  23. package/public/serve-shells/tools-video.html +104 -6
  24. /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/find-and-fork-template.md +0 -0
  25. /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/local-edit-render-approve.md +0 -0
  26. /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/onboard-a-new-director.md +0 -0
  27. /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/retheme-template.md +0 -0
  28. /package/.agents/skills/{vidfarm-director → vidfarm}/references/onboarding.md +0 -0
  29. /package/.agents/skills/{vidfarm-director → vidfarm}/references/primitives.md +0 -0
@@ -382,7 +382,10 @@
382
382
  /* chat column */
383
383
  .rk-aichat-main{flex:1;min-width:0;display:flex;flex-direction:column}
384
384
  .rk-aichat-head{display:flex;align-items:center;gap:8px;padding:14px 14px;border-bottom:1px solid var(--rk-border);flex:none}
385
- .rk-aichat-head-main{flex:1;min-width:0;display:grid;gap:1px}
385
+ .rk-aichat-head-main{flex:1;min-width:0;display:flex;align-items:center;gap:10px}
386
+ .rk-aichat-head-titles{display:grid;gap:1px;min-width:0}
387
+ .rk-aichat-watch{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--rk-text-muted);font-size:12px;font-weight:400;font-family:inherit;cursor:pointer;padding:3px 8px;border-radius:8px;white-space:nowrap;transition:color .15s,background .15s}
388
+ .rk-aichat-watch:hover{color:var(--rk-ink);background:var(--rk-n-100)}
386
389
  .rk-aichat-title{font-family:var(--rk-font-display);font-weight:800;font-size:15px;color:var(--rk-ink);letter-spacing:-.01em}
387
390
  .rk-aichat-sub{font-size:11.5px;color:var(--rk-text-muted)}
388
391
  .rk-aichat-back{flex:1;min-width:0;display:inline-flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;
@@ -1367,6 +1370,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1367
1370
  .rk-clips-dialog-note[data-tone="error"]{background:#fdecea;color:#8d3b2f}
1368
1371
  .rk-clips-dialog-note[data-tone="success"]{background:#e8f7ee;color:#1f7a44}
1369
1372
  .rk-clips-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
1373
+ /* Import Raws: file-upload dropzone (mirrors discover) + folder picker trigger. */
1374
+ .rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
1375
+ .rk-add-or{display:flex;align-items:center;gap:12px;color:var(--rk-text-faint);font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
1376
+ .rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
1377
+ .rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);padding:13px 14px;cursor:pointer;color:var(--rk-n-700);transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
1378
+ .rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
1379
+ .rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
1380
+ .rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
1381
+ .rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
1382
+ .rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
1383
+ .rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
1384
+ .rk-folderpick{display:flex;align-items:stretch;gap:8px}
1385
+ .rk-folderpick-btn{flex:1;min-width:0;display:flex;align-items:center;gap:9px;text-align:left;cursor:pointer;color:var(--rk-text-faint);font-weight:500}
1386
+ .rk-folderpick-btn:hover{border-color:var(--rk-gold-600)}
1387
+ .rk-folderpick-btn.is-picked{color:var(--rk-ink);font-weight:600}
1388
+ .rk-folderpick-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
1389
+ .rk-folderpick-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1390
+ .rk-folderpick-clear{flex:none;width:38px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-50);color:var(--rk-n-700);cursor:pointer;font-size:13px;line-height:1;transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
1391
+ .rk-folderpick-clear:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
1370
1392
  /* ── files tab: full-page mount of the shared directory-explorer component ──
1371
1393
  The component (.rk-dir + .rk-aichat-* rows) is tuned for the narrow chat
1372
1394
  drawer; on a full page we wrap it in a card and scale the search, crumbs and
@@ -1437,12 +1459,12 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1437
1459
  <div class="vf-skill-install-popover" data-skill-menu hidden>
1438
1460
  <div class="vf-skill-install-popover-kicker">Install Command</div>
1439
1461
  <div class="vf-skill-install-command">
1440
- <input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm-director" data-skill-command-input aria-label="Skill install command">
1462
+ <input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm" data-skill-command-input aria-label="Skill install command">
1441
1463
  <button class="vf-skill-install-copy" type="button" data-skill-command-copy aria-label="Copy install command" title="Copy install command">
1442
1464
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
1443
1465
  </button>
1444
1466
  </div>
1445
- <a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm-director" target="_blank" rel="noopener noreferrer">
1467
+ <a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm" target="_blank" rel="noopener noreferrer">
1446
1468
  <span class="vf-skill-install-link-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6"></path><path d="M10 14 20 4"></path><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5"></path></svg></span>
1447
1469
  <span>View GitHub</span>
1448
1470
  </a>
@@ -1532,12 +1554,30 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1532
1554
  <div class="rk-card-head">
1533
1555
  <span class="rk-eyebrow">Import source</span>
1534
1556
  <h2>Import Raws</h2>
1535
- <p class="rk-muted">Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link. We'll open the Clipper to preview it and save an exact subrange to your raws.</p>
1557
+ <p class="rk-muted">Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link — or upload a video file. We'll import it (or open the Clipper to trim a subrange) into your raws.</p>
1536
1558
  </div>
1537
1559
  <div class="rk-field">
1538
1560
  <label class="rk-label" for="rkScanUrl">Video URL</label>
1539
1561
  <input class="rk-input" id="rkScanUrl" name="url" type="url" placeholder="https://youtube.com/watch?v=… or https://…/video.mp4" autocomplete="off" />
1540
1562
  </div>
1563
+ <div class="rk-add-or"><span>or</span></div>
1564
+ <label class="rk-add-file" data-rk-scan-filelabel>
1565
+ <input type="file" accept="video/*,.mp4,.mov,.webm,.m4v" data-rk-scan-file />
1566
+ <span class="rk-add-file-ic"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg></span>
1567
+ <span class="rk-add-file-text" data-rk-scan-filetext>Upload a video<span class="rk-add-optional">MP4 / MOV / WebM, up to 100 MB</span></span>
1568
+ </label>
1569
+ <div class="rk-field">
1570
+ <label class="rk-label">Save to folder <span class="rk-hint">optional</span></label>
1571
+ <div class="rk-folderpick">
1572
+ <button type="button" class="rk-input rk-folderpick-btn" id="rkScanFolderBtn">
1573
+ <span class="rk-folderpick-ic" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg></span>
1574
+ <span class="rk-folderpick-label" id="rkScanFolderLabel">Auto-named from the video</span>
1575
+ </button>
1576
+ <button type="button" class="rk-folderpick-clear" id="rkScanFolderClear" aria-label="Clear folder" hidden>✕</button>
1577
+ </div>
1578
+ <input type="hidden" id="rkScanFolder" name="folder_path" value="" />
1579
+ <span class="rk-hint">Choose an existing Raws folder or create one. Blank = auto-named from the video.</span>
1580
+ </div>
1541
1581
  <div class="rk-field">
1542
1582
  <label class="rk-label" for="rkScanTracer">Tracer <span class="rk-hint">optional</span></label>
1543
1583
  <input class="rk-input" id="rkScanTracer" name="tracer" type="text" placeholder="my-campaign-001" autocomplete="off" />
@@ -1649,8 +1689,13 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1649
1689
  <header class="rk-aichat-head">
1650
1690
  <span class="rk-brand-mark">V</span>
1651
1691
  <div class="rk-aichat-head-main">
1652
- <div class="rk-aichat-title">VidFarm AI</div>
1653
- <div class="rk-aichat-sub">on your own AI keys</div>
1692
+ <div class="rk-aichat-head-titles">
1693
+ <div class="rk-aichat-title">VidFarm AI</div>
1694
+ <div class="rk-aichat-sub">on your own AI keys</div>
1695
+ </div>
1696
+ <button class="rk-aichat-watch" id="rkWatchPoppanel" type="button" title="Watch Tutorial" aria-label="Watch Tutorial">
1697
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>
1698
+ </button>
1654
1699
  </div>
1655
1700
  <button class="rk-aichat-tool" id="rkHistoryToggle" type="button" title="Chat history" aria-label="Toggle chat history" aria-pressed="false">
1656
1701
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>
@@ -2201,6 +2246,19 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2201
2246
  });
2202
2247
  // Copy a raw's canonical route path — handy to paste into the /editor Insert
2203
2248
  // Media panel. Works from the visible chip AND the kebab "Copy path" item.
2249
+ // NOTE: this raws panel is its own IIFE — it can't see the files-panel copyText,
2250
+ // so define a local one here (falls back to execCommand if the async API rejects,
2251
+ // e.g. when the document isn't focused).
2252
+ function copyText(txt,ok){
2253
+ function fb(){ try{ var ta=document.createElement('textarea'); ta.value=txt; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }catch(e){} }
2254
+ try{
2255
+ if(navigator.clipboard&&navigator.clipboard.writeText){
2256
+ navigator.clipboard.writeText(txt).then(function(){ if(ok)ok(); },function(){ fb(); if(ok)ok(); });
2257
+ return;
2258
+ }
2259
+ }catch(e){}
2260
+ fb(); if(ok)ok();
2261
+ }
2204
2262
  document.addEventListener('click',function(e){
2205
2263
  var cp=e.target&&e.target.closest?e.target.closest('[data-raw-copy-path]'):null;
2206
2264
  if(!cp) return;
@@ -2410,7 +2468,84 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2410
2468
  var scanPrompt=root.getElementById('rkScanPrompt');
2411
2469
  var scanProvider=root.getElementById('rkScanProvider');
2412
2470
  var scanTracer=root.getElementById('rkScanTracer');
2471
+ var scanFolder=root.getElementById('rkScanFolder');
2472
+ var scanFolderBtn=root.getElementById('rkScanFolderBtn');
2473
+ var scanFolderLabel=root.getElementById('rkScanFolderLabel');
2474
+ var scanFolderClear=root.getElementById('rkScanFolderClear');
2475
+ var scanFile=root.querySelector('[data-rk-scan-file]');
2476
+ var scanFileLabel=root.querySelector('[data-rk-scan-filelabel]');
2477
+ var scanFileText=root.querySelector('[data-rk-scan-filetext]');
2478
+ var defaultScanFileText=scanFileText?scanFileText.innerHTML:'';
2479
+ var MAX_TEMP_UPLOAD=100*1024*1024;
2413
2480
  var scanNote=root.getElementById('rkScanNote');
2481
+ function selectedScanFile(){ return scanFile&&scanFile.files&&scanFile.files[0]?scanFile.files[0]:null; }
2482
+ function syncScanFileUi(){
2483
+ var file=selectedScanFile();
2484
+ if(scanFileLabel)scanFileLabel.classList.toggle('is-picked',!!file);
2485
+ if(scanFileText)scanFileText.innerHTML=file?esc(file.name):defaultScanFileText;
2486
+ }
2487
+ function resetScanUpload(){ if(scanFile)scanFile.value=''; syncScanFileUi(); }
2488
+ // Presign → upload → finalize a picked video into the user's temp store, then
2489
+ // hand /raws/scan its temp_file_id (which the route resolves to an S3 key).
2490
+ function uploadScanTempFile(file){
2491
+ return fetch('/api/v1/user/me/temporary-files/presign',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json',Accept:'application/json'},
2492
+ body:JSON.stringify({ file_name:file.name, content_type:file.type||null, size_bytes:file.size, folder_path:'raws/_inputs' })})
2493
+ .then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
2494
+ .then(function(res){
2495
+ if(!res.ok||!res.data||!res.data.transport) throw new Error((res.data&&res.data.error)||'Unable to prepare upload.');
2496
+ var p=res.data;
2497
+ if(p.transport==='server'){
2498
+ var form=new FormData();
2499
+ form.append((p.upload&&p.upload.form_field)||'file',file,file.name);
2500
+ form.append('folder_path','raws/_inputs');
2501
+ return fetch((p.upload&&p.upload.url)||'/api/v1/user/me/temporary-files/upload',{method:(p.upload&&p.upload.method)||'POST',credentials:'same-origin',body:form})
2502
+ .then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
2503
+ .then(function(done){ var tf=done&&done.data&&done.data.file; var id=tf&&(tf.id||tf.fileId||tf.file_id); if(!done.ok||!id) throw new Error((done.data&&done.data.error)||'Unable to upload video.'); return id; });
2504
+ }
2505
+ return fetch(p.upload.url,{method:p.upload.method,headers:p.upload.headers,body:file})
2506
+ .then(function(r){ if(!r.ok) throw new Error('Upload failed with status '+r.status+'.');
2507
+ return fetch('/api/v1/user/me/temporary-files',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json',Accept:'application/json'},
2508
+ body:JSON.stringify({ file_id:p.file_id, file_name:p.file_name, content_type:p.content_type, size_bytes:file.size, storage_key:p.storage_key, folder_path:p.folder_path||'raws/_inputs' })}); })
2509
+ .then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
2510
+ .then(function(done){ var tf=done&&done.data&&done.data.file; var id=tf&&(tf.id||tf.fileId||tf.file_id); if(!done.ok||!id) throw new Error((done.data&&done.data.error)||'Unable to save uploaded video.'); return id; });
2511
+ });
2512
+ }
2513
+ // "Save to folder" uses the shared file-directory pop-panel (window.__vidfarmDirectory)
2514
+ // scoped to the Raws root — the same picker as "Add to folder…", with create-new-folder
2515
+ // inside. The chosen folder's relative folderPath is stashed on the hidden #rkScanFolder
2516
+ // input (name=folder_path); its full /raws/… path shows on the trigger button.
2517
+ function setScanFolder(folderPath, displayPath){
2518
+ if(scanFolder)scanFolder.value=folderPath||'';
2519
+ if(scanFolderLabel)scanFolderLabel.textContent=folderPath?(displayPath||('/raws/'+folderPath)):'Auto-named from the video';
2520
+ if(scanFolderBtn)scanFolderBtn.classList.toggle('is-picked',!!folderPath);
2521
+ if(scanFolderClear)scanFolderClear.hidden=!folderPath;
2522
+ }
2523
+ function openScanFolderPicker(){
2524
+ var dir=window.__vidfarmDirectory;
2525
+ if(!dir||typeof dir.pick!=='function'){
2526
+ // Explorer not mounted — degrade to a prompt so the field still works.
2527
+ var typed=window.prompt('Save these raws to which folder? (leave blank to auto-name)', (scanFolder&&scanFolder.value)||'');
2528
+ if(typed!=null)setScanFolder((typed||'').trim().replace(/^\/+|\/+$/g,''));
2529
+ return;
2530
+ }
2531
+ // The Import dialog is a top-layer <dialog> (showModal); the picker overlay is
2532
+ // z-index only, so it would render BEHIND the dialog. Close the dialog while
2533
+ // picking, then reopen it — the form's inputs persist across close/reopen.
2534
+ var reopen=function(){ try{ if(dialog&&typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }catch(e){} };
2535
+ try{ if(dialog&&dialog.open)dialog.close(); }catch(e){}
2536
+ dir.pick({
2537
+ select:'folder',
2538
+ roots:['raws'],
2539
+ initialPath:(scanFolder&&scanFolder.value)?('/raws/'+scanFolder.value):'/raws',
2540
+ title:'Save raws to folder',
2541
+ hint:'Pick a Raws folder (or create a new one), then choose “Save here”.',
2542
+ confirmLabel:'Save here',
2543
+ onPick:function(folder){ setScanFolder((folder&&folder.folderPath)||'', folder&&folder.path); reopen(); },
2544
+ onCancel:reopen
2545
+ });
2546
+ }
2547
+ if(scanFolderBtn)scanFolderBtn.addEventListener('click',openScanFolderPicker);
2548
+ if(scanFolderClear)scanFolderClear.addEventListener('click',function(){ setScanFolder(''); });
2414
2549
  var scanSubmit=root.getElementById('rkClipsScanSubmit');
2415
2550
  var scanClip=root.getElementById('rkScanClip');
2416
2551
  var scanAudioOnly=root.getElementById('rkScanAudioOnly');
@@ -2455,18 +2590,21 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2455
2590
  .then(function(r){ return r.ok?r.json():null; })
2456
2591
  .then(function(d){ if(d){ SCAN_OPTS=d; applyScanOpts(); } }).catch(function(){});
2457
2592
  }
2593
+ var FILE_IMPORT_NOTE='Uploaded videos import straight into your raws library. Turn on Clip raws with AI to auto-mine matching moments instead.';
2458
2594
  function syncClipMode(){
2459
2595
  var on=!!(scanClip&&scanClip.checked);
2596
+ var file=selectedScanFile();
2460
2597
  if(scanAdvanced)scanAdvanced.hidden=!on;
2461
- if(scanSubmit)scanSubmit.innerHTML=(on?'Start scan':'Open clipper')+' <span class="rk-arrow">→</span>';
2598
+ if(scanSubmit)scanSubmit.innerHTML=(on?'Start scan':(file?'Import video':'Open clipper'))+' <span class="rk-arrow">→</span>';
2462
2599
  if(on&&!clipReady()){
2463
2600
  setNote('No AI provider available — add a Gemini, OpenAI, or OpenRouter key in Settings to clip raws (or uncheck to import the whole video).','error');
2464
2601
  } else {
2465
- setNote(on?CLIP_NOTE:IMPORT_NOTE);
2602
+ setNote(on?CLIP_NOTE:(file?FILE_IMPORT_NOTE:IMPORT_NOTE));
2466
2603
  }
2467
2604
  }
2468
2605
  if(scanProvider)scanProvider.addEventListener('change',function(){ scanProviderTouched=true; });
2469
2606
  if(scanClip)scanClip.addEventListener('change',syncClipMode);
2607
+ if(scanFile)scanFile.addEventListener('change',function(){ syncScanFileUi(); syncClipMode(); });
2470
2608
  if(dialog){ openBtns.forEach(function(openBtn){ openBtn.addEventListener('click',function(){ if(!SCAN_OPTS)loadScanOpts(); syncClipMode(); if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }); }); }
2471
2609
  if(cancelBtn&&dialog){ cancelBtn.addEventListener('click',function(){ if(dialog.open)dialog.close(); }); }
2472
2610
  if(dialog){
@@ -2479,33 +2617,43 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2479
2617
  scanForm.addEventListener('submit',function(e){
2480
2618
  e.preventDefault();
2481
2619
  var url=(scanUrl&&scanUrl.value||'').trim();
2620
+ var file=selectedScanFile();
2482
2621
  var tracer=(scanTracer&&scanTracer.value||'').trim();
2622
+ var folder=(scanFolder&&scanFolder.value||'').trim();
2483
2623
  var clip=!!(scanClip&&scanClip.checked);
2484
2624
  var prompt=clip?(scanPrompt&&scanPrompt.value||'').trim():'';
2485
2625
  var provider=clip?(scanProvider&&scanProvider.value||'').trim():'';
2486
2626
  var audioOnly=clip&&!!(scanAudioOnly&&scanAudioOnly.checked);
2487
- if(!url){ setNote('Enter a video URL.','error'); return; }
2488
- // Whole-video (non-AI) path now opens the Clipper so the user can pick an
2489
- // exact subrange (or save the whole thing) with a live video preview.
2490
- if(!clip){
2627
+ if(!file&&!url){ setNote('Enter a video URL or upload a video file.','error'); return; }
2628
+ if(file&&file.size>MAX_TEMP_UPLOAD){ setNote('Video uploads can be at most 100 MB.','error'); return; }
2629
+ // Whole-video (non-AI) URL path opens the Clipper so the user can pick an
2630
+ // exact subrange (or save the whole thing) with a live video preview. An
2631
+ // uploaded FILE has no previewable URL, so it imports directly instead.
2632
+ if(url&&!file&&!clip){
2491
2633
  var clp=new URL('/tools/clipper', window.location.origin);
2492
2634
  clp.searchParams.set('source_url', url);
2493
2635
  if(tracer) clp.searchParams.set('tracer', tracer);
2636
+ if(folder) clp.searchParams.set('folder_path', folder);
2494
2637
  window.location.href=clp.pathname+clp.search;
2495
2638
  return;
2496
2639
  }
2497
- setNote('Starting scan…');
2640
+ setNote(file?'Uploading…':'Starting scan…');
2498
2641
  if(scanSubmit){ scanSubmit.disabled=true; scanSubmit.classList.add('is-loading'); }
2499
2642
 
2500
- var payload={source_url:url};
2643
+ var payload={};
2644
+ if(url&&!file)payload.source_url=url;
2501
2645
  if(clip){ payload.prompt=prompt; if(provider)payload.provider=provider; if(audioOnly)payload.audio_only=true; } else { payload.import_only=true; }
2502
2646
  if(tracer)payload.tracer=tracer;
2647
+ if(folder)payload.folder_path=folder;
2503
2648
 
2504
2649
  // Fire the request now; hold it and only wire the result AFTER the modal
2505
2650
  // closes and the optimistic card lands (so a fast reply can't race ahead
2506
- // of its own placeholder).
2651
+ // of its own placeholder). An uploaded file is presigned+staged first, then
2652
+ // its temp_file_id rides the same /raws/scan body.
2507
2653
  var id='imp'+Date.now()+'-'+Math.floor(Math.random()*1e6);
2508
- var req=fetch('/raws/scan',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify(payload)})
2654
+ var prep=file?uploadScanTempFile(file).then(function(tempFileId){ payload.temp_file_id=tempFileId; }):Promise.resolve();
2655
+ var req=prep
2656
+ .then(function(){ return fetch('/raws/scan',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify(payload)}); })
2509
2657
  .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); });
2510
2658
 
2511
2659
  // brief spinner beat, then close and optimistically drop an "importing…" card
@@ -2513,12 +2661,13 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2513
2661
  if(scanSubmit){ scanSubmit.disabled=false; scanSubmit.classList.remove('is-loading'); }
2514
2662
  if(dialog&&dialog.open)dialog.close();
2515
2663
  if(scanForm.reset)scanForm.reset();
2664
+ resetScanUpload();
2516
2665
  syncClipMode();
2517
- PENDING.unshift({id:id,url:url,tracer:tracer,clip:clip,label:clip?'Starting scan…':'Importing…'});
2666
+ PENDING.unshift({id:id,url:url||(file&&file.name)||'',tracer:tracer,clip:clip,label:clip?'Starting scan…':(file?'Uploading…':'Importing…')});
2518
2667
  if(query)query.value='';
2519
2668
  paintRoot(); // surface the optimistic "importing…" card at the directory root
2520
2669
  if(statusEl)statusEl.textContent=(clip?'Mining a source…':'Importing a video…')+(tracer?' (tracer '+tracer+')':'');
2521
- req.then(function(res){ onScanResult(id,clip,res); }).catch(function(){ failImport(id,'Network error — please try again.'); });
2670
+ req.then(function(res){ onScanResult(id,clip,res); }).catch(function(){ failImport(id,'Upload or scan failed — please try again.'); });
2522
2671
  },650);
2523
2672
  });
2524
2673
  }
@@ -4461,6 +4610,63 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
4461
4610
  document.querySelectorAll("[data-skill-menu]").forEach((panel) => { panel.hidden = true; });
4462
4611
  });
4463
4612
  })();</script>
4613
+ <script>window.__vidfarmTutorials=[["/discover/templates/categories","/assets/blog/tutorial-videos/page-categories.mp4"],["/discover/templates/swipe","/assets/blog/tutorial-videos/page-swipe.mp4"],["/discover","/assets/blog/tutorial-videos/page-discover.mp4"],["/tools/image","/assets/blog/tutorial-videos/tools-image.mp4"],["/tools/video","/assets/blog/tutorial-videos/tools-video.mp4"],["/tools/clipper","/assets/blog/tutorial-videos/tools-clipper.mp4"],["/chat","/assets/blog/tutorial-videos/page-chat.mp4"],["/calendar","/assets/blog/tutorial-videos/page-calendar.mp4"],["/settings/profile","/assets/blog/tutorial-videos/page-profile.mp4"],["/settings/wallet","/assets/blog/tutorial-videos/page-wallet.mp4"],["/settings/channels","/assets/blog/tutorial-videos/page-channels.mp4"],["/settings/developer","/assets/blog/tutorial-videos/page-developer.mp4"],["/library","/assets/blog/tutorial-videos/page-library.mp4"]];window.__vfPoppanelVideo="/assets/blog/tutorial-videos/page-poppanel.mp4";</script>
4614
+ <script>(() => {
4615
+ try {
4616
+ function findTitle() {
4617
+ var hs = document.querySelectorAll('.rk-content h1');
4618
+ for (var i = 0; i < hs.length; i++) { var t = (hs[i].textContent || '').trim(); if (t && t.toLowerCase() !== 'vidfarm') return hs[i]; }
4619
+ return null;
4620
+ }
4621
+ function openModal(url) {
4622
+ if (document.getElementById('vf-tut-modal')) return;
4623
+ var ov = document.createElement('div');
4624
+ ov.id = 'vf-tut-modal';
4625
+ ov.style.cssText = 'position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;padding:24px;';
4626
+ var box = document.createElement('div');
4627
+ box.style.cssText = 'position:relative;width:min(900px,92vw);';
4628
+ var vid = document.createElement('video');
4629
+ vid.src = url; vid.controls = true; vid.autoplay = true; vid.playsInline = true;
4630
+ vid.style.cssText = 'width:100%;max-height:86vh;border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,.5);background:#000;display:block;';
4631
+ var x = document.createElement('button');
4632
+ x.type = 'button'; x.setAttribute('aria-label', 'Close'); x.innerHTML = '&times;';
4633
+ x.style.cssText = 'position:absolute;top:-16px;right:-16px;width:40px;height:40px;border-radius:50%;border:0;background:#fff;color:#111;font-size:26px;line-height:1;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;';
4634
+ function close() { try { vid.pause(); } catch (e) {} ov.remove(); document.removeEventListener('keydown', onKey); }
4635
+ function onKey(e) { if (e.key === 'Escape') close(); }
4636
+ x.onclick = close;
4637
+ ov.onclick = function (e) { if (e.target === ov) close(); };
4638
+ document.addEventListener('keydown', onKey);
4639
+ box.appendChild(vid); box.appendChild(x); ov.appendChild(box); document.body.appendChild(ov);
4640
+ }
4641
+ window.__vfOpenTutorial = openModal;
4642
+ // Pop-panel (agent dock) tutorial button — present on every full-chrome page,
4643
+ // next to the "VidFarm AI" title. Retry briefly in case the dock mounts late.
4644
+ var pt = 0;
4645
+ (function wirePop() {
4646
+ var pv = document.getElementById('rkWatchPoppanel');
4647
+ if (pv && window.__vfPoppanelVideo) { pv.onclick = function () { openModal(window.__vfPoppanelVideo); }; return; }
4648
+ if (pt++ < 20) setTimeout(wirePop, 300);
4649
+ })();
4650
+ // Page-title tutorial button — only when this route has a matching video.
4651
+ var routes = window.__vidfarmTutorials || [];
4652
+ var path = location.pathname, hit = null;
4653
+ for (var i = 0; i < routes.length; i++) { if (path === routes[i][0] || path.indexOf(routes[i][0]) === 0) { hit = routes[i][1]; break; } }
4654
+ function inject() {
4655
+ if (document.getElementById('vf-watch-tut')) return true;
4656
+ var h1 = findTitle(); if (!h1) return false;
4657
+ var btn = document.createElement('button');
4658
+ btn.id = 'vf-watch-tut'; btn.type = 'button'; btn.setAttribute('aria-label', 'Watch Tutorial');
4659
+ btn.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-left:16px;padding:4px 10px;border:0;background:transparent;color:#9aa0a6;font-size:13px;font-weight:400;font-family:inherit;letter-spacing:0;cursor:pointer;vertical-align:middle;border-radius:8px;transition:color .15s,background .15s;';
4660
+ btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>';
4661
+ btn.onmouseenter = function () { btn.style.color = '#3c4043'; btn.style.background = 'rgba(0,0,0,.05)'; };
4662
+ btn.onmouseleave = function () { btn.style.color = '#9aa0a6'; btn.style.background = 'transparent'; };
4663
+ btn.onclick = function () { openModal(hit); };
4664
+ h1.appendChild(btn);
4665
+ return true;
4666
+ }
4667
+ if (hit) { var tries = 0; (function loop() { if (inject()) return; if (tries++ > 40) return; setTimeout(loop, 250); })(); }
4668
+ } catch (e) {}
4669
+ })();</script>
4464
4670
  <script type="module" src="/assets/file-directory-app.js"></script>
4465
4671
  </body>
4466
4672
  </html>
@@ -382,7 +382,10 @@
382
382
  /* chat column */
383
383
  .rk-aichat-main{flex:1;min-width:0;display:flex;flex-direction:column}
384
384
  .rk-aichat-head{display:flex;align-items:center;gap:8px;padding:14px 14px;border-bottom:1px solid var(--rk-border);flex:none}
385
- .rk-aichat-head-main{flex:1;min-width:0;display:grid;gap:1px}
385
+ .rk-aichat-head-main{flex:1;min-width:0;display:flex;align-items:center;gap:10px}
386
+ .rk-aichat-head-titles{display:grid;gap:1px;min-width:0}
387
+ .rk-aichat-watch{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--rk-text-muted);font-size:12px;font-weight:400;font-family:inherit;cursor:pointer;padding:3px 8px;border-radius:8px;white-space:nowrap;transition:color .15s,background .15s}
388
+ .rk-aichat-watch:hover{color:var(--rk-ink);background:var(--rk-n-100)}
386
389
  .rk-aichat-title{font-family:var(--rk-font-display);font-weight:800;font-size:15px;color:var(--rk-ink);letter-spacing:-.01em}
387
390
  .rk-aichat-sub{font-size:11.5px;color:var(--rk-text-muted)}
388
391
  .rk-aichat-back{flex:1;min-width:0;display:inline-flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;
@@ -1279,12 +1282,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1279
1282
  <div class="vf-skill-install-popover" data-skill-menu hidden>
1280
1283
  <div class="vf-skill-install-popover-kicker">Install Command</div>
1281
1284
  <div class="vf-skill-install-command">
1282
- <input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm-director" data-skill-command-input aria-label="Skill install command">
1285
+ <input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm" data-skill-command-input aria-label="Skill install command">
1283
1286
  <button class="vf-skill-install-copy" type="button" data-skill-command-copy aria-label="Copy install command" title="Copy install command">
1284
1287
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
1285
1288
  </button>
1286
1289
  </div>
1287
- <a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm-director" target="_blank" rel="noopener noreferrer">
1290
+ <a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm" target="_blank" rel="noopener noreferrer">
1288
1291
  <span class="vf-skill-install-link-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6"></path><path d="M10 14 20 4"></path><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5"></path></svg></span>
1289
1292
  <span>View GitHub</span>
1290
1293
  </a>
@@ -1521,8 +1524,13 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1521
1524
  <header class="rk-aichat-head">
1522
1525
  <span class="rk-brand-mark">V</span>
1523
1526
  <div class="rk-aichat-head-main">
1524
- <div class="rk-aichat-title">VidFarm AI</div>
1525
- <div class="rk-aichat-sub">on your own AI keys</div>
1527
+ <div class="rk-aichat-head-titles">
1528
+ <div class="rk-aichat-title">VidFarm AI</div>
1529
+ <div class="rk-aichat-sub">on your own AI keys</div>
1530
+ </div>
1531
+ <button class="rk-aichat-watch" id="rkWatchPoppanel" type="button" title="Watch Tutorial" aria-label="Watch Tutorial">
1532
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>
1533
+ </button>
1526
1534
  </div>
1527
1535
  <button class="rk-aichat-tool" id="rkHistoryToggle" type="button" title="Chat history" aria-label="Toggle chat history" aria-pressed="false">
1528
1536
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>
@@ -2099,10 +2107,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2099
2107
 
2100
2108
  // ── URL param preload ──
2101
2109
  function firstParam(names){ var p=new URLSearchParams(window.location.search); for(var i=0;i<names.length;i++){ var v=p.get(names[i]); if(v && v.trim()) return v.trim(); } return ""; }
2102
- function clearUrlParams(){ var u=new URL(window.location.href); ["source_url","source_video_url","video_url","url","source_video_name","video_name","file_name","tracer","tracer_id","trace"].forEach(function(k){ u.searchParams.delete(k); }); window.history.replaceState({}, "", u.toString()); }
2110
+ function clearUrlParams(){ var u=new URL(window.location.href); ["source_url","source_video_url","video_url","url","source_video_name","video_name","file_name","tracer","tracer_id","trace","folder_path","folder"].forEach(function(k){ u.searchParams.delete(k); }); window.history.replaceState({}, "", u.toString()); }
2103
2111
  function initFromUrl(){
2104
2112
  var t = firstParam(["tracer","tracer_id","trace"]);
2105
2113
  if(t){ sessionTracer=t; tracerAutoPending=false; if(tracerInput) tracerInput.value=t; if(folderInput && !folderInput.value) folderInput.value=slug(t); }
2114
+ // An explicit target folder (forwarded from the Import Raws modal) wins over
2115
+ // the tracer-derived default so the whole-video import lands where asked.
2116
+ var f = firstParam(["folder_path","folder"]);
2117
+ if(f && folderInput){ folderInput.value=slug(f); }
2106
2118
  var src = firstParam(["source_url","source_video_url","video_url","url"]);
2107
2119
  if(src){ if(urlInput) urlInput.value = src; loadSourceUrl(src, firstParam(["source_video_name","video_name","file_name"])); }
2108
2120
  if(t) loadSession();
@@ -4048,6 +4060,63 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4048
4060
  document.querySelectorAll("[data-skill-menu]").forEach((panel) => { panel.hidden = true; });
4049
4061
  });
4050
4062
  })();</script>
4063
+ <script>window.__vidfarmTutorials=[["/discover/templates/categories","/assets/blog/tutorial-videos/page-categories.mp4"],["/discover/templates/swipe","/assets/blog/tutorial-videos/page-swipe.mp4"],["/discover","/assets/blog/tutorial-videos/page-discover.mp4"],["/tools/image","/assets/blog/tutorial-videos/tools-image.mp4"],["/tools/video","/assets/blog/tutorial-videos/tools-video.mp4"],["/tools/clipper","/assets/blog/tutorial-videos/tools-clipper.mp4"],["/chat","/assets/blog/tutorial-videos/page-chat.mp4"],["/calendar","/assets/blog/tutorial-videos/page-calendar.mp4"],["/settings/profile","/assets/blog/tutorial-videos/page-profile.mp4"],["/settings/wallet","/assets/blog/tutorial-videos/page-wallet.mp4"],["/settings/channels","/assets/blog/tutorial-videos/page-channels.mp4"],["/settings/developer","/assets/blog/tutorial-videos/page-developer.mp4"],["/library","/assets/blog/tutorial-videos/page-library.mp4"]];window.__vfPoppanelVideo="/assets/blog/tutorial-videos/page-poppanel.mp4";</script>
4064
+ <script>(() => {
4065
+ try {
4066
+ function findTitle() {
4067
+ var hs = document.querySelectorAll('.rk-content h1');
4068
+ for (var i = 0; i < hs.length; i++) { var t = (hs[i].textContent || '').trim(); if (t && t.toLowerCase() !== 'vidfarm') return hs[i]; }
4069
+ return null;
4070
+ }
4071
+ function openModal(url) {
4072
+ if (document.getElementById('vf-tut-modal')) return;
4073
+ var ov = document.createElement('div');
4074
+ ov.id = 'vf-tut-modal';
4075
+ ov.style.cssText = 'position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;padding:24px;';
4076
+ var box = document.createElement('div');
4077
+ box.style.cssText = 'position:relative;width:min(900px,92vw);';
4078
+ var vid = document.createElement('video');
4079
+ vid.src = url; vid.controls = true; vid.autoplay = true; vid.playsInline = true;
4080
+ vid.style.cssText = 'width:100%;max-height:86vh;border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,.5);background:#000;display:block;';
4081
+ var x = document.createElement('button');
4082
+ x.type = 'button'; x.setAttribute('aria-label', 'Close'); x.innerHTML = '&times;';
4083
+ x.style.cssText = 'position:absolute;top:-16px;right:-16px;width:40px;height:40px;border-radius:50%;border:0;background:#fff;color:#111;font-size:26px;line-height:1;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;';
4084
+ function close() { try { vid.pause(); } catch (e) {} ov.remove(); document.removeEventListener('keydown', onKey); }
4085
+ function onKey(e) { if (e.key === 'Escape') close(); }
4086
+ x.onclick = close;
4087
+ ov.onclick = function (e) { if (e.target === ov) close(); };
4088
+ document.addEventListener('keydown', onKey);
4089
+ box.appendChild(vid); box.appendChild(x); ov.appendChild(box); document.body.appendChild(ov);
4090
+ }
4091
+ window.__vfOpenTutorial = openModal;
4092
+ // Pop-panel (agent dock) tutorial button — present on every full-chrome page,
4093
+ // next to the "VidFarm AI" title. Retry briefly in case the dock mounts late.
4094
+ var pt = 0;
4095
+ (function wirePop() {
4096
+ var pv = document.getElementById('rkWatchPoppanel');
4097
+ if (pv && window.__vfPoppanelVideo) { pv.onclick = function () { openModal(window.__vfPoppanelVideo); }; return; }
4098
+ if (pt++ < 20) setTimeout(wirePop, 300);
4099
+ })();
4100
+ // Page-title tutorial button — only when this route has a matching video.
4101
+ var routes = window.__vidfarmTutorials || [];
4102
+ var path = location.pathname, hit = null;
4103
+ for (var i = 0; i < routes.length; i++) { if (path === routes[i][0] || path.indexOf(routes[i][0]) === 0) { hit = routes[i][1]; break; } }
4104
+ function inject() {
4105
+ if (document.getElementById('vf-watch-tut')) return true;
4106
+ var h1 = findTitle(); if (!h1) return false;
4107
+ var btn = document.createElement('button');
4108
+ btn.id = 'vf-watch-tut'; btn.type = 'button'; btn.setAttribute('aria-label', 'Watch Tutorial');
4109
+ btn.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-left:16px;padding:4px 10px;border:0;background:transparent;color:#9aa0a6;font-size:13px;font-weight:400;font-family:inherit;letter-spacing:0;cursor:pointer;vertical-align:middle;border-radius:8px;transition:color .15s,background .15s;';
4110
+ btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>';
4111
+ btn.onmouseenter = function () { btn.style.color = '#3c4043'; btn.style.background = 'rgba(0,0,0,.05)'; };
4112
+ btn.onmouseleave = function () { btn.style.color = '#9aa0a6'; btn.style.background = 'transparent'; };
4113
+ btn.onclick = function () { openModal(hit); };
4114
+ h1.appendChild(btn);
4115
+ return true;
4116
+ }
4117
+ if (hit) { var tries = 0; (function loop() { if (inject()) return; if (tries++ > 40) return; setTimeout(loop, 250); })(); }
4118
+ } catch (e) {}
4119
+ })();</script>
4051
4120
  <script type="module" src="/assets/file-directory-app.js"></script>
4052
4121
  </body>
4053
4122
  </html>