Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
emoji-decoration from a song's description while preserving every
language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.
Admin pages:
- /admin/lyrics toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu extracted GPU section, encoder picker, FFmpeg path
- /admin/backup extracted users-and-settings export/import
- /admin/settings now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.
Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
and /videos/{id}?playlist={token}. Dispatched after-response so it
never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.
Player polish:
- Floating mini-player is draggable, persists its position in
localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
(channel tabs, etc.) gets re-executed after content swaps.
Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
324 lines
14 KiB
PHP
324 lines
14 KiB
PHP
@php
|
|
$id = $attributes->get('id', 'cropper_' . Str::random(6));
|
|
$width = (int) $attributes->get('width', 300);
|
|
$height = (int) $attributes->get('height', 300);
|
|
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
|
|
$folder = $attributes->get('folder', 'uploads');
|
|
$filename = $attributes->get('filename', 'image_' . time());
|
|
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
|
|
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
|
|
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
|
|
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
|
|
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
|
|
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
|
|
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
|
|
@endphp
|
|
|
|
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
|
|
so they survive SPA-nav innerHTML swaps on #main. --}}
|
|
|
|
{{-- Modal --}}
|
|
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
|
|
<div class="tc-modal">
|
|
<div class="tc-modal-header">
|
|
<span class="tc-modal-title">
|
|
<i class="bi bi-crop"></i>
|
|
{{ $title }}
|
|
</span>
|
|
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div class="tc-modal-body">
|
|
<div class="tc-file-row">
|
|
<label class="tc-file-label" for="tcInput_{{ $id }}">
|
|
<i class="bi bi-upload"></i>
|
|
Choose image
|
|
</label>
|
|
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
|
|
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
|
|
</div>
|
|
|
|
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
|
|
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
|
|
<i class="bi bi-image"></i>
|
|
<span>Upload an image to start cropping</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tc-controls">
|
|
<div class="tc-control">
|
|
<label class="tc-control-label" for="tcZoom_{{ $id }}">
|
|
<i class="bi bi-zoom-in"></i> Zoom
|
|
</label>
|
|
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
|
|
</div>
|
|
<div class="tc-control">
|
|
<label class="tc-control-label" for="tcRot_{{ $id }}">
|
|
<i class="bi bi-arrow-clockwise"></i> Rotate
|
|
</label>
|
|
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tc-modal-footer">
|
|
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
|
|
onclick="tcUploadAsIs_{{ $id }}()">
|
|
<i class="bi bi-image"></i>
|
|
Upload as-is
|
|
</button>
|
|
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
|
|
Cancel
|
|
</button>
|
|
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
|
|
onclick="tcSave_{{ $id }}()">
|
|
<i class="bi bi-check-lg"></i>
|
|
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
var cropperInst = null;
|
|
var originalFile = null;
|
|
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
|
|
var zoomMin = 0.01, zoomMax = 3;
|
|
|
|
var id = '{{ $id }}';
|
|
var vw = {{ $width }};
|
|
var vh = {{ $height }};
|
|
var shape = '{{ $shape }}';
|
|
var folder = '{{ $folder }}';
|
|
var filename = '{{ $filename }}';
|
|
var uploadUrl = '{{ route('image.upload') }}';
|
|
var updateUrl = '{{ $updateUrl }}';
|
|
var callbackFn = '{{ $callback }}';
|
|
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
|
|
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
|
|
var previewImgId = '{{ $previewImg }}';
|
|
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
|
|
|
|
var isFormMode = targetInputId !== '';
|
|
var isCallbackMode = resultCbName !== '';
|
|
|
|
// In callback mode the host function decides when to close / advance, so we
|
|
// never auto-close here — we just hand the File back and reset the button.
|
|
function deliverResult(file) {
|
|
if (typeof window[resultCbName] === 'function') window[resultCbName](file);
|
|
var sb = document.getElementById('tcSaveBtn_' + id);
|
|
var st = document.getElementById('tcSaveBtnText_' + id);
|
|
var ab = document.getElementById('tcAsIsBtn_' + id);
|
|
if (sb) sb.disabled = false;
|
|
if (ab) ab.disabled = false;
|
|
if (st) st.textContent = 'Crop & Save';
|
|
}
|
|
|
|
function getCsrf() {
|
|
var m = document.querySelector('meta[name="csrf-token"]');
|
|
return m ? m.content : '';
|
|
}
|
|
|
|
/* ── Open / close ── */
|
|
function openModal() {
|
|
document.getElementById('tcOverlay_' + id).classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
window['openCropperModal_' + id] = openModal;
|
|
|
|
window.closeCropperModal = window.closeCropperModal || function (i) {
|
|
var el = document.getElementById('tcOverlay_' + i);
|
|
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
|
|
};
|
|
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
|
|
if (e.target === this) window.closeCropperModal(id);
|
|
});
|
|
|
|
/* ── Preload a File into the cropper ── */
|
|
function preloadFile(file) {
|
|
if (!file) return;
|
|
originalFile = file;
|
|
document.getElementById('tcFileName_' + id).textContent = file.name;
|
|
var reader = new FileReader();
|
|
reader.onload = function (e) { initCropper(e.target.result); };
|
|
reader.readAsDataURL(file);
|
|
}
|
|
window['tcPreload_' + id] = preloadFile;
|
|
|
|
function initCropper(dataUrl) {
|
|
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
|
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
|
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
|
|
|
var canvas = document.getElementById('tcCanvas_' + id);
|
|
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
|
|
|
|
cropperInst = new Cropme(canvas, {
|
|
container: { width: '100%', height: 320 },
|
|
viewport: {
|
|
width: vw, height: vh,
|
|
type: shape,
|
|
border: { enable: true, width: 2, color: '#ef4444' }
|
|
},
|
|
transformOrigin: 'viewport',
|
|
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
|
|
rotation: { enable: true, slider: false }
|
|
});
|
|
cropperInst.bind({ url: dataUrl }).then(function () {
|
|
document.getElementById('tcZoom_' + id).value = 0;
|
|
document.getElementById('tcRot_' + id).value = 0;
|
|
});
|
|
}
|
|
|
|
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
|
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
|
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
|
});
|
|
|
|
/* ── Form-mode: expose open helper for external callers ── */
|
|
// Callers should invoke window['openCropperModal_' + id]() directly from their
|
|
// dropzone click/drop handlers rather than relying on event interception.
|
|
|
|
/* ── Zoom / rotate sliders ── */
|
|
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
|
if (!cropperInst || !cropperInst.properties.image) return;
|
|
var p = parseFloat(this.value) / 100;
|
|
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
|
|
var s = cropperInst.properties;
|
|
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
|
|
});
|
|
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
|
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
|
|
});
|
|
|
|
/* ── Helpers ── */
|
|
function base64ToFile(base64, name) {
|
|
var arr = base64.split(',');
|
|
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
|
|
var bin = atob(arr[1]);
|
|
var u8 = new Uint8Array(bin.length);
|
|
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
|
return new File([u8], name || 'cropped.png', { type: mime });
|
|
}
|
|
|
|
function setOnTargetInput(file) {
|
|
var el = document.getElementById(targetInputId);
|
|
if (!el) return;
|
|
try {
|
|
var dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
_intercept = true;
|
|
el.files = dt.files;
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
} finally {
|
|
_intercept = false;
|
|
}
|
|
if (previewImgId) {
|
|
var prev = document.getElementById(previewImgId);
|
|
if (prev) prev.src = URL.createObjectURL(file);
|
|
}
|
|
}
|
|
|
|
function uploadToServer(base64, onDone, onFail) {
|
|
fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
|
|
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (res) {
|
|
if (!res.success) throw new Error(res.message || 'Upload failed');
|
|
if (updateUrl) {
|
|
return fetch(updateUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
|
|
body: JSON.stringify({ path: res.path })
|
|
}).then(function () { return res; });
|
|
}
|
|
return res;
|
|
})
|
|
.then(onDone)
|
|
.catch(onFail);
|
|
}
|
|
|
|
/* ── Crop & Save ── */
|
|
window['tcSave_' + id] = function () {
|
|
if (!cropperInst) return;
|
|
var btn = document.getElementById('tcSaveBtn_' + id);
|
|
var txt = document.getElementById('tcSaveBtnText_' + id);
|
|
btn.disabled = true;
|
|
txt.textContent = 'Saving…';
|
|
|
|
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
|
|
|
cropperInst.crop(cropOpts).then(function (base64) {
|
|
if (isCallbackMode) {
|
|
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
|
deliverResult(base64ToFile(base64, cbName));
|
|
return;
|
|
}
|
|
if (isFormMode) {
|
|
var fname = originalFile ? originalFile.name : 'cropped.png';
|
|
setOnTargetInput(base64ToFile(base64, fname));
|
|
window.closeCropperModal(id);
|
|
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
|
btn.disabled = false;
|
|
txt.textContent = 'Crop & Save';
|
|
} else {
|
|
uploadToServer(base64, function (res) {
|
|
window.closeCropperModal(id);
|
|
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
|
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
|
btn.disabled = false;
|
|
txt.textContent = 'Crop & Save';
|
|
}, function (err) {
|
|
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
|
btn.disabled = false;
|
|
txt.textContent = 'Crop & Save';
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
/* ── Upload as-is ── */
|
|
window['tcUploadAsIs_' + id] = function () {
|
|
if (!originalFile) { window.closeCropperModal(id); return; }
|
|
|
|
if (isCallbackMode) {
|
|
deliverResult(originalFile);
|
|
} else if (isFormMode) {
|
|
// Put the original (un-cropped) file on the target input so the form sees it.
|
|
setOnTargetInput(originalFile);
|
|
window.closeCropperModal(id);
|
|
} else {
|
|
// Server mode: read original file as base64 and upload unchanged
|
|
var btn = document.getElementById('tcSaveBtn_' + id);
|
|
var txt = document.getElementById('tcSaveBtnText_' + id);
|
|
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
|
|
btn.disabled = true;
|
|
aisBtn.disabled = true;
|
|
txt.textContent = 'Uploading…';
|
|
|
|
var reader = new FileReader();
|
|
reader.onload = function (e) {
|
|
uploadToServer(e.target.result, function (res) {
|
|
window.closeCropperModal(id);
|
|
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
|
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
|
btn.disabled = false;
|
|
aisBtn.disabled = false;
|
|
txt.textContent = 'Crop & Save';
|
|
}, function (err) {
|
|
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
|
btn.disabled = false;
|
|
aisBtn.disabled = false;
|
|
txt.textContent = 'Crop & Save';
|
|
});
|
|
};
|
|
reader.readAsDataURL(originalFile);
|
|
}
|
|
};
|
|
})();
|
|
</script>
|