"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`);
});