updates UI

This commit is contained in:
mike
2026-06-29 12:56:31 +02:00
parent 150ef6dab0
commit 139785d108
4 changed files with 262 additions and 58 deletions

View File

@@ -72,7 +72,9 @@ Scenery:
- we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image - we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image
- backfill, people-count, 21+, IS_COMPLETE, RESTRAINT - backfill, people-count, 21+, IS_COMPLETE, RESTRAINT
- the wireframe / videos / sub-clips worden niet direct weergegeven - the wireframe / videos / sub-clips worden niet direct weergegeven
- - make the faceswap preview mode fixed size instead of 50% we dont know how big the original clip is. just 470p or something.
- include 2 filter(s) in the filmstrip for VIDEO and ORBIT only show them if the sub-set is not empty.
- add a 2nd visible filmstrip to easy move items from one to another strip in the UI
## refine ## refine
- when refresh page, we lose track of current jobs running. - when refresh page, we lose track of current jobs running.

View File

@@ -2334,8 +2334,13 @@
// --- HYDRATION_START --- // --- HYDRATION_START ---
const PRELOADED_IMAGES = [ const PRELOADED_IMAGES = [
"20260629_124948_sc_view_023_345deg.png",
"20260629_124523_sc_view_023_345deg.png",
"20260629_124433_sc_view_023_345deg.png",
"20260629_122907_fs_Step Sis will do anything to make me Delete this Videos_1633s-2269s_image.png_prev50.mp4",
"20260629_123127_test_upload_image.png",
"20260629_123101_test_upload_image.png",
"20260629_120459_fs_bitch insemination_dup_20260626_003125_dup_Pasted image (9).png.mp4", "20260629_120459_fs_bitch insemination_dup_20260626_003125_dup_Pasted image (9).png.mp4",
"20260624_154536_img_72.png",
"20260629_114451_sc_sc_image.nobg.png", "20260629_114451_sc_sc_image.nobg.png",
"20260629_114346_sc_sc_image.nobg.png", "20260629_114346_sc_sc_image.nobg.png",
"_turntable/cg_4e2b6449/views/view_023_345deg.png", "_turntable/cg_4e2b6449/views/view_023_345deg.png",
@@ -5710,6 +5715,7 @@
"20260624_154712_img_72.png", "20260624_154712_img_72.png",
"20260624_154639_img_72.png", "20260624_154639_img_72.png",
"20260624_154548_img_72.png", "20260624_154548_img_72.png",
"20260624_154536_img_72.png",
"20260624_154525_img_72.png", "20260624_154525_img_72.png",
"20260624_154514_img_72.png", "20260624_154514_img_72.png",
"20260624_154503_img_72.png", "20260624_154503_img_72.png",
@@ -12806,7 +12812,8 @@
} }
if (!data) { if (!data) {
try { try {
const r = await fetch(`${API}/videos`); const url = forceFresh ? `${API}/videos?refresh=true` : `${API}/videos`;
const r = await fetch(url);
if (r.ok) { data = await r.json(); } if (r.ok) { data = await r.json(); }
} catch (_) {} } catch (_) {}
} }
@@ -12871,16 +12878,89 @@
} }
// Template grid view // Template grid view
let html = '<div class="sb-label">Template videos</div>'; let html = `
if (!availableVideos.length) { <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="sb-label" style="margin:0">Template videos</div>
<button class="sb-btn" onclick="refreshTemplateVideos(event)" style="font-size:10px;padding:2px 6px;line-height:1">🔄 Refresh</button>
</div>`;
if (!wireframeGroups || !wireframeGroups.length) {
html += '<div style="font-size:11px;color:#555;padding:6px 0">No wireframe videos found</div>'; html += '<div style="font-size:11px;color:#555;padding:6px 0">No wireframe videos found</div>';
} else { } else {
html += '<div class="sb-template-grid">'; html += '<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">';
availableVideos.forEach(v => { if (!window._expandedVideoGroups) {
const vSafe = v.replace(/'/g, "\\'"); window._expandedVideoGroups = {};
const trimBtn = `<button class="sb-template-trim-btn" title="Trim video" }
onclick="event.stopPropagation();openTrimPanel('${vSafe}')">✂</button>`; wireframeGroups.forEach(g => {
html += _tplCardHTML(v, _fsSelectedVideo === v, `sbSelectTemplate('${vSafe}')`, trimBtn); const hasClips = g.clips && g.clips.length > 0;
const repVideo = g.video || (hasClips ? g.clips[0] : null);
if (!repVideo) return;
const isExpanded = !!window._expandedVideoGroups[g.stem];
const repVideoSafe = repVideo.replace(/'/g, "\\'");
const isSelected = _fsSelectedVideo === repVideo || (g.video && _fsSelectedVideo === g.video);
const selectedClip = hasClips ? g.clips.find(clip => _fsSelectedVideo === clip) : null;
const rowBorder = (isSelected || selectedClip) ? 'border-color:#6d28d9;background:#1e1b4b' : 'border-color:#27272a;background:#09090b';
html += `
<div style="border:1px solid;border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:8px;transition:all 0.15s;${rowBorder}">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:70px;height:50px;flex-shrink:0;position:relative;border-radius:4px;overflow:hidden;background:#000">
<div class="sb-template-card compact-card ${isSelected && !selectedClip ? 'selected' : ''}"
onclick="sbSelectTemplate('${repVideoSafe}')"
onmouseenter="_tplPlay(this,'${repVideoSafe}')" onmouseleave="_tplStop(this)"
style="width:100%;height:100%;margin:0;border:none">
<img src="${API}/wireframe/frame/${encodeURIComponent(repVideo)}?t=0" loading="lazy" style="width:100%;height:100%;object-fit:cover" alt="">
</div>
</div>
<div style="flex-grow:1;min-width:0">
<div style="font-size:12px;font-weight:bold;color:#f4f4f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escHtml(g.stem)}">
📹 ${escHtml(g.stem)}
</div>
<div style="font-size:10px;color:#71717a;margin-top:2px;display:flex;align-items:center;gap:4px">
<span>${g.video ? 'Full video' : 'Clips only'}</span>
${selectedClip ? `<span style="color:#a78bfa;font-weight:bold">· Selected clip: ${escHtml(selectedClip.replace(g.stem + '_', '').replace('.mp4', ''))}</span>` : ''}
${isSelected && !selectedClip ? `<span style="color:#a78bfa;font-weight:bold">· Selected</span>` : ''}
</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
${g.video ? `<button class="sb-template-trim-btn" title="Trim video" onclick="event.stopPropagation();openTrimPanel('${repVideoSafe}')" style="position:static;width:24px;height:24px;line-height:24px;font-size:11px;padding:0">✂</button>` : ''}
${hasClips ? `
<button class="sb-btn" onclick="event.stopPropagation();toggleGroupClips('${g.stem.replace(/'/g, "\\'")}')"
style="font-size:10px;padding:3px 6px;line-height:1;background:#27272a;color:#d4d4d8;border:1px solid #3f3f46;display:flex;align-items:center;gap:2px">
<span>Clips (${g.clips.length})</span>
<span>${isExpanded ? '▲' : '▼'}</span>
</button>
` : ''}
</div>
</div>`;
if (hasClips && isExpanded) {
html += `
<div style="border-top:1px solid #27272a;padding-top:6px;margin-top:2px">
<div style="font-size:9px;color:#71717a;text-transform:uppercase;font-weight:bold;letter-spacing:0.5px;margin-bottom:4px">Sub-Clips</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">`;
g.clips.forEach(clip => {
const cSafe = clip.replace(/'/g, "\\'");
const isClipSelected = _fsSelectedVideo === clip;
const clipLabel = clip.replace(g.stem + '_', '').replace('.mp4', '');
const pillBg = isClipSelected ? 'background:#6d28d9;color:#fff;border-color:#8b5cf6' : 'background:#18181b;color:#a1a1aa;border-color:#27272a';
html += `
<button class="sb-btn" onclick="sbSelectTemplate('${cSafe}')"
style="font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid;cursor:pointer;transition:all 0.1s;${pillBg}">
${escHtml(clipLabel)}
</button>`;
});
html += `
</div>
</div>`;
}
html += `</div>`;
}); });
html += '</div>'; html += '</div>';
} }
@@ -12937,6 +13017,28 @@
renderSidebarFaceswap(); renderSidebarFaceswap();
} }
function toggleGroupClips(stem) {
if (!window._expandedVideoGroups) window._expandedVideoGroups = {};
window._expandedVideoGroups[stem] = !window._expandedVideoGroups[stem];
if (_activeSidebarTab === 'faceswap') {
renderSidebarFaceswap();
} else if (_activeSidebarTab === 'scenery') {
renderSidebarScenery();
}
}
async function refreshTemplateVideos(event) {
if (event) event.stopPropagation();
showToast("Refreshing video library...", "info");
await loadTemplateVideos(true);
if (_activeSidebarTab === 'faceswap') {
renderSidebarFaceswap();
} else if (_activeSidebarTab === 'scenery') {
renderSidebarScenery();
}
showToast("Video library refreshed", "success");
}
function openTrimPanel(videoName) { function openTrimPanel(videoName) {
_fsTrimVideo = videoName; _fsTrimVideo = videoName;
renderSidebarFaceswap(); renderSidebarFaceswap();
@@ -13275,56 +13377,118 @@
// --- Video picker grid (collapsible) --- // --- Video picker grid (collapsible) ---
if (!_sceneVideo || gridOpen) { if (!_sceneVideo || gridOpen) {
html += '<div class="sb-label">Background source library</div>'; html += `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="sb-label" style="margin:0">Background source library</div>
<button class="sb-btn" onclick="refreshTemplateVideos(event)" style="font-size:10px;padding:2px 6px;line-height:1">🔄 Refresh</button>
</div>`;
if (wireframeGroups && wireframeGroups.length) { if (wireframeGroups && wireframeGroups.length) {
html += '<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:12px">'; html += '<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">';
if (!window._expandedVideoGroups) {
window._expandedVideoGroups = {};
}
wireframeGroups.forEach(g => { wireframeGroups.forEach(g => {
const hasClips = g.clips && g.clips.length; const hasClips = g.clips && g.clips.length > 0;
const hasFrames = g.frames && g.frames.length; const hasFrames = g.frames && g.frames.length > 0;
const repVideo = g.video || (hasClips ? g.clips[0] : null);
if (!repVideo) return;
const isExpanded = !!window._expandedVideoGroups[g.stem];
const repVideoSafe = repVideo.replace(/'/g, "\\'");
const isSelected = _sceneVideo === repVideo || (g.video && _sceneVideo === g.video);
const selectedClip = hasClips ? g.clips.find(clip => _sceneVideo === clip) : null;
const selectedFrame = hasFrames ? g.frames.find(frame => _sceneRefs[0] && _sceneRefs[0].kind === 'wireframe' && _sceneRefs[0].name === frame) : null;
const rowBorder = (isSelected || selectedClip || selectedFrame) ? 'border-color:#6d28d9;background:#1e1b4b' : 'border-color:#27272a;background:#09090b';
html += ` html += `
<div style="background:#141414;border:1px solid #222;border-radius:6px;padding:8px"> <div style="border:1px solid;border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:8px;transition:all 0.15s;${rowBorder}">
<div style="font-size:12px;font-weight:bold;color:#ccc;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between"> <div style="display:flex;align-items:center;gap:10px">
<span>📹 ${escHtml(g.stem)}</span> <div style="width:70px;height:50px;flex-shrink:0;position:relative;border-radius:4px;overflow:hidden;background:#000">
<span style="font-size:10px;color:#666">${g.video ? 'source video' : 'no source'}</span> <div class="sb-template-card compact-card ${isSelected && !selectedClip ? 'selected' : ''}"
onclick="sceneSelectVideo('${repVideoSafe}')"
onmouseenter="_tplPlay(this,'${repVideoSafe}')" onmouseleave="_tplStop(this)"
style="width:100%;height:100%;margin:0;border:none">
<img src="${API}/wireframe/frame/${encodeURIComponent(repVideo)}?t=0" loading="lazy" style="width:100%;height:100%;object-fit:cover" alt="">
</div>
</div>
<div style="flex-grow:1;min-width:0">
<div style="font-size:12px;font-weight:bold;color:#f4f4f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escHtml(g.stem)}">
📹 ${escHtml(g.stem)}
</div>
<div style="font-size:10px;color:#71717a;margin-top:2px;display:flex;align-items:center;gap:4px">
<span>${g.video ? 'Full video' : 'Clips only'}</span>
${selectedClip ? `<span style="color:#a78bfa;font-weight:bold">· Selected clip: ${escHtml(selectedClip.replace(g.stem + '_', '').replace('.mp4', ''))}</span>` : ''}
${selectedFrame ? `<span style="color:#a78bfa;font-weight:bold">· Selected frame</span>` : ''}
${isSelected && !selectedClip ? `<span style="color:#a78bfa;font-weight:bold">· Selected</span>` : ''}
</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
${(hasClips || hasFrames) ? `
<button class="sb-btn" onclick="event.stopPropagation();toggleGroupClips('${g.stem.replace(/'/g, "\\'")}')"
style="font-size:10px;padding:3px 6px;line-height:1;background:#27272a;color:#d4d4d8;border:1px solid #3f3f46;display:flex;align-items:center;gap:2px">
<span>Clips/Frames</span>
<span>${isExpanded ? '▲' : '▼'}</span>
</button>
` : ''}
</div>
</div>`; </div>`;
// Render main video if exists if ((hasClips || hasFrames) && isExpanded) {
if (g.video) { html += `
html += '<div class="sb-template-grid" style="margin-bottom:6px">'; <div style="border-top:1px solid #27272a;padding-top:6px;margin-top:2px;display:flex;flex-direction:column;gap:6px">`;
const vSafe = g.video.replace(/'/g, "\\'");
html += _tplCardHTML(g.video, _sceneVideo === g.video, `sceneSelectVideo('${vSafe}')`); if (hasClips) {
html += '</div>';
}
// Render trimmed clips if they exist
if (hasClips) {
html += '<div style="font-size:10px;color:#888;margin:4px 0 2px">✂ Trimmed Clips:</div>';
html += '<div class="sb-template-grid" style="margin-bottom:6px">';
g.clips.forEach(clip => {
const cSafe = clip.replace(/'/g, "\\'");
html += _tplCardHTML(clip, _sceneVideo === clip, `sceneSelectVideo('${cSafe}')`);
});
html += '</div>';
}
// Render extracted frames if they exist
if (hasFrames) {
html += '<div style="font-size:10px;color:#888;margin:4px 0 2px">🖼 Extracted Frames:</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
g.frames.forEach(frame => {
const fSafe = frame.replace(/'/g, "\\'");
const isSelected = _sceneRefs[0] && _sceneRefs[0].kind === 'wireframe' && _sceneRefs[0].name === frame;
const borderStyle = isSelected ? 'border:2px solid #2563eb' : 'border:1px solid #333';
html += ` html += `
<div style="cursor:pointer;position:relative" title="${escHtml(frame)}" onclick="sceneSelectWireframeImage('${fSafe}')"> <div>
<img src="${API}/wireframe/${encodeURIComponent(frame)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px;${borderStyle}"> <div style="font-size:9px;color:#71717a;text-transform:uppercase;font-weight:bold;letter-spacing:0.5px;margin-bottom:4px">Sub-Clips</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">`;
g.clips.forEach(clip => {
const cSafe = clip.replace(/'/g, "\\'");
const isClipSelected = _sceneVideo === clip;
const clipLabel = clip.replace(g.stem + '_', '').replace('.mp4', '');
const pillBg = isClipSelected ? 'background:#6d28d9;color:#fff;border-color:#8b5cf6' : 'background:#18181b;color:#a1a1aa;border-color:#27272a';
html += `
<button class="sb-btn" onclick="sceneSelectVideo('${cSafe}')"
style="font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid;cursor:pointer;transition:all 0.1s;${pillBg}">
${escHtml(clipLabel)}
</button>`;
});
html += `
</div>
</div>`; </div>`;
}); }
html += '</div>';
if (hasFrames) {
html += `
<div>
<div style="font-size:9px;color:#71717a;text-transform:uppercase;font-weight:bold;letter-spacing:0.5px;margin-bottom:4px">Extracted Frames</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">`;
g.frames.forEach(frame => {
const fSafe = frame.replace(/'/g, "\\'");
const isSelected = _sceneRefs[0] && _sceneRefs[0].kind === 'wireframe' && _sceneRefs[0].name === frame;
const borderStyle = isSelected ? 'border:2px solid #8b5cf6' : 'border:1px solid #27272a';
html += `
<div style="cursor:pointer;position:relative" title="${escHtml(frame)}" onclick="sceneSelectWireframeImage('${fSafe}')">
<img src="${API}/wireframe/${encodeURIComponent(frame)}" style="width:40px;height:40px;object-fit:cover;border-radius:4px;${borderStyle}">
</div>`;
});
html += `
</div>
</div>`;
}
html += `
</div>`;
} }
html += '</div>'; html += `</div>`;
}); });
html += '</div>'; html += '</div>';
} else if (availableVideos.length) { } else if (availableVideos.length) {

View File

@@ -2452,19 +2452,28 @@ def _get_grouped_wireframes(wireframe_dir: str) -> dict:
} }
def _update_static_videos() -> dict:
output_dir = _load_output_dir()
data_dir = os.path.join(output_dir, "_data")
os.makedirs(data_dir, exist_ok=True)
wireframe_dir = _load_wireframe_dir()
data = _get_grouped_wireframes(wireframe_dir)
_write_json(os.path.join(data_dir, "videos.json"), data)
return data
@app.get("/videos") @app.get("/videos")
def list_videos(): def list_videos(refresh: bool = False):
"""Return available wireframe template videos and grouped wireframe assets.""" """Return available wireframe template videos and grouped wireframe assets."""
output_dir = _load_output_dir() output_dir = _load_output_dir()
static_file = os.path.join(output_dir, "_data", "videos.json") static_file = os.path.join(output_dir, "_data", "videos.json")
if os.path.exists(static_file): if not refresh and os.path.exists(static_file):
try: try:
with open(static_file, "r") as f: with open(static_file, "r") as f:
return json.load(f) return json.load(f)
except Exception as static_err: except Exception as static_err:
print(f"[static-get] Failed to load videos.json: {static_err}") print(f"[static-get] Failed to load videos.json: {static_err}")
wireframe_dir = _load_wireframe_dir() return _update_static_videos()
return _get_grouped_wireframes(wireframe_dir)
@app.get("/wireframe/frame/{video_name}") @app.get("/wireframe/frame/{video_name}")
@@ -2557,6 +2566,11 @@ def trim_wireframe(req: TrimRequest):
if r.returncode != 0: if r.returncode != 0:
raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:500]}") raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:500]}")
try:
_update_static_videos()
except Exception as update_err:
print(f"[trim] Failed to update static videos: {update_err}")
return {'output_name': out_name, 'start': req.start, 'end': req.end} return {'output_name': out_name, 'start': req.start, 'end': req.end}

View File

@@ -599,5 +599,29 @@ class TestAPIRegression(unittest.TestCase):
self.assertIn("test_ref_image_123.png", _failed_backfill_filenames) self.assertIn("test_ref_image_123.png", _failed_backfill_filenames)
def test_11_video_listing_refresh_and_trim_synchronization(self):
from unittest.mock import patch
import edit_api
# Mock wireframe folder and output directories
with patch("edit_api._load_wireframe_dir", return_value="/tmp/test_wireframe"), \
patch("edit_api._load_output_dir", return_value=self.output_dir), \
patch("os.path.isdir", return_value=True), \
patch("os.listdir", return_value=["dance.mp4", "dance_10s-20s.mp4", "notes.txt"]):
# Verify that calling /videos?refresh=true triggers grouping correctly
response = self.client.get("/videos?refresh=true")
self.assertEqual(response.status_code, 200)
data = response.json()
# Grouping checks
self.assertIn("groups", data)
groups = data["groups"]
# Find group with stem "dance"
dance_grp = next((g for g in groups if g["stem"] == "dance"), None)
self.assertIsNotNone(dance_grp)
self.assertEqual(dance_grp["video"], "dance.mp4")
self.assertIn("dance_10s-20s.mp4", dance_grp["clips"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()