Spaces:
Running
Running
feat: all-beats media chain + per-beat regen + ffmpeg stitch + download proxy
Browse files- Dockerfile +1 -0
- app.py +117 -2
- static/app.js +210 -59
- static/index.html +2 -2
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
async function autoMediaChain(seeds) {
|
| 622 |
if (!seeds || !seeds.length) return;
|
| 623 |
-
const
|
| 624 |
-
const
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
const
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
kind: 'image',
|
| 633 |
-
message: i === 0
|
| 634 |
-
? 'πΌ ν
μ€νΈ β μ΄λ―Έμ§ μμ± μ€β¦'
|
| 635 |
-
: `πΌ μ΄λ―Έμ§ νΈμ§ μ€ (μ°Έμ‘° ${refUrls.length}κ°)β¦`,
|
| 636 |
-
});
|
| 637 |
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
}
|
| 657 |
}
|
| 658 |
|
| 659 |
-
|
| 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: '
|
| 666 |
-
message:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 673 |
});
|
| 674 |
const data = await r.json();
|
| 675 |
if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
|
| 676 |
-
_setMediaState(
|
|
|
|
| 677 |
} catch (e) {
|
| 678 |
-
_setMediaState(
|
| 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,
|
| 767 |
const parts = [];
|
| 768 |
-
// Status
|
| 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
|
| 779 |
-
<a class="media-download"
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
| 789 |
</div>
|
| 790 |
`);
|
| 791 |
}
|
| 792 |
-
// Controls β
|
| 793 |
-
if (
|
| 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, '"').replace(/</g, '<');
|
| 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, '"').replace(/</g, '<');
|
| 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.
|
| 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.
|
| 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;
|