You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
395 lines
14 KiB
395 lines
14 KiB
{% extends "portal_base.html" %} |
|
|
|
{% block title %}Video Workshop - OTB Cloud{% endblock %} |
|
|
|
{% block portal_content %} |
|
<style> |
|
#rotationSelect { |
|
background: #1e293b; |
|
color: #e5e7eb; |
|
border: 1px solid rgba(255,255,255,0.18); |
|
} |
|
#rotationSelect option { |
|
background: #1e293b; |
|
color: #e5e7eb; |
|
} |
|
|
|
.workshop-wrap { max-width: 1100px; margin: 0 auto; } |
|
.job-list { display:flex; flex-direction:column; gap:14px; margin-top:14px; } |
|
.job-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:14px; } |
|
.job-head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:8px; } |
|
.job-file { font-weight:700; word-break:break-word; } |
|
.job-head-right { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } |
|
.job-badge { display:inline-block; padding:4px 10px; border-radius:999px; font-size:0.85rem; font-weight:700; border:1px solid rgba(255,255,255,0.14); } |
|
.job-badge.queued { background:rgba(245,158,11,0.18); } |
|
.job-badge.processing { background:rgba(59,130,246,0.18); } |
|
.job-badge.complete { background:rgba(34,197,94,0.18); } |
|
.job-badge.failed { background:rgba(239,68,68,0.18); } |
|
.job-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; margin-top:8px; } |
|
.job-meta { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px; } |
|
.job-label { font-size:0.8rem; opacity:0.75; margin-bottom:4px; } |
|
.job-value { word-break:break-word; } |
|
.job-path, .job-error { margin-top:10px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.03); word-break:break-word; } |
|
.job-error { border:1px solid rgba(239,68,68,0.22); } |
|
.job-empty { padding:16px; border-radius:12px; background:rgba(255,255,255,0.04); opacity:0.85; } |
|
.actions-row { display:flex; gap:10px; flex-wrap:wrap; } |
|
.profile-checks { display:flex; gap:16px; flex-wrap:wrap; margin-top:8px; } |
|
.rotation-wrap { margin-top:12px; } |
|
|
|
.selected-box { |
|
background:rgba(255,255,255,0.04); |
|
padding:12px; |
|
border-radius:12px; |
|
overflow:auto; |
|
min-height:80px; |
|
} |
|
.staged-list { |
|
display:flex; |
|
flex-direction:column; |
|
gap:8px; |
|
} |
|
.staged-row { |
|
display:flex; |
|
align-items:center; |
|
gap:10px; |
|
padding:8px 10px; |
|
border-radius:10px; |
|
background:rgba(255,255,255,0.03); |
|
} |
|
.staged-name { |
|
word-break:break-word; |
|
} |
|
</style> |
|
|
|
<div class="workshop-wrap"> |
|
<div class="portal-page-header"> |
|
<div> |
|
<h1 class="portal-page-title">Video Workshop</h1> |
|
<p class="portal-page-subtitle">Device ID: <strong>{{ device_id }}</strong></p> |
|
</div> |
|
<div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;"> |
|
<a class="portal-btn" href="/devices/{{ device_id }}/files">Back to Device Files</a> |
|
<a class="portal-btn" href="/health">Health</a> |
|
<a class="portal-btn" href="/dashboard">Back to Dashboard</a> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card" style="margin-top:18px;"> |
|
<div class="service-card-header"> |
|
<div> |
|
<h2>Queue Video Jobs</h2> |
|
<p>Selected files from the device browser are staged here. Only checked staged files will be processed.</p> |
|
</div> |
|
<div> |
|
<span class="service-badge service-badge-beta">alpha3-l</span> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card-body" style="display:flex;flex-direction:column;gap:16px;"> |
|
<div> |
|
<label><strong>Profiles</strong></label> |
|
<div class="profile-checks"> |
|
<label><input type="checkbox" class="profile-check" value="default" checked> Default</label> |
|
<label><input type="checkbox" class="profile-check" value="compress"> Compress</label> |
|
<label><input type="checkbox" class="profile-check" value="hq"> High Quality</label> |
|
</div> |
|
</div> |
|
|
|
<div class="rotation-wrap"> |
|
<label><input type="checkbox" id="manualRotationToggle"> Manual rotation override</label> |
|
<div id="rotationSelectWrap" style="display:none;margin-top:8px;"> |
|
<select id="rotationSelect"> |
|
<option value="90">90</option> |
|
<option value="180">180</option> |
|
<option value="270">270</option> |
|
</select> |
|
</div> |
|
</div> |
|
|
|
<div class="rotation-wrap"> |
|
<label><input type="checkbox" id="mkvAcknowledge"> I understand MKV conversion is not recommended (can increase size and produce poor audio on some sources)</label> |
|
</div> |
|
|
|
<div> |
|
<strong>Staged files</strong> |
|
<div id="selected-files" class="selected-box"></div> |
|
</div> |
|
|
|
<div class="actions-row"> |
|
<button class="portal-btn primary" type="button" onclick="processWorkshop()">Process Checked</button> |
|
<button class="portal-btn" type="button" onclick="selectAllStaged(true)">Check All</button> |
|
<button class="portal-btn" type="button" onclick="selectAllStaged(false)">Uncheck All</button> |
|
<button class="portal-btn" type="button" onclick="removeCheckedStaged()">Remove Checked</button> |
|
<button class="portal-btn" type="button" onclick="loadJobs()">Refresh Jobs</button> |
|
<button class="portal-btn" type="button" onclick="clearWorkshopSelection()">Clear All Staged</button> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card" style="margin-top:18px;"> |
|
<div class="service-card-header"> |
|
<div> |
|
<h2>Jobs</h2> |
|
<p>Live queue/status feed for this tenant.</p> |
|
</div> |
|
</div> |
|
<div class="service-card-body"> |
|
<div id="jobs-panel"></div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
function fmtBytes(v){ |
|
if(v === null || v === undefined || v === "") return ""; |
|
const n = Number(v); |
|
if(!Number.isFinite(n)) return String(v); |
|
if(n < 1024) return `${n} B`; |
|
if(n < 1024*1024) return `${(n/1024).toFixed(1)} KB`; |
|
if(n < 1024*1024*1024) return `${(n/1024/1024).toFixed(2)} MB`; |
|
return `${(n/1024/1024/1024).toFixed(2)} GB`; |
|
} |
|
|
|
function esc(v){ |
|
return (v===null||v===undefined) ? "" : |
|
String(v).replaceAll("&","&") |
|
.replaceAll("<","<") |
|
.replaceAll(">",">") |
|
.replaceAll('"',"""); |
|
} |
|
|
|
function getSel(){ |
|
try{return JSON.parse(localStorage.getItem("videoSelection")||"[]");} |
|
catch{return [];} |
|
} |
|
|
|
function setSel(items){ |
|
localStorage.setItem("videoSelection", JSON.stringify(items)); |
|
} |
|
|
|
function renderSel(){ |
|
let f=getSel(); |
|
let el=document.getElementById("selected-files"); |
|
|
|
if(!f.length){ |
|
el.innerHTML = '<div class="job-empty">No files staged</div>'; |
|
return; |
|
} |
|
|
|
el.innerHTML = '<div class="staged-list">' + f.map((item, idx) => { |
|
const id = typeof item === "object" ? item.id : item; |
|
const filename = typeof item === "object" ? item.filename : String(item); |
|
const checked = (typeof item === "object" && item.checked === false) ? "" : "checked"; |
|
return ` |
|
<label class="staged-row"> |
|
<input type="checkbox" class="staged-check" data-index="${idx}" ${checked}> |
|
<span class="staged-name">${esc(filename)} [id:${esc(id)}]</span> |
|
</label> |
|
`; |
|
}).join("") + '</div>'; |
|
|
|
document.querySelectorAll(".staged-check").forEach(cb => { |
|
cb.addEventListener("change", function(){ |
|
let items = getSel(); |
|
let idx = Number(this.dataset.index); |
|
if(items[idx] && typeof items[idx] === "object"){ |
|
items[idx].checked = this.checked; |
|
} else if(items[idx] !== undefined) { |
|
items[idx] = { id: items[idx], filename: String(items[idx]), checked: this.checked }; |
|
} |
|
setSel(items); |
|
}); |
|
}); |
|
} |
|
|
|
function clearWorkshopSelection(){ |
|
localStorage.removeItem("videoSelection"); |
|
renderSel(); |
|
} |
|
|
|
function selectAllStaged(state){ |
|
let items = getSel().map(item => { |
|
if(typeof item === "object"){ |
|
item.checked = state; |
|
return item; |
|
} |
|
return { id: item, filename: String(item), checked: state }; |
|
}); |
|
setSel(items); |
|
renderSel(); |
|
} |
|
|
|
function removeCheckedStaged(){ |
|
let items = getSel().filter(item => { |
|
if(typeof item === "object"){ |
|
return item.checked === false; |
|
} |
|
return false; |
|
}); |
|
setSel(items); |
|
renderSel(); |
|
} |
|
|
|
function getCheckedStaged(){ |
|
return getSel().filter(item => { |
|
if(typeof item === "object"){ |
|
return item.checked !== false; |
|
} |
|
return true; |
|
}); |
|
} |
|
|
|
function getSelectedProfiles(){ |
|
return Array.from(document.querySelectorAll(".profile-check:checked")).map(el => el.value); |
|
} |
|
|
|
async function deleteJob(jobId){ |
|
if(!confirm("Delete this job from the list?")) return; |
|
|
|
const r = await fetch(`/api/video/jobs/${jobId}/delete`, { method: "POST" }); |
|
const text = await r.text(); |
|
let data = {}; |
|
try { data = JSON.parse(text); } catch(e) {} |
|
|
|
if(!r.ok){ |
|
alert(data.error || text || "Delete failed"); |
|
return; |
|
} |
|
|
|
loadJobs(); |
|
} |
|
|
|
async function sendToLTS(jobId){ |
|
if(!confirm("Send this output to LTS storage?")) return; |
|
|
|
const r = await fetch(`/video-output/${jobId}/send-to-lts`, { method: "POST" }); |
|
const text = await r.text(); |
|
let data = {}; |
|
try { data = JSON.parse(text); } catch(e) {} |
|
|
|
if(!r.ok){ |
|
alert(data.error || text || "Send to LTS failed"); |
|
return; |
|
} |
|
|
|
loadJobs(); |
|
} |
|
|
|
function renderJobs(jobs){ |
|
let p=document.getElementById("jobs-panel"); |
|
|
|
if(!jobs.length){ |
|
p.innerHTML='<div class="job-empty">No jobs yet</div>'; |
|
return; |
|
} |
|
|
|
p.innerHTML='<div class="job-list">'+jobs.map(j=>{ |
|
let badge = (j.status||"").toLowerCase(); |
|
|
|
let deleteBtn = (badge === "failed" || badge === "complete") |
|
? `<button class="portal-btn" type="button" onclick="deleteJob(${Number(j.id)})">Delete</button>` |
|
: ""; |
|
|
|
let viewBtn = (badge === "complete" && j.output_relative_path) |
|
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/view" target="_blank">View</a>` |
|
: ""; |
|
|
|
let ltsBtn = (badge === "complete" && j.output_relative_path) |
|
? `<button class="portal-btn" type="button" onclick="sendToLTS(${Number(j.id)})">Send to LTS</button>` |
|
: ""; |
|
|
|
let downloadBtn = (badge === "complete" && j.output_relative_path) |
|
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/download">Download Output</a>` |
|
: ""; |
|
|
|
return ` |
|
<div class="job-card"> |
|
<div class="job-head"> |
|
<div class="job-file">${esc(j.filename)}</div> |
|
<div class="job-head-right"> |
|
${viewBtn} |
|
${ltsBtn} |
|
${downloadBtn} |
|
${deleteBtn} |
|
<span class="job-badge ${badge}">${esc(j.status).toUpperCase()}</span> |
|
</div> |
|
</div> |
|
|
|
<div class="job-grid"> |
|
<div class="job-meta"><div class="job-label">Job ID</div><div class="job-value">${esc(j.id)}</div></div> |
|
<div class="job-meta"><div class="job-label">Device</div><div class="job-value">${esc(j.device_id)}</div></div> |
|
<div class="job-meta"><div class="job-label">Profile</div><div class="job-value">${esc(j.profile)}</div></div> |
|
<div class="job-meta"><div class="job-label">Rotation Override</div><div class="job-value">${esc(j.rotation_override || "auto")}</div></div> |
|
<div class="job-meta"><div class="job-label">Processor</div><div class="job-value">${esc(j.assigned_processor||"pending")}</div></div> |
|
<div class="job-meta"><div class="job-label">Progress</div><div class="job-value">${esc(j.progress_percent)}%</div></div> |
|
<div class="job-meta"><div class="job-label">Created</div><div class="job-value">${esc(j.created_at||"")}</div></div> |
|
<div class="job-meta"><div class="job-label">Started</div><div class="job-value">${esc(j.started_at||"")}</div></div> |
|
<div class="job-meta"><div class="job-label">Completed</div><div class="job-value">${esc(j.completed_at||"")}</div></div> |
|
<div class="job-meta"><div class="job-label">Original Size</div><div class="job-value">${esc(fmtBytes(j.original_size)||"")}</div></div> |
|
<div class="job-meta"><div class="job-label">Processed Size</div><div class="job-value">${esc(fmtBytes(j.processed_size)||"")}</div></div> |
|
</div> |
|
|
|
${j.output_relative_path ? `<div class="job-path"><div class="job-label">Output</div><div class="job-value">${esc(j.output_relative_path)}</div></div>` : ""} |
|
${j.error_message ? `<div class="job-error"><div class="job-label">Error</div><div class="job-value">${esc(j.error_message)}</div></div>` : ""} |
|
</div> |
|
`; |
|
}).join("")+'</div>'; |
|
} |
|
|
|
function processWorkshop(){ |
|
let files=getCheckedStaged(); |
|
if(!files.length){alert("No staged files checked");return;} |
|
|
|
const profiles = getSelectedProfiles(); |
|
if(!profiles.length){ |
|
alert("Select at least one profile"); |
|
return; |
|
} |
|
|
|
const hasMkv = files.some(item => { |
|
const name = (typeof item === "object" ? item.filename : String(item)) || ""; |
|
return name.toLowerCase().endsWith(".mkv"); |
|
}); |
|
|
|
if(hasMkv && !document.getElementById("mkvAcknowledge").checked){ |
|
alert("MKV conversion is not recommended. Please acknowledge the MKV warning checkbox if you still want to proceed."); |
|
return; |
|
} |
|
|
|
const fileIds = files.map(item => { |
|
if(typeof item === "object") return Number(item.id); |
|
return Number(item); |
|
}).filter(v => Number.isFinite(v)); |
|
|
|
let rotation_override = null; |
|
if(document.getElementById("manualRotationToggle").checked){ |
|
rotation_override = Number(document.getElementById("rotationSelect").value); |
|
} |
|
|
|
fetch("/api/video/enqueue",{ |
|
method:"POST", |
|
headers:{"Content-Type":"application/json"}, |
|
body:JSON.stringify({ |
|
device_id:{{device_id}}, |
|
files:fileIds, |
|
profiles:profiles, |
|
rotation_override:rotation_override |
|
}) |
|
}).then(()=>loadJobs()); |
|
} |
|
|
|
function loadJobs(){ |
|
fetch("/api/video/jobs") |
|
.then(r=>r.json()) |
|
.then(d=>renderJobs(d)); |
|
} |
|
|
|
document.getElementById("manualRotationToggle").addEventListener("change", function(){ |
|
document.getElementById("rotationSelectWrap").style.display = this.checked ? "block" : "none"; |
|
}); |
|
|
|
renderSel(); |
|
loadJobs(); |
|
setInterval(loadJobs,5000); |
|
</script> |
|
{% endblock %}
|
|
|