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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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°</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 ? ` — ${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();
|
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";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue