diff --git a/docker-compose.yml b/docker-compose.yml index 23ac364..bc8d33b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/modules/rtube/components/folk-video-player.ts b/modules/rtube/components/folk-video-player.ts index a9c2b70..f952954 100644 --- a/modules/rtube/components/folk-video-player.ts +++ b/modules/rtube/components/folk-video-player.ts @@ -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 | 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; } }
@@ -133,8 +159,10 @@ class FolkVideoPlayer extends HTMLElement {
${this.mode === "library" ? this.renderLibrary() : this.renderLive()} + ${this.splitModalOpen ? this.renderSplitModal() : ""} `; this.bindEvents(); + this.bindSplitEvents(); this._tour.renderOverlay(); } @@ -186,7 +214,7 @@ class FolkVideoPlayer extends HTMLElement { const infoBar = this.currentVideo ? `
${this.currentVideo} - ${!this.isDemo ? `
` : ""} + ${!this.isDemo ? `
` : ""}
` : ""; @@ -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 = ` +

Imported ${this.splitImported.length} files to library

+ + + `; + } else if (isComplete) { + body = ` +

Split complete!

+ + + `; + } else if (isProcessing) { + body = ` +
+
Status: ${job!.status}${job!.currentView ? ` — ${job!.currentView}` : ""}
+
+
${job!.progress || 0}% complete
+
+ `; + } else if (isError) { + body = ` +
${job!.error || "Unknown error"}
+ + `; + } else { + body = ` + +
+ ${[2, 3, 4, 6].map(n => ``).join("")} +
+
+
+
+
+
+
+ + `; + } + + return ` +
+
+
+

Split 360° Video

+ +
+

${this.currentVideo}

+ ${body} +
+
+ `; + } + + 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(); + } } } diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts index ad0d0bd..99ac48f 100644 --- a/modules/rtube/mod.ts +++ b/modules/rtube/mod.ts @@ -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";