| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>musubi-tuner dataset builder</title> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <style> |
| :root{ |
| --bg:#14171a; --panel:#1c2024; --panel-2:#22262b; --line:#2e343a; |
| --text:#d8dee3; --dim:#7d8891; --accent:#62c9a6; --accent-dim:#3a6b58; |
| --warn:#e0a458; --danger:#d9776a; |
| --mono:"IBM Plex Mono","SF Mono","JetBrains Mono",Menlo,Consolas,monospace; |
| } |
| * { box-sizing:border-box; } |
| body { margin:0; background:var(--bg); color:var(--text); font-family:var(--mono); font-size:13px; } |
| .app { display:grid; grid-template-columns:300px 1fr; height:100vh; } |
| @media (max-width:860px){ .app{ grid-template-columns:1fr; height:auto; } } |
| |
| |
| .sidebar { background:var(--panel); border-right:1px solid var(--line); overflow-y:auto; padding:18px 16px 40px; } |
| h1 { font-size:14px; margin:0 0 3px; letter-spacing:.02em; } |
| .subtitle { color:var(--dim); font-size:10.5px; margin:0 0 18px; line-height:1.5; } |
| .section { border:1px solid var(--line); background:var(--panel-2); border-radius:6px; padding:14px 14px; margin-bottom:14px; } |
| .section-title { font-size:10px; text-transform:uppercase; letter-spacing:.08em; color:var(--accent); margin:0 0 12px; display:flex; align-items:center; gap:8px; } |
| .section-title::after{ content:""; flex:1; height:1px; background:var(--line); } |
| label { display:block; font-size:10.5px; color:var(--dim); margin-bottom:4px; } |
| .field{ margin-bottom:10px; } |
| .row{ display:grid; grid-template-columns:1fr 1fr; gap:10px; } |
| input[type=text], input[type=number], select, textarea { |
| width:100%; background:#181b1e; border:1px solid var(--line); color:var(--text); |
| border-radius:4px; padding:6px 8px; font-family:var(--mono); font-size:12px; outline:none; |
| } |
| input:focus, select:focus, textarea:focus{ border-color:var(--accent-dim); } |
| input::placeholder{ color:#4d565f; } |
| .hint{ color:#4d565f; font-size:9.5px; margin-top:3px; line-height:1.4; } |
| .checkrow{ display:flex; align-items:center; gap:7px; margin-bottom:9px; } |
| .checkrow input{ accent-color:var(--accent); width:14px; height:14px; } |
| .checkrow label{ margin:0; color:var(--text); font-size:11.5px; } |
| |
| select{ |
| appearance:none; |
| background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%); |
| background-position:calc(100% - 14px) center, calc(100% - 9px) center; |
| background-size:5px 5px,5px 5px; background-repeat:no-repeat; padding-right:26px; |
| } |
| |
| .dataset-tab{ |
| display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:5px; |
| border:1px solid var(--line); margin-bottom:6px; cursor:pointer; font-size:11.5px; |
| } |
| .dataset-tab.active{ border-color:var(--accent-dim); background:#1a2420; } |
| .dataset-tab .badge{ font-size:9px; text-transform:uppercase; letter-spacing:.05em; padding:1px 6px; border-radius:3px; background:var(--accent); color:var(--bg); font-weight:600; } |
| .dataset-tab .badge.video{ background:#8fa8e0; } |
| .dataset-tab .name{ flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| .dataset-tab .count{ color:var(--dim); font-size:10px; } |
| .dataset-tab .del{ color:var(--dim); font-size:12px; padding:0 3px; } |
| .dataset-tab .del:hover{ color:var(--danger); } |
| |
| .add-btn{ width:100%; background:none; border:1px dashed var(--line); color:var(--dim); border-radius:5px; padding:8px; cursor:pointer; font-family:var(--mono); font-size:11.5px; margin-top:2px; } |
| .add-btn:hover{ border-color:var(--accent-dim); color:var(--accent); } |
| |
| |
| .main{ display:flex; flex-direction:column; min-height:0; } |
| .toolbar{ display:flex; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); background:var(--panel); } |
| .toolbar .title{ font-size:12px; color:var(--dim); } |
| .toolbar .title strong{ color:var(--text); } |
| .spacer{ flex:1; } |
| .tbtn{ background:var(--panel-2); border:1px solid var(--line); color:var(--text); border-radius:4px; padding:6px 12px; font-size:11px; cursor:pointer; font-family:var(--mono); } |
| .tbtn:hover{ border-color:var(--accent-dim); color:var(--accent); } |
| .tbtn.primary{ background:var(--accent); color:#0f1412; border-color:var(--accent); font-weight:600; } |
| .tbtn.primary:hover{ opacity:.9; color:#0f1412; } |
| |
| .content{ flex:1; overflow-y:auto; padding:18px; position:relative; } |
| .dropzone{ |
| border:2px dashed var(--line); border-radius:10px; min-height:260px; |
| display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; |
| color:var(--dim); text-align:center; padding:30px; |
| } |
| .dropzone.drag{ border-color:var(--accent); background:rgba(98,201,166,0.06); color:var(--accent); } |
| .dropzone .big{ font-size:13px; } |
| .dropzone .small{ font-size:10.5px; color:#4d565f; } |
| .dz-actions{ display:flex; gap:8px; margin-top:6px; } |
| |
| .grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:10px; } |
| .thumb{ |
| position:relative; aspect-ratio:1/1; border-radius:6px; overflow:hidden; cursor:pointer; |
| border:1px solid var(--line); background:#0f1214; |
| } |
| .thumb img, .thumb video{ width:100%; height:100%; object-fit:cover; display:block; } |
| .thumb .cap-flag{ |
| position:absolute; bottom:0; left:0; right:0; padding:4px 6px; font-size:9px; |
| background:linear-gradient(transparent, rgba(0,0,0,.85)); color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; |
| } |
| .thumb .no-cap{ color:#e0a458; } |
| .thumb .vidmark{ position:absolute; top:5px; right:5px; background:rgba(0,0,0,.6); color:#fff; font-size:9px; padding:1px 5px; border-radius:3px; } |
| .thumb:hover{ border-color:var(--accent-dim); } |
| |
| |
| .lightbox{ position:fixed; inset:0; background:rgba(8,9,10,.92); z-index:50; display:none; } |
| .lightbox.open{ display:flex; } |
| .lb-inner{ margin:auto; display:flex; width:92%; height:88%; gap:18px; } |
| .lb-media{ flex:1.4; display:flex; align-items:center; justify-content:center; background:#0b0d0e; border-radius:8px; overflow:hidden; border:1px solid var(--line); position:relative; } |
| .lb-media img, .lb-media video{ max-width:100%; max-height:100%; object-fit:contain; } |
| .lb-nav{ position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,.5); border:none; color:#fff; font-size:20px; width:38px; height:38px; border-radius:50%; cursor:pointer; } |
| .lb-nav.prev{ left:12px; } .lb-nav.next{ right:12px; } |
| .lb-panel{ flex:1; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:18px; display:flex; flex-direction:column; } |
| .lb-panel .fname{ font-size:11.5px; color:var(--dim); margin-bottom:10px; word-break:break-all; } |
| .lb-panel textarea{ flex:1; resize:none; font-size:13px; line-height:1.6; } |
| .lb-panel .lb-footer{ display:flex; justify-content:space-between; align-items:center; margin-top:12px; } |
| .lb-close{ position:absolute; top:16px; right:22px; background:none; border:none; color:var(--dim); font-size:26px; cursor:pointer; } |
| .lb-close:hover{ color:var(--text); } |
| .lb-count{ color:var(--dim); font-size:11px; } |
| |
| |
| .drawer{ border-top:1px solid var(--line); background:#0f1214; max-height:0; overflow:hidden; transition:max-height .18s ease; } |
| .drawer.open{ max-height:420px; } |
| .drawer-inner{ padding:16px 18px; display:flex; gap:16px; } |
| pre#output{ flex:1.4; margin:0; background:#0b0d0e; border:1px solid var(--line); border-radius:6px; padding:14px 16px; font-size:11.5px; line-height:1.6; max-height:380px; overflow:auto; white-space:pre-wrap; word-break:break-word; } |
| .c-comment{ color:#5c6670; } .c-section{ color:var(--accent); } .c-key{ color:#8fb3e0; } |
| .c-str{ color:var(--warn); } .c-num{ color:#b48ee0; } .c-bool{ color:#d9776a; } |
| .export-side{ flex:1; display:flex; flex-direction:column; gap:8px; } |
| .export-side .warn{ font-size:10.5px; color:var(--dim); background:var(--panel-2); border:1px solid var(--line); border-radius:6px; padding:10px 12px; } |
| .export-side .warn strong{ color:var(--warn); } |
| .export-note{ font-size:9.5px; color:#4d565f; line-height:1.5; } |
| |
| input[type=file]{ display:none; } |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
|
|
| <div class="sidebar"> |
| <h1>musubi-tuner dataset builder</h1> |
| <p class="subtitle">Drop images/videos in, click each one to write its caption, then export dataset_config.toml + caption files.</p> |
|
|
| <div class="section"> |
| <div class="section-title">general</div> |
| <div class="row"> |
| <div class="field"><label>resolution width</label><input type="text" id="res_w" value="960"></div> |
| <div class="field"><label>resolution height</label><input type="text" id="res_h" value="544"></div> |
| </div> |
| <div class="row"> |
| <div class="field"><label>caption extension</label><input type="text" id="caption_ext" value=".txt"></div> |
| <div class="field"><label>batch size</label><input type="text" id="batch_size" value="1"></div> |
| </div> |
| <div class="field"><label>num_repeats</label><input type="text" id="num_repeats" value="1"></div> |
| <div class="checkrow"><input type="checkbox" id="enable_bucket" checked><label for="enable_bucket">enable_bucket</label></div> |
| <div class="checkrow"><input type="checkbox" id="bucket_no_upscale"><label for="bucket_no_upscale">bucket_no_upscale</label></div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-title">datasets</div> |
| <div id="dataset_list"></div> |
| <button class="add-btn" id="add_dataset_btn">+ new dataset</button> |
| </div> |
|
|
| <div class="section" id="active_settings_section" style="display:none;"> |
| <div class="section-title">selected dataset</div> |
| <div class="field"> |
| <label>path on your machine (browsers can't see real folder paths — type it in)</label> |
| <input type="text" id="ds_path" placeholder="/path/to/folder"> |
| </div> |
| <div class="field"> |
| <label>cache_directory <span class="hint" style="display:inline">(required if using jsonl; otherwise optional)</span></label> |
| <input type="text" id="ds_cache" placeholder="/path/to/cache_dir"> |
| </div> |
| <div class="field"> |
| <label>control_directory (optional — edit/control models)</label> |
| <input type="text" id="ds_control" placeholder="leave blank if unused"> |
| </div> |
| <div class="row"> |
| <div class="field"><label>type</label> |
| <select id="ds_type"> |
| <option value="image">image</option> |
| <option value="video">video</option> |
| </select> |
| </div> |
| <div class="field"><label>num_repeats override</label><input type="text" id="ds_repeats" placeholder="inherit"></div> |
| </div> |
| <div id="video_fields" style="display:none;"> |
| <div class="field"><label>target_frames (comma-separated)</label><input type="text" id="ds_target_frames" value="1, 25, 45"></div> |
| <div class="field"><label>frame_extraction</label> |
| <select id="ds_frame_extraction"> |
| <option value="head">head</option> |
| <option value="chunk">chunk</option> |
| <option value="slide">slide</option> |
| <option value="uniform">uniform</option> |
| <option value="full">full</option> |
| </select> |
| </div> |
| <div class="field" id="ds_stride_field" style="display:none;"><label>frame_stride</label><input type="text" id="ds_frame_stride" value="10"></div> |
| <div class="field" id="ds_sample_field" style="display:none;"><label>frame_sample</label><input type="text" id="ds_frame_sample" value="4"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="main"> |
| <div class="toolbar"> |
| <span class="title" id="toolbar_title">no dataset selected</span> |
| <div class="spacer"></div> |
| <button class="tbtn" id="toggle_drawer_btn">preview & export</button> |
| </div> |
|
|
| <div class="content" id="content"> |
| <div class="dropzone" id="dropzone"> |
| <div class="big">create a dataset on the left, then drag & drop images or videos here</div> |
| <div class="small">or drop a whole folder — subfolders are scanned too. matching .txt files are picked up as existing captions.</div> |
| <div class="dz-actions"> |
| <button class="tbtn" id="choose_folder_btn">choose folder</button> |
| <button class="tbtn" id="choose_files_btn">choose files</button> |
| </div> |
| </div> |
| <div class="grid" id="grid" style="display:none;"></div> |
| </div> |
|
|
| <div class="drawer" id="drawer"> |
| <div class="drawer-inner"> |
| <pre id="output"></pre> |
| <div class="export-side"> |
| <div class="warn" id="warn_box"></div> |
| <button class="tbtn" id="copy_toml_btn">copy toml</button> |
| <button class="tbtn" id="download_toml_btn">download toml only</button> |
| <button class="tbtn primary" id="download_zip_btn">download everything (.zip)</button> |
| <button class="tbtn" id="save_disk_btn">save captions directly to folder</button> |
| <div class="export-note">"Save directly to folder" uses your browser's file system access — pick the same folder your images live in. Falls back to the zip if your browser/embed doesn't support it.</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <input type="file" id="folder_input" webkitdirectory directory multiple> |
| <input type="file" id="files_input" multiple accept="image/*,video/*"> |
|
|
| <div class="lightbox" id="lightbox"> |
| <button class="lb-close" id="lb_close">×</button> |
| <div class="lb-inner"> |
| <div class="lb-media"> |
| <button class="lb-nav prev" id="lb_prev">‹</button> |
| <div id="lb_media_slot"></div> |
| <button class="lb-nav next" id="lb_next">›</button> |
| </div> |
| <div class="lb-panel"> |
| <div class="fname" id="lb_fname"></div> |
| <textarea id="lb_caption" placeholder="type the caption for this file..."></textarea> |
| <div class="lb-footer"> |
| <span class="lb-count" id="lb_count"></span> |
| <span class="lb-count" id="lb_saved">saved</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const IMAGE_EXT = ['jpg','jpeg','png','webp','bmp','gif']; |
| const VIDEO_EXT = ['mp4','mov','webm','mkv','avi','m4v']; |
| |
| let datasets = []; |
| let idSeq = 0; |
| let activeDatasetId = null; |
| let lbIndex = null; |
| |
| function extOf(name){ const p = name.split('.'); return p.length>1 ? p.pop().toLowerCase() : ''; } |
| function baseName(name){ return name.replace(/\.[^/.]+$/, ''); } |
| function escapeHtml(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| |
| function activeDataset(){ return datasets.find(d => d.id === activeDatasetId) || null; } |
| |
| function newDataset(name){ |
| idSeq += 1; |
| return { |
| id: idSeq, name: name || ('dataset-' + idSeq), type: 'image', |
| path: '', cache: '', control: '', repeats: '', |
| target_frames: '1, 25, 45', frame_extraction: 'head', frame_stride: '10', frame_sample: '4', |
| images: [] |
| }; |
| } |
| |
| |
| function traverseEntry(entry) { |
| return new Promise((resolve) => { |
| if (entry.isFile) { |
| entry.file(file => resolve([file]), () => resolve([])); |
| } else if (entry.isDirectory) { |
| const reader = entry.createReader(); |
| let all = []; |
| function readBatch() { |
| reader.readEntries(async (entries) => { |
| if (!entries.length) { |
| const nested = await Promise.all(all.map(e => traverseEntry(e))); |
| resolve(nested.flat()); |
| return; |
| } |
| all = all.concat(entries); |
| readBatch(); |
| }, () => resolve([])); |
| } |
| readBatch(); |
| } else { |
| resolve([]); |
| } |
| }); |
| } |
| |
| async function filesFromDataTransfer(dt) { |
| const items = dt.items; |
| if (items && items.length && items[0].webkitGetAsEntry) { |
| const entries = []; |
| for (let i=0; i<items.length; i++) { |
| const entry = items[i].webkitGetAsEntry(); |
| if (entry) entries.push(entry); |
| } |
| const nested = await Promise.all(entries.map(e => traverseEntry(e))); |
| return nested.flat(); |
| } |
| return Array.from(dt.files || []); |
| } |
| |
| async function ingestFiles(fileList) { |
| let ds = activeDataset(); |
| if (!ds) { |
| ds = newDataset(); |
| datasets.push(ds); |
| activeDatasetId = ds.id; |
| } |
| const files = Array.from(fileList); |
| const captionTexts = {}; |
| const mediaFiles = []; |
| |
| await Promise.all(files.map(async f => { |
| const ext = extOf(f.name); |
| if (ext === 'txt') { |
| const text = await f.text(); |
| captionTexts[baseName(f.name)] = text; |
| } else if (IMAGE_EXT.includes(ext) || VIDEO_EXT.includes(ext)) { |
| mediaFiles.push(f); |
| } |
| })); |
| |
| let imgCount = 0, vidCount = 0; |
| mediaFiles.forEach(f => { |
| const isVideo = VIDEO_EXT.includes(extOf(f.name)); |
| if (isVideo) vidCount++; else imgCount++; |
| idSeq += 1; |
| ds.images.push({ |
| id: idSeq, |
| name: f.name, |
| file: f, |
| url: URL.createObjectURL(f), |
| caption: captionTexts[baseName(f.name)] || '', |
| isVideo |
| }); |
| }); |
| if (vidCount > imgCount) ds.type = 'video'; |
| |
| renderAll(); |
| } |
| |
| |
| function renderDatasetList() { |
| const wrap = document.getElementById('dataset_list'); |
| wrap.innerHTML = ''; |
| datasets.forEach(ds => { |
| const tab = document.createElement('div'); |
| tab.className = 'dataset-tab' + (ds.id === activeDatasetId ? ' active' : ''); |
| tab.innerHTML = ` |
| <span class="badge ${ds.type === 'video' ? 'video' : ''}">${ds.type}</span> |
| <span class="name">${escapeHtml(ds.name)}</span> |
| <span class="count">${ds.images.length}</span> |
| <span class="del" title="remove dataset">×</span> |
| `; |
| tab.addEventListener('click', (e) => { |
| if (e.target.classList.contains('del')) { |
| datasets = datasets.filter(d => d.id !== ds.id); |
| if (activeDatasetId === ds.id) activeDatasetId = datasets.length ? datasets[0].id : null; |
| renderAll(); |
| return; |
| } |
| activeDatasetId = ds.id; |
| renderAll(); |
| }); |
| wrap.appendChild(tab); |
| }); |
| } |
| |
| function renderActiveSettings() { |
| const section = document.getElementById('active_settings_section'); |
| const ds = activeDataset(); |
| document.getElementById('toolbar_title').innerHTML = ds |
| ? `<strong>${escapeHtml(ds.name)}</strong> · ${ds.images.length} file${ds.images.length===1?'':'s'}` |
| : 'no dataset selected'; |
| |
| if (!ds) { section.style.display = 'none'; return; } |
| section.style.display = 'block'; |
| |
| document.getElementById('ds_path').value = ds.path; |
| document.getElementById('ds_cache').value = ds.cache; |
| document.getElementById('ds_control').value = ds.control; |
| document.getElementById('ds_type').value = ds.type; |
| document.getElementById('ds_repeats').value = ds.repeats; |
| document.getElementById('ds_target_frames').value = ds.target_frames; |
| document.getElementById('ds_frame_extraction').value = ds.frame_extraction; |
| document.getElementById('ds_frame_stride').value = ds.frame_stride; |
| document.getElementById('ds_frame_sample').value = ds.frame_sample; |
| |
| document.getElementById('video_fields').style.display = ds.type === 'video' ? 'block' : 'none'; |
| document.getElementById('ds_stride_field').style.display = ds.frame_extraction === 'slide' ? 'block' : 'none'; |
| document.getElementById('ds_sample_field').style.display = ds.frame_extraction === 'uniform' ? 'block' : 'none'; |
| } |
| |
| function renderGrid() { |
| const ds = activeDataset(); |
| const dropzone = document.getElementById('dropzone'); |
| const grid = document.getElementById('grid'); |
| |
| if (!ds || ds.images.length === 0) { |
| dropzone.style.display = 'flex'; |
| grid.style.display = 'none'; |
| dropzone.querySelector('.big').textContent = ds |
| ? 'drag & drop images or videos here for "' + ds.name + '"' |
| : 'create a dataset on the left, then drag & drop images or videos here'; |
| return; |
| } |
| dropzone.style.display = 'none'; |
| grid.style.display = 'grid'; |
| grid.innerHTML = ''; |
| |
| ds.images.forEach((img, idx) => { |
| const cell = document.createElement('div'); |
| cell.className = 'thumb'; |
| if (img.isVideo) { |
| cell.innerHTML = `<video src="${img.url}" muted preload="metadata"></video><span class="vidmark">video</span>`; |
| } else { |
| cell.innerHTML = `<img src="${img.url}" loading="lazy">`; |
| } |
| const flag = document.createElement('div'); |
| flag.className = 'cap-flag' + (img.caption ? '' : ' no-cap'); |
| flag.textContent = img.caption ? img.caption : 'no caption yet'; |
| cell.appendChild(flag); |
| cell.addEventListener('click', () => openLightbox(idx)); |
| grid.appendChild(cell); |
| }); |
| } |
| |
| function renderAll() { |
| renderDatasetList(); |
| renderActiveSettings(); |
| renderGrid(); |
| updateToml(); |
| } |
| |
| |
| function openLightbox(idx) { |
| lbIndex = idx; |
| document.getElementById('lightbox').classList.add('open'); |
| renderLightbox(); |
| } |
| function closeLightbox() { |
| saveLightboxCaption(); |
| document.getElementById('lightbox').classList.remove('open'); |
| lbIndex = null; |
| renderGrid(); |
| updateToml(); |
| } |
| function saveLightboxCaption() { |
| const ds = activeDataset(); |
| if (!ds || lbIndex === null) return; |
| const img = ds.images[lbIndex]; |
| if (!img) return; |
| img.caption = document.getElementById('lb_caption').value; |
| } |
| function renderLightbox() { |
| const ds = activeDataset(); |
| if (!ds || lbIndex === null) return; |
| const img = ds.images[lbIndex]; |
| const slot = document.getElementById('lb_media_slot'); |
| slot.innerHTML = img.isVideo |
| ? `<video src="${img.url}" controls autoplay muted loop></video>` |
| : `<img src="${img.url}">`; |
| document.getElementById('lb_fname').textContent = img.name; |
| document.getElementById('lb_caption').value = img.caption; |
| document.getElementById('lb_count').textContent = (lbIndex+1) + ' / ' + ds.images.length; |
| } |
| function navLightbox(delta) { |
| const ds = activeDataset(); |
| if (!ds) return; |
| saveLightboxCaption(); |
| lbIndex = (lbIndex + delta + ds.images.length) % ds.images.length; |
| renderLightbox(); |
| renderGrid(); |
| } |
| |
| document.getElementById('lb_close').onclick = closeLightbox; |
| document.getElementById('lb_prev').onclick = () => navLightbox(-1); |
| document.getElementById('lb_next').onclick = () => navLightbox(1); |
| document.getElementById('lb_caption').addEventListener('input', () => { |
| document.getElementById('lb_saved').style.opacity = '0'; |
| }); |
| document.getElementById('lb_caption').addEventListener('blur', saveLightboxCaption); |
| document.addEventListener('keydown', (e) => { |
| if (!document.getElementById('lightbox').classList.contains('open')) return; |
| if (document.activeElement === document.getElementById('lb_caption')) { |
| if (e.key === 'Escape') { document.getElementById('lb_caption').blur(); } |
| return; |
| } |
| if (e.key === 'Escape') closeLightbox(); |
| if (e.key === 'ArrowLeft') navLightbox(-1); |
| if (e.key === 'ArrowRight') navLightbox(1); |
| }); |
| |
| |
| function tomlStr(v){ return '"' + String(v).replace(/\\/g,'\\\\').replace(/"/g,'\\"') + '"'; } |
| |
| function highlight(raw) { |
| return raw.split('\n').map(line => { |
| if (/^\s*#/.test(line)) return '<span class="c-comment">' + escapeHtml(line) + '</span>'; |
| if (/^\[\[?[\w.]+\]?\]$/.test(line)) return '<span class="c-section">' + escapeHtml(line) + '</span>'; |
| const kv = line.match(/^(\s*)([\w_]+)(\s*=\s*)(.+)$/); |
| if (kv) { |
| const [, indent, key, eq, val] = kv; |
| let vClass = 'c-str'; |
| if (/^-?\d+(\.\d+)?$/.test(val.trim())) vClass = 'c-num'; |
| else if (/^(true|false)$/.test(val.trim())) vClass = 'c-bool'; |
| else if (/^\[.*\]$/.test(val.trim())) vClass = 'c-num'; |
| return indent + '<span class="c-key">' + escapeHtml(key) + '</span>' + escapeHtml(eq) + '<span class="'+vClass+'">' + escapeHtml(val) + '</span>'; |
| } |
| return escapeHtml(line); |
| }).join('\n'); |
| } |
| |
| function buildToml() { |
| const resW = document.getElementById('res_w').value.trim(); |
| const resH = document.getElementById('res_h').value.trim(); |
| const captionExt = document.getElementById('caption_ext').value.trim() || '.txt'; |
| const batchSize = document.getElementById('batch_size').value.trim(); |
| const numRepeats = document.getElementById('num_repeats').value.trim(); |
| const enableBucket = document.getElementById('enable_bucket').checked; |
| const bucketNoUpscale = document.getElementById('bucket_no_upscale').checked; |
| |
| let lines = []; |
| lines.push('# general configurations — apply to all datasets unless overridden below'); |
| lines.push('[general]'); |
| if (resW && resH) lines.push(`resolution = [${resW}, ${resH}]`); |
| lines.push(`caption_extension = ${tomlStr(captionExt)}`); |
| if (batchSize) lines.push(`batch_size = ${batchSize}`); |
| if (numRepeats) lines.push(`num_repeats = ${numRepeats}`); |
| lines.push(`enable_bucket = ${enableBucket}`); |
| lines.push(`bucket_no_upscale = ${bucketNoUpscale}`); |
| lines.push(''); |
| |
| const warnings = []; |
| if (datasets.length === 0) warnings.push('No datasets yet — create one and drop in some files.'); |
| |
| datasets.forEach(ds => { |
| lines.push('[[datasets]]'); |
| if (!ds.path) warnings.push(`"${ds.name}": path is empty — fill in the actual folder path on your machine.`); |
| const dirKey = ds.type === 'video' ? 'video_directory' : 'image_directory'; |
| lines.push(`${dirKey} = ${tomlStr(ds.path || ('/path/to/' + ds.name))}`); |
| if (ds.cache) lines.push(`cache_directory = ${tomlStr(ds.cache)}`); |
| if (ds.control) lines.push(`control_directory = ${tomlStr(ds.control)}`); |
| if (ds.repeats) lines.push(`num_repeats = ${ds.repeats}`); |
| if (ds.type === 'video') { |
| const frames = ds.target_frames.split(',').map(s => s.trim()).filter(Boolean).join(', '); |
| if (frames) lines.push(`target_frames = [${frames}]`); |
| lines.push(`frame_extraction = ${tomlStr(ds.frame_extraction)}`); |
| if (ds.frame_extraction === 'slide' && ds.frame_stride) lines.push(`frame_stride = ${ds.frame_stride}`); |
| if (ds.frame_extraction === 'uniform' && ds.frame_sample) lines.push(`frame_sample = ${ds.frame_sample}`); |
| } |
| const uncaptioned = ds.images.filter(i => !i.caption).length; |
| if (uncaptioned) warnings.push(`"${ds.name}": ${uncaptioned} of ${ds.images.length} file(s) have no caption yet.`); |
| lines.push(''); |
| }); |
| |
| const caches = datasets.map(d => d.cache).filter(Boolean); |
| const seen = new Set(); |
| caches.forEach(c => { if (seen.has(c)) warnings.push(`Cache directory "${c}" is used by more than one dataset.`); seen.add(c); }); |
| |
| return { text: lines.join('\n'), warnings, captionExt }; |
| } |
| |
| function updateToml() { |
| const { text, warnings } = buildToml(); |
| document.getElementById('output').innerHTML = highlight(text); |
| const box = document.getElementById('warn_box'); |
| box.innerHTML = warnings.length |
| ? '<strong>check before running:</strong><br>' + warnings.map(w => '• ' + escapeHtml(w)).join('<br>') |
| : 'looks good — no missing paths or captions detected.'; |
| window._currentToml = text; |
| } |
| |
| |
| ['res_w','res_h','caption_ext','batch_size','num_repeats'].forEach(id => { |
| document.getElementById(id).addEventListener('input', updateToml); |
| }); |
| document.getElementById('enable_bucket').addEventListener('change', updateToml); |
| document.getElementById('bucket_no_upscale').addEventListener('change', updateToml); |
| |
| function bindDsField(id, prop, isSelect) { |
| document.getElementById(id).addEventListener(isSelect ? 'change' : 'input', (e) => { |
| const ds = activeDataset(); |
| if (!ds) return; |
| ds[prop] = e.target.value; |
| if (id === 'ds_type' || id === 'ds_frame_extraction') renderActiveSettings(); |
| if (id === 'ds_type') renderDatasetList(); |
| updateToml(); |
| }); |
| } |
| bindDsField('ds_path','path'); |
| bindDsField('ds_cache','cache'); |
| bindDsField('ds_control','control'); |
| bindDsField('ds_type','type', true); |
| bindDsField('ds_repeats','repeats'); |
| bindDsField('ds_target_frames','target_frames'); |
| bindDsField('ds_frame_extraction','frame_extraction', true); |
| bindDsField('ds_frame_stride','frame_stride'); |
| bindDsField('ds_frame_sample','frame_sample'); |
| |
| document.getElementById('add_dataset_btn').onclick = () => { |
| const ds = newDataset(); |
| datasets.push(ds); |
| activeDatasetId = ds.id; |
| renderAll(); |
| }; |
| |
| |
| const dropzone = document.getElementById('dropzone'); |
| const content = document.getElementById('content'); |
| ['dragenter','dragover'].forEach(evt => content.addEventListener(evt, (e) => { |
| e.preventDefault(); dropzone.classList.add('drag'); |
| })); |
| ['dragleave','drop'].forEach(evt => content.addEventListener(evt, (e) => { |
| e.preventDefault(); dropzone.classList.remove('drag'); |
| })); |
| content.addEventListener('drop', async (e) => { |
| const files = await filesFromDataTransfer(e.dataTransfer); |
| if (files.length) ingestFiles(files); |
| }); |
| |
| document.getElementById('choose_folder_btn').onclick = () => document.getElementById('folder_input').click(); |
| document.getElementById('choose_files_btn').onclick = () => document.getElementById('files_input').click(); |
| document.getElementById('folder_input').addEventListener('change', (e) => ingestFiles(e.target.files)); |
| document.getElementById('files_input').addEventListener('change', (e) => ingestFiles(e.target.files)); |
| |
| |
| const drawer = document.getElementById('drawer'); |
| document.getElementById('toggle_drawer_btn').onclick = () => drawer.classList.toggle('open'); |
| |
| document.getElementById('copy_toml_btn').onclick = () => { |
| navigator.clipboard.writeText(window._currentToml || '').then(() => { |
| const b = document.getElementById('copy_toml_btn'); |
| const orig = b.textContent; b.textContent = 'copied'; setTimeout(() => b.textContent = orig, 1200); |
| }); |
| }; |
| document.getElementById('download_toml_btn').onclick = () => { |
| downloadBlob(new Blob([window._currentToml || ''], {type:'text/plain'}), 'dataset_config.toml'); |
| }; |
| |
| function downloadBlob(blob, filename) { |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; a.download = filename; a.click(); |
| URL.revokeObjectURL(url); |
| } |
| |
| document.getElementById('download_zip_btn').onclick = async () => { |
| const btn = document.getElementById('download_zip_btn'); |
| const orig = btn.textContent; |
| btn.textContent = 'zipping...'; |
| const { text, captionExt } = buildToml(); |
| const zip = new JSZip(); |
| zip.file('dataset_config.toml', text); |
| datasets.forEach(ds => { |
| const folder = zip.folder(ds.name); |
| ds.images.forEach(img => { |
| if (img.caption) folder.file(baseName(img.name) + captionExt, img.caption); |
| }); |
| }); |
| const blob = await zip.generateAsync({ type: 'blob' }); |
| downloadBlob(blob, 'musubi_dataset_export.zip'); |
| btn.textContent = orig; |
| }; |
| |
| document.getElementById('save_disk_btn').onclick = async () => { |
| const btn = document.getElementById('save_disk_btn'); |
| if (!('showDirectoryPicker' in window)) { |
| alert('Direct folder saving isn\'t supported in this browser/embed. Use "download everything (.zip)" instead — unzip it next to your images.'); |
| return; |
| } |
| const { captionExt } = buildToml(); |
| for (const ds of datasets) { |
| if (!ds.images.some(i => i.caption)) continue; |
| try { |
| const dirHandle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'musubi-' + ds.id }); |
| for (const img of ds.images) { |
| if (!img.caption) continue; |
| const fileHandle = await dirHandle.getFileHandle(baseName(img.name) + captionExt, { create: true }); |
| const writable = await fileHandle.createWritable(); |
| await writable.write(img.caption); |
| await writable.close(); |
| } |
| } catch (err) { |
| if (err && err.name === 'AbortError') continue; |
| alert('Couldn\'t write directly to disk here (' + (err && err.message ? err.message : 'permission blocked') + '). Use the zip download instead.'); |
| return; |
| } |
| } |
| btn.textContent = 'saved'; |
| setTimeout(() => btn.textContent = 'save captions directly to folder', 1500); |
| }; |
| |
| renderAll(); |
| </script> |
| </body> |
| </html> |
|
|