| "use strict"; |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| |
| |
| app.use(cors({ |
| origin: function(origin, callback) { |
| |
| 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); |
| }, |
| credentials: true, |
| })); |
|
|
| app.use(express.json()); |
| app.use(express.urlencoded({ extended: true })); |
| app.use(cookieParser()); |
| app.use(express.static(path.join(__dirname, "public"))); |
|
|
| |
| app.use((req, _res, next) => { |
| if (req.path === "/me" || req.path.startsWith("/auth")) { |
| console.log(`[${req.method} ${req.path}] cookies:`, req.cookies); |
| } |
| next(); |
| }); |
|
|
| |
| |
| |
| 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, |
| JOBS_DIR: path.join(__dirname, "jobs"), |
|
|
| |
| IS_PROD: process.env.NODE_ENV !== "development", |
| }; |
|
|
| |
| if (!fs.existsSync(CONFIG.JOBS_DIR)) fs.mkdirSync(CONFIG.JOBS_DIR, { recursive: true }); |
|
|
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| 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" }); |
| } |
| } |
|
|
| |
| |
| |
| |
| app.get("/auth/tiktok", (req, res) => { |
| const state = crypto.randomBytes(16).toString("hex"); |
| |
| |
| 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); |
| }); |
|
|
| |
| |
| |
| |
| app.get("/auth/tiktok/callback", async (req, res) => { |
| const { code, error, error_description } = req.query; |
|
|
| |
| 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 { |
| |
| 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; |
|
|
| |
| 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) { |
| |
| console.warn("Could not fetch user info during OAuth:", userErr.message); |
| } |
|
|
| |
| 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, |
| maxAge: (expires_in || 86400) * 1000, |
| path: "/", |
| }); |
|
|
| res.redirect("/"); |
| } catch (err) { |
| console.error("Auth error:", err.message, err.response?.data); |
| res.redirect(`/?auth=error&msg=${encodeURIComponent(err.message)}`); |
| } |
| }); |
|
|
| |
| |
| |
| |
| 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); |
| |
| 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, |
| }); |
| } |
|
|
| res.json({ user: data.data.user }); |
| } catch (err) { |
| console.warn("/me TikTok API error, falling back to JWT data:", err.message); |
| |
| 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, |
| }); |
| } |
| }); |
|
|
| |
| |
| |
| 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 }); |
| } |
| }); |
|
|
| |
| |
| |
| app.post("/auth/logout", (req, res) => { |
| res.clearCookie("wyr_token", { path: "/" }); |
| res.json({ ok: true }); |
| }); |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| 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() }); |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| runPipeline(jobId, theme, req.user, jobDirs).catch(() => {}); |
|
|
| res.status(202).json({ |
| job_id: jobId, |
| message: "Job queued. Poll GET /jobs/:id for live progress.", |
| poll_url: `/jobs/${jobId}`, |
| }); |
| }); |
|
|
| |
| |
| |
| |
| 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); |
| }); |
|
|
| |
| |
| |
| |
| 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 }); |
| }); |
|
|
| |
| |
| |
| 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}"`); |
|
|
| |
| 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`); |
|
|
| |
| updateJob(jobId, { step: "generating_percentages", stepDetail: "Estimating vote splitsβ¦" }); |
| const questionsWithPercentages = await getPercentages(questions, theme); |
| console.log(`β
[${jobId}] Percentages added`); |
|
|
| |
| 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`); |
|
|
| |
| 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}`); |
|
|
| |
| 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, |
| }); |
| } |
| ); |
|
|
| |
| const videoId = tiktokResult.publicaly_available_post_id?.[0] ?? null; |
|
|
| |
| 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, |
| |
| 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}`); |
|
|
| |
| 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, |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| app.post("/tiktok/webhook", (req, res) => { |
| const challenge = req.body?.challenge; |
| |
| if (challenge) return res.json({ challenge }); |
|
|
| const event = req.body; |
| console.log("π¬ Webhook event received:", JSON.stringify(event, null, 2)); |
|
|
| |
| 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 }); |
| }); |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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); |
| return { ...q, pct1, pct2 }; |
| }); |
| } |
|
|
| |
| |
| |
| 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" }); |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| async function renderFrame(question, phase, outputPath) { |
| |
| const W = 1080, H = 1920; |
| const canvas = createCanvas(W, H); |
| const ctx = canvas.getContext("2d"); |
|
|
| |
| ctx.fillStyle = "#111111"; |
| ctx.fillRect(0, 0, W, H); |
|
|
| if (phase === "question") { |
| |
| |
| ctx.fillStyle = "#FFFFFF"; |
| ctx.font = "bold 58px sans-serif"; |
| ctx.textAlign = "center"; |
| wrapText(ctx, "Would You Rather...", W / 2, 200, W - 80, 70); |
|
|
| |
| ctx.strokeStyle = "#444444"; |
| ctx.lineWidth = 4; |
| ctx.beginPath(); |
| ctx.moveTo(0, H / 2); |
| ctx.lineTo(W, H / 2); |
| ctx.stroke(); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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 { |
| |
| const winner1 = question.pct1 >= question.pct2; |
|
|
| |
| 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); |
|
|
| |
| ctx.font = `bold 150px sans-serif`; |
| ctx.fillStyle = winner1 ? "#00FF88" : "#FF4444"; |
| ctx.fillText(`${question.pct1}%`, W / 2, 680); |
|
|
| |
| ctx.fillStyle = "#111111"; |
| ctx.fillRect(0, H / 2 - 10, W, 20); |
|
|
| |
| 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); |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
|
|
| |
| 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)); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| const audioDurations = await Promise.all(audioFiles.map(probeAudioDuration)); |
|
|
| |
| |
| |
| |
| |
| |
| const TARGET = 60; |
| 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; |
| } |
|
|
| 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` |
| ); |
|
|
| |
| |
| |
| |
| 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) }); |
|
|
| const rFrame = path.join(framesDir, `q${i + 1}_result.png`); |
| await renderFrame(q, "result", rFrame); |
| slideImages.push({ filePath: rFrame, duration: Math.round(resultDuration * 1000) }); |
|
|
| if (onProgress) onProgress(i + 1, usedQuestions.length); |
| } |
|
|
| |
| |
| |
| 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, { |
| |
| |
| |
| ffmpegOptions: { |
| showFfmpegOutput: true, |
| showFfmpegCommand: true, |
| fps: 30, |
| videoCodec: "libx264", |
| x264Preset: "fast", |
| pixelFormat: "yuv420p", |
| streamCopyAudio: true, |
| }, |
| outputOptions: { |
| outputBuffer: false, |
| outputDir: videosDir, |
| }, |
| }); |
|
|
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| 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", |
| "-ar 44100", |
| "-ac 2", |
| "-q:a 2", |
| ]) |
| .output(output) |
| .on("end", resolve) |
| .on("error", reject) |
| .run(); |
| }); |
| } |
|
|
| |
| |
| |
| async function postToTikTok(accessToken, videoPath, caption, onChunkProgress, onPollProgress) { |
| |
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| |
| const privacy_level = "SELF_ONLY"; |
| console.log(`π Creator privacy options: ${privacy_level_options.join(", ")} β using: ${privacy_level} (unaudited app restriction)`); |
|
|
| |
| const videoSizeBytes = fs.statSync(videoPath).size; |
| |
| |
| |
| 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`); |
|
|
| |
| 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; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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"); |
| } |
|
|
| |
| |
| |
| 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() : ""; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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`); |
| }); |