Reaperxxxx commited on
Commit
824bb62
·
verified ·
1 Parent(s): 258e17f

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +124 -221
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 || "hh",
60
- LONGCAT_API_KEY: process.env.LONGCAT_API_KEY || "hh",
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,video.upload,user.info.basic,user.info.profile,user.info.stats" +
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,username,follower_count,following_count,likes_count,video_count",
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, 160, W - 80, 70);
659
 
660
- // Option 1 — RED (top half, from y=260 to H/2-10)
661
- ctx.fillStyle = "#CC0000";
662
- ctx.fillRect(0, 260, W, H / 2 - 270);
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 2BLUE (bottom half, from H/2+10 to H-100)
 
 
 
 
 
 
 
 
677
  ctx.fillStyle = "#0055CC";
678
- ctx.fillRect(0, H / 2 + 10, W, H / 2 - 110);
679
  ctx.fillStyle = "#FFFFFF";
680
- ctx.font = "bold 68px sans-serif";
681
  ctx.textAlign = "center";
682
- wrapText(ctx, question.option2, W / 2, H / 2 + 160, W - 120, 82);
683
 
684
- // OR badge over the divider
685
  ctx.fillStyle = "#FFFFFF";
686
  ctx.beginPath();
687
- ctx.arc(W / 2, H / 2, 72, 0, Math.PI * 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
- // --- Top half: Option 1 ---
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 60px sans-serif";
705
  ctx.textAlign = "center";
706
- const opt1EndY = wrapText(ctx, question.option1, W / 2, 200, W - 100, 72);
707
 
708
- // Percentage below the text with a safe gap
709
- const pct1Y = Math.min(opt1EndY + 130, H / 2 - 80);
710
- ctx.font = "bold 160px sans-serif";
711
- ctx.fillStyle = winner1 ? "#00FF88" : "#FF6666";
712
- ctx.fillText(`${question.pct1}%`, W / 2, pct1Y);
713
 
714
- // --- Divider ---
715
  ctx.fillStyle = "#111111";
716
  ctx.fillRect(0, H / 2 - 10, W, 20);
717
 
718
- // --- Bottom half: Option 2 ---
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 60px sans-serif";
725
  ctx.textAlign = "center";
726
- const opt2StartY = H / 2 + 160;
727
- const opt2EndY = wrapText(ctx, question.option2, W / 2, opt2StartY, W - 100, 72);
728
-
729
- // Percentage below the text with a safe gap
730
- const pct2Y = Math.min(opt2EndY + 130, H - 80);
731
- ctx.font = "bold 160px sans-serif";
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
- // ffmpeg concat format: escape single quotes in paths
819
- const safeQ = qFrame.replace(/'/g, "'\\''");
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
- const safeLastResult = lastResult.replace(/'/g, "'\\''");
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
- .inputOptions([
847
- "-f concat",
848
- "-safe 0",
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
- "-preset fast",
856
- "-crf 23", // quality — lower = better, 23 is a good default
857
  "-pix_fmt yuv420p",
858
- "-r 30", // output framerate
 
859
  "-c:a aac",
860
- "-b:a 192k",
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("end", resolve)
867
- .on("error", (err, stdout, stderr) => {
868
- reject(new Error(`Video encode ffmpeg error: ${err.message}\nstderr: ${stderr}`));
 
 
 
 
 
 
 
 
 
 
 
 
 
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.replace(/'/g, "'\\''")}'`).join("\n");
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", (err, stdout, stderr) => {
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
- // TikTok chunk rules (from docs):
910
- // - Each chunk must be >= 5 MB, except the last chunk
911
- // - Each chunk must be <= 64 MB
912
- // - Files < 5 MB must be uploaded whole (chunk_size = video_size, total_chunk_count = 1)
913
- // - Files > 64 MB must be split into multiple chunks
914
- const MIN_CHUNK = 5 * 1024 * 1024; // 5 MB
915
- const MAX_CHUNK = 64 * 1024 * 1024; // 64 MB
916
-
917
- let chunkSize;
918
- if (videoSizeBytes <= MAX_CHUNK) {
919
- // Upload the whole file in one chunk — satisfies both the < 5 MB and the 5–64 MB cases
920
- chunkSize = videoSizeBytes;
921
- } else {
922
- // Use 10 MB chunks for large files; stays safely within 5–64 MB bounds
923
- chunkSize = CONFIG.CHUNK_SIZE; // 10 MB
924
- }
925
-
926
- const totalChunks = Math.ceil(videoSizeBytes / chunkSize);
927
- console.log(`Video size: ${(videoSizeBytes / 1024 / 1024).toFixed(2)} MB | chunk size: ${(chunkSize / 1024 / 1024).toFixed(2)} MB | chunks: ${totalChunks}`);
928
-
929
- // ── Step 0: Query creator info (REQUIRED before init per TikTok docs) ──────
930
- // This also tells us which privacy_level values are valid for this account.
931
- let privacyLevel = "SELF_ONLY"; // safe fallback
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
- throw new Error(`Init upload HTTP ${err.response?.status}: ${JSON.stringify(body)}`);
1004
- }
1005
-
1006
  if (initData.error?.code !== "ok") {
1007
- throw new Error(`Init upload failed: ${initData.error?.message} (code: ${initData.error?.code})`);
1008
  }
1009
 
1010
  const { publish_id, upload_url } = initData.data;
1011
- console.log(`Init OK — publish_id: ${publish_id}`);
1012
 
1013
- // ── Step 2: Upload chunks ─────────────────────────────────────────────────
1014
  const fd = fs.openSync(videoPath, "r");
1015
  try {
1016
  for (let i = 0; i < totalChunks; i++) {
1017
- const start = i * chunkSize;
1018
- const end = Math.min(start + chunkSize, videoSizeBytes) - 1;
1019
  const chunkLen = end - start + 1;
1020
  const chunk = Buffer.allocUnsafe(chunkLen);
1021
  fs.readSync(fd, chunk, 0, chunkLen, start);
1022
 
1023
- try {
1024
- await axios.put(upload_url, chunk, {
1025
- headers: {
1026
- "Content-Range": `bytes ${start}-${end}/${videoSizeBytes}`,
1027
- "Content-Type": "video/mp4",
1028
- "Content-Length": chunkLen,
1029
- },
1030
- maxBodyLength: Infinity,
1031
- maxContentLength: Infinity,
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
- // ── Step 3: Poll publish status ───────────────────────────────────────────
1044
- for (let attempt = 1; attempt <= 20; attempt++) {
1045
  await new Promise((r) => setTimeout(r, 5000));
1046
-
1047
- let statusData;
1048
- try {
1049
- const statusRes = await axios.post(
1050
- "https://open.tiktokapis.com/v2/post/publish/status/fetch/",
1051
- { publish_id },
1052
- {
1053
- headers: {
1054
- Authorization: `Bearer ${accessToken}`,
1055
- "Content-Type": "application/json; charset=UTF-8",
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 TikTok to process the video");
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 1RED (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
  // ============================================================