Update server.js
Browse files
server.js
CHANGED
|
@@ -56,8 +56,8 @@ const CONFIG = {
|
|
| 56 |
TIKTOK_CLIENT_SECRET: process.env.TIKTOK_CLIENT_SECRET || "hh",
|
| 57 |
TIKTOK_REDIRECT_URI: process.env.TIKTOK_REDIRECT_URI || "https://content.nett.to/auth/tiktok/callback",
|
| 58 |
|
| 59 |
-
JWT_SECRET: process.env.JWT_SECRET || "
|
| 60 |
-
LONGCAT_API_KEY: process.env.LONGCAT_API_KEY || "
|
| 61 |
|
| 62 |
PORT: process.env.PORT || 3000,
|
| 63 |
CHUNK_SIZE: 10 * 1024 * 1024, // 10MB
|
|
@@ -107,7 +107,7 @@ app.get("/auth/tiktok", (req, res) => {
|
|
| 107 |
const url =
|
| 108 |
"https://www.tiktok.com/v2/auth/authorize/" +
|
| 109 |
"?client_key=" + CONFIG.TIKTOK_CLIENT_KEY +
|
| 110 |
-
"&scope=video.publish,
|
| 111 |
"&response_type=code" +
|
| 112 |
"&redirect_uri=" + encodeURIComponent(CONFIG.TIKTOK_REDIRECT_URI) +
|
| 113 |
"&state=" + state;
|
|
@@ -212,7 +212,7 @@ app.get("/auth/tiktok/callback", async (req, res) => {
|
|
| 212 |
app.get("/me", requireAuth, async (req, res) => {
|
| 213 |
try {
|
| 214 |
const userRes = await axios.get(
|
| 215 |
-
"https://open.tiktokapis.com/v2/user/info/?fields=display_name,avatar_url,
|
| 216 |
{ headers: { Authorization: `Bearer ${req.user.tiktok_access_token}` } }
|
| 217 |
);
|
| 218 |
const data = userRes.data;
|
|
@@ -397,16 +397,7 @@ async function runPipeline(jobId, theme, user, jobDirs) {
|
|
| 397 |
}
|
| 398 |
);
|
| 399 |
updateJob(jobId, { stepDetail: `Encoding video (${questionsUsed} questions, ${totalDuration.toFixed(1)}s)`, progress: 100 });
|
| 400 |
-
|
| 401 |
-
// Sanity check — a valid 60s 1080x1920 H.264 video should be at least 3 MB
|
| 402 |
-
const videoBytes = fs.statSync(videoPath).size;
|
| 403 |
-
console.log(`✅ [${jobId}] Video ready: ${videoPath} (${(videoBytes / 1024 / 1024).toFixed(2)} MB)`);
|
| 404 |
-
if (videoBytes < 3 * 1024 * 1024) {
|
| 405 |
-
throw new Error(
|
| 406 |
-
`Video file is suspiciously small (${(videoBytes / 1024 / 1024).toFixed(2)} MB) — ` +
|
| 407 |
-
`ffmpeg likely failed silently. Check that libx264 is installed: run 'ffmpeg -codecs | grep h264'`
|
| 408 |
-
);
|
| 409 |
-
}
|
| 410 |
|
| 411 |
// --- Step 5: Upload to TikTok ---
|
| 412 |
const caption = `Would You Rather: ${theme.charAt(0).toUpperCase() + theme.slice(1)} Edition 🤔 #wouldyourather #${theme.replace(/\s+/g, "")} #fyp`;
|
|
@@ -650,41 +641,40 @@ async function renderFrame(question, phase, outputPath) {
|
|
| 650 |
|
| 651 |
if (phase === "question") {
|
| 652 |
// ---- QUESTION PHASE ----
|
| 653 |
-
|
| 654 |
-
// "Would You Rather..." header
|
| 655 |
ctx.fillStyle = "#FFFFFF";
|
| 656 |
ctx.font = "bold 58px sans-serif";
|
| 657 |
ctx.textAlign = "center";
|
| 658 |
-
wrapText(ctx, "Would You Rather...", W / 2,
|
| 659 |
|
| 660 |
-
//
|
| 661 |
-
ctx.
|
| 662 |
-
ctx.
|
| 663 |
-
ctx.fillStyle = "#FFFFFF";
|
| 664 |
-
ctx.font = "bold 68px sans-serif";
|
| 665 |
-
ctx.textAlign = "center";
|
| 666 |
-
wrapText(ctx, question.option1, W / 2, 400, W - 120, 82);
|
| 667 |
-
|
| 668 |
-
// Divider line at center
|
| 669 |
-
ctx.strokeStyle = "#222222";
|
| 670 |
-
ctx.lineWidth = 6;
|
| 671 |
ctx.beginPath();
|
| 672 |
ctx.moveTo(0, H / 2);
|
| 673 |
ctx.lineTo(W, H / 2);
|
| 674 |
ctx.stroke();
|
| 675 |
|
| 676 |
-
// Option
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
ctx.fillStyle = "#0055CC";
|
| 678 |
-
ctx.fillRect(0, H / 2
|
| 679 |
ctx.fillStyle = "#FFFFFF";
|
| 680 |
-
ctx.font = "bold
|
| 681 |
ctx.textAlign = "center";
|
| 682 |
-
wrapText(ctx, question.option2, W / 2, H / 2 +
|
| 683 |
|
| 684 |
-
// OR badge
|
| 685 |
ctx.fillStyle = "#FFFFFF";
|
| 686 |
ctx.beginPath();
|
| 687 |
-
ctx.arc(W / 2, H / 2,
|
| 688 |
ctx.fill();
|
| 689 |
ctx.fillStyle = "#111111";
|
| 690 |
ctx.font = "bold 52px sans-serif";
|
|
@@ -695,42 +685,35 @@ async function renderFrame(question, phase, outputPath) {
|
|
| 695 |
// ---- RESULT PHASE ----
|
| 696 |
const winner1 = question.pct1 >= question.pct2;
|
| 697 |
|
| 698 |
-
//
|
| 699 |
ctx.fillStyle = "#CC0000";
|
| 700 |
ctx.fillRect(0, 0, W, H / 2 - 10);
|
| 701 |
-
|
| 702 |
-
// Option 1 label text — start at y=200, track where it ends
|
| 703 |
ctx.fillStyle = "#FFFFFF";
|
| 704 |
-
ctx.font = "bold
|
| 705 |
ctx.textAlign = "center";
|
| 706 |
-
|
| 707 |
|
| 708 |
-
//
|
| 709 |
-
|
| 710 |
-
ctx.
|
| 711 |
-
ctx.
|
| 712 |
-
ctx.fillText(`${question.pct1}%`, W / 2, pct1Y);
|
| 713 |
|
| 714 |
-
//
|
| 715 |
ctx.fillStyle = "#111111";
|
| 716 |
ctx.fillRect(0, H / 2 - 10, W, 20);
|
| 717 |
|
| 718 |
-
//
|
| 719 |
ctx.fillStyle = "#0055CC";
|
| 720 |
ctx.fillRect(0, H / 2 + 10, W, H / 2 - 10);
|
| 721 |
-
|
| 722 |
-
// Option 2 label text — start below the divider
|
| 723 |
ctx.fillStyle = "#FFFFFF";
|
| 724 |
-
ctx.font = "bold
|
| 725 |
ctx.textAlign = "center";
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
ctx.
|
| 732 |
-
ctx.fillStyle = !winner1 ? "#00FF88" : "#FF6666";
|
| 733 |
-
ctx.fillText(`${question.pct2}%`, W / 2, pct2Y);
|
| 734 |
}
|
| 735 |
|
| 736 |
const buffer = canvas.toBuffer("image/png");
|
|
@@ -815,19 +798,15 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 815 |
const rFrame = path.join(framesDir, `q${i + 1}_result.png`);
|
| 816 |
await renderFrame(q, "result", rFrame);
|
| 817 |
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
const safeR = rFrame.replace(/'/g, "'\\''");
|
| 821 |
-
concatList.push(`file '${safeQ}'\nduration ${questionDuration}`);
|
| 822 |
-
concatList.push(`file '${safeR}'\nduration ${resultDuration}`);
|
| 823 |
|
| 824 |
if (onProgress) onProgress(i + 1, usedQuestions.length);
|
| 825 |
}
|
| 826 |
|
| 827 |
// ffmpeg concat requires the last entry repeated without a duration
|
| 828 |
const lastResult = path.join(framesDir, `q${usedQuestions.length}_result.png`);
|
| 829 |
-
|
| 830 |
-
concatList.push(`file '${safeLastResult}'`);
|
| 831 |
fs.writeFileSync(framesList, concatList.join("\n"));
|
| 832 |
|
| 833 |
// ------------------------------------------------------------------
|
|
@@ -843,29 +822,37 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 843 |
await new Promise((resolve, reject) => {
|
| 844 |
ffmpeg()
|
| 845 |
.input(framesList)
|
| 846 |
-
.
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
"-r 30", // ← REQUIRED: set input framerate for image concat demuxer
|
| 850 |
-
// without this, ffmpeg ignores `duration` directives
|
| 851 |
-
])
|
| 852 |
.input(combinedAudio)
|
| 853 |
.outputOptions([
|
| 854 |
"-c:v libx264",
|
| 855 |
-
"-
|
| 856 |
-
"-crf 23", // quality — lower = better, 23 is a good default
|
| 857 |
"-pix_fmt yuv420p",
|
| 858 |
-
"-
|
|
|
|
| 859 |
"-c:a aac",
|
| 860 |
-
"-
|
| 861 |
-
// NOTE: no -shortest — it can cut the video early if frame/audio timing
|
| 862 |
-
// has any floating-point mismatch. We control duration via the concat list.
|
| 863 |
"-movflags +faststart",
|
| 864 |
])
|
| 865 |
.output(videoPath)
|
| 866 |
-
.on("
|
| 867 |
-
.on("
|
| 868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
})
|
| 870 |
.run();
|
| 871 |
});
|
|
@@ -878,7 +865,7 @@ async function buildVideo(questions, audioFiles, framesDir, videosDir, onProgres
|
|
| 878 |
// ============================================================
|
| 879 |
function concatAudio(files, output, audioDir) {
|
| 880 |
const listPath = path.join(audioDir, "audio_list.txt");
|
| 881 |
-
const content = files.map((f) => `file '${f
|
| 882 |
fs.writeFileSync(listPath, content);
|
| 883 |
|
| 884 |
return new Promise((resolve, reject) => {
|
|
@@ -886,16 +873,14 @@ function concatAudio(files, output, audioDir) {
|
|
| 886 |
.input(listPath)
|
| 887 |
.inputOptions(["-f concat", "-safe 0"])
|
| 888 |
.outputOptions([
|
| 889 |
-
"-acodec libmp3lame",
|
| 890 |
"-ar 44100",
|
| 891 |
"-ac 2",
|
| 892 |
-
"-q:a 2",
|
| 893 |
])
|
| 894 |
.output(output)
|
| 895 |
.on("end", resolve)
|
| 896 |
-
.on("error",
|
| 897 |
-
reject(new Error(`concatAudio ffmpeg error: ${err.message}\nstderr: ${stderr}`));
|
| 898 |
-
})
|
| 899 |
.run();
|
| 900 |
});
|
| 901 |
}
|
|
@@ -905,170 +890,89 @@ function concatAudio(files, output, audioDir) {
|
|
| 905 |
// ============================================================
|
| 906 |
async function postToTikTok(accessToken, videoPath, caption, onChunkProgress, onPollProgress) {
|
| 907 |
const videoSizeBytes = fs.statSync(videoPath).size;
|
|
|
|
| 908 |
|
| 909 |
-
//
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
try {
|
| 933 |
-
const creatorRes = await axios.post(
|
| 934 |
-
"https://open.tiktokapis.com/v2/post/publish/creator_info/query/",
|
| 935 |
-
{},
|
| 936 |
-
{
|
| 937 |
-
headers: {
|
| 938 |
-
Authorization: `Bearer ${accessToken}`,
|
| 939 |
-
"Content-Type": "application/json; charset=UTF-8",
|
| 940 |
-
},
|
| 941 |
-
}
|
| 942 |
-
);
|
| 943 |
-
const creatorData = creatorRes.data;
|
| 944 |
-
console.log("Creator info:", JSON.stringify(creatorData));
|
| 945 |
-
|
| 946 |
-
if (creatorData.error?.code !== "ok") {
|
| 947 |
-
throw new Error(`Creator info query failed: ${creatorData.error?.message}`);
|
| 948 |
-
}
|
| 949 |
-
|
| 950 |
-
// Use the first available privacy option — SELF_ONLY is always safe for unaudited apps
|
| 951 |
-
const options = creatorData.data?.privacy_level_options || [];
|
| 952 |
-
privacyLevel = options.includes("SELF_ONLY") ? "SELF_ONLY"
|
| 953 |
-
: options[0] || "SELF_ONLY";
|
| 954 |
-
|
| 955 |
-
console.log(`Privacy options: ${options.join(", ")} → using ${privacyLevel}`);
|
| 956 |
-
} catch (err) {
|
| 957 |
-
// Non-fatal for creator_info — proceed with SELF_ONLY, init may still work
|
| 958 |
-
console.warn("creator_info/query failed (non-fatal):", err.message);
|
| 959 |
-
}
|
| 960 |
-
|
| 961 |
-
// ── Step 1: Init upload ───────────────────────────────────────────────────
|
| 962 |
-
let initData;
|
| 963 |
-
try {
|
| 964 |
-
const initRes = await axios.post(
|
| 965 |
-
"https://open.tiktokapis.com/v2/post/publish/video/init/",
|
| 966 |
-
{
|
| 967 |
-
post_info: {
|
| 968 |
-
title: caption,
|
| 969 |
-
privacy_level: privacyLevel,
|
| 970 |
-
disable_duet: false,
|
| 971 |
-
disable_comment: false,
|
| 972 |
-
disable_stitch: false,
|
| 973 |
-
video_cover_timestamp_ms: 1000,
|
| 974 |
-
},
|
| 975 |
-
source_info: {
|
| 976 |
-
source: "FILE_UPLOAD",
|
| 977 |
-
video_size: videoSizeBytes,
|
| 978 |
-
chunk_size: chunkSize,
|
| 979 |
-
total_chunk_count: totalChunks,
|
| 980 |
-
},
|
| 981 |
},
|
| 982 |
-
{
|
| 983 |
-
headers: {
|
| 984 |
-
Authorization: `Bearer ${accessToken}`,
|
| 985 |
-
"Content-Type": "application/json; charset=UTF-8",
|
| 986 |
-
},
|
| 987 |
-
}
|
| 988 |
-
);
|
| 989 |
-
initData = initRes.data;
|
| 990 |
-
} catch (err) {
|
| 991 |
-
// Axios throws on 4xx/5xx — log the full TikTok error body
|
| 992 |
-
const body = err.response?.data;
|
| 993 |
-
const code = body?.error?.code;
|
| 994 |
-
|
| 995 |
-
if (code === "unaudited_client_can_only_post_to_private_accounts") {
|
| 996 |
-
throw new Error(
|
| 997 |
-
"Your TikTok account must be set to PRIVATE before posting. " +
|
| 998 |
-
"Go to TikTok → Settings → Privacy → set account to Private. " +
|
| 999 |
-
"Once the app passes TikTok's audit you can switch back to Public."
|
| 1000 |
-
);
|
| 1001 |
}
|
|
|
|
| 1002 |
|
| 1003 |
-
|
| 1004 |
-
}
|
| 1005 |
-
|
| 1006 |
if (initData.error?.code !== "ok") {
|
| 1007 |
-
throw new Error(`Init upload failed: ${initData.error?.message}
|
| 1008 |
}
|
| 1009 |
|
| 1010 |
const { publish_id, upload_url } = initData.data;
|
| 1011 |
-
console.log(`Init OK — publish_id: ${publish_id}`);
|
| 1012 |
|
| 1013 |
-
//
|
| 1014 |
const fd = fs.openSync(videoPath, "r");
|
| 1015 |
try {
|
| 1016 |
for (let i = 0; i < totalChunks; i++) {
|
| 1017 |
-
const start = i *
|
| 1018 |
-
const end = Math.min(start +
|
| 1019 |
const chunkLen = end - start + 1;
|
| 1020 |
const chunk = Buffer.allocUnsafe(chunkLen);
|
| 1021 |
fs.readSync(fd, chunk, 0, chunkLen, start);
|
| 1022 |
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
});
|
| 1033 |
-
} catch (err) {
|
| 1034 |
-
throw new Error(`Chunk ${i + 1} upload HTTP ${err.response?.status}: ${JSON.stringify(err.response?.data)}`);
|
| 1035 |
-
}
|
| 1036 |
|
| 1037 |
if (onChunkProgress) onChunkProgress(i + 1, totalChunks);
|
| 1038 |
}
|
| 1039 |
} finally {
|
| 1040 |
-
fs.closeSync(fd);
|
| 1041 |
}
|
| 1042 |
|
| 1043 |
-
//
|
| 1044 |
-
for (let attempt = 1; attempt <=
|
| 1045 |
await new Promise((r) => setTimeout(r, 5000));
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
}
|
| 1058 |
-
);
|
| 1059 |
-
statusData = statusRes.data;
|
| 1060 |
-
} catch (err) {
|
| 1061 |
-
console.warn(`Poll ${attempt} failed:`, err.message);
|
| 1062 |
-
continue;
|
| 1063 |
-
}
|
| 1064 |
-
|
| 1065 |
const status = statusData.data?.status;
|
| 1066 |
console.log(` Poll ${attempt}: ${status}`);
|
| 1067 |
if (onPollProgress) onPollProgress(status, statusData.data?.uploaded_bytes);
|
| 1068 |
if (status === "PUBLISH_COMPLETE") return statusData.data;
|
| 1069 |
if (status === "FAILED") throw new Error(`Post failed: ${JSON.stringify(statusData.data)}`);
|
| 1070 |
}
|
| 1071 |
-
throw new Error("Timed out waiting for
|
| 1072 |
}
|
| 1073 |
|
| 1074 |
// ============================================================
|
|
@@ -1128,7 +1032,6 @@ function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
|
| 1128 |
}
|
| 1129 |
}
|
| 1130 |
ctx.fillText(line.trim(), x, currentY);
|
| 1131 |
-
return currentY; // return final Y so callers can place elements below the text
|
| 1132 |
}
|
| 1133 |
|
| 1134 |
// ============================================================
|
|
|
|
| 56 |
TIKTOK_CLIENT_SECRET: process.env.TIKTOK_CLIENT_SECRET || "hh",
|
| 57 |
TIKTOK_REDIRECT_URI: process.env.TIKTOK_REDIRECT_URI || "https://content.nett.to/auth/tiktok/callback",
|
| 58 |
|
| 59 |
+
JWT_SECRET: process.env.JWT_SECRET || "key",
|
| 60 |
+
LONGCAT_API_KEY: process.env.LONGCAT_API_KEY || "akkey",
|
| 61 |
|
| 62 |
PORT: process.env.PORT || 3000,
|
| 63 |
CHUNK_SIZE: 10 * 1024 * 1024, // 10MB
|
|
|
|
| 107 |
const url =
|
| 108 |
"https://www.tiktok.com/v2/auth/authorize/" +
|
| 109 |
"?client_key=" + CONFIG.TIKTOK_CLIENT_KEY +
|
| 110 |
+
"&scope=video.publish,user.info.basic,user.info.profile" +
|
| 111 |
"&response_type=code" +
|
| 112 |
"&redirect_uri=" + encodeURIComponent(CONFIG.TIKTOK_REDIRECT_URI) +
|
| 113 |
"&state=" + state;
|
|
|
|
| 212 |
app.get("/me", requireAuth, async (req, res) => {
|
| 213 |
try {
|
| 214 |
const userRes = await axios.get(
|
| 215 |
+
"https://open.tiktokapis.com/v2/user/info/?fields=display_name,avatar_url,follower_count,following_count,likes_count,video_count,username",
|
| 216 |
{ headers: { Authorization: `Bearer ${req.user.tiktok_access_token}` } }
|
| 217 |
);
|
| 218 |
const data = userRes.data;
|
|
|
|
| 397 |
}
|
| 398 |
);
|
| 399 |
updateJob(jobId, { stepDetail: `Encoding video (${questionsUsed} questions, ${totalDuration.toFixed(1)}s)`, progress: 100 });
|
| 400 |
+
console.log(`✅ [${jobId}] Video ready: ${videoPath}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
// --- Step 5: Upload to TikTok ---
|
| 403 |
const caption = `Would You Rather: ${theme.charAt(0).toUpperCase() + theme.slice(1)} Edition 🤔 #wouldyourather #${theme.replace(/\s+/g, "")} #fyp`;
|
|
|
|
| 641 |
|
| 642 |
if (phase === "question") {
|
| 643 |
// ---- QUESTION PHASE ----
|
| 644 |
+
// Question text at top
|
|
|
|
| 645 |
ctx.fillStyle = "#FFFFFF";
|
| 646 |
ctx.font = "bold 58px sans-serif";
|
| 647 |
ctx.textAlign = "center";
|
| 648 |
+
wrapText(ctx, "Would You Rather...", W / 2, 200, W - 80, 70);
|
| 649 |
|
| 650 |
+
// Divider line
|
| 651 |
+
ctx.strokeStyle = "#444444";
|
| 652 |
+
ctx.lineWidth = 4;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
ctx.beginPath();
|
| 654 |
ctx.moveTo(0, H / 2);
|
| 655 |
ctx.lineTo(W, H / 2);
|
| 656 |
ctx.stroke();
|
| 657 |
|
| 658 |
+
// Option 1 — RED (top half)
|
| 659 |
+
ctx.fillStyle = "#CC0000";
|
| 660 |
+
ctx.fillRect(0, 320, W, H / 2 - 320);
|
| 661 |
+
ctx.fillStyle = "#FFFFFF";
|
| 662 |
+
ctx.font = "bold 72px sans-serif";
|
| 663 |
+
ctx.textAlign = "center";
|
| 664 |
+
wrapText(ctx, question.option1, W / 2, 520, W - 100, 85);
|
| 665 |
+
|
| 666 |
+
// Option 2 — BLUE (bottom half)
|
| 667 |
ctx.fillStyle = "#0055CC";
|
| 668 |
+
ctx.fillRect(0, H / 2, W, H / 2 - 100);
|
| 669 |
ctx.fillStyle = "#FFFFFF";
|
| 670 |
+
ctx.font = "bold 72px sans-serif";
|
| 671 |
ctx.textAlign = "center";
|
| 672 |
+
wrapText(ctx, question.option2, W / 2, H / 2 + 200, W - 100, 85);
|
| 673 |
|
| 674 |
+
// OR badge in center
|
| 675 |
ctx.fillStyle = "#FFFFFF";
|
| 676 |
ctx.beginPath();
|
| 677 |
+
ctx.arc(W / 2, H / 2, 70, 0, Math.PI * 2);
|
| 678 |
ctx.fill();
|
| 679 |
ctx.fillStyle = "#111111";
|
| 680 |
ctx.font = "bold 52px sans-serif";
|
|
|
|
| 685 |
// ---- RESULT PHASE ----
|
| 686 |
const winner1 = question.pct1 >= question.pct2;
|
| 687 |
|
| 688 |
+
// Option 1 block
|
| 689 |
ctx.fillStyle = "#CC0000";
|
| 690 |
ctx.fillRect(0, 0, W, H / 2 - 10);
|
|
|
|
|
|
|
| 691 |
ctx.fillStyle = "#FFFFFF";
|
| 692 |
+
ctx.font = "bold 64px sans-serif";
|
| 693 |
ctx.textAlign = "center";
|
| 694 |
+
wrapText(ctx, question.option1, W / 2, 280, W - 80, 76);
|
| 695 |
|
| 696 |
+
// Option 1 percentage
|
| 697 |
+
ctx.font = `bold 150px sans-serif`;
|
| 698 |
+
ctx.fillStyle = winner1 ? "#00FF88" : "#FF4444";
|
| 699 |
+
ctx.fillText(`${question.pct1}%`, W / 2, 680);
|
|
|
|
| 700 |
|
| 701 |
+
// Divider
|
| 702 |
ctx.fillStyle = "#111111";
|
| 703 |
ctx.fillRect(0, H / 2 - 10, W, 20);
|
| 704 |
|
| 705 |
+
// Option 2 block
|
| 706 |
ctx.fillStyle = "#0055CC";
|
| 707 |
ctx.fillRect(0, H / 2 + 10, W, H / 2 - 10);
|
|
|
|
|
|
|
| 708 |
ctx.fillStyle = "#FFFFFF";
|
| 709 |
+
ctx.font = "bold 64px sans-serif";
|
| 710 |
ctx.textAlign = "center";
|
| 711 |
+
wrapText(ctx, question.option2, W / 2, H / 2 + 230, W - 80, 76);
|
| 712 |
+
|
| 713 |
+
// Option 2 percentage
|
| 714 |
+
ctx.font = `bold 150px sans-serif`;
|
| 715 |
+
ctx.fillStyle = !winner1 ? "#00FF88" : "#FF4444";
|
| 716 |
+
ctx.fillText(`${question.pct2}%`, W / 2, H / 2 + 600);
|
|
|
|
|
|
|
| 717 |
}
|
| 718 |
|
| 719 |
const buffer = canvas.toBuffer("image/png");
|
|
|
|
| 798 |
const rFrame = path.join(framesDir, `q${i + 1}_result.png`);
|
| 799 |
await renderFrame(q, "result", rFrame);
|
| 800 |
|
| 801 |
+
concatList.push(`file '${qFrame}'\nduration ${questionDuration}`);
|
| 802 |
+
concatList.push(`file '${rFrame}'\nduration ${resultDuration}`);
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
if (onProgress) onProgress(i + 1, usedQuestions.length);
|
| 805 |
}
|
| 806 |
|
| 807 |
// ffmpeg concat requires the last entry repeated without a duration
|
| 808 |
const lastResult = path.join(framesDir, `q${usedQuestions.length}_result.png`);
|
| 809 |
+
concatList.push(`file '${lastResult}'`);
|
|
|
|
| 810 |
fs.writeFileSync(framesList, concatList.join("\n"));
|
| 811 |
|
| 812 |
// ------------------------------------------------------------------
|
|
|
|
| 822 |
await new Promise((resolve, reject) => {
|
| 823 |
ffmpeg()
|
| 824 |
.input(framesList)
|
| 825 |
+
// -framerate is REQUIRED when concatenating still images with duration directives.
|
| 826 |
+
// Without it ffmpeg ignores the duration lines and produces a near-zero-length video.
|
| 827 |
+
.inputOptions(["-f concat", "-safe 0", "-framerate 30"])
|
|
|
|
|
|
|
|
|
|
| 828 |
.input(combinedAudio)
|
| 829 |
.outputOptions([
|
| 830 |
"-c:v libx264",
|
| 831 |
+
"-r 30", // output framerate must match input
|
|
|
|
| 832 |
"-pix_fmt yuv420p",
|
| 833 |
+
"-preset fast",
|
| 834 |
+
"-crf 23",
|
| 835 |
"-c:a aac",
|
| 836 |
+
"-shortest",
|
|
|
|
|
|
|
| 837 |
"-movflags +faststart",
|
| 838 |
])
|
| 839 |
.output(videoPath)
|
| 840 |
+
.on("start", (cmdLine) => console.log("ffmpeg cmd:", cmdLine))
|
| 841 |
+
.on("stderr", (line) => console.log("ffmpeg:", line))
|
| 842 |
+
.on("end", () => {
|
| 843 |
+
let fileSize = 0;
|
| 844 |
+
try { fileSize = fs.statSync(videoPath).size; } catch (_) {}
|
| 845 |
+
if (fileSize < 50 * 1024) {
|
| 846 |
+
return reject(new Error(
|
| 847 |
+
`ffmpeg finished but output is only ${(fileSize / 1024).toFixed(1)} KB — ` +
|
| 848 |
+
`check server logs above for ffmpeg stderr output.`
|
| 849 |
+
));
|
| 850 |
+
}
|
| 851 |
+
console.log(`✅ Video encoded: ${(fileSize / 1024 / 1024).toFixed(2)} MB`);
|
| 852 |
+
resolve();
|
| 853 |
+
})
|
| 854 |
+
.on("error", (err, _stdout, stderr) => {
|
| 855 |
+
reject(new Error(`ffmpeg error: ${err.message}\nstderr: ${stderr}`));
|
| 856 |
})
|
| 857 |
.run();
|
| 858 |
});
|
|
|
|
| 865 |
// ============================================================
|
| 866 |
function concatAudio(files, output, audioDir) {
|
| 867 |
const listPath = path.join(audioDir, "audio_list.txt");
|
| 868 |
+
const content = files.map((f) => `file '${f}'`).join("\n");
|
| 869 |
fs.writeFileSync(listPath, content);
|
| 870 |
|
| 871 |
return new Promise((resolve, reject) => {
|
|
|
|
| 873 |
.input(listPath)
|
| 874 |
.inputOptions(["-f concat", "-safe 0"])
|
| 875 |
.outputOptions([
|
| 876 |
+
"-acodec libmp3lame", // re-encode so mismatched bitrates/sample-rates never cause failures
|
| 877 |
"-ar 44100",
|
| 878 |
"-ac 2",
|
| 879 |
+
"-q:a 2", // VBR ~190kbps — good quality, small file
|
| 880 |
])
|
| 881 |
.output(output)
|
| 882 |
.on("end", resolve)
|
| 883 |
+
.on("error", reject)
|
|
|
|
|
|
|
| 884 |
.run();
|
| 885 |
});
|
| 886 |
}
|
|
|
|
| 890 |
// ============================================================
|
| 891 |
async function postToTikTok(accessToken, videoPath, caption, onChunkProgress, onPollProgress) {
|
| 892 |
const videoSizeBytes = fs.statSync(videoPath).size;
|
| 893 |
+
const totalChunks = Math.ceil(videoSizeBytes / CONFIG.CHUNK_SIZE);
|
| 894 |
|
| 895 |
+
// Init upload
|
| 896 |
+
const initRes = await axios.post(
|
| 897 |
+
"https://open.tiktokapis.com/v2/post/publish/video/init/",
|
| 898 |
+
{
|
| 899 |
+
post_info: {
|
| 900 |
+
title: caption,
|
| 901 |
+
privacy_level: "SELF_ONLY", // Change to PUBLIC_TO_EVERYONE after app audit
|
| 902 |
+
disable_duet: false,
|
| 903 |
+
disable_comment: false,
|
| 904 |
+
disable_stitch: false,
|
| 905 |
+
video_cover_timestamp_ms: 1000,
|
| 906 |
+
},
|
| 907 |
+
source_info: {
|
| 908 |
+
source: "FILE_UPLOAD",
|
| 909 |
+
video_size: videoSizeBytes,
|
| 910 |
+
chunk_size: CONFIG.CHUNK_SIZE,
|
| 911 |
+
total_chunk_count: totalChunks,
|
| 912 |
+
},
|
| 913 |
+
},
|
| 914 |
+
{
|
| 915 |
+
headers: {
|
| 916 |
+
Authorization: `Bearer ${accessToken}`,
|
| 917 |
+
"Content-Type": "application/json; charset=UTF-8",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 918 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
}
|
| 920 |
+
);
|
| 921 |
|
| 922 |
+
const initData = initRes.data;
|
|
|
|
|
|
|
| 923 |
if (initData.error?.code !== "ok") {
|
| 924 |
+
throw new Error(`Init upload failed: ${initData.error?.message}`);
|
| 925 |
}
|
| 926 |
|
| 927 |
const { publish_id, upload_url } = initData.data;
|
|
|
|
| 928 |
|
| 929 |
+
// Upload chunks — stream each chunk directly from disk, no full-file buffer
|
| 930 |
const fd = fs.openSync(videoPath, "r");
|
| 931 |
try {
|
| 932 |
for (let i = 0; i < totalChunks; i++) {
|
| 933 |
+
const start = i * CONFIG.CHUNK_SIZE;
|
| 934 |
+
const end = Math.min(start + CONFIG.CHUNK_SIZE, videoSizeBytes) - 1;
|
| 935 |
const chunkLen = end - start + 1;
|
| 936 |
const chunk = Buffer.allocUnsafe(chunkLen);
|
| 937 |
fs.readSync(fd, chunk, 0, chunkLen, start);
|
| 938 |
|
| 939 |
+
await axios.put(upload_url, chunk, {
|
| 940 |
+
headers: {
|
| 941 |
+
"Content-Range": `bytes ${start}-${end}/${videoSizeBytes}`,
|
| 942 |
+
"Content-Type": "video/mp4",
|
| 943 |
+
"Content-Length": chunkLen,
|
| 944 |
+
},
|
| 945 |
+
maxBodyLength: Infinity,
|
| 946 |
+
maxContentLength: Infinity,
|
| 947 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
if (onChunkProgress) onChunkProgress(i + 1, totalChunks);
|
| 950 |
}
|
| 951 |
} finally {
|
| 952 |
+
fs.closeSync(fd); // always release the file descriptor
|
| 953 |
}
|
| 954 |
|
| 955 |
+
// Poll status
|
| 956 |
+
for (let attempt = 1; attempt <= 15; attempt++) {
|
| 957 |
await new Promise((r) => setTimeout(r, 5000));
|
| 958 |
+
const statusRes = await axios.post(
|
| 959 |
+
"https://open.tiktokapis.com/v2/post/publish/status/fetch/",
|
| 960 |
+
{ publish_id },
|
| 961 |
+
{
|
| 962 |
+
headers: {
|
| 963 |
+
Authorization: `Bearer ${accessToken}`,
|
| 964 |
+
"Content-Type": "application/json; charset=UTF-8",
|
| 965 |
+
},
|
| 966 |
+
}
|
| 967 |
+
);
|
| 968 |
+
const statusData = statusRes.data;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
const status = statusData.data?.status;
|
| 970 |
console.log(` Poll ${attempt}: ${status}`);
|
| 971 |
if (onPollProgress) onPollProgress(status, statusData.data?.uploaded_bytes);
|
| 972 |
if (status === "PUBLISH_COMPLETE") return statusData.data;
|
| 973 |
if (status === "FAILED") throw new Error(`Post failed: ${JSON.stringify(statusData.data)}`);
|
| 974 |
}
|
| 975 |
+
throw new Error("Timed out waiting for post");
|
| 976 |
}
|
| 977 |
|
| 978 |
// ============================================================
|
|
|
|
| 1032 |
}
|
| 1033 |
}
|
| 1034 |
ctx.fillText(line.trim(), x, currentY);
|
|
|
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
// ============================================================
|