Skip to content

Commit 64d5ecb

Browse files
authored
Merge pull request #1533 from Scottcjn/bottube-studio-rtc
BoTTube Studio: pay RTC to generate video (Alibaba-ready)
2 parents c7a6eda + 7395f50 commit 64d5ecb

3 files changed

Lines changed: 303 additions & 0 deletions

File tree

bottube_server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15428,6 +15428,15 @@ def generate_captions_async(video_id, video_path):
1542815428
except Exception as _sophia_e:
1542915429
print(f"[WARN] Sophia router not loaded: {_sophia_e}")
1543015430

15431+
# --- BoTTube Studio (/studio) — pay RTC to generate video (demo of pay-to-generate on
15432+
# our own token; generation layer pluggable, Alibaba API slots in later). ---
15433+
try:
15434+
from studio_blueprint import studio_bp
15435+
app.register_blueprint(studio_bp)
15436+
print('[studio] registered Studio blueprint')
15437+
except Exception as _studio_e:
15438+
print(f"[WARN] Studio blueprint not loaded: {_studio_e}")
15439+
1543115440
# ---------------------------------------------------------------------------
1543215441
# Push Notification Subscriptions (FCM / Web Push)
1543315442
# ---------------------------------------------------------------------------

bottube_templates/studio.html

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
{% extends "base.html" %}
2+
{% block title %}Studio — Pay RTC, Generate Video{% endblock %}
3+
4+
{% block extra_css %}
5+
<style>
6+
.st-wrap { max-width: 860px; margin: 0 auto; padding: 8px 0 48px; }
7+
.st-hero { text-align:center; padding: 26px 16px 6px; }
8+
.st-hero h1 { font-size: 30px; margin-bottom: 8px; }
9+
.st-hero p { color: var(--text-secondary); font-size:16px; max-width:580px; margin:0 auto; }
10+
.st-bal { text-align:center; margin: 14px 0 6px; color: var(--text-secondary); font-size: 14px; }
11+
.st-bal strong { color:#ffcf33; font-size: 18px; }
12+
.st-prompt { width:100%; box-sizing:border-box; background:var(--bg-secondary); border:1px solid var(--border);
13+
border-radius:var(--radius); color:var(--text-primary); padding:14px; font-size:15px; min-height:84px; resize:vertical; margin:10px 0 6px; }
14+
.tiers { display:grid; grid-template-columns: repeat(3, 1fr); gap:14px; margin-top:8px; }
15+
@media (max-width: 680px){ .tiers { grid-template-columns: 1fr; } }
16+
.tier { position:relative; background:var(--bg-secondary); border:1px solid var(--border); border-left-width:5px;
17+
border-radius:var(--radius); padding:16px 18px; }
18+
.tier.t0 { border-left-color:#2ba640; } .tier.t1 { border-left-color:#3ea6ff; } .tier.t2 { border-left-color:#ff4444; }
19+
.tier h3 { font-size:17px; margin-bottom:4px; } .tier p { color:var(--text-secondary); font-size:13px; margin-bottom:10px; }
20+
.tier .price { font-size:24px; font-weight:800; color:#ffcf33; margin-bottom:10px; }
21+
.tier .tbadge { position:absolute; top:14px; right:14px; font-size:10px; font-weight:700; padding:3px 9px; border-radius:999px; color:#fff; }
22+
.tier.t0 .tbadge { background:#2ba640; } .tier.t1 .tbadge { background:#3ea6ff; } .tier.t2 .tbadge { background:#ff4444; }
23+
.tier .gen { width:100%; padding:10px; border:none; border-radius:999px; background:var(--accent); color:#0a0a0a; font-weight:700; cursor:pointer; }
24+
.tier .gen:hover { background:var(--accent-hover); } .tier .gen:disabled { opacity:.5; cursor:default; }
25+
.st-status { text-align:center; margin:18px auto; max-width:600px; color:var(--text-secondary); font-size:14px; min-height:20px; }
26+
.st-status a { color:var(--accent); font-weight:700; }
27+
.st-foot { text-align:center; color:var(--text-muted); font-size:12px; margin-top:24px; }
28+
</style>
29+
{% endblock %}
30+
31+
{% block content %}
32+
<div class="st-wrap">
33+
<div class="st-hero">
34+
<span style="display:inline-block;background:#3ea6ff;color:#001;font-weight:700;font-size:12px;letter-spacing:.5px;padding:4px 12px;border-radius:999px;margin-bottom:12px;">🎬 BOTTUBE STUDIO</span>
35+
<h1>Pay RTC · Generate an AI Video</h1>
36+
<p>Type a prompt, pick a tier, pay in RTC — we render it on the LTX-2 pipeline and post it to your channel.</p>
37+
</div>
38+
39+
<div class="st-bal" id="st-bal">Checking your RTC balance…</div>
40+
41+
<textarea class="st-prompt" id="st-prompt" maxlength="500" placeholder="Describe the video you want… e.g. 'a neon cajun bayou at night, fireflies over the water, cinematic'"></textarea>
42+
43+
<div class="tiers">
44+
{% for t in studio_tiers %}
45+
<div class="tier t{{ loop.index0 }}">
46+
<span class="tbadge">{{ t.badge }}</span>
47+
<h3>{{ t.name }}</h3>
48+
<p>{{ t.desc }}</p>
49+
<div class="price">{{ t.rtc }} RTC</div>
50+
<button class="gen" data-tier="{{ t.key }}" onclick="stGenerate(this.getAttribute('data-tier'))">Generate · {{ t.rtc }} RTC</button>
51+
</div>
52+
{% endfor %}
53+
</div>
54+
55+
<div class="st-status" id="st-status"></div>
56+
57+
<div class="st-foot">
58+
Generation engine is pluggable — LTX-2 today, more backends soon. RTC powers it; no gatekeepers.
59+
Need RTC? <a href="/bridge/wrtc">Bridge credits</a>.
60+
</div>
61+
</div>
62+
63+
<script>
64+
(function () {
65+
var statusEl = document.getElementById("st-status");
66+
var balEl = document.getElementById("st-bal");
67+
var buttons = function(){ return document.querySelectorAll(".tier .gen"); };
68+
function setBusy(b){ buttons().forEach(function(x){ x.disabled = b; }); }
69+
function say(m){ statusEl.innerHTML = m; }
70+
71+
function loadInfo() {
72+
fetch("/api/studio/info", { credentials: "same-origin" })
73+
.then(function(r){ return r.json(); })
74+
.then(function(d){
75+
if (!d.signed_in) { balEl.innerHTML = 'Sign in to generate — <a href="/login">log in</a>.'; setBusy(true); }
76+
else { balEl.innerHTML = "Your balance: <strong>" + (d.rtc_balance != null ? d.rtc_balance : "?") + " RTC</strong>"; }
77+
})
78+
.catch(function(){ balEl.textContent = ""; });
79+
}
80+
loadInfo();
81+
82+
window.stGenerate = function (tier) {
83+
var prompt = (document.getElementById("st-prompt").value || "").trim();
84+
if (!prompt) { say("Type a prompt first."); return; }
85+
setBusy(true); say("Charging RTC and starting generation…");
86+
fetch("/api/studio/generate", {
87+
method: "POST", headers: { "Content-Type": "application/json" },
88+
credentials: "same-origin", body: JSON.stringify({ prompt: prompt, tier: tier })
89+
}).then(function(r){ return r.json().then(function(d){ return { ok: r.ok, status: r.status, d: d }; }); })
90+
.then(function(res){
91+
if (!res.ok) {
92+
setBusy(false);
93+
if (res.status === 402) say("Not enough RTC — " + (res.d.balance||0) + " RTC, need " + res.d.needed + ".");
94+
else say(res.d.error || ("error " + res.status));
95+
return;
96+
}
97+
balEl.innerHTML = "Your balance: <strong>" + res.d.new_balance + " RTC</strong>";
98+
say("✅ Charged " + res.d.charged_rtc + " RTC. Generating your video… this can take a couple minutes.");
99+
poll(res.d.status_url);
100+
})
101+
.catch(function(){ setBusy(false); say("Couldn't reach the studio. Try again."); });
102+
};
103+
104+
function poll(url) {
105+
var tries = 0;
106+
var iv = setInterval(function(){
107+
tries++;
108+
fetch(url, { credentials: "same-origin" }).then(function(r){ return r.json(); }).then(function(d){
109+
if (d.status === "completed" || d.video_id) {
110+
clearInterval(iv); setBusy(false);
111+
var watch = d.watch_url || (d.video_id ? ("/watch/" + d.video_id) : null);
112+
say(watch ? ("🎬 Done! <a href='" + watch + "'>Watch your video →</a>") : "🎬 Done — check your channel.");
113+
} else if (d.status === "failed" || d.error) {
114+
clearInterval(iv); setBusy(false);
115+
say("Generation failed: " + (d.error || "unknown") + ". (RTC for failed jobs is refunded.)");
116+
} else {
117+
say("Generating… (" + (d.status || "working") + ")");
118+
}
119+
}).catch(function(){ /* keep polling */ });
120+
if (tries > 120) { clearInterval(iv); setBusy(false); say("Still working — check your channel shortly."); }
121+
}, 3000);
122+
}
123+
})();
124+
</script>
125+
{% endblock %}

studio_blueprint.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Author: @Scottcjn (Elyan Labs)
3+
"""
4+
BoTTube Studio — pay RTC to generate a video. Technical demo of the pay-to-generate
5+
flow on RustChain's own RTC token (no external gatekeeper, ships today). The GENERATION
6+
layer is pluggable: today it uses the existing /api/generate-video cascade
7+
(LTX -> Ken Burns -> ffmpeg); when the Alibaba Cloud video API + SDK arrive, Alibaba
8+
slots into that cascade (video_providers.py) with NO change to this Studio code — the
9+
currency + UI here stay identical.
10+
11+
Endpoints:
12+
GET /studio -> the Studio storefront page
13+
GET /api/studio/info -> tiers + caller's RTC balance
14+
POST /api/studio/generate -> {prompt, tier} : atomic RTC debit + start generation
15+
16+
RTC debit is atomic (conditional UPDATE ... WHERE rtc_balance >= cost) and refunds if
17+
the generation fails to start.
18+
"""
19+
import os
20+
import sqlite3
21+
import threading
22+
import time
23+
from pathlib import Path
24+
25+
from flask import Blueprint, jsonify, render_template, request, session
26+
27+
studio_bp = Blueprint("studio", __name__)
28+
29+
# RTC price per tier (env-overridable). Demo defaults; tune freely.
30+
STUDIO_TIERS = {
31+
"text_card": {"rtc": float(os.environ.get("STUDIO_RTC_TEXT", "1")),
32+
"name": "Text Card", "desc": "Instant title-card video", "badge": "CHEAPEST", "duration": 5},
33+
"ken_burns": {"rtc": float(os.environ.get("STUDIO_RTC_KENBURNS", "3")),
34+
"name": "Ken Burns", "desc": "Cinematic pan & zoom over images", "badge": "POPULAR", "duration": 8},
35+
"full_ai": {"rtc": float(os.environ.get("STUDIO_RTC_FULLAI", "5")),
36+
"name": "Full AI Video", "desc": "LTX-2 generated, with audio", "badge": "PREMIUM", "duration": 8},
37+
}
38+
PROMPT_MAX = 500
39+
_rate = {}
40+
_COOLDOWN = float(os.environ.get("STUDIO_COOLDOWN", "30")) # per-caller, protects the GPU
41+
42+
43+
def _db_path() -> str:
44+
base = os.environ.get("BOTTUBE_BASE_DIR", str(Path(__file__).resolve().parent))
45+
return os.environ.get("BOTTUBE_DB_PATH", str(Path(base) / "bottube.db"))
46+
47+
48+
def _conn():
49+
c = sqlite3.connect(_db_path(), timeout=30)
50+
c.row_factory = sqlite3.Row
51+
c.execute("PRAGMA busy_timeout=30000")
52+
return c
53+
54+
55+
def _resolve_caller(conn):
56+
"""(id, agent_name, rtc_balance) for an API agent (X-API-Key/agent_api_key) or a
57+
logged-in human (session), else None."""
58+
api_key = request.headers.get("X-API-Key", "")
59+
if not api_key:
60+
api_key = ((request.get_json(silent=True) or {}).get("agent_api_key") or "").strip()
61+
if api_key:
62+
row = conn.execute(
63+
"SELECT id, agent_name, rtc_balance FROM agents WHERE api_key=? AND COALESCE(is_banned,0)=0",
64+
(api_key,)).fetchone()
65+
if row:
66+
return row
67+
uid = session.get("user_id")
68+
if uid:
69+
row = conn.execute(
70+
"SELECT id, agent_name, rtc_balance FROM agents WHERE id=? AND COALESCE(is_banned,0)=0",
71+
(uid,)).fetchone()
72+
if row:
73+
return row
74+
return None
75+
76+
77+
@studio_bp.route("/studio")
78+
def studio_home():
79+
tiers = [{"key": k, **v} for k, v in STUDIO_TIERS.items()]
80+
return render_template("studio.html", studio_tiers=tiers)
81+
82+
83+
@studio_bp.route("/api/studio/info", methods=["GET"])
84+
def studio_info():
85+
conn = _conn()
86+
try:
87+
caller = _resolve_caller(conn)
88+
bal = round(caller["rtc_balance"], 6) if caller else None
89+
signed_in = caller is not None
90+
finally:
91+
conn.close()
92+
return jsonify({
93+
"ok": True,
94+
"signed_in": signed_in,
95+
"rtc_balance": bal,
96+
"tiers": {k: v["rtc"] for k, v in STUDIO_TIERS.items()},
97+
})
98+
99+
100+
@studio_bp.route("/api/studio/generate", methods=["POST"])
101+
def studio_generate():
102+
body = request.get_json(silent=True) or {}
103+
prompt = (body.get("prompt") or "").strip()
104+
tier = (body.get("tier") or "").strip()
105+
if not prompt:
106+
return jsonify({"error": "prompt required"}), 400
107+
if len(prompt) > PROMPT_MAX:
108+
return jsonify({"error": f"prompt exceeds {PROMPT_MAX} characters"}), 400
109+
if tier not in STUDIO_TIERS:
110+
return jsonify({"error": "unknown tier"}), 400
111+
cost = STUDIO_TIERS[tier]["rtc"]
112+
113+
conn = _conn()
114+
try:
115+
caller = _resolve_caller(conn)
116+
if not caller:
117+
return jsonify({"error": "sign in (or use an API key) to generate"}), 401
118+
agent_id = caller["id"]
119+
120+
# Per-caller cooldown (protect the single GPU).
121+
now = time.time()
122+
last = _rate.get(agent_id, 0)
123+
if now - last < _COOLDOWN:
124+
return jsonify({"error": "slow down a moment",
125+
"retry_after": round(_COOLDOWN - (now - last), 1)}), 429
126+
127+
# ATOMIC debit: only succeeds if balance covers the cost (prevents races/overspend).
128+
cur = conn.execute(
129+
"UPDATE agents SET rtc_balance = rtc_balance - ? WHERE id = ? AND rtc_balance >= ?",
130+
(cost, agent_id, cost))
131+
conn.commit()
132+
if cur.rowcount == 0:
133+
bal = conn.execute("SELECT rtc_balance FROM agents WHERE id=?", (agent_id,)).fetchone()
134+
return jsonify({"error": "insufficient RTC balance", "needed": cost,
135+
"balance": round(bal["rtc_balance"], 6) if bal else 0}), 402
136+
_rate[agent_id] = now
137+
new_balance = conn.execute("SELECT rtc_balance FROM agents WHERE id=?", (agent_id,)).fetchone()["rtc_balance"]
138+
finally:
139+
conn.close()
140+
141+
# Start generation by reusing the existing cascade (Alibaba slots in here later).
142+
try:
143+
from video_gen_blueprint import _create_job, _generation_worker
144+
job_id = _create_job(agent_id, prompt)
145+
threading.Thread(
146+
target=_generation_worker,
147+
args=(job_id, agent_id, prompt, STUDIO_TIERS[tier]["duration"], "ai-art", prompt[:200]),
148+
daemon=True,
149+
).start()
150+
except Exception as e:
151+
# Refund on failure to start — never charge for a job we couldn't launch.
152+
try:
153+
rc = _conn()
154+
rc.execute("UPDATE agents SET rtc_balance = rtc_balance + ? WHERE id = ?", (cost, agent_id))
155+
rc.commit(); rc.close()
156+
except sqlite3.Error:
157+
pass
158+
print(f"[studio] generation start failed (refunded {cost} RTC): {e}", flush=True)
159+
return jsonify({"error": "couldn't start generation; your RTC was refunded"}), 502
160+
161+
return jsonify({
162+
"ok": True,
163+
"job_id": job_id,
164+
"tier": tier,
165+
"charged_rtc": cost,
166+
"new_balance": round(new_balance, 6),
167+
"status_url": f"/api/generate-video/status/{job_id}",
168+
"message": "Generation started. Poll status_url for the video.",
169+
}), 202

0 commit comments

Comments
 (0)