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:
Jeff Emmett 2026-03-16 13:55:31 -07:00
parent bf28e96ae6
commit d270e7c03a
3 changed files with 385 additions and 1 deletions

View File

@ -64,6 +64,7 @@ services:
- INFISICAL_AI_SECRET_PATH=/ai - INFISICAL_AI_SECRET_PATH=/ai
- LISTMONK_URL=https://newsletter.cosmolocal.world - LISTMONK_URL=https://newsletter.cosmolocal.world
- NOTEBOOK_API_URL=http://open-notebook:5055 - NOTEBOOK_API_URL=http://open-notebook:5055
- SPLIT_360_URL=http://video360-splitter:5000
depends_on: depends_on:
rspace-db: rspace-db:
condition: service_healthy condition: service_healthy

View File

@ -6,6 +6,7 @@
*/ */
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
class FolkVideoPlayer extends HTMLElement { class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
@ -16,6 +17,12 @@ class FolkVideoPlayer extends HTMLElement {
private streamKey = ""; private streamKey = "";
private searchTerm = ""; private searchTerm = "";
private isDemo = false; 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 _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false }, { 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 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 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; } .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; } } @media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
</style> </style>
<div class="container"> <div class="container">
@ -133,8 +159,10 @@ class FolkVideoPlayer extends HTMLElement {
</div> </div>
${this.mode === "library" ? this.renderLibrary() : this.renderLive()} ${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
</div> </div>
${this.splitModalOpen ? this.renderSplitModal() : ""}
`; `;
this.bindEvents(); this.bindEvents();
this.bindSplitEvents();
this._tour.renderOverlay(); this._tour.renderOverlay();
} }
@ -186,7 +214,7 @@ class FolkVideoPlayer extends HTMLElement {
const infoBar = this.currentVideo ? ` const infoBar = this.currentVideo ? `
<div class="info-bar"> <div class="info-bar">
<span class="info-name">${this.currentVideo}</span> <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&deg;</button></div>` : ""}
</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 ? ` &mdash; ${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 (&deg;)</label><input type="number" data-split-input="hFov" value="${s.hFov}" min="30" max="360" /></div>
<div><label>V-FOV (&deg;)</label><input type="number" data-split-input="vFov" value="${s.vFov}" min="30" max="180" /></div>
<div><label>Overlap (&deg;)</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&deg; Video</h3>
<button class="btn" data-split="close" style="padding:0.25rem 0.6rem;font-size:0.9rem">&times;</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();
}
} }
} }

View File

@ -15,6 +15,9 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, Pu
const routes = new Hono(); const routes = new Hono();
// ── 360split config ──
const SPLIT_360_URL = process.env.SPLIT_360_URL || "";
// ── R2 / S3 config ── // ── R2 / S3 config ──
const R2_ENDPOINT = process.env.R2_ENDPOINT || ""; const R2_ENDPOINT = process.env.R2_ENDPOINT || "";
const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos"; const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos";
@ -189,6 +192,120 @@ routes.get("/api/info", (c) => {
// GET /api/health // GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true })); 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 ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";