moonlantern1 commited on
Commit
baf6b2a
·
verified ·
1 Parent(s): 86cc370

Add clip poster thumbnails and hover skim preview

Browse files
Files changed (1) hide show
  1. app.py +134 -19
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] = ClipFile(
219
- name=path.name,
220
- url=f"/api/jobs/{job.id}/files/{path.name}",
221
- duration=duration,
222
- )
223
- elif existing.duration == "0:00" and duration != "0:00":
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] = ClipFile(
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
- return FileResponse(path, media_type="video/mp4", filename=path.name)
 
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
- if (!renderedClips.some(c => c.name === clip.name)) {
 
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.innerHTML = `<div class="clip-thumb"><video src="${clip.url}#t=0.2" muted playsinline preload="metadata"></video><div class="clip-play">▶</div></div><div class="clip-meta"><div class="clip-num">Clip ${idx + 1}</div><div class="clip-dur">${clip.duration || '0:00'}</div><a class="clip-download" href="${clip.url}" download onclick="event.stopPropagation()">Download</a></div>`;
903
- const preview = card.querySelector('video');
904
- card.addEventListener('mouseenter', () => { preview.play().catch(() => {}); });
905
- card.addEventListener('mouseleave', () => { preview.pause(); preview.currentTime = 0.2; });
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');