Spaces:
Sleeping
Sleeping
Add clip poster thumbnails and hover skim preview
Browse files
app.py
CHANGED
|
@@ -63,6 +63,7 @@ class ClipFile:
|
|
| 63 |
name: str
|
| 64 |
url: str
|
| 65 |
duration: str
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
@dataclass
|
|
@@ -208,20 +209,65 @@ def _duration_label(path: Path) -> str:
|
|
| 208 |
return f"{total // 60}:{total % 60:02d}" if total else "0:00"
|
| 209 |
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def _publish_files(job: Job) -> None:
|
| 212 |
for path in sorted(job.output_dir.glob("short_*.mp4")):
|
| 213 |
if not path.is_file():
|
| 214 |
continue
|
| 215 |
duration = _duration_label(path)
|
|
|
|
|
|
|
| 216 |
existing = job.clips.get(path.name)
|
| 217 |
if existing is None:
|
| 218 |
-
job.clips[path.name] =
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
duration=duration
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
existing.duration = duration
|
| 225 |
|
| 226 |
|
| 227 |
def _validate_credentials() -> None:
|
|
@@ -325,11 +371,7 @@ def _run_job(job_id: str) -> None:
|
|
| 325 |
local_job = JOBS[job_id]
|
| 326 |
for output in outputs:
|
| 327 |
if Path(output).exists():
|
| 328 |
-
local_job.clips[Path(output).name] =
|
| 329 |
-
name=Path(output).name,
|
| 330 |
-
url=f"/api/jobs/{job_id}/files/{Path(output).name}",
|
| 331 |
-
duration=_duration_label(Path(output)),
|
| 332 |
-
)
|
| 333 |
if worker_error:
|
| 334 |
local_job.error = worker_error
|
| 335 |
local_job.status = f"Failed: {worker_error}"
|
|
@@ -475,7 +517,8 @@ def get_job_file(job_id: str, filename: str) -> FileResponse:
|
|
| 475 |
path = (job.output_dir / Path(filename).name).resolve(strict=False)
|
| 476 |
if job.output_dir.resolve(strict=False) not in path.parents or not path.is_file():
|
| 477 |
raise HTTPException(status_code=404, detail="File not found.")
|
| 478 |
-
|
|
|
|
| 479 |
|
| 480 |
|
| 481 |
@app.get("/health")
|
|
@@ -561,8 +604,12 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
| 561 |
.clip-thumb { aspect-ratio: 9/16; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background:#000; }
|
| 562 |
.clip-thumb video { width:100%; height:100%; object-fit:cover; display:block; background:#000; }
|
| 563 |
.clip-thumb::after { content:""; position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0.02), rgba(0,0,0,0.18)); pointer-events:none; }
|
| 564 |
-
.clip-play { width: 44px; height: 44px; background: rgba(255,255,255,0.88); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; z-index: 2; box-shadow: 0 2px 12px rgba(0,0,0,0.2); transition: transform 0.2s; }
|
| 565 |
.clip-card:hover .clip-play { transform: scale(1.1); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
.clip-meta { padding: 10px 12px; } .clip-num { font-size: 0.72rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; }
|
| 567 |
.clip-dur { font-size: 0.82rem; color: var(--ink); font-weight: 400; margin-top: 2px; }
|
| 568 |
.clip-download { margin-top: 8px; display:inline-block; font-size:.74rem; color:var(--gold); text-decoration:none; }
|
|
@@ -877,9 +924,13 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
| 877 |
pct.textContent = step.pct ? `${Math.floor(step.pct)}%` : '';
|
| 878 |
});
|
| 879 |
(job.clips || []).forEach((clip, idx) => {
|
| 880 |
-
|
|
|
|
| 881 |
renderedClips.push(clip);
|
| 882 |
addClip(renderedClips.length - 1);
|
|
|
|
|
|
|
|
|
|
| 883 |
}
|
| 884 |
});
|
| 885 |
if (renderedClips.length) {
|
|
@@ -899,14 +950,78 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|
| 899 |
const grid = document.getElementById('clips-grid');
|
| 900 |
const card = document.createElement('div');
|
| 901 |
card.className = 'clip-card';
|
| 902 |
-
card.
|
| 903 |
-
const
|
| 904 |
-
card.
|
| 905 |
-
|
| 906 |
card.onclick = () => openModal(idx);
|
| 907 |
grid.appendChild(card);
|
| 908 |
}
|
| 909 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
function openModal(idx) {
|
| 911 |
const clip = renderedClips[idx];
|
| 912 |
const modal = document.getElementById('modal');
|
|
|
|
| 63 |
name: str
|
| 64 |
url: str
|
| 65 |
duration: str
|
| 66 |
+
poster_url: str | None = None
|
| 67 |
|
| 68 |
|
| 69 |
@dataclass
|
|
|
|
| 209 |
return f"{total // 60}:{total % 60:02d}" if total else "0:00"
|
| 210 |
|
| 211 |
|
| 212 |
+
def _poster_path_for_video(path: Path) -> Path:
|
| 213 |
+
return path.with_name(f"{path.stem}.poster.jpg")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _ensure_poster(path: Path) -> Path | None:
|
| 217 |
+
poster_path = _poster_path_for_video(path)
|
| 218 |
+
if poster_path.is_file() and poster_path.stat().st_size > 0:
|
| 219 |
+
return poster_path
|
| 220 |
+
try:
|
| 221 |
+
subprocess.run(
|
| 222 |
+
[
|
| 223 |
+
"ffmpeg",
|
| 224 |
+
"-y",
|
| 225 |
+
"-loglevel",
|
| 226 |
+
"error",
|
| 227 |
+
"-ss",
|
| 228 |
+
"0.45",
|
| 229 |
+
"-i",
|
| 230 |
+
str(path),
|
| 231 |
+
"-frames:v",
|
| 232 |
+
"1",
|
| 233 |
+
"-q:v",
|
| 234 |
+
"3",
|
| 235 |
+
str(poster_path),
|
| 236 |
+
],
|
| 237 |
+
check=True,
|
| 238 |
+
capture_output=True,
|
| 239 |
+
timeout=20,
|
| 240 |
+
)
|
| 241 |
+
except Exception:
|
| 242 |
+
return None
|
| 243 |
+
return poster_path if poster_path.is_file() and poster_path.stat().st_size > 0 else None
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _clip_file(job: Job, path: Path, duration: str | None = None) -> ClipFile:
|
| 247 |
+
poster = _ensure_poster(path)
|
| 248 |
+
return ClipFile(
|
| 249 |
+
name=path.name,
|
| 250 |
+
url=f"/api/jobs/{job.id}/files/{path.name}",
|
| 251 |
+
duration=duration or _duration_label(path),
|
| 252 |
+
poster_url=f"/api/jobs/{job.id}/files/{poster.name}" if poster else None,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
def _publish_files(job: Job) -> None:
|
| 257 |
for path in sorted(job.output_dir.glob("short_*.mp4")):
|
| 258 |
if not path.is_file():
|
| 259 |
continue
|
| 260 |
duration = _duration_label(path)
|
| 261 |
+
poster = _ensure_poster(path)
|
| 262 |
+
poster_url = f"/api/jobs/{job.id}/files/{poster.name}" if poster else None
|
| 263 |
existing = job.clips.get(path.name)
|
| 264 |
if existing is None:
|
| 265 |
+
job.clips[path.name] = _clip_file(job, path, duration=duration)
|
| 266 |
+
else:
|
| 267 |
+
if existing.duration == "0:00" and duration != "0:00":
|
| 268 |
+
existing.duration = duration
|
| 269 |
+
if existing.poster_url is None and poster_url:
|
| 270 |
+
existing.poster_url = poster_url
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
def _validate_credentials() -> None:
|
|
|
|
| 371 |
local_job = JOBS[job_id]
|
| 372 |
for output in outputs:
|
| 373 |
if Path(output).exists():
|
| 374 |
+
local_job.clips[Path(output).name] = _clip_file(local_job, Path(output))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
if worker_error:
|
| 376 |
local_job.error = worker_error
|
| 377 |
local_job.status = f"Failed: {worker_error}"
|
|
|
|
| 517 |
path = (job.output_dir / Path(filename).name).resolve(strict=False)
|
| 518 |
if job.output_dir.resolve(strict=False) not in path.parents or not path.is_file():
|
| 519 |
raise HTTPException(status_code=404, detail="File not found.")
|
| 520 |
+
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "video/mp4"
|
| 521 |
+
return FileResponse(path, media_type=media_type, filename=path.name)
|
| 522 |
|
| 523 |
|
| 524 |
@app.get("/health")
|
|
|
|
| 604 |
.clip-thumb { aspect-ratio: 9/16; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background:#000; }
|
| 605 |
.clip-thumb video { width:100%; height:100%; object-fit:cover; display:block; background:#000; }
|
| 606 |
.clip-thumb::after { content:""; position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0.02), rgba(0,0,0,0.18)); pointer-events:none; }
|
| 607 |
+
.clip-play { width: 44px; height: 44px; background: rgba(255,255,255,0.88); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; z-index: 2; box-shadow: 0 2px 12px rgba(0,0,0,0.2); transition: transform 0.2s, opacity 0.2s; pointer-events:none; }
|
| 608 |
.clip-card:hover .clip-play { transform: scale(1.1); }
|
| 609 |
+
.clip-card.previewing .clip-play { opacity: 0; transform: scale(0.92); }
|
| 610 |
+
.clip-skim { position:absolute; left:10px; right:10px; bottom:10px; height:3px; border-radius:99px; background:rgba(255,255,255,0.35); z-index:3; overflow:hidden; opacity:0; transition:opacity 0.2s; pointer-events:none; }
|
| 611 |
+
.clip-skim-fill { height:100%; width:0%; background:var(--champagne); border-radius:inherit; }
|
| 612 |
+
.clip-card:hover .clip-skim { opacity:1; }
|
| 613 |
.clip-meta { padding: 10px 12px; } .clip-num { font-size: 0.72rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; }
|
| 614 |
.clip-dur { font-size: 0.82rem; color: var(--ink); font-weight: 400; margin-top: 2px; }
|
| 615 |
.clip-download { margin-top: 8px; display:inline-block; font-size:.74rem; color:var(--gold); text-decoration:none; }
|
|
|
|
| 924 |
pct.textContent = step.pct ? `${Math.floor(step.pct)}%` : '';
|
| 925 |
});
|
| 926 |
(job.clips || []).forEach((clip, idx) => {
|
| 927 |
+
const existingIdx = renderedClips.findIndex(c => c.name === clip.name);
|
| 928 |
+
if (existingIdx === -1) {
|
| 929 |
renderedClips.push(clip);
|
| 930 |
addClip(renderedClips.length - 1);
|
| 931 |
+
} else {
|
| 932 |
+
renderedClips[existingIdx] = Object.assign({}, renderedClips[existingIdx], clip);
|
| 933 |
+
updateClipCard(existingIdx);
|
| 934 |
}
|
| 935 |
});
|
| 936 |
if (renderedClips.length) {
|
|
|
|
| 950 |
const grid = document.getElementById('clips-grid');
|
| 951 |
const card = document.createElement('div');
|
| 952 |
card.className = 'clip-card';
|
| 953 |
+
card.dataset.clipName = clip.name;
|
| 954 |
+
const posterAttr = clip.poster_url ? ` poster="${escapeHtml(clip.poster_url)}"` : '';
|
| 955 |
+
card.innerHTML = `<div class="clip-thumb"><video src="${escapeHtml(clip.url)}#t=0.35"${posterAttr} muted playsinline preload="auto" loop></video><div class="clip-play">▶</div><div class="clip-skim"><div class="clip-skim-fill"></div></div></div><div class="clip-meta"><div class="clip-num">Clip ${idx + 1}</div><div class="clip-dur">${escapeHtml(clip.duration || '0:00')}</div><a class="clip-download" href="${escapeHtml(clip.url)}" download onclick="event.stopPropagation()">Download</a></div>`;
|
| 956 |
+
wireClipPreview(card);
|
| 957 |
card.onclick = () => openModal(idx);
|
| 958 |
grid.appendChild(card);
|
| 959 |
}
|
| 960 |
|
| 961 |
+
function updateClipCard(idx) {
|
| 962 |
+
const clip = renderedClips[idx];
|
| 963 |
+
const card = document.querySelector(`.clip-card[data-clip-name="${CSS.escape(clip.name)}"]`);
|
| 964 |
+
if (!card) return;
|
| 965 |
+
const duration = card.querySelector('.clip-dur');
|
| 966 |
+
const preview = card.querySelector('video');
|
| 967 |
+
if (duration) duration.textContent = clip.duration || '0:00';
|
| 968 |
+
if (preview && clip.poster_url && preview.getAttribute('poster') !== clip.poster_url) {
|
| 969 |
+
preview.setAttribute('poster', clip.poster_url);
|
| 970 |
+
}
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
function wireClipPreview(card) {
|
| 974 |
+
const preview = card.querySelector('video');
|
| 975 |
+
const thumb = card.querySelector('.clip-thumb');
|
| 976 |
+
const skimFill = card.querySelector('.clip-skim-fill');
|
| 977 |
+
let scrubRaf = 0;
|
| 978 |
+
let pendingTime = null;
|
| 979 |
+
|
| 980 |
+
function duration() {
|
| 981 |
+
return Number.isFinite(preview.duration) && preview.duration > 0.2 ? preview.duration : 0;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
function setSkim(percent) {
|
| 985 |
+
if (skimFill) skimFill.style.width = `${Math.max(0, Math.min(1, percent)) * 100}%`;
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
function seekFromPointer(event) {
|
| 989 |
+
const total = duration();
|
| 990 |
+
if (!total) return;
|
| 991 |
+
const rect = thumb.getBoundingClientRect();
|
| 992 |
+
const pct = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width)));
|
| 993 |
+
pendingTime = Math.max(0.05, Math.min(total - 0.05, total * pct));
|
| 994 |
+
setSkim(pct);
|
| 995 |
+
if (!scrubRaf) {
|
| 996 |
+
scrubRaf = requestAnimationFrame(() => {
|
| 997 |
+
if (pendingTime !== null && Math.abs(preview.currentTime - pendingTime) > 0.08) {
|
| 998 |
+
preview.currentTime = pendingTime;
|
| 999 |
+
}
|
| 1000 |
+
scrubRaf = 0;
|
| 1001 |
+
});
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
preview.addEventListener('timeupdate', () => {
|
| 1006 |
+
const total = duration();
|
| 1007 |
+
if (total) setSkim(preview.currentTime / total);
|
| 1008 |
+
});
|
| 1009 |
+
preview.addEventListener('loadeddata', () => card.classList.add('has-preview'));
|
| 1010 |
+
thumb.addEventListener('mouseenter', event => {
|
| 1011 |
+
card.classList.add('previewing');
|
| 1012 |
+
seekFromPointer(event);
|
| 1013 |
+
preview.play().catch(() => {});
|
| 1014 |
+
});
|
| 1015 |
+
thumb.addEventListener('mousemove', seekFromPointer);
|
| 1016 |
+
thumb.addEventListener('mouseleave', () => {
|
| 1017 |
+
card.classList.remove('previewing');
|
| 1018 |
+
preview.pause();
|
| 1019 |
+
setSkim(0);
|
| 1020 |
+
const resetTime = Math.min(0.35, duration() || 0.35);
|
| 1021 |
+
try { preview.currentTime = resetTime; } catch (_) {}
|
| 1022 |
+
});
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
function openModal(idx) {
|
| 1026 |
const clip = renderedClips[idx];
|
| 1027 |
const modal = document.getElementById('modal');
|