Audio songs: one-folder storage, version-aware download/share, GPU-checked renders

Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
  unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
  subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
  updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).

Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
  is carried through share links and auto-selects audio + title + flag + about +
  OG/meta on open.

GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
  256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
  frequency bars; loop-filter rebuild makes generation ~25x faster.

Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-05-23 14:03:43 +03:00
1 parent 66fd78c10f
commit a4384113c2
24 files changed
+1738 -258

No files matched your search

+46 -9
View File
@@ -198,8 +198,7 @@
{{-- Description --}}
<div class="form-group">
<label class="form-label">Description</label>
<textarea name="description" id="edit-description" rows="4" class="form-textarea"
placeholder="Tell viewers about your video (Markdown supported)">{{ $video->description }}</textarea>
<x-rich-text-editor name="description" id="edit-description" :value="$video->description" placeholder="Tell viewers about your video…" />
</div>
@if(!$video->isAudioOnly())
@@ -279,9 +278,11 @@
value="{{ $track->title ?? '' }}"
placeholder="Title in this language…"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;margin-bottom:6px;">
<textarea name="track_description_updates[{{ $track->id }}]"
placeholder="Description in this language…" rows="2"
style="background:#121212;border:1px solid #333;border-radius:6px;padding:7px 10px;font-size:13px;color:#e5e5e5;outline:none;width:100%;box-sizing:border-box;resize:none;margin-bottom:6px;">{{ $track->description ?? '' }}</textarea>
<x-rich-text-editor :name="'track_description_updates[' . $track->id . ']'"
:id="'ep-track-desc-' . $track->id"
:value="$track->description ?? ''"
min-height="72px"
placeholder="Description in this language…" />
<label style="display:flex;align-items:center;gap:8px;background:#0d0d0d;border:1px solid #222;border-radius:6px;padding:8px 10px;cursor:pointer;"
onmouseenter="this.style.borderColor='#e61e1e'" onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:14px;flex-shrink:0;"></i>
@@ -542,16 +543,42 @@
}
document.getElementById('ep-slides-add-input').addEventListener('change', function() {
Array.from(this.files).forEach(file => {
epSlidesCropStart(this.files);
this.value = '';
});
// ── Slides crop queue: every added image is cropped before it enters the strip ──
let _epSlidesCropQueue = [];
function epSlidesCropStart(fileList) {
const imgs = Array.from(fileList || []).filter(f => f.type && f.type.startsWith('image/'));
if (!imgs.length) return;
_epSlidesCropQueue = imgs;
_epSlidesCropLoadNext();
}
function _epSlidesCropLoadNext() {
if (!_epSlidesCropQueue.length) { window.closeCropperModal('slides_edit_mobile'); return; }
const f = _epSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit_mobile === 'function') {
window.tcPreload_slides_edit_mobile(f);
window.openCropperModal_slides_edit_mobile();
}
}
window.epSlidesCropDone = function(file) {
if (file) {
const reader = new FileReader();
reader.onload = e => {
epSlidesData.push({ file, url: e.target.result });
epSlidesRefresh();
_epSlidesCropLoadNext();
};
reader.readAsDataURL(file);
});
this.value = '';
});
return;
}
_epSlidesCropLoadNext();
};
// Initialise strip with existing slides
epSlidesRefresh();
@@ -785,4 +812,14 @@
output-width="1280"
title="Crop Thumbnail"
/>
<x-image-cropper
id="slides_edit_mobile"
:width="448"
:height="252"
shape="square"
output-width="1280"
title="Crop Cover Slide"
result-callback="epSlidesCropDone"
/>
@endsection