Spaces:
Running
Running
Download frontend/src/api.js from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 13.5 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/api.js
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/api.js
-
curl -L -o api.js https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/api.js
13.5 kB
| import { currentCtx as trackCtx } from "./lib/track"; | |
| // Client gọi backend FastAPI. Dev: Vite proxy /api -> :8000. | |
| const BASE = ""; | |
| // Máy chủ KHÔNG phải lúc nào cũng trả JSON: Space bận, vừa khởi động lại hoặc | |
| // cổng vào HF hết giờ chờ thì nó chen vào một trang HTML ("<!DOCTYPE html>..."). | |
| // Gọi thẳng r.json() lúc đó ném "Unexpected token '<'" — câu người dùng không | |
| // hiểu, lại che mất chuyện đang thật sự xảy ra. Mọi lời gọi đi qua đây: đọc body | |
| // MỘT lần, tự nhận ra HTML/không-JSON, và gắn cờ `transient` cho những lỗi chỉ | |
| // là hụt hơi tạm thời (mạng chớp, 5xx, 429, 408) để chỗ gọi biết nên thử lại. | |
| const BUSY_MSG = "Máy chủ đang bận hoặc vừa khởi động lại, chưa trả về dữ liệu."; | |
| function tagged(msg, { status = 0, transient = false } = {}) { | |
| const e = new Error(msg); | |
| e.status = status; | |
| e.transient = transient; | |
| return e; | |
| } | |
| // Lỗi FastAPI trả về dạng {"detail": "..."} — lấy ra câu chữ cho người đọc, | |
| // đừng ném nguyên cục JSON (hay nguyên trang HTML) vào alert. | |
| function httpError(r, text, data) { | |
| const d = data?.detail ?? data?.error ?? data?.message; | |
| let msg = ""; | |
| if (typeof d === "string" && d) msg = d; | |
| else if (Array.isArray(d) && d.length) msg = d.map((x) => x.msg || "").join("; "); | |
| else if (text && !/^\s*</.test(text) && text.trim().length <= 300) msg = text.trim(); | |
| else msg = `${BUSY_MSG} (lỗi ${r.status}${r.statusText ? " " + r.statusText : ""})`; | |
| return tagged(msg, { | |
| status: r.status, | |
| transient: r.status >= 500 || r.status === 429 || r.status === 408, | |
| }); | |
| } | |
| async function readJson(r) { | |
| const text = await r.text().catch(() => ""); | |
| let data = null, isJson = false; | |
| if (text) { try { data = JSON.parse(text); isJson = true; } catch { /* không phải JSON */ } } | |
| if (!r.ok) throw httpError(r, text, data); | |
| if (!isJson) throw tagged(`${BUSY_MSG} Thử lại sau giây lát.`, { status: r.status, transient: true }); | |
| return data; | |
| } | |
| const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms)); | |
| // Mở ra cho các trang gọi thẳng (Listing, Flux2, Đăng nhập, Quản trị...) — | |
| // đừng dùng fetch trần rồi r.json() nữa, lỗi "Unexpected token '<'" sinh ra từ đó. | |
| // | |
| // Lời gọi CHỈ ĐỌC (GET/HEAD) gặp trục trặc tạm thời thì tự thử lại vài nhịp: | |
| // đọc lại thì cùng lắm tốn thêm một vòng, không đẻ ra việc mới. Lệnh GHI | |
| // (POST/DELETE) KHÔNG tự thử lại — bấm một lần mà chạy hai job thì tai hại hơn | |
| // là báo lỗi để người dùng tự bấm lại. | |
| export async function request(path, init, { retries = 2, backoff = 600 } = {}) { | |
| const method = (init?.method || "GET").toUpperCase(); | |
| const canRetry = method === "GET" || method === "HEAD"; | |
| for (let attempt = 0; ; attempt++) { | |
| try { | |
| let r; | |
| try { | |
| r = await fetch(BASE + path, init); | |
| } catch (e) { | |
| // Huỷ có chủ đích (AbortController hết giờ chờ) không phải trục trặc | |
| // mạng — trả nguyên lỗi để chỗ gọi tự phân biệt. | |
| if (e?.name === "AbortError") throw e; | |
| // fetch chỉ ném khi mạng đứt / máy chủ chưa nghe — luôn là tạm thời. | |
| throw tagged("Mất kết nối tới máy chủ. Kiểm tra mạng rồi thử lại.", { transient: true }); | |
| } | |
| return await readJson(r); | |
| } catch (e) { | |
| if (!canRetry || !e?.transient || attempt >= retries || init?.signal?.aborted) throw e; | |
| await sleep(backoff * (attempt + 1)); | |
| } | |
| } | |
| } | |
| export const jget = (path) => request(path); | |
| export function jpost(path, body) { | |
| return request(path, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| }); | |
| } | |
| function upload(path, file, fields = {}) { | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v); | |
| return request(path, { method: "POST", body: fd }); | |
| } | |
| // Upload 1 file kèm tiến độ BYTE (fetch không có progress event -> dùng XHR). | |
| // onProgress(loaded, total) gọi liên tục khi bytes đẩy lên. | |
| export function uploadWithProgress(path, file, fields = {}, onProgress) { | |
| return new Promise((resolve, reject) => { | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| for (const [k, v] of Object.entries(fields)) if (v != null) fd.append(k, v); | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open("POST", BASE + path); | |
| // Upload đi bằng XHR (để có % tiến độ) nên KHÔNG qua bản vá fetch trong | |
| // lib/track.js -> gắn tay ngữ cảnh theo dõi để backend vẫn quy được về | |
| // đúng task của đúng người. | |
| try { | |
| const c = trackCtx(); | |
| if (c.user_id) { | |
| xhr.setRequestHeader("X-TNT-User", c.user_id); | |
| xhr.setRequestHeader("X-TNT-Session", c.session_id); | |
| if (c.task_id) xhr.setRequestHeader("X-TNT-Task", c.task_id); | |
| if (c.feature) xhr.setRequestHeader("X-TNT-Feature", c.feature); | |
| } | |
| } catch { /* thiếu header theo dõi không được chặn upload */ } | |
| if (xhr.upload && onProgress) { | |
| xhr.upload.onprogress = (e) => { | |
| if (e.lengthComputable) onProgress(e.loaded, e.total); | |
| }; | |
| } | |
| xhr.onload = () => { | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| // 200 mà thân là HTML (cổng vào chen vào) thì coi như hỏng tạm thời, | |
| // đừng resolve {} rồi để chỗ gọi vấp vào chỗ thiếu trường. | |
| try { resolve(JSON.parse(xhr.responseText)); } | |
| catch { reject(tagged(`${BUSY_MSG} Thử lại sau giây lát.`, { status: xhr.status, transient: true })); } | |
| } else { | |
| // Non-2xx cũng có thể là trang HTML của cổng vào, không phải JSON. | |
| let msg = ""; | |
| try { | |
| const j = JSON.parse(xhr.responseText); | |
| const d = j?.detail ?? j?.error ?? j?.message; | |
| if (typeof d === "string") msg = d; | |
| } catch { /* không phải JSON */ } | |
| if (!msg) { | |
| const t = (xhr.responseText || "").trim(); | |
| msg = t && !t.startsWith("<") && t.length <= 300 | |
| ? t | |
| : `${BUSY_MSG} (lỗi ${xhr.status}${xhr.statusText ? " " + xhr.statusText : ""})`; | |
| } | |
| reject(tagged(msg, { status: xhr.status, transient: xhr.status >= 500 })); | |
| } | |
| }; | |
| xhr.onerror = () => reject(new Error("Lỗi mạng khi tải lên")); | |
| xhr.onabort = () => reject(new Error("Đã huỷ tải lên")); | |
| xhr.send(fd); | |
| }); | |
| } | |
| // Chạy nhiều tác vụ song song có GIỚI HẠN luồng (mặc định 4) — nhanh hơn tuần tự | |
| // mà không làm nghẽn trình duyệt (mỗi host chỉ ~6 kết nối đồng thời). | |
| export async function runPool(count, worker, limit = 4) { | |
| let next = 0; | |
| const run = async () => { | |
| while (next < count) { | |
| const i = next++; | |
| await worker(i); | |
| } | |
| }; | |
| await Promise.all(Array.from({ length: Math.min(limit, count) }, run)); | |
| } | |
| export const fileUrl = (u) => BASE + u; | |
| export const api = { | |
| job: (id) => jget(`/api/jobs/${id}`), | |
| listInputs: (sid) => jget(`/api/sessions/${sid}/inputs`), | |
| cancel: (id) => jpost(`/api/jobs/${id}/cancel`, {}), | |
| dropSession: (sid) => request(`/api/sessions/${sid}`, { method: "DELETE" }), | |
| // Down list video (không dùng cookie) | |
| parseLinks: (raw) => jpost("/api/download/parse", { raw }), | |
| startDownload: (urls, zip_output, username = "") => | |
| jpost("/api/download/start", { urls, zip_output, username }), | |
| push: (session, paths, target_tool) => | |
| jpost("/api/download/push", { session, paths, target_tool }), | |
| // Cut video | |
| cutUpload: (file, session) => upload("/api/cut/upload", file, { session }), | |
| cutPreview: (req) => jpost("/api/cut/preview", req), | |
| cutExecute: (req) => jpost("/api/cut/execute", req), | |
| cutExecuteBatch: (req) => jpost("/api/cut/execute-batch", req), | |
| // segments = mảng nhóm: segments[i] = các khúc thứ i của mọi video -> cùng 1 vị trí Xào | |
| cutPushShuffle: (session, segments) => | |
| jpost("/api/cut/push-shuffle", { session, segments }), | |
| // Tách video -> video không lời + mp3 | |
| cutSplitAudio: (session, file, username = "") => | |
| jpost("/api/cut/split-audio", { session, file, username }), | |
| // Xào video | |
| shuffleUpload: (file, segment, session) => | |
| upload("/api/shuffle/upload", file, { segment, session }), | |
| shuffleUploadAudio: (file, session) => | |
| upload("/api/shuffle/upload-audio", file, { session }), | |
| // Bản có tiến độ byte (dùng cho loader nhanh + chuẩn khi thả clip/thư mục). | |
| shuffleUploadP: (file, segment, session, onProgress) => | |
| uploadWithProgress("/api/shuffle/upload", file, { segment, session }, onProgress), | |
| shuffleUploadAudioP: (file, session, onProgress) => | |
| uploadWithProgress("/api/shuffle/upload-audio", file, { session }, onProgress), | |
| shuffleSegments: (sid) => jget(`/api/shuffle/segments/${sid}`), | |
| shuffleEstimate: (req) => jpost("/api/shuffle/estimate", req), | |
| shufflePlan: (req) => jpost("/api/shuffle/plan", req), | |
| shuffleGenerate: (req) => jpost("/api/shuffle/generate", req), | |
| // Dịch / đổi giọng | |
| dubUpload: (file, session) => upload("/api/dub/upload", file, { session }), | |
| dubVoiceSample: (file, session) => | |
| upload("/api/dub/voice-sample", file, { session }), | |
| dubVoices: (lang) => jget(`/api/dub/voices?lang=${lang}`), | |
| dubStart: (req) => jpost("/api/dub/start", req), | |
| // 2 pha: dịch trước (xem/sửa) rồi mới tạo giọng | |
| dubPrepare: (req) => jpost("/api/dub/prepare", req), | |
| dubSynthesize: (req) => jpost("/api/dub/synthesize", req), | |
| dubTts: (req) => jpost("/api/dub/tts", req), | |
| // Nạp SRT tự tải (thay bản dịch tự động) — trả lines đã bỏ nhãn [tên] | |
| dubSrtImport: (file, session) => upload("/api/dub/subtitle/import", file, { session }), | |
| // Nhận dạng lời gốc trong video (ASR, không dịch) — cho tab "Chèn chữ vào video" | |
| dubTranscribe: (req) => jpost("/api/dub/subtitle/transcribe", req), | |
| // Hard subtitle | |
| subtitlePlan: (session, srt) => jpost("/api/dub/subtitle/plan", { session, srt }), | |
| subtitleBurn: (req) => jpost("/api/dub/subtitle", req), | |
| // Xem trước KHỚP 100% bản burn: server render đúng 1 khung tại `time`. | |
| subtitlePreviewFrame: (session, video, segments, mode, time) => | |
| jpost("/api/dub/subtitle/preview-frame", { session, video, segments, mode, time }), | |
| // Drive | |
| driveStatus: (username) => jget(`/api/drive/status?username=${encodeURIComponent(username)}`), | |
| driveSetup: (username, folder_link) => jpost("/api/drive/setup", { username, folder_link }), | |
| driveRemove: (username) => request(`/api/drive/setup?username=${encodeURIComponent(username)}`, { method: "DELETE" }), | |
| // History | |
| getHistory: (username) => jget(`/api/history?username=${encodeURIComponent(username)}`), | |
| }; | |
| // Poll một job tới khi xong. onTick(job) gọi mỗi lần cập nhật. | |
| // `signal` (AbortSignal, tuỳ chọn): khi component unmount/huỷ, truyền signal đã | |
| // abort để DỪNG vòng poll — tránh rò rỉ setTimeout và setState trên component đã | |
| // gỡ. Khi bị abort, promise KHÔNG resolve (để code đang await không chạy tiếp). | |
| // Chịu đựng máy chủ mất tiếng tối đa chừng này rồi mới báo hỏng. | |
| const POLL_GRACE_MS = 90_000; | |
| export function pollJob(id, onTick, interval = 1000, signal) { | |
| return new Promise((resolve, reject) => { | |
| let timer = null; | |
| const stop = () => { if (timer) clearTimeout(timer); }; | |
| if (signal) signal.addEventListener("abort", stop, { once: true }); | |
| // Job chạy TRÊN MÁY CHỦ, vòng này chỉ đứng ngoài hỏi thăm. Một cú 502 của | |
| // cổng vào Space hay mạng chớp giữa chừng KHÔNG có nghĩa là job hỏng — nên | |
| // hỏi lại, chỉ bỏ cuộc khi mất liên lạc quá lâu. (Trước đây lỗi đầu tiên là | |
| // văng luôn: người dùng mất việc đang chạy dở ở 60%.) | |
| let failedSince = 0; | |
| const tick = async () => { | |
| if (signal && signal.aborted) return; | |
| try { | |
| const job = await api.job(id); | |
| if (signal && signal.aborted) return; | |
| failedSince = 0; | |
| onTick && onTick(job); | |
| if (["done", "error", "cancelled"].includes(job.status)) { | |
| resolve(job); | |
| } else { | |
| timer = setTimeout(tick, interval); | |
| } | |
| } catch (e) { | |
| if (signal && signal.aborted) return; | |
| // Máy chủ trả lời rành mạch "không có job này" -> nó vừa khởi động lại, | |
| // tiến trình trong RAM mất rồi, hỏi thêm cũng vô ích. | |
| if (e?.status === 404) { | |
| return reject(new Error("Máy chủ vừa khởi động lại nên không còn tiến trình này. Hãy chạy lại.")); | |
| } | |
| if (!e?.transient) return reject(e); | |
| const now = Date.now(); | |
| if (!failedSince) failedSince = now; | |
| if (now - failedSince > POLL_GRACE_MS) { | |
| return reject(new Error(`${e.message} (mất liên lạc với máy chủ quá lâu)`)); | |
| } | |
| // Máy chủ đang hụt hơi -> giãn nhịp hỏi ra, đừng dồn thêm. | |
| timer = setTimeout(tick, Math.min(5000, Math.max(interval, interval * 2))); | |
| } | |
| }; | |
| tick(); | |
| }); | |
| } | |