"use strict"; // ============================================================ // 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: Would you rather... Option A Option B Would you rather have option A or option B? ... 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: 65 35 40 60 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 = /]*>([\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 = /]*>([\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`); });