SeaWolf-AI commited on
Commit
a60fe8e
Β·
verified Β·
1 Parent(s): cf04566

feat: all-beats media chain + per-beat regen + ffmpeg stitch + download proxy

Browse files
Files changed (5) hide show
  1. Dockerfile +1 -0
  2. app.py +117 -2
  3. static/app.js +210 -59
  4. static/index.html +2 -2
  5. static/styles.css +66 -0
Dockerfile CHANGED
@@ -10,6 +10,7 @@ ENV PYTHONUNBUFFERED=1 \
10
  RUN apt-get update && apt-get install -y --no-install-recommends \
11
  ca-certificates \
12
  curl \
 
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
  WORKDIR /app
 
10
  RUN apt-get update && apt-get install -y --no-install-recommends \
11
  ca-certificates \
12
  curl \
13
+ ffmpeg \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
  WORKDIR /app
app.py CHANGED
@@ -15,10 +15,10 @@ from typing import Any, Literal
15
 
16
  import httpx
17
  from dotenv import load_dotenv
18
- from fastapi import FastAPI, HTTPException
19
  from fastapi.concurrency import run_in_threadpool
20
  from fastapi.middleware.cors import CORSMiddleware
21
- from fastapi.responses import FileResponse
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel, Field
24
 
@@ -50,6 +50,8 @@ log = logging.getLogger("aether_ad.app")
50
  PRODUCTS_DIR = ROOT / "aether_ad" / "data" / "products"
51
  TENSIONS_PATH = ROOT / "aether_ad" / "data" / "tensions" / "default_tensions.json"
52
  STATIC_DIR = ROOT / "static"
 
 
53
 
54
  CONTEXT = load_context(TENSIONS_PATH)
55
 
@@ -583,8 +585,121 @@ async def generate_video(req: VideoGenRequest) -> dict[str, Any]:
583
  return {"video_url": url}
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  if STATIC_DIR.exists():
587
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
 
588
 
589
 
590
  if __name__ == "__main__": # pragma: no cover
 
15
 
16
  import httpx
17
  from dotenv import load_dotenv
18
+ from fastapi import FastAPI, HTTPException, Query
19
  from fastapi.concurrency import run_in_threadpool
20
  from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse, Response, StreamingResponse
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel, Field
24
 
 
50
  PRODUCTS_DIR = ROOT / "aether_ad" / "data" / "products"
51
  TENSIONS_PATH = ROOT / "aether_ad" / "data" / "tensions" / "default_tensions.json"
52
  STATIC_DIR = ROOT / "static"
53
+ OUTPUTS_DIR = Path("/tmp/aether_outputs")
54
+ OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
55
 
56
  CONTEXT = load_context(TENSIONS_PATH)
57
 
 
585
  return {"video_url": url}
586
 
587
 
588
+ # ── Video stitching (ffmpeg concat) ────────────────────────────────────────
589
+
590
+ class StitchRequest(BaseModel):
591
+ video_urls: list[str] = Field(min_length=1, max_length=12)
592
+
593
+
594
+ def _ffmpeg_concat(video_urls: list[str]) -> str:
595
+ """Download N video URLs, ffmpeg-concat them, return /outputs/<name>.mp4."""
596
+ import subprocess
597
+ import uuid
598
+
599
+ job_id = uuid.uuid4().hex[:12]
600
+ work_dir = OUTPUTS_DIR / f"job_{job_id}"
601
+ work_dir.mkdir(parents=True, exist_ok=True)
602
+
603
+ local_paths: list[Path] = []
604
+ with httpx.Client(timeout=120.0, follow_redirects=True) as client:
605
+ for i, url in enumerate(video_urls):
606
+ r = client.get(url)
607
+ r.raise_for_status()
608
+ p = work_dir / f"clip_{i:02d}.mp4"
609
+ p.write_bytes(r.content)
610
+ local_paths.append(p)
611
+
612
+ list_file = work_dir / "concat.txt"
613
+ list_file.write_text(
614
+ "\n".join(f"file '{str(p).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'"
615
+ for p in local_paths),
616
+ encoding="utf-8",
617
+ )
618
+
619
+ out_file = OUTPUTS_DIR / f"stitched_{job_id}.mp4"
620
+
621
+ # First attempt: stream copy (works when all clips share codec/parameters)
622
+ cmd_copy = [
623
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
624
+ "-i", str(list_file), "-c", "copy", str(out_file),
625
+ ]
626
+ proc = subprocess.run(cmd_copy, capture_output=True, text=True, timeout=180)
627
+ if proc.returncode != 0:
628
+ log.warning("ffmpeg concat -c copy failed, retrying with re-encode: %s", proc.stderr[-300:])
629
+ # Fallback: re-encode (slower but tolerant of mismatched parameters)
630
+ inputs: list[str] = []
631
+ filter_parts: list[str] = []
632
+ for i, p in enumerate(local_paths):
633
+ inputs += ["-i", str(p)]
634
+ filter_parts.append(f"[{i}:v:0][{i}:a:0?]")
635
+ n = len(local_paths)
636
+ filter_complex = "".join(filter_parts) + f"concat=n={n}:v=1:a=0[outv]"
637
+ cmd_reenc = [
638
+ "ffmpeg", "-y", *inputs,
639
+ "-filter_complex", filter_complex,
640
+ "-map", "[outv]",
641
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
642
+ "-pix_fmt", "yuv420p",
643
+ str(out_file),
644
+ ]
645
+ proc2 = subprocess.run(cmd_reenc, capture_output=True, text=True, timeout=300)
646
+ if proc2.returncode != 0:
647
+ raise RuntimeError(f"ffmpeg re-encode failed: {proc2.stderr[-500:]}")
648
+
649
+ return f"/outputs/stitched_{job_id}.mp4"
650
+
651
+
652
+ @app.post("/api/media/stitch")
653
+ async def stitch_videos(req: StitchRequest) -> dict[str, Any]:
654
+ """Concatenate fal-generated videos into one final ad."""
655
+ if len(req.video_urls) < 2:
656
+ raise HTTPException(status_code=400, detail="Need at least 2 video URLs")
657
+ try:
658
+ served_url = await run_in_threadpool(_ffmpeg_concat, req.video_urls)
659
+ except Exception as e:
660
+ log.exception("stitch failed")
661
+ raise HTTPException(status_code=500, detail=f"Stitch failed: {e}") from e
662
+ return {"video_url": served_url}
663
+
664
+
665
+ # ── Download proxy (forces 'attachment' so browsers actually save) ─────────
666
+
667
+ @app.get("/api/media/download")
668
+ async def download_proxy(
669
+ url: str = Query(..., min_length=8, max_length=2000),
670
+ filename: str = Query("scene.mp4", max_length=120),
671
+ ) -> StreamingResponse:
672
+ """Stream a remote media file with Content-Disposition: attachment.
673
+ Necessary because fal.media is cross-origin so the browser ignores the
674
+ HTML download attribute when the user clicks.
675
+ """
676
+ safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", filename)[:120] or "scene.mp4"
677
+ media_type = "video/mp4" if safe_name.endswith(".mp4") else "image/jpeg"
678
+ client = httpx.AsyncClient(timeout=120.0, follow_redirects=True)
679
+ upstream = await client.send(client.build_request("GET", url), stream=True)
680
+ if upstream.status_code != 200:
681
+ await upstream.aclose()
682
+ await client.aclose()
683
+ raise HTTPException(status_code=upstream.status_code, detail="upstream fetch failed")
684
+
685
+ async def _stream():
686
+ try:
687
+ async for chunk in upstream.aiter_bytes(chunk_size=64 * 1024):
688
+ yield chunk
689
+ finally:
690
+ await upstream.aclose()
691
+ await client.aclose()
692
+
693
+ return StreamingResponse(
694
+ _stream(),
695
+ media_type=upstream.headers.get("content-type") or media_type,
696
+ headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
697
+ )
698
+
699
+
700
  if STATIC_DIR.exists():
701
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
702
+ app.mount("/outputs", StaticFiles(directory=str(OUTPUTS_DIR)), name="outputs")
703
 
704
 
705
  if __name__ == "__main__": # pragma: no cover
static/app.js CHANGED
@@ -526,6 +526,8 @@ function renderSeedCard(s, rank) {
526
  `).join('')}
527
  </div>
528
 
 
 
529
  <div class="aether-provenance" title="AETHER 5-μƒμ„±μžκ°€ λ‚΄λΆ€μ—μ„œ μ„œλ‘œλ₯Ό μƒμƒΒ·μƒκ·ΉμœΌλ‘œ μ‘°μœ¨ν•˜μ—¬ 이 ν•˜λ‚˜μ˜ μž₯면을 ν•©μ„±ν–ˆμŠ΅λ‹ˆλ‹€.">
530
  βš™οΈ AETHER 5-μƒμ„±μž 메타인지: 木(씨앗) β†’ 火(증폭) β†’ 土(μ§€λ°˜) β†’ 金(νŽΈμ§‘) β†’ ζ°΄(톡합)
531
  </div>
@@ -585,6 +587,7 @@ function renderSeedCard(s, rank) {
585
  // the card is already in the tree when we query data-media attributes.
586
  queueMicrotask(() => {
587
  seed.beats.forEach((_, bi) => renderMediaSlot(rank - 1, bi));
 
588
  });
589
 
590
  return card;
@@ -617,70 +620,125 @@ function renderConstraints(c) {
617
 
618
  /* ── Media (fal.ai Grok Imagine) ───────────────────────────────────────── */
619
 
620
- /** Auto-chain: top 3 seeds' first beats β†’ 3 images (ref-chained) β†’ final video. */
 
 
 
 
 
 
 
621
  async function autoMediaChain(seeds) {
622
  if (!seeds || !seeds.length) return;
623
- const top = seeds.slice(0, Math.min(3, seeds.length));
624
- const refUrls = [];
625
-
626
- for (let i = 0; i < top.length; i++) {
627
- const beat = top[i].seed.beats[0];
628
- if (!beat || !beat.content) continue;
629
- const scene = beat.content;
630
- _setMediaState(i, 0, {
631
- status: 'loading',
632
- kind: 'image',
633
- message: i === 0
634
- ? 'πŸ–Ό ν…μŠ€νŠΈ β†’ 이미지 생성 쀑…'
635
- : `πŸ–Ό 이미지 νŽΈμ§‘ 쀑 (μ°Έμ‘° ${refUrls.length}개)…`,
636
- });
637
 
638
- try {
639
- const body = { scene };
640
- if (refUrls.length > 0) body.ref_image_urls = [...refUrls];
641
- const r = await fetch('/api/media/image', {
642
- method: 'POST',
643
- headers: {'Content-Type': 'application/json'},
644
- body: JSON.stringify(body),
645
- });
646
- const data = await r.json();
647
- if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
648
- refUrls.push(data.image_url);
649
- _setMediaState(i, 0, { image_url: data.image_url, status: 'ready' });
650
- } catch (e) {
651
- _setMediaState(i, 0, {
652
- status: 'error',
653
- message: `이미지 μ‹€νŒ¨: ${e.message || e}`,
654
- });
655
- return; // stop chain on error
 
 
 
 
 
656
  }
657
  }
658
 
659
- // Final: image-to-video on the last seed's first-beat image
660
- if (refUrls.length === 0) return;
661
- const lastIdx = refUrls.length - 1;
662
- const lastScene = top[lastIdx].seed.beats[0]?.content || '';
663
- _setMediaState(lastIdx, 0, {
664
  status: 'loading',
665
- kind: 'video',
666
- message: '🎬 μ΅œμ’… 이미지 β†’ λΉ„λ””μ˜€ 생성 쀑… (60-180초)',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
  });
668
  try {
669
  const r = await fetch('/api/media/video', {
670
  method: 'POST',
671
  headers: {'Content-Type': 'application/json'},
672
- body: JSON.stringify({scene: lastScene, image_url: refUrls[lastIdx]}),
673
  });
674
  const data = await r.json();
675
  if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
676
- _setMediaState(lastIdx, 0, { video_url: data.video_url, status: 'ready' });
 
677
  } catch (e) {
678
- _setMediaState(lastIdx, 0, {
679
- status: 'error',
680
- kind: 'video',
681
  message: `λΉ„λ””μ˜€ μ‹€νŒ¨: ${e.message || e}`,
682
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  }
 
 
684
  }
685
 
686
  /** Manual: text-to-image for any beat (no refs). */
@@ -763,20 +821,24 @@ function renderMediaSlot(sIdx, bIdx) {
763
  slot.innerHTML = mediaSlotHtml(sIdx, bIdx, st, isAuto);
764
  }
765
 
766
- function mediaSlotHtml(sIdx, bIdx, st, isAuto) {
767
  const parts = [];
768
- // Status message (loading or error)
769
  if (st.status === 'loading' && st.message) {
770
  parts.push(`<div class="media-status media-loading">${escapeHtml(st.message)}</div>`);
771
  } else if (st.status === 'error' && st.message) {
772
  parts.push(`<div class="media-status media-error-msg">${escapeHtml(st.message)}</div>`);
773
  }
 
 
774
  // Image preview
775
  if (st.image_url) {
776
  parts.push(`
777
  <div class="media-thumb">
778
- <img src="${escapeAttr(st.image_url)}" alt="scene image" loading="lazy" />
779
- <a class="media-download" href="${escapeAttr(st.image_url)}" target="_blank" rel="noopener" title="원본 μ—΄κΈ°">β†—</a>
 
 
780
  </div>
781
  `);
782
  }
@@ -785,16 +847,18 @@ function mediaSlotHtml(sIdx, bIdx, st, isAuto) {
785
  parts.push(`
786
  <div class="media-thumb">
787
  <video src="${escapeAttr(st.video_url)}" controls playsinline preload="metadata"></video>
788
- <a class="media-download" href="${escapeAttr(st.video_url)}" target="_blank" rel="noopener" title="원본 μ—΄κΈ°">β†—</a>
 
 
789
  </div>
790
  `);
791
  }
792
- // Controls β€” only for non-auto beats
793
- if (!isAuto && HAS_FAL) {
794
  const imgDisabled = st.status === 'loading' ? 'disabled' : '';
795
  const vidDisabled = st.status === 'loading' ? 'disabled' : '';
796
- const imgLabel = st.image_url ? 'πŸ–Ό 이미지 μž¬μƒμ„±' : 'πŸ–Ό 이미지 생성';
797
- const vidLabel = st.video_url ? '🎬 λΉ„λ””μ˜€ μž¬μƒμ„±' : '🎬 λΉ„λ””μ˜€ 생성';
798
  parts.push(`
799
  <div class="media-controls">
800
  <button type="button" class="btn-media" data-media-btn="image" data-seed="${sIdx}" data-beat="${bIdx}" ${imgDisabled}>${imgLabel}</button>
@@ -802,19 +866,106 @@ function mediaSlotHtml(sIdx, bIdx, st, isAuto) {
802
  </div>
803
  `);
804
  }
805
- // If the auto beat hasn't started yet, show a faint placeholder
806
- if (isAuto && !st.status && !st.image_url && !st.video_url) {
807
- parts.push(`<div class="media-status media-idle">🎨 μžλ™ 생성 λŒ€κΈ° 쀑…</div>`);
808
- }
809
  return parts.join('');
810
  }
811
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  function escapeAttr(s) {
813
  return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
814
  }
815
 
816
- /* Delegate clicks on generate buttons */
817
  document.addEventListener('click', (e) => {
 
 
 
 
 
 
818
  const btn = e.target.closest('.btn-media');
819
  if (!btn) return;
820
  const sIdx = parseInt(btn.dataset.seed, 10);
 
526
  `).join('')}
527
  </div>
528
 
529
+ <div data-stitch-host="${rank - 1}"></div>
530
+
531
  <div class="aether-provenance" title="AETHER 5-μƒμ„±μžκ°€ λ‚΄λΆ€μ—μ„œ μ„œλ‘œλ₯Ό μƒμƒΒ·μƒκ·ΉμœΌλ‘œ μ‘°μœ¨ν•˜μ—¬ 이 ν•˜λ‚˜μ˜ μž₯면을 ν•©μ„±ν–ˆμŠ΅λ‹ˆλ‹€.">
532
  βš™οΈ AETHER 5-μƒμ„±μž 메타인지: 木(씨앗) β†’ 火(증폭) β†’ 土(μ§€λ°˜) β†’ 金(νŽΈμ§‘) β†’ ζ°΄(톡합)
533
  </div>
 
587
  // the card is already in the tree when we query data-media attributes.
588
  queueMicrotask(() => {
589
  seed.beats.forEach((_, bi) => renderMediaSlot(rank - 1, bi));
590
+ renderStitchSection(rank - 1);
591
  });
592
 
593
  return card;
 
620
 
621
  /* ── Media (fal.ai Grok Imagine) ───────────────────────────────────────── */
622
 
623
+ const STITCH_STATE = {}; // STITCH_STATE[seedIdx] = { status, video_url, message }
624
+
625
+ /** Auto-pipeline (top seed only):
626
+ * Phase 1 β€” generate images for ALL beats with sliding-window ref chain
627
+ * (each new image gets the previous up-to-3 images as references)
628
+ * Phase 2 β€” generate i2v video for each image, sequentially
629
+ * Phase 3 β€” show "stitch all clips" button when β‰₯2 videos ready
630
+ */
631
  async function autoMediaChain(seeds) {
632
  if (!seeds || !seeds.length) return;
633
+ const sIdx = 0; // top_k=1 β‡’ only seed
634
+ const beats = seeds[sIdx].seed.beats || [];
635
+ if (!beats.length) return;
636
+
637
+ // Phase 1 β€” image chain
638
+ for (let i = 0; i < beats.length; i++) {
639
+ const ok = await _genImageForBeat(sIdx, i, /*useChain*/ true);
640
+ if (!ok) break; // stop the image chain on error; user can retry per-beat
641
+ }
 
 
 
 
 
642
 
643
+ // Phase 2 β€” per-image i2v, sequential
644
+ for (let i = 0; i < beats.length; i++) {
645
+ if (!MEDIA_STATE[sIdx]?.[i]?.image_url) continue;
646
+ if (MEDIA_STATE[sIdx]?.[i]?.video_url) continue;
647
+ await _genVideoForBeat(sIdx, i);
648
+ // continue even if one fails β€” user can regen
649
+ }
650
+
651
+ // Phase 3 β€” show stitch UI
652
+ renderStitchSection(sIdx);
653
+ }
654
+
655
+ /** Generate image for one beat. If useChain, attach last 3 prior images as refs. */
656
+ async function _genImageForBeat(sIdx, bIdx, useChain) {
657
+ const beat = LATEST_RESULTS?.seeds?.[sIdx]?.seed?.beats?.[bIdx];
658
+ if (!beat?.content) return false;
659
+ const total = LATEST_RESULTS.seeds[sIdx].seed.beats.length;
660
+
661
+ const refs = [];
662
+ if (useChain) {
663
+ for (let k = Math.max(0, bIdx - 3); k < bIdx; k++) {
664
+ const u = MEDIA_STATE[sIdx]?.[k]?.image_url;
665
+ if (u) refs.push(u);
666
  }
667
  }
668
 
669
+ _setMediaState(sIdx, bIdx, {
 
 
 
 
670
  status: 'loading',
671
+ kind: 'image',
672
+ message: refs.length
673
+ ? `πŸ–Ό 이미지 ${bIdx + 1}/${total} (μ°Έμ‘° ${refs.length})…`
674
+ : `πŸ–Ό 이미지 ${bIdx + 1}/${total} 생성 쀑…`,
675
+ });
676
+ try {
677
+ const body = { scene: beat.content };
678
+ if (refs.length) body.ref_image_urls = refs;
679
+ const r = await fetch('/api/media/image', {
680
+ method: 'POST',
681
+ headers: {'Content-Type': 'application/json'},
682
+ body: JSON.stringify(body),
683
+ });
684
+ const data = await r.json();
685
+ if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
686
+ _setMediaState(sIdx, bIdx, { image_url: data.image_url, status: 'ready' });
687
+ return true;
688
+ } catch (e) {
689
+ _setMediaState(sIdx, bIdx, {
690
+ status: 'error', kind: 'image',
691
+ message: `οΏ½οΏ½οΏ½λ―Έμ§€ μ‹€νŒ¨: ${e.message || e}`,
692
+ });
693
+ return false;
694
+ }
695
+ }
696
+
697
+ async function _genVideoForBeat(sIdx, bIdx) {
698
+ const beat = LATEST_RESULTS?.seeds?.[sIdx]?.seed?.beats?.[bIdx];
699
+ if (!beat?.content) return false;
700
+ const imgUrl = MEDIA_STATE[sIdx]?.[bIdx]?.image_url;
701
+ if (!imgUrl) return false;
702
+ const total = LATEST_RESULTS.seeds[sIdx].seed.beats.length;
703
+
704
+ _setMediaState(sIdx, bIdx, {
705
+ status: 'loading', kind: 'video',
706
+ message: `🎬 λΉ„λ””μ˜€ ${bIdx + 1}/${total} 생성 쀑… (60-180초)`,
707
  });
708
  try {
709
  const r = await fetch('/api/media/video', {
710
  method: 'POST',
711
  headers: {'Content-Type': 'application/json'},
712
+ body: JSON.stringify({scene: beat.content, image_url: imgUrl}),
713
  });
714
  const data = await r.json();
715
  if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
716
+ _setMediaState(sIdx, bIdx, { video_url: data.video_url, status: 'ready' });
717
+ return true;
718
  } catch (e) {
719
+ _setMediaState(sIdx, bIdx, {
720
+ status: 'error', kind: 'video',
 
721
  message: `λΉ„λ””μ˜€ μ‹€νŒ¨: ${e.message || e}`,
722
  });
723
+ return false;
724
+ }
725
+ }
726
+
727
+ /** Manual handlers β€” buttons call these. */
728
+ async function manualGenerateImage(sIdx, bIdx) {
729
+ await _genImageForBeat(sIdx, bIdx, /*useChain*/ true);
730
+ // re-render stitch in case state changed
731
+ renderStitchSection(sIdx);
732
+ }
733
+
734
+ async function manualGenerateVideo(sIdx, bIdx) {
735
+ let imgUrl = MEDIA_STATE[sIdx]?.[bIdx]?.image_url;
736
+ if (!imgUrl) {
737
+ const ok = await _genImageForBeat(sIdx, bIdx, /*useChain*/ true);
738
+ if (!ok) return;
739
  }
740
+ await _genVideoForBeat(sIdx, bIdx);
741
+ renderStitchSection(sIdx);
742
  }
743
 
744
  /** Manual: text-to-image for any beat (no refs). */
 
821
  slot.innerHTML = mediaSlotHtml(sIdx, bIdx, st, isAuto);
822
  }
823
 
824
+ function mediaSlotHtml(sIdx, bIdx, st, _isAuto) {
825
  const parts = [];
826
+ // Status (loading / error)
827
  if (st.status === 'loading' && st.message) {
828
  parts.push(`<div class="media-status media-loading">${escapeHtml(st.message)}</div>`);
829
  } else if (st.status === 'error' && st.message) {
830
  parts.push(`<div class="media-status media-error-msg">${escapeHtml(st.message)}</div>`);
831
  }
832
+ const fname = (kind) => `aether_${sIdx}_${String(bIdx).padStart(2,'0')}.${kind}`;
833
+
834
  // Image preview
835
  if (st.image_url) {
836
  parts.push(`
837
  <div class="media-thumb">
838
+ <img src="${escapeAttr(st.image_url)}" alt="scene ${bIdx + 1}" loading="lazy" />
839
+ <a class="media-download"
840
+ href="/api/media/download?url=${encodeURIComponent(st.image_url)}&filename=${encodeURIComponent(fname('jpg'))}"
841
+ title="이미지 λ‹€μš΄λ‘œλ“œ">↓</a>
842
  </div>
843
  `);
844
  }
 
847
  parts.push(`
848
  <div class="media-thumb">
849
  <video src="${escapeAttr(st.video_url)}" controls playsinline preload="metadata"></video>
850
+ <a class="media-download"
851
+ href="/api/media/download?url=${encodeURIComponent(st.video_url)}&filename=${encodeURIComponent(fname('mp4'))}"
852
+ title="λΉ„λ””μ˜€ λ‹€μš΄λ‘œλ“œ">↓</a>
853
  </div>
854
  `);
855
  }
856
+ // Controls β€” always render when fal is available; labels switch to μž¬μƒμ„±
857
+ if (HAS_FAL) {
858
  const imgDisabled = st.status === 'loading' ? 'disabled' : '';
859
  const vidDisabled = st.status === 'loading' ? 'disabled' : '';
860
+ const imgLabel = st.image_url ? 'πŸ”„ 이미지 μž¬μƒμ„±' : 'πŸ–Ό 이미지 생성';
861
+ const vidLabel = st.video_url ? 'πŸ”„ λΉ„λ””μ˜€ μž¬μƒμ„±' : '🎬 λΉ„λ””μ˜€ 생성';
862
  parts.push(`
863
  <div class="media-controls">
864
  <button type="button" class="btn-media" data-media-btn="image" data-seed="${sIdx}" data-beat="${bIdx}" ${imgDisabled}>${imgLabel}</button>
 
866
  </div>
867
  `);
868
  }
 
 
 
 
869
  return parts.join('');
870
  }
871
 
872
+ /** Render the "stitch all videos" section at the bottom of the seed card. */
873
+ function renderStitchSection(sIdx) {
874
+ const host = document.querySelector(`[data-stitch-host="${sIdx}"]`);
875
+ if (!host) return;
876
+ const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed;
877
+ if (!seed) { host.innerHTML = ''; return; }
878
+
879
+ const videoUrls = [];
880
+ for (let i = 0; i < seed.beats.length; i++) {
881
+ const u = MEDIA_STATE[sIdx]?.[i]?.video_url;
882
+ if (u) videoUrls.push(u);
883
+ }
884
+
885
+ const stitch = STITCH_STATE[sIdx] || {};
886
+ const html = [];
887
+
888
+ html.push(`<div class="seed-section-title">🎬 μ΅œμ’… μ˜μƒ (λͺ¨λ“  λΉ„νŠΈ ν•©μΉ˜κΈ°)</div>`);
889
+ html.push(`<div class="final-stitch">`);
890
+
891
+ if (stitch.status === 'loading') {
892
+ html.push(`<div class="media-status media-loading">${escapeHtml(stitch.message || '🎬 ffmpeg ν•©μΉ˜λŠ” 쀑…')}</div>`);
893
+ } else if (stitch.status === 'error') {
894
+ html.push(`<div class="media-status media-error-msg">${escapeHtml(stitch.message || 'ν•©μΉ˜κΈ° μ‹€νŒ¨')}</div>`);
895
+ }
896
+
897
+ if (stitch.video_url) {
898
+ html.push(`
899
+ <div class="media-thumb stitch-thumb">
900
+ <video src="${escapeAttr(stitch.video_url)}" controls playsinline preload="metadata"></video>
901
+ </div>
902
+ <div class="stitch-actions">
903
+ <a class="btn-stitch-download"
904
+ href="/api/media/download?url=${encodeURIComponent(stitch.video_url)}&filename=aether_final_seed${sIdx}.mp4"
905
+ title="μ΅œμ’… μ˜μƒ λ‹€μš΄λ‘œλ“œ">↓ μ΅œμ’… μ˜μƒ λ‹€μš΄λ‘œλ“œ</a>
906
+ <button type="button" class="btn-stitch" data-stitch-seed="${sIdx}">πŸ”„ λ‹€μ‹œ ν•©μΉ˜κΈ°</button>
907
+ </div>
908
+ `);
909
+ } else {
910
+ const ready = videoUrls.length;
911
+ const total = seed.beats.length;
912
+ const enough = ready >= 2;
913
+ const disabled = !enough || stitch.status === 'loading' ? 'disabled' : '';
914
+ html.push(`
915
+ <div class="stitch-progress">λΉ„νŠΈ λΉ„λ””μ˜€ ${ready}/${total} μ€€λΉ„ ${enough ? 'Β· ν•©μΉ  수 μžˆμŠ΅λ‹ˆλ‹€' : 'β€” μ΅œμ†Œ 2개 ν•„μš”'}</div>
916
+ <button type="button" class="btn-stitch" data-stitch-seed="${sIdx}" ${disabled}>
917
+ 🎬 λͺ¨λ“  λΉ„νŠΈ μ˜μƒ ν•˜λ‚˜λ‘œ ν•©μΉ˜κΈ°
918
+ </button>
919
+ `);
920
+ }
921
+
922
+ html.push(`</div>`);
923
+ host.innerHTML = html.join('');
924
+ }
925
+
926
+ /** Stitch handler. */
927
+ async function stitchAllVideos(sIdx) {
928
+ const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed;
929
+ if (!seed) return;
930
+ const urls = [];
931
+ for (let i = 0; i < seed.beats.length; i++) {
932
+ const u = MEDIA_STATE[sIdx]?.[i]?.video_url;
933
+ if (u) urls.push(u);
934
+ }
935
+ if (urls.length < 2) {
936
+ STITCH_STATE[sIdx] = { status: 'error', message: 'μ΅œμ†Œ 2개의 λΉ„νŠΈ λΉ„λ””μ˜€κ°€ ν•„μš”ν•©λ‹ˆλ‹€.' };
937
+ renderStitchSection(sIdx);
938
+ return;
939
+ }
940
+ STITCH_STATE[sIdx] = { status: 'loading', message: `🎬 ${urls.length}개 λΉ„λ””μ˜€ ν•©μΉ˜λŠ” 쀑… (λ‹€μš΄λ‘œλ“œ + ffmpeg)` };
941
+ renderStitchSection(sIdx);
942
+ try {
943
+ const r = await fetch('/api/media/stitch', {
944
+ method: 'POST',
945
+ headers: {'Content-Type': 'application/json'},
946
+ body: JSON.stringify({video_urls: urls}),
947
+ });
948
+ const data = await r.json();
949
+ if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
950
+ STITCH_STATE[sIdx] = { status: 'ready', video_url: data.video_url };
951
+ } catch (e) {
952
+ STITCH_STATE[sIdx] = { status: 'error', message: `ν•©μΉ˜κΈ° μ‹€νŒ¨: ${e.message || e}` };
953
+ }
954
+ renderStitchSection(sIdx);
955
+ }
956
+
957
  function escapeAttr(s) {
958
  return String(s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
959
  }
960
 
961
+ /* Delegate clicks on generate / stitch buttons */
962
  document.addEventListener('click', (e) => {
963
+ const stitchBtn = e.target.closest('.btn-stitch');
964
+ if (stitchBtn && !stitchBtn.disabled) {
965
+ const sIdx = parseInt(stitchBtn.dataset.stitchSeed, 10);
966
+ stitchAllVideos(sIdx);
967
+ return;
968
+ }
969
  const btn = e.target.closest('.btn-media');
970
  if (!btn) return;
971
  const sIdx = parseInt(btn.dataset.seed, 10);
static/index.html CHANGED
@@ -10,7 +10,7 @@
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
11
  <link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" rel="stylesheet" />
12
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
13
- <link rel="stylesheet" href="/static/styles.css?v=1.0.0" />
14
  </head>
15
  <body>
16
  <header class="hero">
@@ -249,6 +249,6 @@
249
  </div>
250
  </footer>
251
 
252
- <script src="/static/app.js?v=1.0.0"></script>
253
  </body>
254
  </html>
 
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
11
  <link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" rel="stylesheet" />
12
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script>
13
+ <link rel="stylesheet" href="/static/styles.css?v=1.1.0" />
14
  </head>
15
  <body>
16
  <header class="hero">
 
249
  </div>
250
  </footer>
251
 
252
+ <script src="/static/app.js?v=1.1.0"></script>
253
  </body>
254
  </html>
static/styles.css CHANGED
@@ -890,6 +890,72 @@ input[type="range"] { padding: 0; width: 100%; accent-color: var(--primary); }
890
  .beat-media { grid-column: 1 / 3; }
891
  }
892
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  /* AETHER 5-generator metacognitive provenance (compact one-liner) */
894
  .aether-provenance {
895
  margin-top: 14px;
 
890
  .beat-media { grid-column: 1 / 3; }
891
  }
892
 
893
+ /* ── FINAL STITCH (combine all beat videos) ──────────────────── */
894
+ .final-stitch {
895
+ margin: 6px 0 14px;
896
+ padding: 14px 16px;
897
+ background: linear-gradient(135deg, #f5f3ff 0%, #ffffff 100%);
898
+ border: 1px solid #c4b5fd;
899
+ border-radius: 12px;
900
+ display: flex; flex-direction: column; gap: 10px;
901
+ }
902
+ .stitch-progress {
903
+ font-size: 12px; color: var(--text-muted);
904
+ }
905
+ .btn-stitch {
906
+ align-self: flex-start;
907
+ padding: 9px 16px;
908
+ border: none;
909
+ border-radius: 8px;
910
+ background: linear-gradient(135deg, var(--primary), var(--accent));
911
+ color: white;
912
+ font-weight: 700; font-size: 13px;
913
+ cursor: pointer;
914
+ font-family: inherit;
915
+ transition: all 0.15s;
916
+ box-shadow: 0 4px 12px rgba(79, 70, 229, 0.25);
917
+ }
918
+ .btn-stitch:hover:not(:disabled) {
919
+ transform: translateY(-1px);
920
+ box-shadow: 0 6px 18px rgba(79, 70, 229, 0.35);
921
+ }
922
+ .btn-stitch:disabled {
923
+ opacity: 0.5; cursor: not-allowed;
924
+ background: var(--surface-2); color: var(--text-subtle);
925
+ box-shadow: none;
926
+ }
927
+ .stitch-thumb { max-width: 100%; }
928
+ .stitch-thumb video {
929
+ width: 100%; max-height: 360px; height: auto;
930
+ object-fit: contain;
931
+ background: #0f172a;
932
+ }
933
+ .stitch-actions {
934
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
935
+ }
936
+ .btn-stitch-download {
937
+ display: inline-flex; align-items: center; gap: 6px;
938
+ padding: 8px 14px;
939
+ background: var(--success);
940
+ color: white;
941
+ border-radius: 8px;
942
+ text-decoration: none;
943
+ font-size: 13px; font-weight: 700;
944
+ font-family: inherit;
945
+ box-shadow: 0 3px 10px rgba(16, 185, 129, 0.28);
946
+ transition: all 0.15s;
947
+ }
948
+ .btn-stitch-download:hover {
949
+ transform: translateY(-1px);
950
+ box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4);
951
+ }
952
+
953
+ /* Per-beat regen download button β€” replaces the old β†— open icon */
954
+ .media-download {
955
+ /* upgrade existing style: now a download (proxy) anchor */
956
+ font-weight: 800;
957
+ }
958
+
959
  /* AETHER 5-generator metacognitive provenance (compact one-liner) */
960
  .aether-provenance {
961
  margin-top: 14px;