vidpipe 1.3.13 → 1.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -85,6 +85,7 @@ var init_types = __esm({
85
85
  PipelineStage2["Chapters"] = "chapters";
86
86
  PipelineStage2["Captions"] = "captions";
87
87
  PipelineStage2["CaptionBurn"] = "caption-burn";
88
+ PipelineStage2["IntroOutro"] = "intro-outro";
88
89
  PipelineStage2["Summary"] = "summary";
89
90
  PipelineStage2["Shorts"] = "shorts";
90
91
  PipelineStage2["MediumClips"] = "medium-clips";
@@ -102,15 +103,16 @@ var init_types = __esm({
102
103
  { stage: "visual-enhancement" /* VisualEnhancement */, name: "Visual Enhancement", stageNumber: 4 },
103
104
  { stage: "captions" /* Captions */, name: "Captions", stageNumber: 5 },
104
105
  { stage: "caption-burn" /* CaptionBurn */, name: "Caption Burn", stageNumber: 6 },
105
- { stage: "shorts" /* Shorts */, name: "Shorts", stageNumber: 7 },
106
- { stage: "medium-clips" /* MediumClips */, name: "Medium Clips", stageNumber: 8 },
107
- { stage: "chapters" /* Chapters */, name: "Chapters", stageNumber: 9 },
108
- { stage: "summary" /* Summary */, name: "Summary", stageNumber: 10 },
109
- { stage: "social-media" /* SocialMedia */, name: "Social Media", stageNumber: 11 },
110
- { stage: "short-posts" /* ShortPosts */, name: "Short Posts", stageNumber: 12 },
111
- { stage: "medium-clip-posts" /* MediumClipPosts */, name: "Medium Clip Posts", stageNumber: 13 },
112
- { stage: "queue-build" /* QueueBuild */, name: "Queue Build", stageNumber: 14 },
113
- { stage: "blog" /* Blog */, name: "Blog", stageNumber: 15 }
106
+ { stage: "intro-outro" /* IntroOutro */, name: "Intro/Outro", stageNumber: 7 },
107
+ { stage: "shorts" /* Shorts */, name: "Shorts", stageNumber: 8 },
108
+ { stage: "medium-clips" /* MediumClips */, name: "Medium Clips", stageNumber: 9 },
109
+ { stage: "chapters" /* Chapters */, name: "Chapters", stageNumber: 10 },
110
+ { stage: "summary" /* Summary */, name: "Summary", stageNumber: 11 },
111
+ { stage: "social-media" /* SocialMedia */, name: "Social Media", stageNumber: 12 },
112
+ { stage: "short-posts" /* ShortPosts */, name: "Short Posts", stageNumber: 13 },
113
+ { stage: "medium-clip-posts" /* MediumClipPosts */, name: "Medium Clip Posts", stageNumber: 14 },
114
+ { stage: "queue-build" /* QueueBuild */, name: "Queue Build", stageNumber: 15 },
115
+ { stage: "blog" /* Blog */, name: "Blog", stageNumber: 16 }
114
116
  ];
115
117
  TOTAL_STAGES = PIPELINE_STAGES.length;
116
118
  PLATFORM_CHAR_LIMITS = {
@@ -446,6 +448,12 @@ function saveGlobalConfig(config2) {
446
448
  chmodSync(configPath, 384);
447
449
  }
448
450
  }
451
+ function getGlobalConfigValue(section, key) {
452
+ const config2 = loadGlobalConfig();
453
+ const sectionValues = config2[section];
454
+ const value = sectionValues[key];
455
+ return typeof value === "string" ? value : void 0;
456
+ }
449
457
  function setGlobalConfigValue(section, key, value) {
450
458
  const config2 = loadGlobalConfig();
451
459
  const sectionValues = config2[section];
@@ -592,6 +600,11 @@ function resolveConfig(cliOptions = {}) {
592
600
  process.env.SKIP_VISUAL_ENHANCEMENT,
593
601
  false
594
602
  ),
603
+ SKIP_INTRO_OUTRO: resolveBoolean(
604
+ cliOptions.introOutro === void 0 ? void 0 : !cliOptions.introOutro,
605
+ process.env.SKIP_INTRO_OUTRO,
606
+ false
607
+ ),
595
608
  LATE_API_KEY: resolveString(
596
609
  cliOptions.lateApiKey,
597
610
  process.env.LATE_API_KEY,
@@ -863,7 +876,7 @@ var init_githubClient = __esm({
863
876
  const response = await this.octokit.rest.issues.listForRepo({
864
877
  owner: this.owner,
865
878
  repo: this.repo,
866
- state: "open",
879
+ state: options.state ?? "all",
867
880
  labels: options.labels && options.labels.length > 0 ? normalizeLabels(options.labels).join(",") : void 0,
868
881
  sort: void 0,
869
882
  direction: void 0,
@@ -4661,6 +4674,150 @@ var require_node = __commonJS({
4661
4674
  }
4662
4675
  });
4663
4676
 
4677
+ // src/L3-services/postStore/postStore.ts
4678
+ function getQueueDir() {
4679
+ const { OUTPUT_DIR } = getConfig();
4680
+ return join(OUTPUT_DIR, "publish-queue");
4681
+ }
4682
+ function getPublishedDir() {
4683
+ const { OUTPUT_DIR } = getConfig();
4684
+ return join(OUTPUT_DIR, "published");
4685
+ }
4686
+ async function readQueueItem(folderPath, id) {
4687
+ const metadataPath = join(folderPath, "metadata.json");
4688
+ const postPath = join(folderPath, "post.md");
4689
+ try {
4690
+ const metadataRaw = await readTextFile(metadataPath);
4691
+ const metadata = JSON.parse(metadataRaw);
4692
+ let postContent = "";
4693
+ try {
4694
+ postContent = await readTextFile(postPath);
4695
+ } catch {
4696
+ logger_default.debug(`No post.md found for ${String(id).replace(/[\r\n]/g, "")}`);
4697
+ }
4698
+ const videoPath = join(folderPath, "media.mp4");
4699
+ const imagePath = join(folderPath, "media.png");
4700
+ let mediaPath = null;
4701
+ let hasMedia = false;
4702
+ if (await fileExists(videoPath)) {
4703
+ mediaPath = videoPath;
4704
+ hasMedia = true;
4705
+ } else if (await fileExists(imagePath)) {
4706
+ mediaPath = imagePath;
4707
+ hasMedia = true;
4708
+ }
4709
+ return {
4710
+ id,
4711
+ metadata,
4712
+ postContent,
4713
+ hasMedia,
4714
+ mediaPath,
4715
+ folderPath
4716
+ };
4717
+ } catch (err) {
4718
+ logger_default.debug(`Failed to read queue item ${String(id).replace(/[\r\n]/g, "")}: ${String(err).replace(/[\r\n]/g, "")}`);
4719
+ return null;
4720
+ }
4721
+ }
4722
+ async function getPendingItems() {
4723
+ const queueDir = getQueueDir();
4724
+ await ensureDirectory(queueDir);
4725
+ let entries;
4726
+ try {
4727
+ const dirents = await listDirectoryWithTypes(queueDir);
4728
+ entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
4729
+ } catch {
4730
+ return [];
4731
+ }
4732
+ const items = [];
4733
+ for (const name of entries) {
4734
+ const item = await readQueueItem(join(queueDir, name), name);
4735
+ if (item) items.push(item);
4736
+ }
4737
+ items.sort((a, b) => {
4738
+ if (a.hasMedia !== b.hasMedia) return a.hasMedia ? -1 : 1;
4739
+ return a.metadata.createdAt.localeCompare(b.metadata.createdAt);
4740
+ });
4741
+ return items;
4742
+ }
4743
+ async function createItem(id, metadata, postContent, mediaSourcePath) {
4744
+ if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
4745
+ throw new Error(`Invalid ID format: ${id}`);
4746
+ }
4747
+ const folderPath = join(getQueueDir(), basename(id));
4748
+ await ensureDirectory(folderPath);
4749
+ await writeJsonFile(join(folderPath, "metadata.json"), metadata);
4750
+ await writeTextFile(join(folderPath, "post.md"), postContent);
4751
+ let hasMedia = false;
4752
+ const ext = mediaSourcePath ? extname(mediaSourcePath) : ".mp4";
4753
+ const mediaFilename = `media${ext}`;
4754
+ const mediaPath = join(folderPath, mediaFilename);
4755
+ if (mediaSourcePath) {
4756
+ await copyFile(mediaSourcePath, mediaPath);
4757
+ hasMedia = true;
4758
+ }
4759
+ logger_default.debug(`Created queue item: ${String(id).replace(/[\r\n]/g, "")}`);
4760
+ return {
4761
+ id,
4762
+ metadata,
4763
+ postContent,
4764
+ hasMedia,
4765
+ mediaPath: hasMedia ? mediaPath : null,
4766
+ folderPath
4767
+ };
4768
+ }
4769
+ async function getPublishedItems() {
4770
+ const publishedDir = getPublishedDir();
4771
+ await ensureDirectory(publishedDir);
4772
+ let entries;
4773
+ try {
4774
+ const dirents = await listDirectoryWithTypes(publishedDir);
4775
+ entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
4776
+ } catch {
4777
+ return [];
4778
+ }
4779
+ const items = [];
4780
+ for (const name of entries) {
4781
+ const item = await readQueueItem(join(publishedDir, name), name);
4782
+ if (item) items.push(item);
4783
+ }
4784
+ items.sort((a, b) => a.metadata.createdAt.localeCompare(b.metadata.createdAt));
4785
+ return items;
4786
+ }
4787
+ async function getScheduledItemsByIdeaIds(ideaIds) {
4788
+ if (ideaIds.length === 0) return [];
4789
+ const ideaIdSet = new Set(ideaIds);
4790
+ const [pendingItems, publishedItems] = await Promise.all([
4791
+ getPendingItems(),
4792
+ getPublishedItems()
4793
+ ]);
4794
+ return [...pendingItems, ...publishedItems].filter(
4795
+ (item) => item.metadata.ideaIds?.some((id) => ideaIdSet.has(id)) ?? false
4796
+ );
4797
+ }
4798
+ async function itemExists(id) {
4799
+ if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
4800
+ throw new Error(`Invalid ID format: ${id}`);
4801
+ }
4802
+ if (await fileExists(join(getQueueDir(), basename(id)))) {
4803
+ return "pending";
4804
+ }
4805
+ if (await fileExists(join(getPublishedDir(), basename(id)))) {
4806
+ return "published";
4807
+ }
4808
+ return null;
4809
+ }
4810
+ var init_postStore = __esm({
4811
+ "src/L3-services/postStore/postStore.ts"() {
4812
+ "use strict";
4813
+ init_types();
4814
+ init_environment();
4815
+ init_configLogger();
4816
+ init_fileSystem();
4817
+ init_paths();
4818
+ }
4819
+ });
4820
+
4664
4821
  // src/L7-app/sdk/VidPipeSDK.ts
4665
4822
  init_types();
4666
4823
  init_environment();
@@ -4712,6 +4869,17 @@ var progressEmitter = new ProgressEmitter();
4712
4869
  // src/L1-infra/process/process.ts
4713
4870
  import { execFile as nodeExecFile, execSync as nodeExecSync, spawnSync as nodeSpawnSync } from "child_process";
4714
4871
  import { createRequire } from "module";
4872
+ function execCommand(cmd, args, opts) {
4873
+ return new Promise((resolve3, reject) => {
4874
+ nodeExecFile(cmd, args, { ...opts, encoding: "utf-8" }, (error, stdout, stderr) => {
4875
+ if (error) {
4876
+ reject(Object.assign(error, { stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
4877
+ } else {
4878
+ resolve3({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
4879
+ }
4880
+ });
4881
+ });
4882
+ }
4715
4883
  function execFileRaw(cmd, args, opts, callback) {
4716
4884
  nodeExecFile(cmd, args, { ...opts, encoding: "utf-8" }, (error, stdout, stderr) => {
4717
4885
  callback(error, String(stdout ?? ""), String(stderr ?? ""));
@@ -7403,16 +7571,10 @@ var LateApiClient = class {
7403
7571
  return data.accounts ?? [];
7404
7572
  }
7405
7573
  async getScheduledPosts(platform) {
7406
- const params = new URLSearchParams({ status: "scheduled" });
7407
- if (platform) params.set("platform", platform);
7408
- const data = await this.request(`/posts?${params}`);
7409
- return data.posts ?? [];
7574
+ return this.listPosts({ status: "scheduled", platform });
7410
7575
  }
7411
7576
  async getDraftPosts(platform) {
7412
- const params = new URLSearchParams({ status: "draft" });
7413
- if (platform) params.set("platform", platform);
7414
- const data = await this.request(`/posts?${params}`);
7415
- return data.posts ?? [];
7577
+ return this.listPosts({ status: "draft", platform });
7416
7578
  }
7417
7579
  async createPost(params) {
7418
7580
  const data = await this.request("/posts", {
@@ -7518,6 +7680,7 @@ function createLateApiClient(...args) {
7518
7680
  // src/L2-clients/scheduleStore/scheduleStore.ts
7519
7681
  init_fileSystem();
7520
7682
  init_paths();
7683
+ init_globalConfig();
7521
7684
  async function readScheduleFile(filePath) {
7522
7685
  return readTextFile(filePath);
7523
7686
  }
@@ -7529,7 +7692,10 @@ async function writeScheduleFile(filePath, content) {
7529
7692
  });
7530
7693
  }
7531
7694
  function resolveSchedulePath(configPath) {
7532
- return configPath ?? join(process.cwd(), "schedule.json");
7695
+ if (configPath) return configPath;
7696
+ const globalPath = getGlobalConfigValue("defaults", "scheduleConfig");
7697
+ if (globalPath) return globalPath;
7698
+ return join(process.cwd(), "schedule.json");
7533
7699
  }
7534
7700
 
7535
7701
  // src/L3-services/scheduler/scheduleConfig.ts
@@ -7794,7 +7960,7 @@ function getPlatformSchedule(platform, clipType) {
7794
7960
  avoidDays: sub.avoidDays
7795
7961
  };
7796
7962
  }
7797
- if (clipType && schedule.slots.length === 0 && schedule.byClipType) {
7963
+ if (schedule.slots.length === 0 && schedule.byClipType) {
7798
7964
  const allSlots = [];
7799
7965
  const allAvoidDays = /* @__PURE__ */ new Set();
7800
7966
  for (const sub of Object.values(schedule.byClipType)) {
@@ -7815,162 +7981,36 @@ function getDisplacementConfig() {
7815
7981
  return cachedConfig?.displacement ?? { ...defaultDisplacement };
7816
7982
  }
7817
7983
 
7818
- // src/L3-services/postStore/postStore.ts
7819
- init_types();
7820
- init_environment();
7984
+ // src/L3-services/scheduler/realign.ts
7985
+ init_postStore();
7821
7986
  init_configLogger();
7822
- init_fileSystem();
7823
- init_paths();
7824
- function getQueueDir() {
7825
- const { OUTPUT_DIR } = getConfig();
7826
- return join(OUTPUT_DIR, "publish-queue");
7827
- }
7828
- function getPublishedDir() {
7829
- const { OUTPUT_DIR } = getConfig();
7830
- return join(OUTPUT_DIR, "published");
7987
+
7988
+ // src/L3-services/scheduler/scheduler.ts
7989
+ init_configLogger();
7990
+ init_postStore();
7991
+ var MAX_LOOKAHEAD_DAYS = 730;
7992
+ var DAY_MS = 24 * 60 * 60 * 1e3;
7993
+ var HOUR_MS = 60 * 60 * 1e3;
7994
+ function normalizeDateTime(isoString) {
7995
+ return new Date(isoString).getTime();
7831
7996
  }
7832
- async function readQueueItem(folderPath, id) {
7833
- const metadataPath = join(folderPath, "metadata.json");
7834
- const postPath = join(folderPath, "post.md");
7835
- try {
7836
- const metadataRaw = await readTextFile(metadataPath);
7837
- const metadata = JSON.parse(metadataRaw);
7838
- let postContent = "";
7839
- try {
7840
- postContent = await readTextFile(postPath);
7841
- } catch {
7842
- logger_default.debug(`No post.md found for ${String(id).replace(/[\r\n]/g, "")}`);
7843
- }
7844
- const videoPath = join(folderPath, "media.mp4");
7845
- const imagePath = join(folderPath, "media.png");
7846
- let mediaPath = null;
7847
- let hasMedia = false;
7848
- if (await fileExists(videoPath)) {
7849
- mediaPath = videoPath;
7850
- hasMedia = true;
7851
- } else if (await fileExists(imagePath)) {
7852
- mediaPath = imagePath;
7853
- hasMedia = true;
7854
- }
7855
- return {
7856
- id,
7857
- metadata,
7858
- postContent,
7859
- hasMedia,
7860
- mediaPath,
7861
- folderPath
7862
- };
7863
- } catch (err) {
7864
- logger_default.debug(`Failed to read queue item ${String(id).replace(/[\r\n]/g, "")}: ${String(err).replace(/[\r\n]/g, "")}`);
7865
- return null;
7866
- }
7997
+ function sanitizeLogValue(value) {
7998
+ return value.replace(/[\r\n]/g, "");
7867
7999
  }
7868
- async function getPendingItems() {
7869
- const queueDir = getQueueDir();
7870
- await ensureDirectory(queueDir);
7871
- let entries;
7872
- try {
7873
- const dirents = await listDirectoryWithTypes(queueDir);
7874
- entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
7875
- } catch {
7876
- return [];
7877
- }
7878
- const items = [];
7879
- for (const name of entries) {
7880
- const item = await readQueueItem(join(queueDir, name), name);
7881
- if (item) items.push(item);
7882
- }
7883
- items.sort((a, b) => {
7884
- if (a.hasMedia !== b.hasMedia) return a.hasMedia ? -1 : 1;
7885
- return a.metadata.createdAt.localeCompare(b.metadata.createdAt);
8000
+ function getTimezoneOffset(timezone, date) {
8001
+ const formatter = new Intl.DateTimeFormat("en-US", {
8002
+ timeZone: timezone,
8003
+ timeZoneName: "longOffset"
7886
8004
  });
7887
- return items;
7888
- }
7889
- async function createItem(id, metadata, postContent, mediaSourcePath) {
7890
- if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
7891
- throw new Error(`Invalid ID format: ${id}`);
7892
- }
7893
- const folderPath = join(getQueueDir(), basename(id));
7894
- await ensureDirectory(folderPath);
7895
- await writeJsonFile(join(folderPath, "metadata.json"), metadata);
7896
- await writeTextFile(join(folderPath, "post.md"), postContent);
7897
- let hasMedia = false;
7898
- const ext = mediaSourcePath ? extname(mediaSourcePath) : ".mp4";
7899
- const mediaFilename = `media${ext}`;
7900
- const mediaPath = join(folderPath, mediaFilename);
7901
- if (mediaSourcePath) {
7902
- await copyFile(mediaSourcePath, mediaPath);
7903
- hasMedia = true;
7904
- }
7905
- logger_default.debug(`Created queue item: ${String(id).replace(/[\r\n]/g, "")}`);
7906
- return {
7907
- id,
7908
- metadata,
7909
- postContent,
7910
- hasMedia,
7911
- mediaPath: hasMedia ? mediaPath : null,
7912
- folderPath
7913
- };
7914
- }
7915
- async function getPublishedItems() {
7916
- const publishedDir = getPublishedDir();
7917
- await ensureDirectory(publishedDir);
7918
- let entries;
7919
- try {
7920
- const dirents = await listDirectoryWithTypes(publishedDir);
7921
- entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
7922
- } catch {
7923
- return [];
7924
- }
7925
- const items = [];
7926
- for (const name of entries) {
7927
- const item = await readQueueItem(join(publishedDir, name), name);
7928
- if (item) items.push(item);
7929
- }
7930
- items.sort((a, b) => a.metadata.createdAt.localeCompare(b.metadata.createdAt));
7931
- return items;
7932
- }
7933
- async function getScheduledItemsByIdeaIds(ideaIds) {
7934
- if (ideaIds.length === 0) return [];
7935
- const ideaIdSet = new Set(ideaIds);
7936
- const [pendingItems, publishedItems] = await Promise.all([
7937
- getPendingItems(),
7938
- getPublishedItems()
7939
- ]);
7940
- return [...pendingItems, ...publishedItems].filter(
7941
- (item) => item.metadata.ideaIds?.some((id) => ideaIdSet.has(id)) ?? false
7942
- );
7943
- }
7944
- async function getPublishedItemByLatePostId(latePostId) {
7945
- const publishedItems = await getPublishedItems();
7946
- return publishedItems.find((item) => item.metadata.latePostId === latePostId) ?? null;
7947
- }
7948
- async function itemExists(id) {
7949
- if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
7950
- throw new Error(`Invalid ID format: ${id}`);
7951
- }
7952
- if (await fileExists(join(getQueueDir(), basename(id)))) {
7953
- return "pending";
7954
- }
7955
- if (await fileExists(join(getPublishedDir(), basename(id)))) {
7956
- return "published";
7957
- }
7958
- return null;
7959
- }
7960
-
7961
- // src/L3-services/scheduler/realign.ts
7962
- init_configLogger();
7963
- function getTimezoneOffset(timezone, date) {
7964
- const formatter = new Intl.DateTimeFormat("en-US", {
7965
- timeZone: timezone,
7966
- timeZoneName: "longOffset"
7967
- });
7968
- const parts = formatter.formatToParts(date);
7969
- const tzPart = parts.find((p) => p.type === "timeZoneName");
7970
- const match = tzPart?.value?.match(/GMT([+-]\d{2}:\d{2})/);
7971
- if (match) return match[1];
7972
- if (tzPart?.value === "GMT") return "+00:00";
7973
- return "+00:00";
8005
+ const parts = formatter.formatToParts(date);
8006
+ const tzPart = parts.find((part) => part.type === "timeZoneName");
8007
+ const match = tzPart?.value?.match(/GMT([+-]\d{2}:\d{2})/);
8008
+ if (match) return match[1];
8009
+ if (tzPart?.value === "GMT") return "+00:00";
8010
+ logger_default.warn(
8011
+ `Could not parse timezone offset for timezone "${timezone}" on date "${date.toISOString()}". Raw timeZoneName part: "${tzPart?.value ?? "undefined"}". Falling back to UTC (+00:00).`
8012
+ );
8013
+ return "+00:00";
7974
8014
  }
7975
8015
  function buildSlotDatetime(date, time, timezone) {
7976
8016
  const formatter = new Intl.DateTimeFormat("en-US", {
@@ -7980,9 +8020,12 @@ function buildSlotDatetime(date, time, timezone) {
7980
8020
  day: "2-digit"
7981
8021
  });
7982
8022
  const parts = formatter.formatToParts(date);
7983
- const year = parts.find((p) => p.type === "year")?.value ?? String(date.getFullYear());
7984
- const month = (parts.find((p) => p.type === "month")?.value ?? String(date.getMonth() + 1)).padStart(2, "0");
7985
- const day = (parts.find((p) => p.type === "day")?.value ?? String(date.getDate())).padStart(2, "0");
8023
+ const yearPart = parts.find((part) => part.type === "year")?.value;
8024
+ const monthPart = parts.find((part) => part.type === "month")?.value;
8025
+ const dayPart = parts.find((part) => part.type === "day")?.value;
8026
+ const year = yearPart ?? String(date.getFullYear());
8027
+ const month = (monthPart ?? String(date.getMonth() + 1)).padStart(2, "0");
8028
+ const day = (dayPart ?? String(date.getDate())).padStart(2, "0");
7986
8029
  const offset = getTimezoneOffset(timezone, date);
7987
8030
  return `${year}-${month}-${day}T${time}:00${offset}`;
7988
8031
  }
@@ -8003,6 +8046,277 @@ function getDayOfWeekInTimezone(date, timezone) {
8003
8046
  };
8004
8047
  return map[short] ?? "mon";
8005
8048
  }
8049
+ async function fetchScheduledPostsSafe(platform) {
8050
+ try {
8051
+ const client = new LateApiClient();
8052
+ return await client.getScheduledPosts(platform);
8053
+ } catch (err) {
8054
+ const msg = err instanceof Error ? err.message : String(err);
8055
+ logger_default.warn(`Late API unreachable, using local data only: ${msg}`);
8056
+ return [];
8057
+ }
8058
+ }
8059
+ async function buildBookedMap(platform) {
8060
+ const [latePosts, publishedItems] = await Promise.all([
8061
+ fetchScheduledPostsSafe(platform),
8062
+ getPublishedItems()
8063
+ ]);
8064
+ const ideaLinkedPostIds = /* @__PURE__ */ new Set();
8065
+ for (const item of publishedItems) {
8066
+ if (item.metadata.latePostId && item.metadata.ideaIds?.length) {
8067
+ ideaLinkedPostIds.add(item.metadata.latePostId);
8068
+ }
8069
+ }
8070
+ const map = /* @__PURE__ */ new Map();
8071
+ for (const post of latePosts) {
8072
+ if (!post.scheduledFor) continue;
8073
+ for (const scheduledPlatform of post.platforms) {
8074
+ if (!platform || scheduledPlatform.platform === platform) {
8075
+ const ms = normalizeDateTime(post.scheduledFor);
8076
+ map.set(ms, {
8077
+ scheduledFor: post.scheduledFor,
8078
+ source: "late",
8079
+ postId: post._id,
8080
+ platform: scheduledPlatform.platform,
8081
+ status: post.status,
8082
+ ideaLinked: ideaLinkedPostIds.has(post._id)
8083
+ });
8084
+ }
8085
+ }
8086
+ }
8087
+ for (const item of publishedItems) {
8088
+ if (platform && item.metadata.platform !== platform) continue;
8089
+ if (!item.metadata.scheduledFor) continue;
8090
+ const ms = normalizeDateTime(item.metadata.scheduledFor);
8091
+ if (!map.has(ms)) {
8092
+ map.set(ms, {
8093
+ scheduledFor: item.metadata.scheduledFor,
8094
+ source: "local",
8095
+ itemId: item.id,
8096
+ platform: item.metadata.platform,
8097
+ ideaLinked: Boolean(item.metadata.ideaIds?.length)
8098
+ });
8099
+ }
8100
+ }
8101
+ return map;
8102
+ }
8103
+ async function getIdeaLinkedLatePostIds() {
8104
+ const publishedItems = await getPublishedItems();
8105
+ const ids = /* @__PURE__ */ new Set();
8106
+ for (const item of publishedItems) {
8107
+ if (item.metadata.latePostId && item.metadata.ideaIds?.length) {
8108
+ ids.add(item.metadata.latePostId);
8109
+ }
8110
+ }
8111
+ return ids;
8112
+ }
8113
+ function* generateTimeslots(platformConfig, timezone, fromMs, maxMs) {
8114
+ const baseDate = new Date(fromMs);
8115
+ const upperMs = maxMs ?? fromMs + MAX_LOOKAHEAD_DAYS * DAY_MS;
8116
+ for (let dayOffset = 0; dayOffset <= MAX_LOOKAHEAD_DAYS; dayOffset++) {
8117
+ const day = new Date(baseDate);
8118
+ day.setDate(day.getDate() + dayOffset);
8119
+ const dayOfWeek = getDayOfWeekInTimezone(day, timezone);
8120
+ if (platformConfig.avoidDays.includes(dayOfWeek)) continue;
8121
+ const dayCandidates = [];
8122
+ for (const slot of platformConfig.slots) {
8123
+ if (!slot.days.includes(dayOfWeek)) continue;
8124
+ const datetime = buildSlotDatetime(day, slot.time, timezone);
8125
+ const ms = normalizeDateTime(datetime);
8126
+ if (ms <= fromMs) continue;
8127
+ if (ms > upperMs) continue;
8128
+ dayCandidates.push({ datetime, ms });
8129
+ }
8130
+ dayCandidates.sort((a, b) => a.ms - b.ms);
8131
+ for (const candidate of dayCandidates) yield candidate;
8132
+ if (dayCandidates.length === 0) {
8133
+ const dayStartMs = normalizeDateTime(buildSlotDatetime(day, "00:00", timezone));
8134
+ if (dayStartMs > upperMs) break;
8135
+ }
8136
+ }
8137
+ }
8138
+ function passesIdeaSpacing(candidateMs, candidatePlatform, ideaRefs, samePlatformMs, crossPlatformMs) {
8139
+ for (const ref of ideaRefs) {
8140
+ const diff = Math.abs(candidateMs - ref.scheduledForMs);
8141
+ if (ref.platform === candidatePlatform && diff < samePlatformMs) return false;
8142
+ if (diff < crossPlatformMs) return false;
8143
+ }
8144
+ return true;
8145
+ }
8146
+ async function getIdeaReferences(ideaIds, bookedMap) {
8147
+ const sameIdeaPosts = await getScheduledItemsByIdeaIds(ideaIds);
8148
+ const lateSlotsByPostId = /* @__PURE__ */ new Map();
8149
+ const localSlotsByItemId = /* @__PURE__ */ new Map();
8150
+ for (const slot of bookedMap.values()) {
8151
+ if (slot.postId) {
8152
+ const arr = lateSlotsByPostId.get(slot.postId) ?? [];
8153
+ arr.push(slot);
8154
+ lateSlotsByPostId.set(slot.postId, arr);
8155
+ }
8156
+ if (slot.itemId) {
8157
+ const arr = localSlotsByItemId.get(slot.itemId) ?? [];
8158
+ arr.push(slot);
8159
+ localSlotsByItemId.set(slot.itemId, arr);
8160
+ }
8161
+ }
8162
+ const refs = [];
8163
+ const seen = /* @__PURE__ */ new Set();
8164
+ const addRef = (platform, scheduledFor) => {
8165
+ if (!scheduledFor) return;
8166
+ const key = `${platform}@${scheduledFor}`;
8167
+ if (seen.has(key)) return;
8168
+ seen.add(key);
8169
+ refs.push({ platform, scheduledForMs: normalizeDateTime(scheduledFor) });
8170
+ };
8171
+ for (const item of sameIdeaPosts) {
8172
+ addRef(item.metadata.platform, item.metadata.scheduledFor);
8173
+ if (item.metadata.latePostId) {
8174
+ for (const slot of lateSlotsByPostId.get(item.metadata.latePostId) ?? []) {
8175
+ addRef(slot.platform, slot.scheduledFor);
8176
+ }
8177
+ }
8178
+ for (const slot of localSlotsByItemId.get(item.id) ?? []) {
8179
+ addRef(slot.platform, slot.scheduledFor);
8180
+ }
8181
+ }
8182
+ return refs;
8183
+ }
8184
+ async function schedulePost(platformConfig, fromMs, isIdeaPost, label, ctx) {
8185
+ const indent = " ".repeat(ctx.depth);
8186
+ let checked = 0;
8187
+ let skippedBooked = 0;
8188
+ let skippedSpacing = 0;
8189
+ logger_default.debug(`${indent}[schedulePost] Looking for slot for ${label} (idea=${isIdeaPost}) from ${new Date(fromMs).toISOString()}`);
8190
+ for (const { datetime, ms } of generateTimeslots(platformConfig, ctx.timezone, fromMs)) {
8191
+ checked++;
8192
+ const booked = ctx.bookedMap.get(ms);
8193
+ if (!booked) {
8194
+ if (isIdeaPost && ctx.ideaRefs.length > 0 && !passesIdeaSpacing(ms, ctx.platform, ctx.ideaRefs, ctx.samePlatformMs, ctx.crossPlatformMs)) {
8195
+ skippedSpacing++;
8196
+ if (skippedSpacing <= 5 || skippedSpacing % 50 === 0) {
8197
+ logger_default.debug(`${indent}[schedulePost] \u23ED\uFE0F Slot ${datetime} too close to same-idea post \u2014 skipping`);
8198
+ }
8199
+ continue;
8200
+ }
8201
+ logger_default.debug(`${indent}[schedulePost] \u2705 Found empty slot: ${datetime} (checked ${checked} candidates, skipped ${skippedBooked} booked, ${skippedSpacing} spacing)`);
8202
+ return datetime;
8203
+ }
8204
+ if (isIdeaPost && ctx.displacementEnabled && !booked.ideaLinked && booked.source === "late" && booked.postId) {
8205
+ if (ctx.ideaRefs.length > 0 && !passesIdeaSpacing(ms, ctx.platform, ctx.ideaRefs, ctx.samePlatformMs, ctx.crossPlatformMs)) {
8206
+ skippedSpacing++;
8207
+ if (skippedSpacing <= 5 || skippedSpacing % 50 === 0) {
8208
+ logger_default.debug(`${indent}[schedulePost] \u23ED\uFE0F Slot ${datetime} too close to same-idea post \u2014 skipping (even though displaceable)`);
8209
+ }
8210
+ continue;
8211
+ }
8212
+ logger_default.info(`${indent}[schedulePost] \u{1F504} Slot ${datetime} taken by non-idea post ${booked.postId} \u2014 displacing`);
8213
+ const newHome = await schedulePost(
8214
+ platformConfig,
8215
+ ms,
8216
+ false,
8217
+ `displaced:${booked.postId}`,
8218
+ { ...ctx, depth: ctx.depth + 1 }
8219
+ );
8220
+ if (newHome) {
8221
+ if (!ctx.dryRun) {
8222
+ try {
8223
+ await ctx.lateClient.schedulePost(booked.postId, newHome);
8224
+ } catch (err) {
8225
+ const msg = err instanceof Error ? err.message : String(err);
8226
+ logger_default.warn(`${indent}[schedulePost] \u26A0\uFE0F Failed to displace ${booked.postId} via Late API: ${msg} \u2014 skipping slot`);
8227
+ continue;
8228
+ }
8229
+ }
8230
+ logger_default.info(`${indent}[schedulePost] \u{1F4E6} Displaced ${booked.postId}: ${datetime} \u2192 ${newHome}`);
8231
+ ctx.bookedMap.delete(ms);
8232
+ const newMs = normalizeDateTime(newHome);
8233
+ ctx.bookedMap.set(newMs, { ...booked, scheduledFor: newHome });
8234
+ logger_default.debug(`${indent}[schedulePost] \u2705 Taking slot: ${datetime} (checked ${checked} candidates)`);
8235
+ return datetime;
8236
+ }
8237
+ logger_default.warn(`${indent}[schedulePost] \u26A0\uFE0F Could not displace ${booked.postId} \u2014 no empty slot found after ${datetime}`);
8238
+ }
8239
+ if (booked.ideaLinked) {
8240
+ skippedBooked++;
8241
+ if (skippedBooked <= 5 || skippedBooked % 50 === 0) {
8242
+ logger_default.debug(`${indent}[schedulePost] \u23ED\uFE0F Slot ${datetime} taken by idea post ${booked.postId ?? booked.itemId} \u2014 skipping`);
8243
+ }
8244
+ continue;
8245
+ }
8246
+ skippedBooked++;
8247
+ if (skippedBooked <= 5 || skippedBooked % 50 === 0) {
8248
+ logger_default.debug(`${indent}[schedulePost] \u23ED\uFE0F Slot ${datetime} taken (${booked.source}/${booked.postId ?? booked.itemId}) \u2014 skipping`);
8249
+ }
8250
+ }
8251
+ logger_default.warn(`[schedulePost] \u274C No slot found for ${label} \u2014 checked ${checked} candidates, skipped ${skippedBooked} booked, ${skippedSpacing} spacing`);
8252
+ return null;
8253
+ }
8254
+ async function findNextSlot(platform, clipType, options) {
8255
+ const config2 = await loadScheduleConfig();
8256
+ const platformConfig = getPlatformSchedule(platform, clipType);
8257
+ if (!platformConfig) {
8258
+ logger_default.warn(`No schedule config found for platform "${sanitizeLogValue(platform)}"`);
8259
+ return null;
8260
+ }
8261
+ const { timezone } = config2;
8262
+ const nowMs = Date.now();
8263
+ const ideaIds = options?.ideaIds?.filter(Boolean) ?? [];
8264
+ const isIdeaAware = ideaIds.length > 0;
8265
+ const bookedMap = await buildBookedMap(platform);
8266
+ const ideaLinkedPostIds = await getIdeaLinkedLatePostIds();
8267
+ const label = `${platform}/${clipType ?? "default"}`;
8268
+ let ideaRefs = [];
8269
+ let samePlatformMs = 0;
8270
+ let crossPlatformMs = 0;
8271
+ if (isIdeaAware) {
8272
+ const allBookedMap = await buildBookedMap();
8273
+ ideaRefs = await getIdeaReferences(ideaIds, allBookedMap);
8274
+ const spacingConfig = getIdeaSpacingConfig();
8275
+ samePlatformMs = spacingConfig.samePlatformHours * HOUR_MS;
8276
+ crossPlatformMs = spacingConfig.crossPlatformHours * HOUR_MS;
8277
+ }
8278
+ logger_default.info(`[findNextSlot] Scheduling ${label} (idea=${isIdeaAware}, booked=${bookedMap.size} slots, spacingRefs=${ideaRefs.length})`);
8279
+ const ctx = {
8280
+ timezone,
8281
+ bookedMap,
8282
+ ideaLinkedPostIds,
8283
+ lateClient: new LateApiClient(),
8284
+ displacementEnabled: getDisplacementConfig().enabled,
8285
+ dryRun: false,
8286
+ depth: 0,
8287
+ ideaRefs,
8288
+ samePlatformMs,
8289
+ crossPlatformMs,
8290
+ platform
8291
+ };
8292
+ const result = await schedulePost(platformConfig, nowMs, isIdeaAware, label, ctx);
8293
+ if (!result) {
8294
+ logger_default.warn(`[findNextSlot] No available slot for "${sanitizeLogValue(platform)}" within ${MAX_LOOKAHEAD_DAYS} days`);
8295
+ }
8296
+ return result;
8297
+ }
8298
+ async function getScheduleCalendar(startDate, endDate) {
8299
+ const bookedMap = await buildBookedMap();
8300
+ let filtered = [...bookedMap.values()].filter((slot) => slot.source === "local" || slot.status === "scheduled").map((slot) => ({
8301
+ platform: slot.platform,
8302
+ scheduledFor: slot.scheduledFor,
8303
+ source: slot.source,
8304
+ postId: slot.postId,
8305
+ itemId: slot.itemId
8306
+ }));
8307
+ if (startDate) {
8308
+ const startMs = startDate.getTime();
8309
+ filtered = filtered.filter((slot) => normalizeDateTime(slot.scheduledFor) >= startMs);
8310
+ }
8311
+ if (endDate) {
8312
+ const endMs = endDate.getTime();
8313
+ filtered = filtered.filter((slot) => normalizeDateTime(slot.scheduledFor) <= endMs);
8314
+ }
8315
+ filtered.sort((left, right) => normalizeDateTime(left.scheduledFor) - normalizeDateTime(right.scheduledFor));
8316
+ return filtered;
8317
+ }
8318
+
8319
+ // src/L3-services/scheduler/realign.ts
8006
8320
  var PLATFORM_ALIASES2 = { twitter: "x" };
8007
8321
  function normalizeSchedulePlatform(platform) {
8008
8322
  return PLATFORM_ALIASES2[platform] ?? platform;
@@ -8035,33 +8349,22 @@ async function fetchAllPosts(client, statuses, platform) {
8035
8349
  }
8036
8350
  return allPosts;
8037
8351
  }
8038
- function generateSlots(platform, clipType, count, bookedMs, timezone) {
8039
- const schedule = getPlatformSchedule(platform, clipType);
8040
- if (!schedule || schedule.slots.length === 0) {
8041
- logger_default.warn(`No schedule slots for ${platform}/${clipType}`);
8042
- return [];
8043
- }
8044
- const available = [];
8045
- const now = /* @__PURE__ */ new Date();
8046
- const nowMs = now.getTime();
8047
- for (let dayOffset = 0; dayOffset < 730 && available.length < count; dayOffset++) {
8048
- const day = new Date(now);
8049
- day.setDate(day.getDate() + dayOffset);
8050
- const dayOfWeek = getDayOfWeekInTimezone(day, timezone);
8051
- if (schedule.avoidDays.includes(dayOfWeek)) continue;
8052
- for (const slot of schedule.slots) {
8053
- if (available.length >= count) break;
8054
- if (!slot.days.includes(dayOfWeek)) continue;
8055
- const iso = buildSlotDatetime(day, slot.time, timezone);
8056
- const ms = new Date(iso).getTime();
8057
- if (ms <= nowMs) continue;
8058
- if (!bookedMs.has(ms)) {
8059
- available.push(iso);
8060
- bookedMs.add(ms);
8061
- }
8062
- }
8063
- }
8064
- return available;
8352
+ function isOnValidSlot(iso, schedule, timezone) {
8353
+ if (schedule.slots.length === 0) return false;
8354
+ const date = new Date(iso);
8355
+ const dayOfWeek = getDayOfWeekInTimezone(date, timezone);
8356
+ if (schedule.avoidDays.includes(dayOfWeek)) return false;
8357
+ const timeFormatter = new Intl.DateTimeFormat("en-US", {
8358
+ timeZone: timezone,
8359
+ hour: "2-digit",
8360
+ minute: "2-digit",
8361
+ hour12: false
8362
+ });
8363
+ const timeParts = timeFormatter.formatToParts(date);
8364
+ const hour = timeParts.find((p) => p.type === "hour")?.value ?? "00";
8365
+ const minute = timeParts.find((p) => p.type === "minute")?.value ?? "00";
8366
+ const timeKey = `${hour}:${minute}`;
8367
+ return schedule.slots.some((slot) => slot.time === timeKey && slot.days.includes(dayOfWeek));
8065
8368
  }
8066
8369
  async function buildRealignPlan(options = {}) {
8067
8370
  const config2 = await loadScheduleConfig();
@@ -8073,9 +8376,8 @@ async function buildRealignPlan(options = {}) {
8073
8376
  return { posts: [], toCancel: [], skipped: 0, unmatched: 0, totalFetched: 0 };
8074
8377
  }
8075
8378
  const { byLatePostId, byContent } = options.clipTypeMaps ?? await buildClipTypeMaps();
8076
- const grouped = /* @__PURE__ */ new Map();
8077
8379
  let unmatched = 0;
8078
- let contentMatched = 0;
8380
+ const tagged = [];
8079
8381
  for (const post of allPosts) {
8080
8382
  const platform = post.platforms[0]?.platform;
8081
8383
  if (!platform) continue;
@@ -8083,494 +8385,135 @@ async function buildRealignPlan(options = {}) {
8083
8385
  if (!clipType && post.content) {
8084
8386
  const contentKey = `${platform}::${normalizeContent(post.content)}`;
8085
8387
  clipType = byContent.get(contentKey) ?? null;
8086
- if (clipType) contentMatched++;
8087
8388
  }
8088
8389
  if (!clipType) {
8089
8390
  clipType = "short";
8090
8391
  unmatched++;
8091
8392
  }
8092
- const key = `${platform}::${clipType}`;
8093
- if (!grouped.has(key)) grouped.set(key, []);
8094
- grouped.get(key).push({ post, platform, clipType });
8095
- }
8096
- const bookedMs = /* @__PURE__ */ new Set();
8097
- if (contentMatched > 0) {
8098
- logger_default.info(`${contentMatched} post(s) matched by content fallback (no latePostId)`);
8393
+ tagged.push({ post, platform, clipType });
8099
8394
  }
8100
- const result = [];
8101
- const toCancel = [];
8102
- let skipped = 0;
8103
- for (const [key, posts] of grouped) {
8104
- const [platform, clipType] = key.split("::");
8105
- const schedulePlatform = normalizeSchedulePlatform(platform);
8106
- const schedule = getPlatformSchedule(schedulePlatform, clipType);
8107
- const hasSlots = schedule && schedule.slots.length > 0;
8108
- if (!hasSlots) {
8109
- for (const { post, clipType: ct } of posts) {
8110
- if (post.status === "cancelled") continue;
8111
- toCancel.push({
8112
- post,
8113
- platform,
8114
- clipType: ct,
8115
- reason: `No schedule slots for ${schedulePlatform}/${clipType}`
8116
- });
8117
- }
8118
- continue;
8119
- }
8120
- posts.sort((a, b) => {
8121
- const aTime = a.post.scheduledFor ? new Date(a.post.scheduledFor).getTime() : Infinity;
8122
- const bTime = b.post.scheduledFor ? new Date(b.post.scheduledFor).getTime() : Infinity;
8123
- return aTime - bTime;
8124
- });
8125
- const slots = generateSlots(schedulePlatform, clipType, posts.length, bookedMs, timezone);
8126
- for (let i = 0; i < posts.length; i++) {
8127
- const { post } = posts[i];
8128
- const newSlot = slots[i];
8129
- if (!newSlot) {
8130
- if (post.status !== "cancelled") {
8131
- toCancel.push({
8132
- post,
8133
- platform,
8134
- clipType: posts[i].clipType,
8135
- reason: `No more available slots for ${schedulePlatform}/${clipType}`
8136
- });
8137
- }
8138
- continue;
8139
- }
8140
- const currentMs = post.scheduledFor ? new Date(post.scheduledFor).getTime() : 0;
8141
- const newMs = new Date(newSlot).getTime();
8142
- if (currentMs === newMs && post.status === "scheduled") {
8143
- skipped++;
8144
- continue;
8145
- }
8146
- result.push({
8147
- post,
8148
- platform,
8149
- clipType: posts[i].clipType,
8150
- oldScheduledFor: post.scheduledFor ?? null,
8151
- newScheduledFor: newSlot
8152
- });
8153
- }
8154
- }
8155
- result.sort((a, b) => new Date(a.newScheduledFor).getTime() - new Date(b.newScheduledFor).getTime());
8156
- return { posts: result, toCancel, skipped, unmatched, totalFetched: allPosts.length };
8157
- }
8158
- async function executeRealignPlan(plan, onProgress) {
8159
- const client = new LateApiClient();
8160
- let updated = 0;
8161
- let cancelled = 0;
8162
- let failed = 0;
8163
- const errors = [];
8164
- const totalOps = plan.toCancel.length + plan.posts.length;
8165
- let completed = 0;
8166
- for (const entry of plan.toCancel) {
8167
- completed++;
8168
- try {
8169
- await client.updatePost(entry.post._id, { status: "cancelled" });
8170
- cancelled++;
8171
- const preview = entry.post.content.slice(0, 40).replace(/\n/g, " ");
8172
- logger_default.info(`[${completed}/${totalOps}] \u{1F6AB} Cancelled ${entry.platform}/${entry.clipType}: "${preview}..."`);
8173
- onProgress?.(completed, totalOps, "cancelling");
8174
- await new Promise((r) => setTimeout(r, 300));
8175
- } catch (err) {
8176
- const msg = err instanceof Error ? err.message : String(err);
8177
- errors.push({ postId: entry.post._id, error: msg });
8178
- failed++;
8179
- logger_default.error(`[${completed}/${totalOps}] \u274C Failed to cancel ${entry.post._id}: ${msg}`);
8180
- }
8181
- }
8182
- for (const entry of plan.posts) {
8183
- completed++;
8184
- try {
8185
- await client.schedulePost(entry.post._id, entry.newScheduledFor);
8186
- updated++;
8187
- const preview = entry.post.content.slice(0, 40).replace(/\n/g, " ");
8188
- logger_default.info(`[${completed}/${totalOps}] \u2705 ${entry.platform}/${entry.clipType}: "${preview}..." \u2192 ${entry.newScheduledFor}`);
8189
- onProgress?.(completed, totalOps, "updating");
8190
- await new Promise((r) => setTimeout(r, 300));
8191
- } catch (err) {
8192
- const msg = err instanceof Error ? err.message : String(err);
8193
- errors.push({ postId: entry.post._id, error: msg });
8194
- failed++;
8195
- logger_default.error(`[${completed}/${totalOps}] \u274C Failed to update ${entry.post._id}: ${msg}`);
8196
- }
8197
- }
8198
- return { updated, cancelled, failed, errors };
8199
- }
8200
-
8201
- // src/L3-services/scheduler/scheduler.ts
8202
- init_configLogger();
8203
- function normalizeDateTime(isoString) {
8204
- return new Date(isoString).getTime();
8205
- }
8206
- var CHUNK_DAYS = 14;
8207
- var MAX_LOOKAHEAD_DAYS = 730;
8208
- var DEFAULT_IDEA_WINDOW_DAYS = 14;
8209
- var DAY_MS = 24 * 60 * 60 * 1e3;
8210
- var HOUR_MS = 60 * 60 * 1e3;
8211
- function sanitizeLogValue(value) {
8212
- return value.replace(/[\r\n]/g, "");
8213
- }
8214
- function getTimezoneOffset2(timezone, date) {
8215
- const formatter = new Intl.DateTimeFormat("en-US", {
8216
- timeZone: timezone,
8217
- timeZoneName: "longOffset"
8218
- });
8219
- const parts = formatter.formatToParts(date);
8220
- const tzPart = parts.find((part) => part.type === "timeZoneName");
8221
- const match = tzPart?.value?.match(/GMT([+-]\d{2}:\d{2})/);
8222
- if (match) return match[1];
8223
- if (tzPart?.value === "GMT") return "+00:00";
8224
- logger_default.warn(
8225
- `Could not parse timezone offset for timezone "${timezone}" on date "${date.toISOString()}". Raw timeZoneName part: "${tzPart?.value ?? "undefined"}". Falling back to UTC (+00:00).`
8226
- );
8227
- return "+00:00";
8228
- }
8229
- function buildSlotDatetime2(date, time, timezone) {
8230
- const formatter = new Intl.DateTimeFormat("en-US", {
8231
- timeZone: timezone,
8232
- year: "numeric",
8233
- month: "2-digit",
8234
- day: "2-digit"
8235
- });
8236
- const parts = formatter.formatToParts(date);
8237
- const yearPart = parts.find((part) => part.type === "year")?.value;
8238
- const monthPart = parts.find((part) => part.type === "month")?.value;
8239
- const dayPart = parts.find((part) => part.type === "day")?.value;
8240
- const year = yearPart ?? String(date.getFullYear());
8241
- const month = (monthPart ?? String(date.getMonth() + 1)).padStart(2, "0");
8242
- const day = (dayPart ?? String(date.getDate())).padStart(2, "0");
8243
- const offset = getTimezoneOffset2(timezone, date);
8244
- return `${year}-${month}-${day}T${time}:00${offset}`;
8245
- }
8246
- function getDayOfWeekInTimezone2(date, timezone) {
8247
- const formatter = new Intl.DateTimeFormat("en-US", {
8248
- timeZone: timezone,
8249
- weekday: "short"
8250
- });
8251
- const short = formatter.format(date).toLowerCase().slice(0, 3);
8252
- const map = {
8253
- sun: "sun",
8254
- mon: "mon",
8255
- tue: "tue",
8256
- wed: "wed",
8257
- thu: "thu",
8258
- fri: "fri",
8259
- sat: "sat"
8260
- };
8261
- return map[short] ?? "mon";
8262
- }
8263
- async function fetchScheduledPostsSafe(platform) {
8264
- try {
8265
- const client = new LateApiClient();
8266
- return await client.getScheduledPosts(platform);
8267
- } catch (err) {
8268
- const msg = err instanceof Error ? err.message : String(err);
8269
- logger_default.warn(`Late API unreachable, using local data only: ${msg}`);
8270
- return [];
8271
- }
8272
- }
8273
- async function buildBookedSlots(platform) {
8274
- const [latePosts, publishedItems] = await Promise.all([
8275
- fetchScheduledPostsSafe(platform),
8276
- getPublishedItems()
8277
- ]);
8278
- const slots = [];
8279
- for (const post of latePosts) {
8280
- if (!post.scheduledFor) continue;
8281
- for (const scheduledPlatform of post.platforms) {
8282
- if (!platform || scheduledPlatform.platform === platform) {
8283
- slots.push({
8284
- scheduledFor: post.scheduledFor,
8285
- source: "late",
8286
- postId: post._id,
8287
- platform: scheduledPlatform.platform,
8288
- status: post.status
8289
- });
8290
- }
8291
- }
8292
- }
8293
- for (const item of publishedItems) {
8294
- if (platform && item.metadata.platform !== platform) continue;
8295
- if (!item.metadata.scheduledFor) continue;
8296
- slots.push({
8297
- scheduledFor: item.metadata.scheduledFor,
8298
- source: "local",
8299
- itemId: item.id,
8300
- platform: item.metadata.platform
8301
- });
8302
- }
8303
- return slots;
8304
- }
8305
- function buildIdeaReferences(sameIdeaPosts, allBookedSlots) {
8306
- const lateSlotsByPostId = /* @__PURE__ */ new Map();
8307
- const localSlotsByItemId = /* @__PURE__ */ new Map();
8308
- for (const slot of allBookedSlots) {
8309
- if (slot.postId) {
8310
- const slots = lateSlotsByPostId.get(slot.postId) ?? [];
8311
- slots.push(slot);
8312
- lateSlotsByPostId.set(slot.postId, slots);
8313
- }
8314
- if (slot.itemId) {
8315
- const slots = localSlotsByItemId.get(slot.itemId) ?? [];
8316
- slots.push(slot);
8317
- localSlotsByItemId.set(slot.itemId, slots);
8318
- }
8319
- }
8320
- const references = [];
8321
- const seen = /* @__PURE__ */ new Set();
8322
- const addReference = (platformName, scheduledFor) => {
8323
- if (!scheduledFor) return;
8324
- const key = `${platformName}@${scheduledFor}`;
8325
- if (seen.has(key)) return;
8326
- seen.add(key);
8327
- references.push({ platform: platformName, scheduledFor });
8328
- };
8329
- for (const item of sameIdeaPosts) {
8330
- addReference(item.metadata.platform, item.metadata.scheduledFor);
8331
- if (item.metadata.latePostId) {
8332
- for (const slot of lateSlotsByPostId.get(item.metadata.latePostId) ?? []) {
8333
- addReference(slot.platform, slot.scheduledFor);
8334
- }
8335
- }
8336
- for (const slot of localSlotsByItemId.get(item.id) ?? []) {
8337
- addReference(slot.platform, slot.scheduledFor);
8395
+ const bookedMap = await buildBookedMap();
8396
+ const ctx = {
8397
+ timezone,
8398
+ bookedMap,
8399
+ ideaLinkedPostIds: /* @__PURE__ */ new Set(),
8400
+ lateClient: client,
8401
+ displacementEnabled: getDisplacementConfig().enabled,
8402
+ dryRun: true,
8403
+ depth: 0,
8404
+ ideaRefs: [],
8405
+ samePlatformMs: 0,
8406
+ crossPlatformMs: 0,
8407
+ platform: ""
8408
+ };
8409
+ for (const [, slot] of bookedMap) {
8410
+ if (slot.ideaLinked && slot.postId) {
8411
+ ctx.ideaLinkedPostIds.add(slot.postId);
8338
8412
  }
8339
8413
  }
8340
- return references;
8341
- }
8342
- function createSpacingGuard(ideaReferences, samePlatformHours, crossPlatformHours) {
8343
- const samePlatformWindowMs = samePlatformHours * HOUR_MS;
8344
- const crossPlatformWindowMs = crossPlatformHours * HOUR_MS;
8345
- return (candidateMs, candidatePlatform) => {
8346
- for (const reference of ideaReferences) {
8347
- const referenceMs = normalizeDateTime(reference.scheduledFor);
8348
- const diff = Math.abs(candidateMs - referenceMs);
8349
- if (reference.platform === candidatePlatform && diff < samePlatformWindowMs) {
8350
- return false;
8414
+ const result = [];
8415
+ const toCancel = [];
8416
+ let skipped = 0;
8417
+ tagged.sort((a, b) => {
8418
+ const aIdea = ctx.ideaLinkedPostIds.has(a.post._id) ? 0 : 1;
8419
+ const bIdea = ctx.ideaLinkedPostIds.has(b.post._id) ? 0 : 1;
8420
+ return aIdea - bIdea;
8421
+ });
8422
+ const nowMs = Date.now();
8423
+ for (const { post, platform, clipType } of tagged) {
8424
+ const schedulePlatform = normalizeSchedulePlatform(platform);
8425
+ const platformConfig = getPlatformSchedule(schedulePlatform, clipType);
8426
+ if (!platformConfig || platformConfig.slots.length === 0) {
8427
+ if (post.status !== "cancelled") {
8428
+ toCancel.push({ post, platform, clipType, reason: `No schedule slots for ${schedulePlatform}/${clipType}` });
8351
8429
  }
8352
- if (diff < crossPlatformWindowMs) {
8353
- return false;
8430
+ continue;
8431
+ }
8432
+ if (post.scheduledFor && post.status === "scheduled" && isOnValidSlot(post.scheduledFor, platformConfig, timezone)) {
8433
+ skipped++;
8434
+ continue;
8435
+ }
8436
+ if (post.scheduledFor) {
8437
+ const currentMs2 = new Date(post.scheduledFor).getTime();
8438
+ const currentBooked = bookedMap.get(currentMs2);
8439
+ if (currentBooked?.postId === post._id) {
8440
+ bookedMap.delete(currentMs2);
8354
8441
  }
8355
8442
  }
8356
- return true;
8357
- };
8358
- }
8359
- function resolveSearchWindow(nowMs, options) {
8360
- const defaultWindowEndMs = nowMs + DEFAULT_IDEA_WINDOW_DAYS * DAY_MS;
8361
- const publishBy = options?.publishBy;
8362
- if (!publishBy) {
8363
- return {
8364
- emptyWindowEndMs: defaultWindowEndMs,
8365
- displacementWindowEndMs: defaultWindowEndMs
8366
- };
8367
- }
8368
- const publishByMs = normalizeDateTime(publishBy);
8369
- if (Number.isNaN(publishByMs)) {
8370
- logger_default.warn(`Invalid publishBy "${sanitizeLogValue(publishBy)}" provided; scheduling normally without urgency bias`);
8371
- return {};
8372
- }
8373
- const daysUntilPublishBy = (publishByMs - nowMs) / DAY_MS;
8374
- if (daysUntilPublishBy <= 0) {
8375
- logger_default.warn(`publishBy "${sanitizeLogValue(publishBy)}" has already passed; scheduling normally without urgency bias`);
8376
- return {};
8377
- }
8378
- if (daysUntilPublishBy < 3) {
8379
- logger_default.debug(`Urgent publishBy "${sanitizeLogValue(publishBy)}"; prioritizing earliest displaceable slot`);
8380
- }
8381
- return {
8382
- emptyWindowEndMs: publishByMs,
8383
- displacementWindowEndMs: daysUntilPublishBy < 7 ? Math.min(publishByMs, nowMs + 3 * DAY_MS) : publishByMs
8384
- };
8385
- }
8386
- function findEmptySlot({
8387
- platformConfig,
8388
- timezone,
8389
- bookedDatetimes,
8390
- platform,
8391
- searchFromMs,
8392
- includeSearchDay = false,
8393
- maxCandidateMs,
8394
- passesCandidate
8395
- }) {
8396
- if (maxCandidateMs !== void 0 && maxCandidateMs < searchFromMs) {
8397
- return null;
8398
- }
8399
- const baseDate = new Date(searchFromMs);
8400
- const initialOffset = includeSearchDay ? 0 : 1;
8401
- let maxDayOffset = MAX_LOOKAHEAD_DAYS;
8402
- if (maxCandidateMs !== void 0) {
8403
- maxDayOffset = Math.min(
8404
- MAX_LOOKAHEAD_DAYS,
8405
- Math.max(initialOffset, Math.ceil((maxCandidateMs - searchFromMs) / DAY_MS))
8406
- );
8407
- }
8408
- let startOffset = initialOffset;
8409
- while (startOffset <= maxDayOffset) {
8410
- const endOffset = Math.min(startOffset + CHUNK_DAYS - 1, maxDayOffset);
8411
- const candidates = [];
8412
- for (let dayOffset = startOffset; dayOffset <= endOffset; dayOffset++) {
8413
- const candidateDate = new Date(baseDate);
8414
- candidateDate.setDate(candidateDate.getDate() + dayOffset);
8415
- const dayOfWeek = getDayOfWeekInTimezone2(candidateDate, timezone);
8416
- if (platformConfig.avoidDays.includes(dayOfWeek)) continue;
8417
- for (const slot of platformConfig.slots) {
8418
- if (!slot.days.includes(dayOfWeek)) continue;
8419
- const candidate = buildSlotDatetime2(candidateDate, slot.time, timezone);
8420
- const candidateMs = normalizeDateTime(candidate);
8421
- if (candidateMs <= searchFromMs) continue;
8422
- if (maxCandidateMs !== void 0 && candidateMs > maxCandidateMs) continue;
8423
- if (bookedDatetimes.has(candidateMs)) continue;
8424
- if (passesCandidate && !passesCandidate(candidateMs, platform)) continue;
8425
- candidates.push(candidate);
8426
- }
8427
- }
8428
- candidates.sort((left, right) => normalizeDateTime(left) - normalizeDateTime(right));
8429
- if (candidates.length > 0) {
8430
- return candidates[0];
8431
- }
8432
- startOffset = endOffset + 1;
8433
- }
8434
- return null;
8435
- }
8436
- async function tryDisplacement({
8437
- bookedSlots,
8438
- platform,
8439
- platformConfig,
8440
- timezone,
8441
- bookedDatetimes,
8442
- options,
8443
- nowMs,
8444
- maxCandidateMs,
8445
- passesSpacing
8446
- }) {
8447
- const displacementConfig = getDisplacementConfig();
8448
- if (!displacementConfig.enabled || !options.ideaIds?.length) {
8449
- return null;
8450
- }
8451
- const candidateSlots = bookedSlots.filter((slot) => {
8452
- const slotMs = normalizeDateTime(slot.scheduledFor);
8453
- if (slotMs <= nowMs) return false;
8454
- if (maxCandidateMs !== void 0 && slotMs > maxCandidateMs) return false;
8455
- return true;
8456
- }).sort((left, right) => normalizeDateTime(left.scheduledFor) - normalizeDateTime(right.scheduledFor));
8457
- const lateClient = new LateApiClient();
8458
- const publishedItemCache = /* @__PURE__ */ new Map();
8459
- for (const slot of candidateSlots) {
8460
- if (slot.source !== "late" || !slot.postId) continue;
8461
- const candidateMs = normalizeDateTime(slot.scheduledFor);
8462
- if (passesSpacing && !passesSpacing(candidateMs, platform)) continue;
8463
- let publishedItem = publishedItemCache.get(slot.postId);
8464
- if (publishedItem === void 0) {
8465
- publishedItem = await getPublishedItemByLatePostId(slot.postId);
8466
- publishedItemCache.set(slot.postId, publishedItem);
8467
- }
8468
- if (!publishedItem) {
8443
+ const isIdea = ctx.ideaLinkedPostIds.has(post._id);
8444
+ const label = `${schedulePlatform}/${clipType}:${post._id.slice(-6)}`;
8445
+ const newSlot = await schedulePost(platformConfig, nowMs, isIdea, label, ctx);
8446
+ if (!newSlot) {
8447
+ if (post.status !== "cancelled") {
8448
+ toCancel.push({ post, platform, clipType, reason: `No available slot for ${schedulePlatform}/${clipType}` });
8449
+ }
8469
8450
  continue;
8470
8451
  }
8471
- if (publishedItem.metadata.ideaIds?.length) {
8452
+ const newMs = new Date(newSlot).getTime();
8453
+ ctx.bookedMap.set(newMs, {
8454
+ scheduledFor: newSlot,
8455
+ source: "late",
8456
+ postId: post._id,
8457
+ platform: schedulePlatform,
8458
+ ideaLinked: isIdea
8459
+ });
8460
+ const currentMs = post.scheduledFor ? new Date(post.scheduledFor).getTime() : 0;
8461
+ if (currentMs === newMs && post.status === "scheduled") {
8462
+ skipped++;
8472
8463
  continue;
8473
8464
  }
8474
- const displacedPlatformConfig = publishedItem?.metadata.clipType ? getPlatformSchedule(platform, publishedItem.metadata.clipType) ?? platformConfig : platformConfig;
8475
- const newSlot = findEmptySlot({
8476
- platformConfig: displacedPlatformConfig,
8477
- timezone,
8478
- bookedDatetimes,
8465
+ result.push({
8466
+ post,
8479
8467
  platform,
8480
- searchFromMs: candidateMs,
8481
- includeSearchDay: true
8468
+ clipType,
8469
+ oldScheduledFor: post.scheduledFor ?? null,
8470
+ newScheduledFor: newSlot
8482
8471
  });
8483
- if (!newSlot) continue;
8484
- await lateClient.schedulePost(slot.postId, newSlot);
8485
- logger_default.info(
8486
- `Displaced post ${sanitizeLogValue(slot.postId)} from ${sanitizeLogValue(slot.scheduledFor)} to ${sanitizeLogValue(newSlot)} for idea-linked content`
8487
- );
8488
- return {
8489
- slot: slot.scheduledFor,
8490
- displaced: {
8491
- postId: slot.postId,
8492
- originalSlot: slot.scheduledFor,
8493
- newSlot
8494
- }
8495
- };
8496
8472
  }
8497
- return null;
8473
+ result.sort((a, b) => new Date(a.newScheduledFor).getTime() - new Date(b.newScheduledFor).getTime());
8474
+ return { posts: result, toCancel, skipped, unmatched, totalFetched: allPosts.length };
8498
8475
  }
8499
- async function findNextSlot(platform, clipType, options) {
8500
- const config2 = await loadScheduleConfig();
8501
- const platformConfig = getPlatformSchedule(platform, clipType);
8502
- if (!platformConfig) {
8503
- logger_default.warn(`No schedule config found for platform "${sanitizeLogValue(platform)}"`);
8504
- return null;
8505
- }
8506
- const ideaIds = options?.ideaIds?.filter(Boolean) ?? [];
8507
- const isIdeaAware = ideaIds.length > 0;
8508
- const nowMs = Date.now();
8509
- const { timezone } = config2;
8510
- const [allBookedSlots, sameIdeaPosts] = await Promise.all([
8511
- isIdeaAware ? buildBookedSlots() : Promise.resolve([]),
8512
- isIdeaAware ? getScheduledItemsByIdeaIds(ideaIds) : Promise.resolve([])
8513
- ]);
8514
- const bookedSlots = isIdeaAware ? allBookedSlots.filter((slot) => slot.platform === platform) : await buildBookedSlots(platform);
8515
- const bookedDatetimes = new Set(bookedSlots.map((slot) => normalizeDateTime(slot.scheduledFor)));
8516
- const spacingConfig = isIdeaAware ? getIdeaSpacingConfig() : null;
8517
- const spacingGuard = spacingConfig ? createSpacingGuard(
8518
- buildIdeaReferences(sameIdeaPosts, allBookedSlots),
8519
- spacingConfig.samePlatformHours,
8520
- spacingConfig.crossPlatformHours
8521
- ) : void 0;
8522
- const searchWindow = isIdeaAware ? resolveSearchWindow(nowMs, options) : {};
8523
- const emptySlot = findEmptySlot({
8524
- platformConfig,
8525
- timezone,
8526
- bookedDatetimes,
8527
- platform,
8528
- searchFromMs: nowMs,
8529
- maxCandidateMs: searchWindow.emptyWindowEndMs,
8530
- passesCandidate: spacingGuard
8531
- });
8532
- if (emptySlot) {
8533
- logger_default.debug(`Found available slot for ${sanitizeLogValue(platform)}: ${sanitizeLogValue(emptySlot)}`);
8534
- return emptySlot;
8535
- }
8536
- if (isIdeaAware) {
8537
- const displaced = await tryDisplacement({
8538
- bookedSlots,
8539
- platform,
8540
- platformConfig,
8541
- timezone,
8542
- bookedDatetimes,
8543
- options: { ...options, ideaIds },
8544
- nowMs,
8545
- maxCandidateMs: searchWindow.displacementWindowEndMs,
8546
- passesSpacing: spacingGuard
8547
- });
8548
- if (displaced) {
8549
- return displaced.slot;
8476
+ async function executeRealignPlan(plan, onProgress) {
8477
+ const client = new LateApiClient();
8478
+ let updated = 0;
8479
+ let cancelled = 0;
8480
+ let failed = 0;
8481
+ const errors = [];
8482
+ const totalOps = plan.toCancel.length + plan.posts.length;
8483
+ let completed = 0;
8484
+ for (const entry of plan.toCancel) {
8485
+ completed++;
8486
+ try {
8487
+ await client.updatePost(entry.post._id, { status: "cancelled" });
8488
+ cancelled++;
8489
+ const preview = entry.post.content.slice(0, 40).replace(/\n/g, " ");
8490
+ logger_default.info(`[${completed}/${totalOps}] \u{1F6AB} Cancelled ${entry.platform}/${entry.clipType}: "${preview}..."`);
8491
+ onProgress?.(completed, totalOps, "cancelling");
8492
+ await new Promise((r) => setTimeout(r, 300));
8493
+ } catch (err) {
8494
+ const msg = err instanceof Error ? err.message : String(err);
8495
+ errors.push({ postId: entry.post._id, error: msg });
8496
+ failed++;
8497
+ logger_default.error(`[${completed}/${totalOps}] \u274C Failed to cancel ${entry.post._id}: ${msg}`);
8550
8498
  }
8551
8499
  }
8552
- logger_default.warn(`No available slot found for "${sanitizeLogValue(platform)}" within ${MAX_LOOKAHEAD_DAYS} days`);
8553
- return null;
8554
- }
8555
- async function getScheduleCalendar(startDate, endDate) {
8556
- const slots = await buildBookedSlots();
8557
- let filtered = slots.filter((slot) => slot.source === "local" || slot.status === "scheduled").map((slot) => ({
8558
- platform: slot.platform,
8559
- scheduledFor: slot.scheduledFor,
8560
- source: slot.source,
8561
- postId: slot.postId,
8562
- itemId: slot.itemId
8563
- }));
8564
- if (startDate) {
8565
- const startMs = startDate.getTime();
8566
- filtered = filtered.filter((slot) => normalizeDateTime(slot.scheduledFor) >= startMs);
8567
- }
8568
- if (endDate) {
8569
- const endMs = endDate.getTime();
8570
- filtered = filtered.filter((slot) => normalizeDateTime(slot.scheduledFor) <= endMs);
8500
+ for (const entry of plan.posts) {
8501
+ completed++;
8502
+ try {
8503
+ await client.schedulePost(entry.post._id, entry.newScheduledFor);
8504
+ updated++;
8505
+ const preview = entry.post.content.slice(0, 40).replace(/\n/g, " ");
8506
+ logger_default.info(`[${completed}/${totalOps}] \u2705 ${entry.platform}/${entry.clipType}: "${preview}..." \u2192 ${entry.newScheduledFor}`);
8507
+ onProgress?.(completed, totalOps, "updating");
8508
+ await new Promise((r) => setTimeout(r, 300));
8509
+ } catch (err) {
8510
+ const msg = err instanceof Error ? err.message : String(err);
8511
+ errors.push({ postId: entry.post._id, error: msg });
8512
+ failed++;
8513
+ logger_default.error(`[${completed}/${totalOps}] \u274C Failed to update ${entry.post._id}: ${msg}`);
8514
+ }
8571
8515
  }
8572
- filtered.sort((left, right) => normalizeDateTime(left.scheduledFor) - normalizeDateTime(right.scheduledFor));
8573
- return filtered;
8516
+ return { updated, cancelled, failed, errors };
8574
8517
  }
8575
8518
 
8576
8519
  // src/L2-clients/ffmpeg/audioExtraction.ts
@@ -10079,6 +10022,9 @@ function platformAcceptsMedia(platform, clipType) {
10079
10022
  return getMediaRule(platform, clipType) !== null;
10080
10023
  }
10081
10024
 
10025
+ // src/L3-services/queueBuilder/queueBuilder.ts
10026
+ init_postStore();
10027
+
10082
10028
  // src/L1-infra/image/image.ts
10083
10029
  import { default as default7 } from "sharp";
10084
10030
 
@@ -10577,6 +10523,10 @@ function getWhisperPrompt() {
10577
10523
  const brand = getBrandConfig();
10578
10524
  return brand.customVocabulary.join(", ");
10579
10525
  }
10526
+ function getIntroOutroConfig() {
10527
+ const brand = getBrandConfig();
10528
+ return brand.introOutro ?? { enabled: false, fadeDuration: 0 };
10529
+ }
10580
10530
 
10581
10531
  // src/L4-agents/IdeationAgent.ts
10582
10532
  init_environment();
@@ -10766,7 +10716,7 @@ function buildSystemPrompt(brand, existingIdeas, seedTopics, count, ideaRepo) {
10766
10716
  }
10767
10717
  return promptSections.join("\n");
10768
10718
  }
10769
- function buildUserMessage(count, seedTopics, hasMcpServers) {
10719
+ function buildUserMessage(count, seedTopics, hasMcpServers, userPrompt) {
10770
10720
  const focusText = seedTopics.length > 0 ? `Focus areas: ${seedTopics.join(", ")}` : "Focus areas: choose the strongest timely opportunities from the creator context and current trends.";
10771
10721
  const steps = [
10772
10722
  "1. Call get_brand_context to load the creator profile.",
@@ -10790,13 +10740,15 @@ function buildUserMessage(count, seedTopics, hasMcpServers) {
10790
10740
  "5. Call finalize_ideas when done."
10791
10741
  );
10792
10742
  }
10793
- return [
10743
+ const sections = [
10794
10744
  `Generate ${count} new content ideas.`,
10795
- focusText,
10796
- "",
10797
- "Follow this exact workflow:",
10798
- ...steps
10799
- ].join("\n");
10745
+ focusText
10746
+ ];
10747
+ if (userPrompt) {
10748
+ sections.push("", `## User Prompt`, userPrompt);
10749
+ }
10750
+ sections.push("", "Follow this exact workflow:", ...steps);
10751
+ return sections.join("\n");
10800
10752
  }
10801
10753
  async function loadBrandContext(brandPath) {
10802
10754
  if (!brandPath) {
@@ -11313,7 +11265,7 @@ async function generateIdeas(options = {}) {
11313
11265
  });
11314
11266
  try {
11315
11267
  const hasMcpServers = !!(config2.EXA_API_KEY || config2.YOUTUBE_API_KEY || config2.PERPLEXITY_API_KEY);
11316
- const userMessage = buildUserMessage(count, seedTopics, hasMcpServers);
11268
+ const userMessage = buildUserMessage(count, seedTopics, hasMcpServers, options.prompt);
11317
11269
  await agent.run(userMessage);
11318
11270
  const ideas = agent.getGeneratedIdeas();
11319
11271
  if (!agent.isFinalized()) {
@@ -11443,6 +11395,290 @@ var Asset = class {
11443
11395
  init_paths();
11444
11396
  init_fileSystem();
11445
11397
 
11398
+ // src/L2-clients/ffmpeg/videoConcat.ts
11399
+ init_fileSystem();
11400
+ init_paths();
11401
+ init_configLogger();
11402
+ async function concatVideos(segments, output, opts = {}) {
11403
+ if (segments.length === 0) {
11404
+ throw new Error("concatVideos: no segments provided");
11405
+ }
11406
+ if (segments.length === 1) {
11407
+ await ensureDirectory(dirname(output));
11408
+ await execCommand(getFFmpegPath(), [
11409
+ "-y",
11410
+ "-i",
11411
+ segments[0],
11412
+ "-c",
11413
+ "copy",
11414
+ output
11415
+ ], { maxBuffer: 50 * 1024 * 1024 });
11416
+ return output;
11417
+ }
11418
+ const fadeDuration = opts.fadeDuration ?? 0;
11419
+ if (fadeDuration > 0) {
11420
+ return concatWithXfade(segments, output, fadeDuration);
11421
+ }
11422
+ return concatWithDemuxer(segments, output);
11423
+ }
11424
+ async function concatWithDemuxer(segments, output) {
11425
+ await ensureDirectory(dirname(output));
11426
+ const listContent = segments.map((s) => `file '${s.replace(/'/g, "'\\''")}'`).join("\n");
11427
+ const listPath = output + ".concat-list.txt";
11428
+ await writeTextFile(listPath, listContent);
11429
+ logger_default.info(`Concat (demuxer): ${segments.length} segments \u2192 ${output}`);
11430
+ await execCommand(getFFmpegPath(), [
11431
+ "-y",
11432
+ "-f",
11433
+ "concat",
11434
+ "-safe",
11435
+ "0",
11436
+ "-i",
11437
+ listPath,
11438
+ "-c",
11439
+ "copy",
11440
+ "-movflags",
11441
+ "+faststart",
11442
+ output
11443
+ ], { maxBuffer: 50 * 1024 * 1024 });
11444
+ return output;
11445
+ }
11446
+ async function concatWithXfade(segments, output, fadeDuration) {
11447
+ await ensureDirectory(dirname(output));
11448
+ logger_default.info(`Concat (xfade ${fadeDuration}s): ${segments.length} segments \u2192 ${output}`);
11449
+ const durations = await Promise.all(segments.map((s) => getVideoDuration2(s)));
11450
+ const inputs = segments.flatMap((s) => ["-i", s]);
11451
+ const filterParts = [];
11452
+ for (let i = 0; i < segments.length; i++) {
11453
+ filterParts.push(`[${i}:v]fps=30,settb=AVTB,setpts=PTS-STARTPTS[vin${i}]`);
11454
+ filterParts.push(`[${i}:a]aresample=async=1,asetpts=PTS-STARTPTS[ain${i}]`);
11455
+ }
11456
+ let prevLabel = "[vin0]";
11457
+ let prevAudioLabel = "[ain0]";
11458
+ let cumulativeOffset = 0;
11459
+ for (let i = 1; i < segments.length; i++) {
11460
+ const offset = cumulativeOffset + durations[i - 1] - fadeDuration;
11461
+ const outLabel = i < segments.length - 1 ? `[v${i}]` : "[vout]";
11462
+ const outAudioLabel = i < segments.length - 1 ? `[a${i}]` : "[aout]";
11463
+ filterParts.push(
11464
+ `${prevLabel}[vin${i}]xfade=transition=fade:duration=${fadeDuration}:offset=${offset.toFixed(3)}${outLabel}`
11465
+ );
11466
+ filterParts.push(
11467
+ `${prevAudioLabel}[ain${i}]acrossfade=d=${fadeDuration}${outAudioLabel}`
11468
+ );
11469
+ prevLabel = outLabel;
11470
+ prevAudioLabel = outAudioLabel;
11471
+ cumulativeOffset = offset;
11472
+ }
11473
+ const filterComplex = filterParts.join(";");
11474
+ await execCommand(getFFmpegPath(), [
11475
+ "-y",
11476
+ ...inputs,
11477
+ "-filter_complex",
11478
+ filterComplex,
11479
+ "-map",
11480
+ "[vout]",
11481
+ "-map",
11482
+ "[aout]",
11483
+ "-c:v",
11484
+ "libx264",
11485
+ "-pix_fmt",
11486
+ "yuv420p",
11487
+ "-preset",
11488
+ "ultrafast",
11489
+ "-crf",
11490
+ "23",
11491
+ "-c:a",
11492
+ "aac",
11493
+ "-b:a",
11494
+ "128k",
11495
+ "-movflags",
11496
+ "+faststart",
11497
+ output
11498
+ ], { maxBuffer: 50 * 1024 * 1024 });
11499
+ return output;
11500
+ }
11501
+ async function normalizeForConcat(videoPath, referenceVideo, output) {
11502
+ await ensureDirectory(dirname(output));
11503
+ const refProps = await getVideoProperties(referenceVideo);
11504
+ logger_default.info(`Normalizing ${videoPath} to match ${referenceVideo} (${refProps.width}x${refProps.height} ${refProps.fps}fps)`);
11505
+ await execCommand(getFFmpegPath(), [
11506
+ "-y",
11507
+ "-i",
11508
+ videoPath,
11509
+ "-vf",
11510
+ `scale=${refProps.width}:${refProps.height}:force_original_aspect_ratio=decrease,pad=${refProps.width}:${refProps.height}:(ow-iw)/2:(oh-ih)/2,fps=${refProps.fps}`,
11511
+ "-c:v",
11512
+ "libx264",
11513
+ "-pix_fmt",
11514
+ "yuv420p",
11515
+ "-preset",
11516
+ "ultrafast",
11517
+ "-crf",
11518
+ "23",
11519
+ "-c:a",
11520
+ "aac",
11521
+ "-b:a",
11522
+ "128k",
11523
+ "-ar",
11524
+ "48000",
11525
+ "-ac",
11526
+ "2",
11527
+ "-movflags",
11528
+ "+faststart",
11529
+ output
11530
+ ], { maxBuffer: 50 * 1024 * 1024 });
11531
+ return output;
11532
+ }
11533
+ async function getVideoDuration2(videoPath) {
11534
+ const { stdout } = await execCommand(getFFprobePath(), [
11535
+ "-v",
11536
+ "error",
11537
+ "-show_entries",
11538
+ "format=duration",
11539
+ "-of",
11540
+ "csv=p=0",
11541
+ videoPath
11542
+ ], { timeout: 1e4 });
11543
+ const duration = parseFloat(stdout.trim());
11544
+ if (!isFinite(duration) || duration <= 0) {
11545
+ throw new Error(`Failed to get duration for ${videoPath}: ${stdout.trim()}`);
11546
+ }
11547
+ return duration;
11548
+ }
11549
+ async function getVideoProperties(videoPath) {
11550
+ const { stdout } = await execCommand(getFFprobePath(), [
11551
+ "-v",
11552
+ "error",
11553
+ "-select_streams",
11554
+ "v:0",
11555
+ "-show_entries",
11556
+ "stream=width,height,r_frame_rate",
11557
+ "-of",
11558
+ "json",
11559
+ videoPath
11560
+ ], { timeout: 1e4 });
11561
+ const data = JSON.parse(stdout);
11562
+ const stream = data.streams?.[0];
11563
+ if (!stream) throw new Error(`No video stream found in ${videoPath}`);
11564
+ const fpsRaw = stream.r_frame_rate ?? "30/1";
11565
+ const fpsParts = fpsRaw.split("/");
11566
+ const fps = fpsParts.length === 2 ? Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1])) : 30;
11567
+ return {
11568
+ width: stream.width,
11569
+ height: stream.height,
11570
+ fps: isFinite(fps) && fps > 0 ? fps : 30
11571
+ };
11572
+ }
11573
+
11574
+ // src/L3-services/introOutro/introOutroService.ts
11575
+ init_fileSystem();
11576
+ init_paths();
11577
+ init_environment();
11578
+ init_configLogger();
11579
+
11580
+ // src/L0-pure/introOutro/introOutroResolver.ts
11581
+ function resolveIntroOutroToggle(config2, videoType, platform) {
11582
+ const globalDefault = { intro: config2.enabled, outro: config2.enabled };
11583
+ const videoTypeRule = config2.rules?.[videoType];
11584
+ const baseToggle = videoTypeRule ? { intro: videoTypeRule.intro, outro: videoTypeRule.outro } : globalDefault;
11585
+ if (!platform || !config2.platformOverrides?.[platform]?.[videoType]) {
11586
+ return baseToggle;
11587
+ }
11588
+ const platformRule = config2.platformOverrides[platform][videoType];
11589
+ return {
11590
+ intro: platformRule.intro ?? baseToggle.intro,
11591
+ outro: platformRule.outro ?? baseToggle.outro
11592
+ };
11593
+ }
11594
+ function resolveIntroPath(config2, platform, aspectRatio) {
11595
+ if (!config2.intro) return null;
11596
+ if (aspectRatio && config2.intro.aspectRatios?.[aspectRatio]) {
11597
+ return config2.intro.aspectRatios[aspectRatio];
11598
+ }
11599
+ if (platform && config2.intro.platforms?.[platform]) {
11600
+ return config2.intro.platforms[platform];
11601
+ }
11602
+ return config2.intro.default ?? null;
11603
+ }
11604
+ function resolveOutroPath(config2, platform, aspectRatio) {
11605
+ if (!config2.outro) return null;
11606
+ if (aspectRatio && config2.outro.aspectRatios?.[aspectRatio]) {
11607
+ return config2.outro.aspectRatios[aspectRatio];
11608
+ }
11609
+ if (platform && config2.outro.platforms?.[platform]) {
11610
+ return config2.outro.platforms[platform];
11611
+ }
11612
+ return config2.outro.default ?? null;
11613
+ }
11614
+
11615
+ // src/L3-services/introOutro/introOutroService.ts
11616
+ async function applyIntroOutro(videoPath, videoType, outputPath, platform, aspectRatio) {
11617
+ const envConfig = getConfig();
11618
+ if (envConfig.SKIP_INTRO_OUTRO) {
11619
+ logger_default.debug("Intro/outro skipped via SKIP_INTRO_OUTRO");
11620
+ return videoPath;
11621
+ }
11622
+ const config2 = getIntroOutroConfig();
11623
+ if (!config2.enabled) {
11624
+ logger_default.debug("Intro/outro disabled in brand config");
11625
+ return videoPath;
11626
+ }
11627
+ const toggle = resolveIntroOutroToggle(config2, videoType, platform);
11628
+ if (!toggle.intro && !toggle.outro) {
11629
+ logger_default.debug(`Intro/outro both disabled for ${videoType}${platform ? ` / ${platform}` : ""}`);
11630
+ return videoPath;
11631
+ }
11632
+ const brandPath = envConfig.BRAND_PATH;
11633
+ const brandDir = dirname(brandPath);
11634
+ const introRelative = toggle.intro ? resolveIntroPath(config2, platform, aspectRatio) : null;
11635
+ const outroRelative = toggle.outro ? resolveOutroPath(config2, platform, aspectRatio) : null;
11636
+ const introPath = introRelative ? resolve(brandDir, introRelative) : null;
11637
+ const outroPath = outroRelative ? resolve(brandDir, outroRelative) : null;
11638
+ const introExists = introPath ? await fileExists(introPath) : false;
11639
+ const outroExists = outroPath ? await fileExists(outroPath) : false;
11640
+ if (introPath && !introExists) {
11641
+ logger_default.warn(`Intro video not found: ${introPath} \u2014 skipping intro`);
11642
+ }
11643
+ if (outroPath && !outroExists) {
11644
+ logger_default.warn(`Outro video not found: ${outroPath} \u2014 skipping outro`);
11645
+ }
11646
+ const validIntro = introPath && introExists ? introPath : null;
11647
+ const validOutro = outroPath && outroExists ? outroPath : null;
11648
+ if (!validIntro && !validOutro) {
11649
+ logger_default.debug("No valid intro/outro files found \u2014 skipping");
11650
+ return videoPath;
11651
+ }
11652
+ const videoDir = dirname(outputPath);
11653
+ const segments = [];
11654
+ const normalizedIntroPath = validIntro ? join(videoDir, ".intro-normalized.mp4") : null;
11655
+ const normalizedOutroPath = validOutro ? join(videoDir, ".outro-normalized.mp4") : null;
11656
+ try {
11657
+ if (validIntro && normalizedIntroPath) {
11658
+ await normalizeForConcat(validIntro, videoPath, normalizedIntroPath);
11659
+ segments.push(normalizedIntroPath);
11660
+ }
11661
+ segments.push(videoPath);
11662
+ if (validOutro && normalizedOutroPath) {
11663
+ await normalizeForConcat(validOutro, videoPath, normalizedOutroPath);
11664
+ segments.push(normalizedOutroPath);
11665
+ }
11666
+ logger_default.info(`Applying intro/outro (${validIntro ? "intro" : ""}${validIntro && validOutro ? "+" : ""}${validOutro ? "outro" : ""}) for ${videoType}${platform ? ` / ${platform}` : ""}: ${outputPath}`);
11667
+ await concatVideos(segments, outputPath, { fadeDuration: config2.fadeDuration });
11668
+ return outputPath;
11669
+ } finally {
11670
+ if (normalizedIntroPath) await removeFile(normalizedIntroPath).catch(() => {
11671
+ });
11672
+ if (normalizedOutroPath) await removeFile(normalizedOutroPath).catch(() => {
11673
+ });
11674
+ }
11675
+ }
11676
+
11677
+ // src/L4-agents/videoServiceBridge.ts
11678
+ function applyIntroOutro2(videoPath, videoType, outputPath, platform, aspectRatio) {
11679
+ return applyIntroOutro(videoPath, videoType, outputPath, platform, aspectRatio);
11680
+ }
11681
+
11446
11682
  // src/L0-pure/captions/captionGenerator.ts
11447
11683
  function pad(n, width) {
11448
11684
  return String(n).padStart(width, "0");
@@ -12588,6 +12824,10 @@ var ShortVideoAsset = class extends VideoAsset {
12588
12824
  get videoPath() {
12589
12825
  return join(this.videoDir, "media.mp4");
12590
12826
  }
12827
+ /** Path to the short with intro/outro applied */
12828
+ get introOutroVideoPath() {
12829
+ return join(this.videoDir, "media-intro-outro.mp4");
12830
+ }
12591
12831
  /** Directory containing social posts for this short */
12592
12832
  get postsDir() {
12593
12833
  return join(this.videoDir, "posts");
@@ -12654,6 +12894,57 @@ var ShortVideoAsset = class extends VideoAsset {
12654
12894
  await extractCompositeClip2(parentVideo, this.clip.segments, this.videoPath);
12655
12895
  return this.videoPath;
12656
12896
  }
12897
+ /**
12898
+ * Apply intro/outro to the short clip.
12899
+ * Uses brand config rules for 'shorts' video type.
12900
+ *
12901
+ * @returns Path to the intro/outro'd video, or the original path if skipped
12902
+ */
12903
+ async getIntroOutroVideo() {
12904
+ if (await fileExists(this.introOutroVideoPath)) {
12905
+ return this.introOutroVideoPath;
12906
+ }
12907
+ const candidates = [this.clip.captionedPath, this.clip.outputPath];
12908
+ let clipPath;
12909
+ for (const candidate of candidates) {
12910
+ if (candidate && await fileExists(candidate)) {
12911
+ clipPath = candidate;
12912
+ break;
12913
+ }
12914
+ }
12915
+ if (!clipPath) {
12916
+ clipPath = await this.getResult();
12917
+ }
12918
+ return applyIntroOutro2(clipPath, "shorts", this.introOutroVideoPath);
12919
+ }
12920
+ /**
12921
+ * Apply intro/outro to all platform variants of this short.
12922
+ * Resolves the correct intro/outro file per aspect ratio, auto-cropping
12923
+ * from the default file when no ratio-specific file is configured.
12924
+ *
12925
+ * @returns Map of platform to intro/outro'd variant path
12926
+ */
12927
+ async getIntroOutroVariants() {
12928
+ const results = /* @__PURE__ */ new Map();
12929
+ if (!this.clip.variants || this.clip.variants.length === 0) return results;
12930
+ for (const variant of this.clip.variants) {
12931
+ const outputPath = join(this.videoDir, `media-${variant.platform}-intro-outro.mp4`);
12932
+ if (await fileExists(outputPath)) {
12933
+ results.set(variant.platform, outputPath);
12934
+ continue;
12935
+ }
12936
+ if (!await fileExists(variant.path)) continue;
12937
+ const result = await applyIntroOutro2(
12938
+ variant.path,
12939
+ "shorts",
12940
+ outputPath,
12941
+ variant.platform,
12942
+ variant.aspectRatio
12943
+ );
12944
+ results.set(variant.platform, result);
12945
+ }
12946
+ return results;
12947
+ }
12657
12948
  // ── Transcript ───────────────────────────────────────────────────────────────
12658
12949
  /**
12659
12950
  * Get transcript filtered to this short's time range.
@@ -12723,6 +13014,10 @@ var MediumClipAsset = class extends VideoAsset {
12723
13014
  get videoPath() {
12724
13015
  return join(this.videoDir, "media.mp4");
12725
13016
  }
13017
+ /** Path to the clip with intro/outro applied */
13018
+ get introOutroVideoPath() {
13019
+ return join(this.videoDir, "media-intro-outro.mp4");
13020
+ }
12726
13021
  /**
12727
13022
  * Directory containing social media posts for this clip.
12728
13023
  */
@@ -12770,6 +13065,29 @@ var MediumClipAsset = class extends VideoAsset {
12770
13065
  await extractCompositeClip2(parentVideo, this.clip.segments, this.videoPath);
12771
13066
  return this.videoPath;
12772
13067
  }
13068
+ /**
13069
+ * Apply intro/outro to the medium clip.
13070
+ * Uses brand config rules for 'medium-clips' video type.
13071
+ *
13072
+ * @returns Path to the intro/outro'd video, or the original path if skipped
13073
+ */
13074
+ async getIntroOutroVideo() {
13075
+ if (await fileExists(this.introOutroVideoPath)) {
13076
+ return this.introOutroVideoPath;
13077
+ }
13078
+ const candidates = [this.clip.captionedPath, this.clip.outputPath];
13079
+ let clipPath;
13080
+ for (const candidate of candidates) {
13081
+ if (candidate && await fileExists(candidate)) {
13082
+ clipPath = candidate;
13083
+ break;
13084
+ }
13085
+ }
13086
+ if (!clipPath) {
13087
+ clipPath = await this.getResult();
13088
+ }
13089
+ return applyIntroOutro2(clipPath, "medium-clips", this.introOutroVideoPath);
13090
+ }
12773
13091
  };
12774
13092
 
12775
13093
  // src/L5-assets/MainVideoAsset.ts
@@ -12861,7 +13179,7 @@ var SilenceRemovalAgent = class extends BaseAgent {
12861
13179
  return this.removals;
12862
13180
  }
12863
13181
  };
12864
- async function getVideoDuration2(videoPath) {
13182
+ async function getVideoDuration3(videoPath) {
12865
13183
  try {
12866
13184
  const metadata = await ffprobe2(videoPath);
12867
13185
  return metadata.format.duration ?? 0;
@@ -12936,7 +13254,7 @@ async function removeDeadSilence(video, transcript, model) {
12936
13254
  logger_default.info("[SilenceRemoval] All removals exceeded 20% cap \u2014 skipping edit");
12937
13255
  return noEdit;
12938
13256
  }
12939
- const videoDuration = await getVideoDuration2(video.repoPath);
13257
+ const videoDuration = await getVideoDuration3(video.repoPath);
12940
13258
  const sortedRemovals = [...removals].sort((a, b) => a.start - b.start);
12941
13259
  const keepSegments = [];
12942
13260
  let cursor = 0;
@@ -15417,6 +15735,10 @@ var MainVideoAsset = class _MainVideoAsset extends VideoAsset {
15417
15735
  get producedVideoPath() {
15418
15736
  return join(this.videoDir, `${this.slug}-produced.mp4`);
15419
15737
  }
15738
+ /** Path to the video with intro/outro applied: videoDir/{slug}-intro-outro.mp4 */
15739
+ get introOutroVideoPath() {
15740
+ return join(this.videoDir, `${this.slug}-intro-outro.mp4`);
15741
+ }
15420
15742
  /** Path to a produced video for a specific aspect ratio: videoDir/{slug}-produced-{ar}.mp4 */
15421
15743
  producedVideoPathFor(aspectRatio) {
15422
15744
  const arSuffix = aspectRatio.replace(":", "x");
@@ -15694,6 +16016,21 @@ var MainVideoAsset = class _MainVideoAsset extends VideoAsset {
15694
16016
  logger_default.info(`Captions burned into video: ${this.captionedVideoPath}`);
15695
16017
  return this.captionedVideoPath;
15696
16018
  }
16019
+ /**
16020
+ * Get the video with intro/outro applied.
16021
+ * Concatenates intro (if configured) + captioned video + outro (if configured).
16022
+ *
16023
+ * @param opts - Options controlling generation
16024
+ * @returns Path to the intro/outro video, or captioned video if skipped
16025
+ */
16026
+ async getIntroOutroVideo(opts) {
16027
+ if (!opts?.force && await fileExists(this.introOutroVideoPath)) {
16028
+ return this.introOutroVideoPath;
16029
+ }
16030
+ const captionedPath = await this.getCaptionedVideo(opts);
16031
+ const result = await applyIntroOutro2(captionedPath, "main", this.introOutroVideoPath);
16032
+ return result;
16033
+ }
15697
16034
  /**
15698
16035
  * Get the fully produced video.
15699
16036
  * If not already generated, runs the ProducerAgent.
@@ -16424,16 +16761,53 @@ async function processVideo(videoPath, ideas) {
16424
16761
  } else {
16425
16762
  skipStage("caption-burn" /* CaptionBurn */, "SKIP_CAPTIONS");
16426
16763
  }
16764
+ let introOutroVideoPath;
16765
+ if (!cfg.SKIP_INTRO_OUTRO) {
16766
+ introOutroVideoPath = await trackStage("intro-outro" /* IntroOutro */, () => asset.getIntroOutroVideo());
16767
+ } else {
16768
+ skipStage("intro-outro" /* IntroOutro */, "SKIP_INTRO_OUTRO");
16769
+ }
16427
16770
  let shorts = [];
16428
16771
  if (!cfg.SKIP_SHORTS) {
16429
- const shortAssets = await trackStage("shorts" /* Shorts */, () => asset.getShorts()) ?? [];
16772
+ const shortAssets = await trackStage("shorts" /* Shorts */, async () => {
16773
+ const assets = await asset.getShorts();
16774
+ if (!cfg.SKIP_INTRO_OUTRO) {
16775
+ for (const shortAsset of assets) {
16776
+ const introOutroPath = await shortAsset.getIntroOutroVideo();
16777
+ if (introOutroPath !== shortAsset.clip.outputPath) {
16778
+ shortAsset.clip.outputPath = introOutroPath;
16779
+ shortAsset.clip.captionedPath = introOutroPath;
16780
+ }
16781
+ const variantResults = await shortAsset.getIntroOutroVariants();
16782
+ if (shortAsset.clip.variants) {
16783
+ for (const variant of shortAsset.clip.variants) {
16784
+ const updated = variantResults.get(variant.platform);
16785
+ if (updated) variant.path = updated;
16786
+ }
16787
+ }
16788
+ }
16789
+ }
16790
+ return assets;
16791
+ }) ?? [];
16430
16792
  shorts = shortAssets.map((s) => s.clip);
16431
16793
  } else {
16432
16794
  skipStage("shorts" /* Shorts */, "SKIP_SHORTS");
16433
16795
  }
16434
16796
  let mediumClips = [];
16435
16797
  if (!cfg.SKIP_MEDIUM_CLIPS) {
16436
- const mediumAssets = await trackStage("medium-clips" /* MediumClips */, () => asset.getMediumClips()) ?? [];
16798
+ const mediumAssets = await trackStage("medium-clips" /* MediumClips */, async () => {
16799
+ const assets = await asset.getMediumClips();
16800
+ if (!cfg.SKIP_INTRO_OUTRO) {
16801
+ for (const clipAsset of assets) {
16802
+ const introOutroPath = await clipAsset.getIntroOutroVideo();
16803
+ if (introOutroPath !== clipAsset.clip.outputPath) {
16804
+ clipAsset.clip.outputPath = introOutroPath;
16805
+ clipAsset.clip.captionedPath = introOutroPath;
16806
+ }
16807
+ }
16808
+ }
16809
+ return assets;
16810
+ }) ?? [];
16437
16811
  mediumClips = mediumAssets.map((m) => m.clip);
16438
16812
  } else {
16439
16813
  skipStage("medium-clips" /* MediumClips */, "SKIP_MEDIUM_CLIPS");
@@ -16470,7 +16844,7 @@ async function processVideo(videoPath, ideas) {
16470
16844
  skipStage("medium-clip-posts" /* MediumClipPosts */, "SKIP_SOCIAL");
16471
16845
  }
16472
16846
  if (!cfg.SKIP_SOCIAL_PUBLISH && socialPosts.length > 0) {
16473
- await trackStage("queue-build" /* QueueBuild */, () => asset.buildQueue(shorts, mediumClips, socialPosts, captionedVideoPath));
16847
+ await trackStage("queue-build" /* QueueBuild */, () => asset.buildQueue(shorts, mediumClips, socialPosts, introOutroVideoPath ?? captionedVideoPath));
16474
16848
  } else if (cfg.SKIP_SOCIAL_PUBLISH) {
16475
16849
  skipStage("queue-build" /* QueueBuild */, "SKIP_SOCIAL_PUBLISH");
16476
16850
  } else {
@@ -16506,6 +16880,7 @@ async function processVideo(videoPath, ideas) {
16506
16880
  enhancedVideoPath,
16507
16881
  captions: captions ? [captions.srt, captions.vtt, captions.ass] : void 0,
16508
16882
  captionedVideoPath,
16883
+ introOutroVideoPath,
16509
16884
  summary,
16510
16885
  chapters,
16511
16886
  shorts,
@@ -16599,7 +16974,8 @@ var defaultKeys = [
16599
16974
  "brandPath",
16600
16975
  "ideasRepo",
16601
16976
  "lateProfileId",
16602
- "geminiModel"
16977
+ "geminiModel",
16978
+ "scheduleConfig"
16603
16979
  ];
16604
16980
  var configKeyMap = {
16605
16981
  "openai-key": { section: "credentials", key: "openaiApiKey" },
@@ -16617,7 +16993,8 @@ var configKeyMap = {
16617
16993
  "brand-path": { section: "defaults", key: "brandPath" },
16618
16994
  "ideas-repo": { section: "defaults", key: "ideasRepo" },
16619
16995
  "late-profile-id": { section: "defaults", key: "lateProfileId" },
16620
- "gemini-model": { section: "defaults", key: "geminiModel" }
16996
+ "gemini-model": { section: "defaults", key: "geminiModel" },
16997
+ "schedule-config": { section: "defaults", key: "scheduleConfig" }
16621
16998
  };
16622
16999
  var providerDefaults = {
16623
17000
  copilot: "Claude Opus 4.6",