feat(rtube): integrate 360split for splitting 360° videos into flat perspectives
Server-side proxy routes (POST /api/360split, GET status, POST import) fetch video from R2, submit to video360-splitter, and import results back. Frontend adds Split 360° button with settings modal, progress polling, and library import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bf28e96ae6
commit
d270e7c03a
|
|
@ -64,6 +64,7 @@ services:
|
|||
- INFISICAL_AI_SECRET_PATH=/ai
|
||||
- LISTMONK_URL=https://newsletter.cosmolocal.world
|
||||
- NOTEBOOK_API_URL=http://open-notebook:5055
|
||||
- SPLIT_360_URL=http://video360-splitter:5000
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { TourEngine } from "../../../shared/tour-engine";
|
||||
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
|
||||
|
||||
class FolkVideoPlayer extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
|
|
@ -16,6 +17,12 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
private streamKey = "";
|
||||
private searchTerm = "";
|
||||
private isDemo = false;
|
||||
private splitModalOpen = false;
|
||||
private splitJob: { jobId: string; status: string; progress: number; currentView: string; outputFiles: string[]; error: string } | null = null;
|
||||
private splitSettings = { numViews: 4, hFov: 90, vFov: 90, overlap: 0, outputRes: "" };
|
||||
private splitPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private splitImporting = false;
|
||||
private splitImported: string[] | null = null;
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
|
||||
|
|
@ -122,6 +129,25 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
.setup h3 { font-size: 0.875rem; color: #ef4444; margin-bottom: 0.5rem; }
|
||||
.setup code { display: block; background: var(--rs-input-bg); padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.75rem; color: var(--rs-text-secondary); margin-top: 0.5rem; overflow-x: auto; }
|
||||
.setup ol { padding-left: 1.25rem; color: var(--rs-text-secondary); font-size: 0.8rem; line-height: 1.8; }
|
||||
.split-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.split-modal { background: var(--rs-bg-surface, #1a1a2e); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.5rem; width: 420px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
|
||||
.split-modal h3 { margin: 0 0 1rem; font-size: 1rem; }
|
||||
.split-modal label { display: block; font-size: 0.8rem; color: var(--rs-text-secondary); margin-bottom: 0.25rem; }
|
||||
.split-modal input[type="number"], .split-modal input[type="text"] { margin-bottom: 0.75rem; }
|
||||
.view-btns { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.view-btn { flex: 1; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; }
|
||||
.view-btn.active { background: #ef4444; color: white; border-color: #ef4444; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.75rem; }
|
||||
.split-progress { margin-top: 1rem; }
|
||||
.progress-bar { height: 8px; background: var(--rs-bg-hover, #333); border-radius: 4px; overflow: hidden; margin: 0.5rem 0; }
|
||||
.progress-fill { height: 100%; background: #ef4444; border-radius: 4px; transition: width 0.3s; }
|
||||
.split-status { font-size: 0.8rem; color: var(--rs-text-secondary); }
|
||||
.split-error { color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem; }
|
||||
.split-results { margin-top: 1rem; }
|
||||
.split-results li { font-size: 0.8rem; color: var(--rs-text-secondary); padding: 0.25rem 0; list-style: none; }
|
||||
.split-results li::before { content: ""; display: inline-block; width: 6px; height: 6px; background: #22c55e; border-radius: 50%; margin-right: 0.5rem; vertical-align: middle; }
|
||||
.btn-success { padding: 0.75rem 1.5rem; background: #22c55e; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; width: 100%; font-size: 0.875rem; margin-top: 0.75rem; }
|
||||
.btn-success:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<div class="container">
|
||||
|
|
@ -133,8 +159,10 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
</div>
|
||||
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
|
||||
</div>
|
||||
${this.splitModalOpen ? this.renderSplitModal() : ""}
|
||||
`;
|
||||
this.bindEvents();
|
||||
this.bindSplitEvents();
|
||||
this._tour.renderOverlay();
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +214,7 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
const infoBar = this.currentVideo ? `
|
||||
<div class="info-bar">
|
||||
<span class="info-name">${this.currentVideo}</span>
|
||||
${!this.isDemo ? `<div class="actions"><button class="btn" data-action="copy">Copy Link</button></div>` : ""}
|
||||
${!this.isDemo ? `<div class="actions"><button class="btn" data-action="copy">Copy Link</button><button class="btn" data-action="split360">Split 360°</button></div>` : ""}
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
|
|
@ -262,6 +290,244 @@ class FolkVideoPlayer extends HTMLElement {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
const splitBtn = this.shadow.querySelector('[data-action="split360"]');
|
||||
if (splitBtn) {
|
||||
splitBtn.addEventListener("click", () => {
|
||||
if (!requireAuth("split 360° video")) return;
|
||||
this.splitJob = null;
|
||||
this.splitImported = null;
|
||||
this.splitImporting = false;
|
||||
this.splitModalOpen = true;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderSplitModal(): string {
|
||||
const s = this.splitSettings;
|
||||
const job = this.splitJob;
|
||||
const isProcessing = job && (job.status === "queued" || job.status === "processing");
|
||||
const isComplete = job?.status === "complete";
|
||||
const isError = job?.status === "error";
|
||||
|
||||
let body: string;
|
||||
if (this.splitImported) {
|
||||
body = `
|
||||
<p style="color:#22c55e;font-weight:500;margin-bottom:0.5rem">Imported ${this.splitImported.length} files to library</p>
|
||||
<ul class="split-results" style="padding:0;margin:0">${this.splitImported.map(f => `<li>${f}</li>`).join("")}</ul>
|
||||
<button class="btn-primary" data-split="close" style="margin-top:1rem">Done</button>
|
||||
`;
|
||||
} else if (isComplete) {
|
||||
body = `
|
||||
<p style="color:#22c55e;font-weight:500;margin-bottom:0.5rem">Split complete!</p>
|
||||
<ul class="split-results" style="padding:0;margin:0">${(job!.outputFiles || []).map(f => `<li>${f}</li>`).join("")}</ul>
|
||||
<button class="btn-success" data-split="import" ${this.splitImporting ? "disabled" : ""}>${this.splitImporting ? "Importing..." : "Import All to Library"}</button>
|
||||
`;
|
||||
} else if (isProcessing) {
|
||||
body = `
|
||||
<div class="split-progress">
|
||||
<div class="split-status">Status: ${job!.status}${job!.currentView ? ` — ${job!.currentView}` : ""}</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${job!.progress || 0}%"></div></div>
|
||||
<div class="split-status">${job!.progress || 0}% complete</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (isError) {
|
||||
body = `
|
||||
<div class="split-error">${job!.error || "Unknown error"}</div>
|
||||
<button class="btn-primary" data-split="retry" style="margin-top:0.75rem">Retry</button>
|
||||
`;
|
||||
} else {
|
||||
body = `
|
||||
<label>Number of Views</label>
|
||||
<div class="view-btns">
|
||||
${[2, 3, 4, 6].map(n => `<button class="view-btn ${s.numViews === n ? "active" : ""}" data-views="${n}">${n}</button>`).join("")}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div><label>H-FOV (°)</label><input type="number" data-split-input="hFov" value="${s.hFov}" min="30" max="360" /></div>
|
||||
<div><label>V-FOV (°)</label><input type="number" data-split-input="vFov" value="${s.vFov}" min="30" max="180" /></div>
|
||||
<div><label>Overlap (°)</label><input type="number" data-split-input="overlap" value="${s.overlap}" min="0" max="90" /></div>
|
||||
<div><label>Output Resolution</label><input type="text" data-split-input="outputRes" value="${s.outputRes}" placeholder="e.g. 1920x1080" /></div>
|
||||
</div>
|
||||
<button class="btn-primary" data-split="start">Start Split</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="split-overlay" data-split="overlay">
|
||||
<div class="split-modal">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
|
||||
<h3 style="margin:0">Split 360° Video</h3>
|
||||
<button class="btn" data-split="close" style="padding:0.25rem 0.6rem;font-size:0.9rem">×</button>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:var(--rs-text-secondary);margin-bottom:1rem">${this.currentVideo}</p>
|
||||
${body}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private bindSplitEvents() {
|
||||
if (!this.splitModalOpen) return;
|
||||
|
||||
// Close modal
|
||||
this.shadow.querySelector('[data-split="overlay"]')?.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).dataset.split === "overlay") this.closeSplitModal();
|
||||
});
|
||||
this.shadow.querySelectorAll('[data-split="close"]').forEach(el =>
|
||||
el.addEventListener("click", () => this.closeSplitModal())
|
||||
);
|
||||
|
||||
// View count buttons
|
||||
this.shadow.querySelectorAll(".view-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const n = parseInt((btn as HTMLElement).dataset.views || "4");
|
||||
this.splitSettings.numViews = n;
|
||||
this.splitSettings.hFov = Math.round(360 / n);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Settings inputs
|
||||
this.shadow.querySelectorAll("[data-split-input]").forEach(input => {
|
||||
input.addEventListener("change", () => {
|
||||
const key = (input as HTMLElement).dataset.splitInput as keyof typeof this.splitSettings;
|
||||
const val = (input as HTMLInputElement).value;
|
||||
if (key === "outputRes") {
|
||||
this.splitSettings[key] = val;
|
||||
} else {
|
||||
(this.splitSettings as any)[key] = parseFloat(val) || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start
|
||||
this.shadow.querySelector('[data-split="start"]')?.addEventListener("click", () => this.startSplitJob());
|
||||
|
||||
// Retry
|
||||
this.shadow.querySelector('[data-split="retry"]')?.addEventListener("click", () => {
|
||||
this.splitJob = null;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Import
|
||||
this.shadow.querySelector('[data-split="import"]')?.addEventListener("click", () => this.importSplitResults());
|
||||
}
|
||||
|
||||
private closeSplitModal() {
|
||||
this.splitModalOpen = false;
|
||||
if (this.splitPollInterval) {
|
||||
clearInterval(this.splitPollInterval);
|
||||
this.splitPollInterval = null;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async startSplitJob() {
|
||||
if (!this.currentVideo) return;
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const s = this.splitSettings;
|
||||
|
||||
this.splitJob = { jobId: "", status: "queued", progress: 0, currentView: "", outputFiles: [], error: "" };
|
||||
this.render();
|
||||
|
||||
try {
|
||||
const resp = await authFetch(`${base}/api/360split`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
videoName: this.currentVideo,
|
||||
numViews: s.numViews,
|
||||
hFov: s.hFov,
|
||||
vFov: s.vFov,
|
||||
overlap: s.overlap || undefined,
|
||||
outputRes: s.outputRes || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({ error: "Request failed" }));
|
||||
this.splitJob = { jobId: "", status: "error", progress: 0, currentView: "", outputFiles: [], error: data.error || `HTTP ${resp.status}` };
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const { jobId } = await resp.json();
|
||||
this.splitJob.jobId = jobId;
|
||||
this.splitJob.status = "queued";
|
||||
this.render();
|
||||
this.pollSplitStatus();
|
||||
} catch (e: any) {
|
||||
this.splitJob = { jobId: "", status: "error", progress: 0, currentView: "", outputFiles: [], error: e.message || "Network error" };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private pollSplitStatus() {
|
||||
if (this.splitPollInterval) clearInterval(this.splitPollInterval);
|
||||
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
const jobId = this.splitJob?.jobId;
|
||||
if (!jobId) return;
|
||||
|
||||
this.splitPollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${base}/api/360split/status/${jobId}`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
this.splitJob = {
|
||||
jobId,
|
||||
status: data.status,
|
||||
progress: data.progress || 0,
|
||||
currentView: data.current_view || "",
|
||||
outputFiles: data.output_files || [],
|
||||
error: data.error || "",
|
||||
};
|
||||
this.render();
|
||||
|
||||
if (data.status === "complete" || data.status === "error") {
|
||||
clearInterval(this.splitPollInterval!);
|
||||
this.splitPollInterval = null;
|
||||
}
|
||||
} catch { /* retry on next poll */ }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async importSplitResults() {
|
||||
if (!this.splitJob?.jobId) return;
|
||||
this.splitImporting = true;
|
||||
this.render();
|
||||
|
||||
const base = window.location.pathname.replace(/\/$/, "");
|
||||
try {
|
||||
const resp = await authFetch(`${base}/api/360split/import/${this.splitJob.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ originalName: this.currentVideo }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({ error: "Import failed" }));
|
||||
this.splitJob.status = "error";
|
||||
this.splitJob.error = data.error || "Import failed";
|
||||
this.splitImporting = false;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const { imported } = await resp.json();
|
||||
this.splitImported = imported;
|
||||
this.splitImporting = false;
|
||||
this.render();
|
||||
// Refresh video list to show imported files
|
||||
this.loadVideos();
|
||||
} catch (e: any) {
|
||||
this.splitJob!.status = "error";
|
||||
this.splitJob!.error = e.message || "Import failed";
|
||||
this.splitImporting = false;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, Pu
|
|||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── 360split config ──
|
||||
const SPLIT_360_URL = process.env.SPLIT_360_URL || "";
|
||||
|
||||
// ── R2 / S3 config ──
|
||||
const R2_ENDPOINT = process.env.R2_ENDPOINT || "";
|
||||
const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos";
|
||||
|
|
@ -189,6 +192,120 @@ routes.get("/api/info", (c) => {
|
|||
// GET /api/health
|
||||
routes.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── 360° Split routes ──
|
||||
|
||||
// POST /api/360split — start a split job
|
||||
routes.post("/api/360split", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
|
||||
|
||||
const client = getS3();
|
||||
if (!client) return c.json({ error: "R2 not configured" }, 503);
|
||||
|
||||
const { videoName, numViews, hFov, vFov, overlap, outputRes } = await c.req.json();
|
||||
if (!videoName) return c.json({ error: "videoName required" }, 400);
|
||||
|
||||
try {
|
||||
// Fetch video from R2
|
||||
const obj = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: videoName }));
|
||||
const bytes = await obj.Body!.transformToByteArray();
|
||||
|
||||
// Build multipart form for 360split
|
||||
const form = new FormData();
|
||||
form.append("video", new Blob([bytes.buffer]), videoName);
|
||||
if (numViews) form.append("num_views", String(numViews));
|
||||
if (hFov) form.append("h_fov", String(hFov));
|
||||
if (vFov) form.append("v_fov", String(vFov));
|
||||
if (overlap) form.append("overlap", String(overlap));
|
||||
if (outputRes) form.append("output_res", outputRes);
|
||||
|
||||
const resp = await fetch(`${SPLIT_360_URL}/upload`, { method: "POST", body: form });
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
return c.json({ error: `360split upload failed: ${text}` }, 502);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return c.json({ jobId: data.job_id });
|
||||
} catch (e: any) {
|
||||
if (e.name === "NoSuchKey") return c.json({ error: "Video not found in library" }, 404);
|
||||
console.error("[Tube] 360split start error:", e);
|
||||
return c.json({ error: "Failed to start split job" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/360split/status/:jobId — poll job status
|
||||
routes.get("/api/360split/status/:jobId", async (c) => {
|
||||
if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
|
||||
|
||||
const jobId = c.req.param("jobId");
|
||||
try {
|
||||
const resp = await fetch(`${SPLIT_360_URL}/status/${jobId}`);
|
||||
if (!resp.ok) return c.json({ error: "Status check failed" }, resp.status as any);
|
||||
return c.json(await resp.json());
|
||||
} catch (e) {
|
||||
console.error("[Tube] 360split status error:", e);
|
||||
return c.json({ error: "Cannot reach 360split service" }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/360split/import/:jobId — import results to R2
|
||||
routes.post("/api/360split/import/:jobId", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
|
||||
const client = getS3();
|
||||
if (!client) return c.json({ error: "R2 not configured" }, 503);
|
||||
|
||||
const jobId = c.req.param("jobId");
|
||||
const { originalName } = await c.req.json().catch(() => ({ originalName: "" }));
|
||||
|
||||
try {
|
||||
// Get output file list from status
|
||||
const statusResp = await fetch(`${SPLIT_360_URL}/status/${jobId}`);
|
||||
if (!statusResp.ok) return c.json({ error: "Could not get job status" }, 502);
|
||||
const status = await statusResp.json();
|
||||
if (status.status !== "complete") return c.json({ error: "Job not complete yet" }, 400);
|
||||
|
||||
const baseName = originalName
|
||||
? originalName.replace(/\.[^.]+$/, "")
|
||||
: `video-${jobId}`;
|
||||
const imported: string[] = [];
|
||||
|
||||
for (const filename of status.output_files || []) {
|
||||
// Download from 360split
|
||||
const dlResp = await fetch(`${SPLIT_360_URL}/download/${jobId}/${filename}`);
|
||||
if (!dlResp.ok) {
|
||||
console.error(`[Tube] 360split download failed: ${filename}`);
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await dlResp.arrayBuffer());
|
||||
const key = `360split/${baseName}/${filename}`;
|
||||
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: "video/mp4",
|
||||
ContentLength: buffer.length,
|
||||
}));
|
||||
imported.push(key);
|
||||
}
|
||||
|
||||
// Cleanup temp files on 360split
|
||||
await fetch(`${SPLIT_360_URL}/cleanup/${jobId}`, { method: "POST" }).catch(() => {});
|
||||
|
||||
return c.json({ ok: true, imported });
|
||||
} catch (e) {
|
||||
console.error("[Tube] 360split import error:", e);
|
||||
return c.json({ error: "Import failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
Loading…
Reference in New Issue