Download server.js from ReikerXx/ai-tik: direct link, hf CLI and curl.
- Browser
- Download file 40.6 kB
-
https://huggingface.co/spaces/ReikerXx/ai-tik/resolve/main/server.js
- Command line
-
hf download hf://spaces/ReikerXx/ai-tik/server.js
-
curl -L -o server.js https://huggingface.co/spaces/ReikerXx/ai-tik/resolve/main/server.js
40.6 kB
| ; | |
| // ============================================================ | |
| // TikTok Would-You-Rather Bot β Express + CommonJS | |
| // ============================================================ | |
| require("dotenv").config(); | |
| const express = require("express"); | |
| const axios = require("axios"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const crypto = require("crypto"); | |
| const jwt = require("jsonwebtoken"); | |
| const cookieParser = require("cookie-parser"); | |
| const { createCanvas } = require("canvas"); | |
| const ffmpeg = require("fluent-ffmpeg"); | |
| const { createSlideshow } = require("slideshow-video"); | |
| const cors = require("cors"); | |
| const app = express(); | |
| // ββ CORS β must come before all routes ββββββββββββββββββββββββββββββββββββββ | |
| // Allows the browser to send cookies when frontend and API share the same | |
| // origin (content.nett.to). If you ever serve the frontend from a different | |
| // origin, add it to the `origin` array below. | |
| app.use(cors({ | |
| origin: function(origin, callback) { | |
| // Allow same-origin requests (origin is undefined) and any content.nett.to origin | |
| const allowed = [ | |
| "https://content.nett.to", | |
| "http://localhost:3000", | |
| "http://localhost:5173", | |
| ]; | |
| if (!origin || allowed.includes(origin)) return callback(null, true); | |
| return callback(null, true); // allow all for now β tighten after confirming it works | |
| }, | |
| credentials: true, // REQUIRED for cookies to be sent cross-origin | |
| })); | |
| app.use(express.json()); | |
| app.use(express.urlencoded({ extended: true })); | |
| app.use(cookieParser()); | |
| app.use(express.static(path.join(__dirname, "public"))); | |
| // ββ Cookie debug (remove after confirming auth works) βββββββββββββββββββββββ | |
| app.use((req, _res, next) => { | |
| if (req.path === "/me" || req.path.startsWith("/auth")) { | |
| console.log(`[${req.method} ${req.path}] cookies:`, req.cookies); | |
| } | |
| next(); | |
| }); | |
| // ============================================================ | |
| // CONFIG β fill before running | |
| // ============================================================ | |
| const CONFIG = { | |
| TIKTOK_CLIENT_KEY: process.env.TIKTOK_CLIENT_KEY || "hh", | |
| TIKTOK_CLIENT_SECRET: process.env.TIKTOK_CLIENT_SECRET || "hh", | |
| TIKTOK_REDIRECT_URI: process.env.TIKTOK_REDIRECT_URI || "https://content.nett.to/auth/tiktok/callback", | |
| JWT_SECRET: process.env.JWT_SECRET || "key", | |
| LONGCAT_API_KEY: process.env.LONGCAT_API_KEY || "akkey", | |
| PORT: process.env.PORT || 3000, | |
| CHUNK_SIZE: 10 * 1024 * 1024, // 10MB | |
| JOBS_DIR: path.join(__dirname, "jobs"), | |
| // Set to false only if running behind an HTTP reverse proxy during dev | |
| IS_PROD: process.env.NODE_ENV !== "development", | |
| }; | |
| // Ensure base jobs directory exists | |
| if (!fs.existsSync(CONFIG.JOBS_DIR)) fs.mkdirSync(CONFIG.JOBS_DIR, { recursive: true }); | |
| /** Create per-job isolated directories and return their paths. */ | |
| function makeJobDirs(jobId) { | |
| const jobDir = path.join(CONFIG.JOBS_DIR, jobId); | |
| const audioDir = path.join(jobDir, "audio"); | |
| const framesDir = path.join(jobDir, "frames"); | |
| const videosDir = path.join(jobDir, "video"); | |
| [audioDir, framesDir, videosDir].forEach((d) => fs.mkdirSync(d, { recursive: true })); | |
| return { jobDir, audioDir, framesDir, videosDir }; | |
| } | |
| // ============================================================ | |
| // JWT MIDDLEWARE | |
| // ============================================================ | |
| function requireAuth(req, res, next) { | |
| const token = req.cookies?.wyr_token; | |
| if (!token) { | |
| return res.status(401).json({ error: "Not authenticated" }); | |
| } | |
| try { | |
| req.user = jwt.verify(token, CONFIG.JWT_SECRET); | |
| next(); | |
| } catch (err) { | |
| return res.status(401).json({ error: "Invalid or expired session" }); | |
| } | |
| } | |
| // ============================================================ | |
| // ROUTE: GET /auth/tiktok | |
| // Redirects user to TikTok OAuth consent screen | |
| // ============================================================ | |
| app.get("/auth/tiktok", (req, res) => { | |
| const state = crypto.randomBytes(16).toString("hex"); | |
| // Build URL manually β URLSearchParams double-encodes the redirect_uri, | |
| // which causes TikTok's server-side URI comparison to fail. | |
| const url = | |
| "https://www.tiktok.com/v2/auth/authorize/" + | |
| "?client_key=" + CONFIG.TIKTOK_CLIENT_KEY + | |
| "&scope=video.publish,video.upload,user.info.basic,user.info.profile,user.info.stats" + | |
| "&response_type=code" + | |
| "&redirect_uri=" + encodeURIComponent(CONFIG.TIKTOK_REDIRECT_URI) + | |
| "&state=" + state; | |
| res.redirect(url); | |
| }); | |
| // ============================================================ | |
| // ROUTE: GET /auth/tiktok/callback | |
| // Exchanges code for access token, returns JWT | |
| // ============================================================ | |
| app.get("/auth/tiktok/callback", async (req, res) => { | |
| const { code, error, error_description } = req.query; | |
| // Redirect errors back to the frontend instead of returning raw JSON | |
| if (error) { | |
| console.error("TikTok OAuth error:", error, error_description); | |
| return res.redirect(`/?auth=error&msg=${encodeURIComponent(error_description || error)}`); | |
| } | |
| if (!code) { | |
| return res.redirect(`/?auth=error&msg=${encodeURIComponent("Missing authorization code")}`); | |
| } | |
| try { | |
| // Exchange code for access token | |
| const tokenRes = await axios.post( | |
| "https://open.tiktokapis.com/v2/oauth/token/", | |
| new URLSearchParams({ | |
| client_key: CONFIG.TIKTOK_CLIENT_KEY, | |
| client_secret: CONFIG.TIKTOK_CLIENT_SECRET, | |
| code, | |
| grant_type: "authorization_code", | |
| redirect_uri: CONFIG.TIKTOK_REDIRECT_URI, | |
| }).toString(), | |
| { headers: { "Content-Type": "application/x-www-form-urlencoded" } } | |
| ); | |
| const tokenData = tokenRes.data; | |
| console.log("Token exchange response:", JSON.stringify(tokenData)); | |
| if (!tokenData.access_token) { | |
| console.error("Token exchange failed:", tokenData); | |
| return res.redirect(`/?auth=error&msg=${encodeURIComponent("Token exchange failed: " + (tokenData.message || JSON.stringify(tokenData)))}`); | |
| } | |
| const { access_token, refresh_token, open_id, expires_in } = tokenData; | |
| // Fetch basic user info β store in JWT so /me has a fallback | |
| let displayName = "TikTok User"; | |
| let username = null; | |
| let avatarUrl = null; | |
| try { | |
| const userRes = await axios.get( | |
| "https://open.tiktokapis.com/v2/user/info/?fields=display_name,avatar_url,username", | |
| { headers: { Authorization: `Bearer ${access_token}` } } | |
| ); | |
| const userData = userRes.data; | |
| console.log("User info response:", JSON.stringify(userData)); | |
| displayName = userData.data?.user?.display_name || displayName; | |
| username = userData.data?.user?.username || null; | |
| avatarUrl = userData.data?.user?.avatar_url || null; | |
| } catch (userErr) { | |
| // Non-fatal β we still have the token, dashboard will load with partial info | |
| console.warn("Could not fetch user info during OAuth:", userErr.message); | |
| } | |
| // Issue our own JWT β store enough user info so /me can fallback to this | |
| const jwtPayload = { | |
| tiktok_access_token: access_token, | |
| tiktok_refresh_token: refresh_token || null, | |
| tiktok_open_id: open_id, | |
| display_name: displayName, | |
| username, | |
| avatar_url: avatarUrl, | |
| }; | |
| const jwtToken = jwt.sign(jwtPayload, CONFIG.JWT_SECRET, { | |
| expiresIn: expires_in || 86400, | |
| }); | |
| console.log(`β Auth success for ${displayName} (@${username}), setting cookie`); | |
| res.cookie("wyr_token", jwtToken, { | |
| httpOnly: true, | |
| sameSite: "lax", | |
| secure: CONFIG.IS_PROD, // false in dev (HTTP), true in prod (HTTPS) | |
| maxAge: (expires_in || 86400) * 1000, | |
| path: "/", | |
| }); | |
| res.redirect("/"); // back to index.html | |
| } catch (err) { | |
| console.error("Auth error:", err.message, err.response?.data); | |
| res.redirect(`/?auth=error&msg=${encodeURIComponent(err.message)}`); | |
| } | |
| }); | |
| // ============================================================ | |
| // ROUTE: GET /me (protected) | |
| // Returns TikTok user info for authenticated user | |
| // ============================================================ | |
| app.get("/me", requireAuth, async (req, res) => { | |
| try { | |
| const userRes = await axios.get( | |
| "https://open.tiktokapis.com/v2/user/info/?fields=display_name,avatar_url,follower_count,following_count,likes_count,video_count,username", | |
| { headers: { Authorization: `Bearer ${req.user.tiktok_access_token}` } } | |
| ); | |
| const data = userRes.data; | |
| if (data.error?.code !== "ok") { | |
| console.warn("/me TikTok API returned non-ok:", data.error); | |
| // Fall back to what we stored in the JWT during OAuth | |
| return res.json({ | |
| user: { | |
| display_name: req.user.display_name || "TikTok User", | |
| username: req.user.username || null, | |
| avatar_url: req.user.avatar_url || null, | |
| follower_count: 0, | |
| video_count: 0, | |
| likes_count: 0, | |
| }, | |
| _fallback: true, // tells the frontend this came from JWT cache | |
| }); | |
| } | |
| res.json({ user: data.data.user }); | |
| } catch (err) { | |
| console.warn("/me TikTok API error, falling back to JWT data:", err.message); | |
| // Still return something useful so the dashboard loads | |
| res.json({ | |
| user: { | |
| display_name: req.user.display_name || "TikTok User", | |
| username: req.user.username || null, | |
| avatar_url: req.user.avatar_url || null, | |
| follower_count: 0, | |
| video_count: 0, | |
| likes_count: 0, | |
| }, | |
| _fallback: true, | |
| }); | |
| } | |
| }); | |
| // ============================================================ | |
| // ROUTE: GET /auth/check β debug: shows whether cookie arrived | |
| // ============================================================ | |
| app.get("/auth/check", (req, res) => { | |
| const token = req.cookies?.wyr_token; | |
| if (!token) return res.json({ authenticated: false, reason: "no cookie" }); | |
| try { | |
| const payload = jwt.verify(token, CONFIG.JWT_SECRET); | |
| res.json({ authenticated: true, user: payload.display_name, username: payload.username }); | |
| } catch (e) { | |
| res.json({ authenticated: false, reason: e.message }); | |
| } | |
| }); | |
| // ============================================================ | |
| // ROUTE: POST /auth/logout β clears the session cookie | |
| // ============================================================ | |
| app.post("/auth/logout", (req, res) => { | |
| res.clearCookie("wyr_token", { path: "/" }); | |
| res.json({ ok: true }); | |
| }); | |
| // Each job has: id, status, step, stepDetail, result, error, createdAt | |
| // | |
| // Possible statuses: queued | running | done | failed | |
| // Steps (in order): | |
| // 1 generating_questions | |
| // 2 generating_percentages | |
| // 3 generating_tts (stepDetail: "question X / Y") | |
| // 4 building_video (stepDetail: "rendering frames" | "encoding video") | |
| // 5 uploading_to_tiktok (stepDetail: "chunk X / Y" | "processing") | |
| // done | |
| // ============================================================ | |
| const jobs = new Map(); | |
| function createJob() { | |
| const id = crypto.randomBytes(8).toString("hex"); | |
| jobs.set(id, { | |
| id, | |
| status: "queued", | |
| step: null, | |
| stepDetail: null, | |
| progress: null, // 0-100 where meaningful | |
| result: null, | |
| error: null, | |
| createdAt: new Date().toISOString(), | |
| updatedAt: new Date().toISOString(), | |
| }); | |
| return id; | |
| } | |
| function updateJob(id, patch) { | |
| const job = jobs.get(id); | |
| if (!job) return; | |
| Object.assign(job, patch, { updatedAt: new Date().toISOString() }); | |
| } | |
| // ============================================================ | |
| // ROUTE: POST /post (protected) | |
| // Body: { theme: "superpowers" } | |
| // Returns immediately with job_id β poll GET /jobs/:id for progress | |
| // ============================================================ | |
| app.post("/post", requireAuth, (req, res) => { | |
| const { theme } = req.body; | |
| if (!theme) return res.status(400).json({ error: "Missing theme in body" }); | |
| const jobId = createJob(); | |
| const jobDirs = makeJobDirs(jobId); | |
| // Fire and forget β pipeline runs in the background | |
| runPipeline(jobId, theme, req.user, jobDirs).catch(() => {}); // errors are stored on the job | |
| res.status(202).json({ | |
| job_id: jobId, | |
| message: "Job queued. Poll GET /jobs/:id for live progress.", | |
| poll_url: `/jobs/${jobId}`, | |
| }); | |
| }); | |
| // ============================================================ | |
| // ROUTE: GET /jobs/:id (protected) | |
| // Returns current job state β designed to be polled every 2-3 s | |
| // ============================================================ | |
| app.get("/jobs/:id", requireAuth, (req, res) => { | |
| const job = jobs.get(req.params.id); | |
| if (!job) return res.status(404).json({ error: "Job not found" }); | |
| res.json(job); | |
| }); | |
| // ============================================================ | |
| // ROUTE: GET /jobs (protected) | |
| // Returns all jobs for this server (newest first), capped at 50 | |
| // ============================================================ | |
| app.get("/jobs", requireAuth, (req, res) => { | |
| const all = [...jobs.values()] | |
| .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) | |
| .slice(0, 50); | |
| res.json({ jobs: all }); | |
| }); | |
| // ============================================================ | |
| // PIPELINE β runs asynchronously, writes progress to job store | |
| // ============================================================ | |
| async function runPipeline(jobId, theme, user, jobDirs) { | |
| const { jobDir, audioDir, framesDir, videosDir } = jobDirs; | |
| try { | |
| updateJob(jobId, { status: "running" }); | |
| console.log(`\n㪠[${jobId}] Starting pipeline for theme: "${theme}"`); | |
| // --- Step 1: Generate questions --- | |
| updateJob(jobId, { step: "generating_questions", stepDetail: "Asking AI for 15 questionsβ¦" }); | |
| const questions = await generateQuestions(theme); | |
| updateJob(jobId, { stepDetail: `Got ${questions.length} questions` }); | |
| console.log(`β [${jobId}] Got ${questions.length} questions`); | |
| // --- Step 2: Get percentages --- | |
| updateJob(jobId, { step: "generating_percentages", stepDetail: "Estimating vote splitsβ¦" }); | |
| const questionsWithPercentages = await getPercentages(questions, theme); | |
| console.log(`β [${jobId}] Percentages added`); | |
| // --- Step 3: TTS audio --- | |
| updateJob(jobId, { step: "generating_tts", stepDetail: `0 / ${questionsWithPercentages.length} clips` }); | |
| const audioFiles = await generateAllTTS(questionsWithPercentages, audioDir, (done, total) => { | |
| updateJob(jobId, { | |
| stepDetail: `${done} / ${total} audio clips`, | |
| progress: Math.round((done / total) * 100), | |
| }); | |
| }); | |
| console.log(`β [${jobId}] TTS done`); | |
| // --- Step 4: Build video --- | |
| updateJob(jobId, { step: "building_video", stepDetail: "Rendering framesβ¦", progress: 0 }); | |
| const { videoPath, questionsUsed, totalDuration } = await buildVideo( | |
| questionsWithPercentages, | |
| audioFiles, | |
| framesDir, | |
| videosDir, | |
| (framesDone, framesTotal) => { | |
| updateJob(jobId, { | |
| stepDetail: `Rendered ${framesDone} / ${framesTotal} frames`, | |
| progress: Math.round((framesDone / framesTotal) * 100), | |
| }); | |
| } | |
| ); | |
| updateJob(jobId, { stepDetail: `Encoding video (${questionsUsed} questions, ${totalDuration.toFixed(1)}s)`, progress: 100 }); | |
| console.log(`β [${jobId}] Video ready: ${videoPath}`); | |
| // --- Step 5: Upload to TikTok --- | |
| const caption = `Would You Rather: ${theme.charAt(0).toUpperCase() + theme.slice(1)} Edition π€ #wouldyourather #${theme.replace(/\s+/g, "")} #fyp`; | |
| updateJob(jobId, { step: "uploading_to_tiktok", stepDetail: "Initialising uploadβ¦", progress: 0 }); | |
| const tiktokResult = await postToTikTok( | |
| user.tiktok_access_token, | |
| videoPath, | |
| caption, | |
| (chunkDone, chunkTotal) => { | |
| updateJob(jobId, { | |
| stepDetail: `Uploading chunk ${chunkDone} / ${chunkTotal}`, | |
| progress: Math.round((chunkDone / chunkTotal) * 100), | |
| }); | |
| }, | |
| (pollStatus, uploadedBytes) => { | |
| updateJob(jobId, { | |
| stepDetail: `TikTok processing: ${pollStatus}`, | |
| progress: null, | |
| tiktok_upload_status: pollStatus, | |
| tiktok_uploaded_bytes: uploadedBytes ?? null, | |
| }); | |
| } | |
| ); | |
| // publicaly_available_post_id is TikTok's documented field name (typo is in their API spec) | |
| const videoId = tiktokResult.publicaly_available_post_id?.[0] ?? null; | |
| // Build video URL using username (URL-safe handle), NOT display_name (may contain spaces/emoji) | |
| const urlHandle = user.username || null; | |
| updateJob(jobId, { | |
| status: "done", | |
| step: "done", | |
| stepDetail: "Published β", | |
| progress: 100, | |
| result: { | |
| theme, | |
| questions_generated: questions.length, | |
| questions_used: questionsUsed, | |
| estimated_duration_seconds: +totalDuration.toFixed(1), | |
| caption, | |
| // TikTok fields | |
| publish_id: tiktokResult.publish_id ?? null, | |
| tiktok_status: tiktokResult.status ?? "PUBLISH_COMPLETE", | |
| tiktok_video_id: videoId, | |
| tiktok_video_url: videoId && urlHandle | |
| ? `https://www.tiktok.com/@${urlHandle}/video/${videoId}` | |
| : null, | |
| tiktok_uploaded_bytes: tiktokResult.uploaded_bytes ?? null, | |
| tiktok_fail_reason: tiktokResult.fail_reason ?? null, | |
| }, | |
| }); | |
| console.log(`π [${jobId}] Done! TikTok video id: ${videoId}`); | |
| // Clean up per-job temp files after a successful publish | |
| try { | |
| fs.rmSync(jobDir, { recursive: true, force: true }); | |
| } catch (cleanErr) { | |
| console.warn(`[${jobId}] Cleanup warning:`, cleanErr.message); | |
| } | |
| } catch (err) { | |
| console.error(`β [${jobId}] Pipeline error:`, err.message); | |
| updateJob(jobId, { | |
| status: "failed", | |
| error: err.message, | |
| }); | |
| } | |
| } | |
| // ============================================================ | |
| // ROUTE: POST /tiktok/webhook | |
| // Handles TikTok webhook events | |
| // ============================================================ | |
| app.post("/tiktok/webhook", (req, res) => { | |
| const challenge = req.body?.challenge; | |
| // TikTok sends a challenge on first verification | |
| if (challenge) return res.json({ challenge }); | |
| const event = req.body; | |
| console.log("π¬ Webhook event received:", JSON.stringify(event, null, 2)); | |
| // Handle specific event types | |
| switch (event?.event) { | |
| case "video.publish": | |
| console.log("π Video published:", event.data); | |
| break; | |
| case "new_follower": | |
| console.log("π€ New follower:", event.data); | |
| break; | |
| case "comment": | |
| console.log("π¬ New comment:", event.data); | |
| break; | |
| default: | |
| console.log("π Unhandled event type:", event?.event); | |
| } | |
| res.json({ received: true }); | |
| }); | |
| // ============================================================ | |
| // AI: Generate Would-You-Rather questions via LongCat | |
| // ============================================================ | |
| async function generateQuestions(theme) { | |
| const prompt = `You are generating content for a TikTok "Would You Rather" video about the theme: "${theme}". | |
| Generate a pool of 15 questions. We will select as many as needed to fill at least 60 seconds based on actual audio length β so generate more than enough. | |
| Respond ONLY in this exact XML format, no preamble, no explanation: | |
| <questions> | |
| <question id="1"> | |
| <text>Would you rather...</text> | |
| <option1>Option A</option1> | |
| <option2>Option B</option2> | |
| <tts>Would you rather have option A or option B?</tts> | |
| </question> | |
| <question id="2"> | |
| ... | |
| </question> | |
| </questions> | |
| Rules: | |
| - Keep option text SHORT (max 6 words each) so it fits on screen | |
| - The tts field should be a natural spoken sentence for text-to-speech | |
| - Make questions fun, engaging, and relevant to "${theme}" | |
| - Options should be genuinely hard to choose between`; | |
| const response = await axios.post( | |
| "https://api.longcat.chat/anthropic/v1/messages", | |
| { | |
| model: "LongCat-Flash-Chat", | |
| max_tokens: 2000, | |
| messages: [{ role: "user", content: prompt }], | |
| }, | |
| { | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${CONFIG.LONGCAT_API_KEY}`, | |
| "anthropic-version": "2023-06-01", | |
| }, | |
| } | |
| ); | |
| const data = response.data; | |
| const text = data.content?.[0]?.text || ""; | |
| return parseQuestionsXML(text); | |
| } | |
| // ============================================================ | |
| // AI: Get percentage estimates for each question | |
| // ============================================================ | |
| async function getPercentages(questions, theme) { | |
| const questionsText = questions | |
| .map((q, i) => `Q${i + 1}: "${q.option1}" vs "${q.option2}"`) | |
| .join("\n"); | |
| const prompt = `For a TikTok "Would You Rather" video about "${theme}", estimate what percentage of people would choose each option based on general public preference patterns. | |
| Questions: | |
| ${questionsText} | |
| Respond ONLY in this exact XML format: | |
| <percentages> | |
| <question id="1"> | |
| <pct1>65</pct1> | |
| <pct2>35</pct2> | |
| </question> | |
| <question id="2"> | |
| <pct1>40</pct1> | |
| <pct2>60</pct2> | |
| </question> | |
| </percentages> | |
| Rules: | |
| - pct1 + pct2 must always equal 100 | |
| - Be realistic, vary the splits (not always 50/50) | |
| - Only output the XML, nothing else`; | |
| const response = await axios.post( | |
| "https://api.longcat.chat/anthropic/v1/messages", | |
| { | |
| model: "LongCat-Flash-Chat", | |
| max_tokens: 800, | |
| messages: [{ role: "user", content: prompt }], | |
| }, | |
| { | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${CONFIG.LONGCAT_API_KEY}`, | |
| "anthropic-version": "2023-06-01", | |
| }, | |
| } | |
| ); | |
| const data = response.data; | |
| const text = data.content?.[0]?.text || ""; | |
| const percentages = parsePercentagesXML(text); | |
| return questions.map((q, i) => { | |
| const p = percentages[i]; | |
| const pct1 = p?.pct1 ?? 50; | |
| const pct2 = p?.pct2 ?? (100 - pct1); // guarantee sum = 100 | |
| return { ...q, pct1, pct2 }; | |
| }); | |
| } | |
| // ============================================================ | |
| // TTS: Download audio for each question | |
| // ============================================================ | |
| async function generateAllTTS(questions, audioDir, onProgress) { | |
| const audioFiles = []; | |
| for (let i = 0; i < questions.length; i++) { | |
| const q = questions[i]; | |
| const ttsText = encodeURIComponent(q.tts); | |
| const url = `https://api.bk9.dev/tools/tts?q=${ttsText}&lang=en`; | |
| const audioPath = path.join(audioDir, `q${i + 1}.mp3`); | |
| const res = await axios.get(url, { responseType: "arraybuffer" }); | |
| // Guard: TTS API sometimes returns a JSON error body with HTTP 200. | |
| // Writing that to disk produces a corrupt "mp3" that ffprobe can't read. | |
| const firstBytes = Buffer.from(res.data).slice(0, 3).toString("hex"); | |
| const isMp3 = firstBytes.startsWith("494433") || firstBytes.startsWith("fffb") || firstBytes.startsWith("fff3") || firstBytes.startsWith("fff2"); | |
| if (!isMp3) { | |
| const preview = Buffer.from(res.data).slice(0, 200).toString("utf8"); | |
| throw new Error(`TTS API returned non-MP3 data for question ${i + 1}. Preview: ${preview}`); | |
| } | |
| fs.writeFileSync(audioPath, res.data); | |
| audioFiles.push(audioPath); | |
| if (onProgress) onProgress(i + 1, questions.length); | |
| } | |
| return audioFiles; | |
| } | |
| // ============================================================ | |
| // VIDEO: Render a single frame as PNG using canvas | |
| // ============================================================ | |
| async function renderFrame(question, phase, outputPath) { | |
| // 1080x1920 (TikTok portrait) | |
| const W = 1080, H = 1920; | |
| const canvas = createCanvas(W, H); | |
| const ctx = canvas.getContext("2d"); | |
| // Background | |
| ctx.fillStyle = "#111111"; | |
| ctx.fillRect(0, 0, W, H); | |
| if (phase === "question") { | |
| // ---- QUESTION PHASE ---- | |
| // Question text at top | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.font = "bold 58px sans-serif"; | |
| ctx.textAlign = "center"; | |
| wrapText(ctx, "Would You Rather...", W / 2, 200, W - 80, 70); | |
| // Divider line | |
| ctx.strokeStyle = "#444444"; | |
| ctx.lineWidth = 4; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, H / 2); | |
| ctx.lineTo(W, H / 2); | |
| ctx.stroke(); | |
| // Option 1 β RED (top half) | |
| ctx.fillStyle = "#CC0000"; | |
| ctx.fillRect(0, 320, W, H / 2 - 320); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.font = "bold 72px sans-serif"; | |
| ctx.textAlign = "center"; | |
| wrapText(ctx, question.option1, W / 2, 520, W - 100, 85); | |
| // Option 2 β BLUE (bottom half) | |
| ctx.fillStyle = "#0055CC"; | |
| ctx.fillRect(0, H / 2, W, H / 2 - 100); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.font = "bold 72px sans-serif"; | |
| ctx.textAlign = "center"; | |
| wrapText(ctx, question.option2, W / 2, H / 2 + 200, W - 100, 85); | |
| // OR badge in center | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.beginPath(); | |
| ctx.arc(W / 2, H / 2, 70, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.fillStyle = "#111111"; | |
| ctx.font = "bold 52px sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText("OR", W / 2, H / 2 + 18); | |
| } else { | |
| // ---- RESULT PHASE ---- | |
| const winner1 = question.pct1 >= question.pct2; | |
| // Option 1 block | |
| ctx.fillStyle = "#CC0000"; | |
| ctx.fillRect(0, 0, W, H / 2 - 10); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.font = "bold 64px sans-serif"; | |
| ctx.textAlign = "center"; | |
| wrapText(ctx, question.option1, W / 2, 280, W - 80, 76); | |
| // Option 1 percentage | |
| ctx.font = `bold 150px sans-serif`; | |
| ctx.fillStyle = winner1 ? "#00FF88" : "#FF4444"; | |
| ctx.fillText(`${question.pct1}%`, W / 2, 680); | |
| // Divider | |
| ctx.fillStyle = "#111111"; | |
| ctx.fillRect(0, H / 2 - 10, W, 20); | |
| // Option 2 block | |
| ctx.fillStyle = "#0055CC"; | |
| ctx.fillRect(0, H / 2 + 10, W, H / 2 - 10); | |
| ctx.fillStyle = "#FFFFFF"; | |
| ctx.font = "bold 64px sans-serif"; | |
| ctx.textAlign = "center"; | |
| wrapText(ctx, question.option2, W / 2, H / 2 + 230, W - 80, 76); | |
| // Option 2 percentage | |
| ctx.font = `bold 150px sans-serif`; | |
| ctx.fillStyle = !winner1 ? "#00FF88" : "#FF4444"; | |
| ctx.fillText(`${question.pct2}%`, W / 2, H / 2 + 600); | |
| } | |
| const buffer = canvas.toBuffer("image/png"); | |
| fs.writeFileSync(outputPath, buffer); | |
| } | |
| // ============================================================ | |
| // VIDEO: Build full video from frames + audio | |
| // ============================================================ | |
| /** Return the duration (seconds) of an audio file via ffprobe. */ | |
| function probeAudioDuration(filePath) { | |
| return new Promise((resolve, reject) => { | |
| ffmpeg.ffprobe(filePath, (err, metadata) => { | |
| if (err) return reject(err); | |
| const dur = metadata?.format?.duration; | |
| if (!dur) return reject(new Error(`Could not read duration of ${filePath}`)); | |
| resolve(parseFloat(dur)); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Per-question timing: | |
| * question frame = actual TTS duration + 0.5s buffer | |
| * result frame = max(2.5s, audioDuration * 0.6) | |
| * | |
| * The result duration scales slightly with TTS length so longer questions | |
| * get a bit more reveal time, but the minimum is always 2.5s β long enough | |
| * to read two percentages comfortably. | |
| */ | |
| function calcDurations(audioDuration) { | |
| const questionDuration = +(audioDuration + 0.5).toFixed(3); | |
| const resultDuration = +Math.max(2.5, audioDuration * 0.6).toFixed(3); | |
| return { questionDuration, resultDuration }; | |
| } | |
| async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgress) { | |
| // ------------------------------------------------------------------ | |
| // 1. Probe every TTS file up-front so we know real durations. | |
| // ------------------------------------------------------------------ | |
| const audioDurations = await Promise.all(audioFiles.map(probeAudioDuration)); | |
| // ------------------------------------------------------------------ | |
| // 2. Figure out how many questions to include. | |
| // Rule: keep adding questions until total >= 60 s, then always | |
| // finish that question (never cut mid-question). If we somehow | |
| // exhaust all 15 before reaching 60 s, just use all of them. | |
| // ------------------------------------------------------------------ | |
| const TARGET = 60; // seconds | |
| let runningTotal = 0; | |
| let useCount = 0; | |
| for (let i = 0; i < questions.length; i++) { | |
| const { questionDuration, resultDuration } = calcDurations(audioDurations[i]); | |
| runningTotal += questionDuration + resultDuration; | |
| useCount = i + 1; | |
| if (runningTotal >= TARGET) break; // crossed 60 s β finish this question and stop | |
| } | |
| const usedQuestions = questions.slice(0, useCount); | |
| const usedAudio = audioFiles.slice(0, useCount); | |
| const usedDurations = audioDurations.slice(0, useCount); | |
| console.log( | |
| `β± Selected ${useCount} questions β estimated total ${runningTotal.toFixed(1)}s` | |
| ); | |
| // ------------------------------------------------------------------ | |
| // 3. Render frames and build image list with per-image durations. | |
| // slideshow-video handles all the ffmpeg complexity internally. | |
| // ------------------------------------------------------------------ | |
| const slideImages = []; | |
| for (let i = 0; i < usedQuestions.length; i++) { | |
| const q = usedQuestions[i]; | |
| const { questionDuration, resultDuration } = calcDurations(usedDurations[i]); | |
| const qFrame = path.join(framesDir, `q${i + 1}_question.png`); | |
| await renderFrame(q, "question", qFrame); | |
| slideImages.push({ filePath: qFrame, duration: Math.round(questionDuration * 1000) }); // ms | |
| const rFrame = path.join(framesDir, `q${i + 1}_result.png`); | |
| await renderFrame(q, "result", rFrame); | |
| slideImages.push({ filePath: rFrame, duration: Math.round(resultDuration * 1000) }); // ms | |
| if (onProgress) onProgress(i + 1, usedQuestions.length); | |
| } | |
| // ------------------------------------------------------------------ | |
| // 4. Concatenate audio files. | |
| // ------------------------------------------------------------------ | |
| const audioDir = path.dirname(usedAudio[0]); | |
| const combinedAudio = path.join(audioDir, "combined.mp3"); | |
| await concatAudio(usedAudio, combinedAudio, audioDir); | |
| console.log(`π¬ Building video with slideshow-video: ${slideImages.length} slides`); | |
| const response = await createSlideshow(slideImages, combinedAudio, { | |
| // No transitionOptions β omitting it entirely gives true instant cuts. | |
| // transitionDuration: 0 does NOT disable transitions; it still runs the | |
| // filter graph and causes frame-timing issues. Omit the block instead. | |
| ffmpegOptions: { | |
| showFfmpegOutput: true, | |
| showFfmpegCommand: true, | |
| fps: 30, | |
| videoCodec: "libx264", | |
| x264Preset: "fast", | |
| pixelFormat: "yuv420p", // explicit β TikTok requires this; library default but be safe | |
| streamCopyAudio: true, // combinedAudio is already a clean 44100Hz stereo MP3 β no need to re-encode | |
| }, | |
| outputOptions: { | |
| outputBuffer: false, | |
| outputDir: videosDir, | |
| }, | |
| }); | |
| // response.filePath is the actual output file written by slideshow-video | |
| const videoPath = response.filePath; | |
| if (!videoPath || !fs.existsSync(videoPath)) { | |
| throw new Error(`slideshow-video did not produce an output file. Response: ${JSON.stringify(response)}`); | |
| } | |
| const fileSize = fs.statSync(videoPath).size; | |
| if (fileSize < 50 * 1024) { | |
| throw new Error(`Video output is only ${(fileSize / 1024).toFixed(1)} KB β encoding likely failed.`); | |
| } | |
| console.log(`β Video encoded: ${(fileSize / 1024 / 1024).toFixed(2)} MB β ${videoPath}`); | |
| return { videoPath, questionsUsed: useCount, totalDuration: runningTotal }; | |
| } | |
| // ============================================================ | |
| // AUDIO: Concatenate mp3 files using ffmpeg | |
| // ============================================================ | |
| function concatAudio(files, output, audioDir) { | |
| const listPath = path.join(audioDir, "audio_list.txt"); | |
| const content = files.map((f) => `file '${f}'`).join("\n"); | |
| fs.writeFileSync(listPath, content); | |
| return new Promise((resolve, reject) => { | |
| ffmpeg() | |
| .input(listPath) | |
| .inputOptions(["-f concat", "-safe 0"]) | |
| .outputOptions([ | |
| "-acodec libmp3lame", // re-encode so mismatched bitrates/sample-rates never cause failures | |
| "-ar 44100", | |
| "-ac 2", | |
| "-q:a 2", // VBR ~190kbps β good quality, small file | |
| ]) | |
| .output(output) | |
| .on("end", resolve) | |
| .on("error", reject) | |
| .run(); | |
| }); | |
| } | |
| // ============================================================ | |
| // TIKTOK: Post video via Content Posting API | |
| // ============================================================ | |
| async function postToTikTok(accessToken, videoPath, caption, onChunkProgress, onPollProgress) { | |
| // ββ Step 1: Query creator info (REQUIRED before init per TikTok docs) ββββββ | |
| // TikTok will reject the init call if you skip this. It also tells us which | |
| // privacy_level values are valid for this account β hardcoding one that isn't | |
| // in the list causes a 400. | |
| const creatorRes = await axios.post( | |
| "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", | |
| {}, | |
| { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json; charset=UTF-8" } } | |
| ); | |
| const creatorData = creatorRes.data; | |
| if (creatorData.error?.code !== "ok") { | |
| throw new Error(`creator_info/query failed: ${JSON.stringify(creatorData.error)}`); | |
| } | |
| const { | |
| privacy_level_options = ["SELF_ONLY"], | |
| duet_disabled = false, | |
| stitch_disabled = false, | |
| comment_disabled = false, | |
| } = creatorData.data; | |
| // Always use SELF_ONLY β unaudited API clients are restricted to private | |
| // viewership by TikTok. Sending any other value (even one in privacy_level_options) | |
| // causes a 403 on the upload PUT. The account must also be set to private. | |
| // After passing TikTok's audit, change this to pick the most permissive option. | |
| const privacy_level = "SELF_ONLY"; | |
| console.log(`π Creator privacy options: ${privacy_level_options.join(", ")} β using: ${privacy_level} (unaudited app restriction)`); | |
| // ββ Step 2: Compute chunk layout ββββββββββββββββββββββββββββββββββββββββββββ | |
| const videoSizeBytes = fs.statSync(videoPath).size; | |
| // TikTok rules: each chunk must be 5 MBβ64 MB (last chunk can be smaller). | |
| // For files β€ 64 MB: upload as one chunk = entire file (no minimum issue). | |
| // For files > 64 MB: split into 10 MB chunks. | |
| const MAX_SINGLE = 64 * 1024 * 1024; | |
| const chunkSize = videoSizeBytes <= MAX_SINGLE ? videoSizeBytes : CONFIG.CHUNK_SIZE; | |
| const totalChunks = Math.ceil(videoSizeBytes / chunkSize); | |
| console.log(`π€ Upload: ${(videoSizeBytes / 1024 / 1024).toFixed(2)} MB, ${totalChunks} chunk(s) of ${(chunkSize / 1024 / 1024).toFixed(1)} MB`); | |
| // ββ Step 3: Init upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const initRes = await axios.post( | |
| "https://open.tiktokapis.com/v2/post/publish/video/init/", | |
| { | |
| post_info: { | |
| title: caption, | |
| privacy_level, | |
| disable_duet: duet_disabled, | |
| disable_comment: comment_disabled, | |
| disable_stitch: stitch_disabled, | |
| video_cover_timestamp_ms: 1000, | |
| }, | |
| source_info: { | |
| source: "FILE_UPLOAD", | |
| video_size: videoSizeBytes, | |
| chunk_size: chunkSize, | |
| total_chunk_count: totalChunks, | |
| }, | |
| }, | |
| { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json; charset=UTF-8" } } | |
| ); | |
| const initData = initRes.data; | |
| if (initData.error?.code !== "ok") { | |
| throw new Error(`Init upload failed (HTTP 200 but error in body): ${JSON.stringify(initData.error)}`); | |
| } | |
| const { publish_id, upload_url } = initData.data; | |
| // ββ Step 4: Upload chunks ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const fd = fs.openSync(videoPath, "r"); | |
| try { | |
| for (let i = 0; i < totalChunks; i++) { | |
| const start = i * chunkSize; | |
| const end = Math.min(start + chunkSize, videoSizeBytes) - 1; | |
| const chunkLen = end - start + 1; | |
| const chunk = Buffer.allocUnsafe(chunkLen); | |
| fs.readSync(fd, chunk, 0, chunkLen, start); | |
| const uploadRes = await axios.put(upload_url, chunk, { | |
| headers: { | |
| "Content-Range": `bytes ${start}-${end}/${videoSizeBytes}`, | |
| "Content-Type": "video/mp4", | |
| "Content-Length": chunkLen, | |
| }, | |
| maxBodyLength: Infinity, | |
| maxContentLength: Infinity, | |
| }); | |
| console.log(` Chunk ${i + 1}/${totalChunks} uploaded β status ${uploadRes.status}`); | |
| if (onChunkProgress) onChunkProgress(i + 1, totalChunks); | |
| } | |
| } finally { | |
| fs.closeSync(fd); | |
| } | |
| // Poll status | |
| for (let attempt = 1; attempt <= 15; attempt++) { | |
| await new Promise((r) => setTimeout(r, 5000)); | |
| const statusRes = await axios.post( | |
| "https://open.tiktokapis.com/v2/post/publish/status/fetch/", | |
| { publish_id }, | |
| { | |
| headers: { | |
| Authorization: `Bearer ${accessToken}`, | |
| "Content-Type": "application/json; charset=UTF-8", | |
| }, | |
| } | |
| ); | |
| const statusData = statusRes.data; | |
| const status = statusData.data?.status; | |
| console.log(` Poll ${attempt}: ${status}`); | |
| if (onPollProgress) onPollProgress(status, statusData.data?.uploaded_bytes); | |
| if (status === "PUBLISH_COMPLETE") return statusData.data; | |
| if (status === "FAILED") throw new Error(`Post failed: ${JSON.stringify(statusData.data)}`); | |
| } | |
| throw new Error("Timed out waiting for post"); | |
| } | |
| // ============================================================ | |
| // HELPERS: XML parsers | |
| // ============================================================ | |
| function parseQuestionsXML(xml) { | |
| const questions = []; | |
| const qRegex = /<question[^>]*>([\s\S]*?)<\/question>/g; | |
| let match; | |
| while ((match = qRegex.exec(xml)) !== null) { | |
| const block = match[1]; | |
| questions.push({ | |
| text: extractTag(block, "text"), | |
| option1: extractTag(block, "option1"), | |
| option2: extractTag(block, "option2"), | |
| tts: extractTag(block, "tts"), | |
| }); | |
| } | |
| if (questions.length === 0) throw new Error("No questions parsed from AI response:\n" + xml); | |
| return questions; | |
| } | |
| function parsePercentagesXML(xml) { | |
| const results = []; | |
| const qRegex = /<question[^>]*>([\s\S]*?)<\/question>/g; | |
| let match; | |
| while ((match = qRegex.exec(xml)) !== null) { | |
| const block = match[1]; | |
| const pct1 = parseInt(extractTag(block, "pct1"), 10) || 50; | |
| const pct2 = parseInt(extractTag(block, "pct2"), 10) || 50; | |
| results.push({ pct1, pct2 }); | |
| } | |
| return results; | |
| } | |
| function extractTag(xml, tag) { | |
| const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`)); | |
| return match ? match[1].trim() : ""; | |
| } | |
| // ============================================================ | |
| // HELPERS: Canvas text wrapping | |
| // ============================================================ | |
| function wrapText(ctx, text, x, y, maxWidth, lineHeight) { | |
| const words = text.split(" "); | |
| let line = ""; | |
| let currentY = y; | |
| for (let n = 0; n < words.length; n++) { | |
| const testLine = line + words[n] + " "; | |
| const metrics = ctx.measureText(testLine); | |
| if (metrics.width > maxWidth && n > 0) { | |
| ctx.fillText(line.trim(), x, currentY); | |
| line = words[n] + " "; | |
| currentY += lineHeight; | |
| } else { | |
| line = testLine; | |
| } | |
| } | |
| ctx.fillText(line.trim(), x, currentY); | |
| } | |
| // ============================================================ | |
| // START | |
| // ============================================================ | |
| app.listen(7860, () => { | |
| console.log(`\nπ TikTok Would-You-Rather Bot running on port ${CONFIG.PORT}`); | |
| console.log(`\nEndpoints:`); | |
| console.log(` GET /auth/tiktok β Start TikTok OAuth`); | |
| console.log(` GET /auth/tiktok/callback β OAuth callback (set as redirect URI in TikTok app)`); | |
| console.log(` GET /me β Your TikTok info (requires JWT)`); | |
| console.log(` POST /post β Generate & post a WYR video (requires JWT)`); | |
| console.log(` POST /tiktok/webhook β TikTok webhook receiver\n`); | |
| }); |