@gentbajko/slopify 3.0.5 → 3.0.6

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.
Files changed (33) hide show
  1. package/dist/edge/http/studio.js +32 -11
  2. package/dist/extension/slopify-studio-chrome.zip +0 -0
  3. package/dist/extension/slopify-studio-firefox.zip +0 -0
  4. package/dist/main.js +1 -1
  5. package/dist/patch-notes/3.0.6.md +34 -0
  6. package/dist/patch-notes/index.json +6 -0
  7. package/dist/slices/rebuild/recipe-legacy.js +2 -0
  8. package/dist/slices/rebuild/runtime-youtube.js +15 -4
  9. package/dist/slices/schedules/agenda.js +1 -1
  10. package/dist/slices/schedules/scheduler.js +3 -2
  11. package/dist/slices/schedules/service.js +9 -7
  12. package/dist/slices/schedules/topics.js +3 -3
  13. package/dist/slices/storage/downloads.js +14 -3
  14. package/dist/slices/storage/layout.js +4 -0
  15. package/dist/slices/storage/model.js +5 -0
  16. package/dist/slices/storage/portable.js +4 -4
  17. package/dist/slices/storage/trim.js +2 -0
  18. package/dist/slices/studio/pack.js +16 -5
  19. package/dist/slices/studio/settings.js +89 -24
  20. package/dist/slices/youtube/answer.js +54 -5
  21. package/dist/slices/youtube/edits-repo.js +8 -2
  22. package/dist/slices/youtube/edits.js +18 -4
  23. package/dist/slices/youtube/model.js +8 -1
  24. package/dist/tutorials/Publishing-to-YouTube.md +4 -4
  25. package/dist/tutorials/Settings-Reference.md +3 -3
  26. package/dist/tutorials/Studio-Extension.md +2 -2
  27. package/dist/tutorials/YouTube-Description.md +13 -5
  28. package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
  29. package/dist/web/assets/index-CskH1GRY.js +310 -0
  30. package/dist/web/assets/{pdf-CkHBkfTB.js → pdf-C5xctS6p.js} +1 -1
  31. package/dist/web/index.html +2 -2
  32. package/package.json +1 -1
  33. package/dist/web/assets/index-QDLAjCSW.js +0 -307
@@ -10,7 +10,7 @@ import { findDownload } from "../../slices/storage/downloads.js";
10
10
  import { studioPlaylistMax } from "../../slices/studio/model.js";
11
11
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
12
12
  import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
- import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylist, resetStudioPairing, saveRealFootage, saveStudioPlaylist, studioPairing, studioPlaylistProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
13
+ import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
14
14
  import { onInvalid, problem, titleOf } from "./problem.js";
15
15
  const id = z
16
16
  .string()
@@ -30,11 +30,20 @@ const queueItemBody = z.object({
30
30
  projectId: id,
31
31
  short: z.number().int().min(1).max(99).nullable().optional(),
32
32
  });
33
- const playlistBody = z.object({
34
- playlist: z.string().max(studioPlaylistMax * 2),
35
- // A channel's own playlist; absent is the default every other channel uses.
33
+ const playlistsBody = z.object({
34
+ playlists: z
35
+ .array(z.object({
36
+ name: z.string().max(studioPlaylistMax * 2),
37
+ byDefault: z.boolean(),
38
+ }))
39
+ .max(studioPlaylistsMax),
40
+ // A channel's own list; absent is the default every other channel uses.
36
41
  channelId: id.optional(),
37
42
  });
43
+ // The names this project's uploads go into; null goes back to the channel's defaults.
44
+ const projectPlaylistsBody = z.object({
45
+ playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
46
+ });
38
47
  const realFootageBody = z.object({ realFootage: z.boolean() });
39
48
  // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
40
49
  const extensionFiles = {
@@ -89,32 +98,32 @@ export function studioRoutes(deps) {
89
98
  if (denied !== undefined)
90
99
  return denied;
91
100
  return c.json({
92
- playlist: readStudioPlaylist(deps.db),
101
+ playlists: readStudioPlaylists(deps.db),
93
102
  channelPlaylists: readChannelPlaylists(deps.db),
94
103
  pairing: studioPairing(deps.db),
95
104
  });
96
105
  })
97
- .put("/settings/playlist", zValidator("json", playlistBody, onInvalid), (c) => {
106
+ .put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
98
107
  const denied = samePage(c);
99
108
  if (denied !== undefined)
100
109
  return denied;
101
- const { playlist: raw, channelId } = c.req.valid("json");
110
+ const { playlists: raw, channelId } = c.req.valid("json");
102
111
  if (channelId !== undefined && channelById(deps.db, channelId) === undefined)
103
112
  return problem(c, {
104
113
  status: 404,
105
114
  title: titleOf(404),
106
- detail: "The playlist wasn't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
115
+ detail: "The playlists weren't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
107
116
  });
108
- const invalid = studioPlaylistProblem(raw);
117
+ const invalid = studioPlaylistsProblem(raw);
109
118
  if (invalid !== undefined)
110
119
  return problem(c, {
111
120
  status: 400,
112
121
  title: titleOf(400),
113
122
  detail: invalid,
114
- extensions: { fields: [{ field: "playlist", message: invalid }] },
123
+ extensions: { fields: [{ field: "playlists", message: invalid }] },
115
124
  });
116
125
  return c.json({
117
- playlist: saveStudioPlaylist(deps.db, raw, channelId),
126
+ playlists: saveStudioPlaylists(deps.db, raw, channelId),
118
127
  channelId: channelId ?? null,
119
128
  });
120
129
  })
@@ -141,6 +150,18 @@ export function studioRoutes(deps) {
141
150
  saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
142
151
  const result = uploadPack(deps, projectId);
143
152
  return result.ok ? c.json(result.pack) : unknownProject(c);
153
+ })
154
+ // Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
155
+ .put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
156
+ const denied = samePage(c);
157
+ if (denied !== undefined)
158
+ return denied;
159
+ const { projectId } = c.req.valid("param");
160
+ if (!uploadPack(deps, projectId).ok)
161
+ return unknownProject(c);
162
+ saveProjectPlaylists(deps.db, projectId, c.req.valid("json").playlists);
163
+ const result = uploadPack(deps, projectId);
164
+ return result.ok ? c.json(result.pack) : unknownProject(c);
144
165
  })
145
166
  .post("/packs/:projectId/choose", zValidator("param", projectParam, onInvalid), zValidator("json", chooseBody, onInvalid), (c) => {
146
167
  const denied = samePage(c);
package/dist/main.js CHANGED
@@ -419,7 +419,7 @@ export async function boot(config, options = {}) {
419
419
  };
420
420
  const scheduleRunnerDeps = {
421
421
  ...draftDeps,
422
- template: (id, templateVersion) => templateById(runtimeDb, id, templateVersion),
422
+ template: (id) => templateById(runtimeDb, id),
423
423
  topicLlm: (call) => standaloneLlm(standalone, call),
424
424
  topicsWaiting: (event) => {
425
425
  hub.emitGlobal(event);
@@ -0,0 +1,34 @@
1
+ # Slopify 3.0.6
2
+
3
+ Released 28 September 2026.
4
+
5
+ 3.0.6 writes more of what YouTube asks for: a comment to pin and two more titles to test, alongside the description and tags. A channel can send its videos to several playlists, and schedules follow the latest version of their template.
6
+
7
+ ## Highlights
8
+
9
+ - **A pinned comment** written with every description.
10
+ - **Two more titles** for YouTube's title A/B test.
11
+ - **Several playlists** per channel, ticked per project.
12
+ - **Schedules use the template as it is now.**
13
+
14
+ ## YouTube description
15
+
16
+ - The description step also writes a comment to pin under the video: a thank-you, a question viewers can answer from their own experience, a pointer to the chapters and an ask for the next topic. It shows under Tags in the project's YouTube section with **Copy**, and **Edit** keeps your changes through a rewrite. Your Description prompt can say how you want it.
17
+ - The same step writes two **Other titles** for YouTube's A/B Testing, next to the title in Studio. Each takes a different angle on the same video, within YouTube's 100 characters. They show in the YouTube section and in Prepare upload's Title step, with **Copy others**.
18
+ - **Write again**, next to Description, writes the description, tags, pinned comment and titles anew after asking. It no longer takes a trip through Edit settings and a remake.
19
+
20
+ ## Publishing
21
+
22
+ - Settings → YouTube Studio → **Playlists** is a list for each channel (or the default list for all channels). Each playlist is either **On by default** or not.
23
+ - Prepare upload's Playlist step has a tick for each playlist, saved for that project, and the Studio extension ticks every chosen playlist in Studio.
24
+ - **Select all** above Prepare upload's steps ticks them all, or clears them.
25
+
26
+ ## Schedules
27
+
28
+ - A schedule runs, previews and checks its topics against its template as it is now. It used to keep the template version it was made with, so edits to the template never reached its runs, and the Calendar showed the template's old name.
29
+
30
+ ## Upgrading from 3.0.5
31
+
32
+ - **Nothing becomes outdated.** A description written before shows no pinned comment or other titles until you press **Write again**.
33
+ - A playlist you set before becomes the first entry of the channel's list, on by default.
34
+ - Update the Studio extension to have it tick more than one playlist. An older extension still ticks the first.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.0.6",
4
+ "title": "Slopify 3.0.6",
5
+ "version": "3.0.6",
6
+ "date": "2026-09-28"
7
+ },
2
8
  {
3
9
  "id": "3.0.5",
4
10
  "title": "Slopify 3.0.5",
@@ -68,6 +68,8 @@ export function legacyOutputWorkKey(output, config) {
68
68
  return "document:pdf";
69
69
  case "youtube_description":
70
70
  case "youtube_tags":
71
+ case "youtube_pinned_comment":
72
+ case "youtube_titles":
71
73
  return "youtube:description";
72
74
  case "shorts":
73
75
  return "shorts:pick";
@@ -8,7 +8,7 @@ import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
8
8
  import { savedCatalogue } from "./runtime-plan.js";
9
9
  import { preparedTexts, publishResult } from "./runtime-publication.js";
10
10
  import { wordsSchema } from "./runtime-subtitles.js";
11
- // Writes the YouTube description and tags from the saved word timing: the transcript's
11
+ // Writes the YouTube description, tags, pinned comment and other titles from the saved word timing: the transcript's
12
12
  // passages carry their start in the final video, so the chapters the model picks are real
13
13
  // times. An answer that breaks YouTube's chapter or tag rules is a failed attempt, which the
14
14
  // provider wrapper asks again while attempts remain.
@@ -66,21 +66,32 @@ export async function executeYoutubeRecipe(deps, context, providers, piece) {
66
66
  webSearch: false,
67
67
  previewLabel: piece.key,
68
68
  check: (value) => {
69
- const checked = checkDescriptionAnswer(value.text, durationSeconds);
69
+ const checked = checkDescriptionAnswer(value.text, durationSeconds, config.title);
70
70
  return checked.ok ? undefined : checked.reason;
71
71
  },
72
72
  });
73
73
  if (!answer.ok)
74
74
  return "held";
75
- const checked = checkDescriptionAnswer(answer.value.text, durationSeconds);
75
+ const checked = checkDescriptionAnswer(answer.value.text, durationSeconds, config.title);
76
76
  if (!checked.ok)
77
77
  throw new Error(checked.reason);
78
78
  const description = assembleDescription(checked.value);
79
79
  const tags = tagsText(checked.value.tags);
80
+ const pinnedComment = checked.value.pinnedComment;
81
+ const titles = checked.value.titles.join("\n");
80
82
  await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
81
83
  ["youtube_description", "description.txt", description],
82
84
  ["youtube_tags", "tags.txt", tags],
83
- ]), { description, tags: checked.value.tags, chapters: checked.value.chapters, durationSeconds });
85
+ ["youtube_pinned_comment", "pinned-comment.txt", pinnedComment],
86
+ ["youtube_titles", "titles.txt", titles],
87
+ ]), {
88
+ description,
89
+ tags: checked.value.tags,
90
+ chapters: checked.value.chapters,
91
+ pinnedComment,
92
+ titles: checked.value.titles,
93
+ durationSeconds,
94
+ });
84
95
  deps.count?.("stage.completed", {
85
96
  provider: llm.provider,
86
97
  model: llm.model,
@@ -52,7 +52,7 @@ function upcomingRuns(deps, schedule, now, from, to) {
52
52
  : paused
53
53
  ? nextOccurrence(schedule.cadence, schedule.timezone, now)
54
54
  : new Date(schedule.nextRunAt);
55
- const template = deps.template(schedule.templateId, schedule.templateVersion);
55
+ const template = deps.template(schedule.templateId);
56
56
  const templateName = template?.name ?? null;
57
57
  const form = template?.document.form;
58
58
  const { items } = schedule;
@@ -133,12 +133,13 @@ function noTopicReason(schedule) {
133
133
  async function execute(deps, claimed) {
134
134
  let run = claimed.run;
135
135
  try {
136
- const template = deps.template(claimed.schedule.templateId, claimed.schedule.templateVersion);
136
+ // The template as it is now, so an edit to it reaches the next run.
137
+ const template = deps.template(claimed.schedule.templateId);
137
138
  if (template === undefined)
138
139
  throw new ScheduleDispatchError("missing-template");
139
140
  const document = freshTemplateDraft(deps, template.document, {
140
141
  id: claimed.schedule.templateId,
141
- version: claimed.schedule.templateVersion,
142
+ version: template.version,
142
143
  });
143
144
  const topic = claimed.schedule.items[0];
144
145
  const fresh = {
@@ -35,7 +35,7 @@ export function createSchedule(deps, input) {
35
35
  ? { ok: true, value: scheduleById(deps.db, parsed.data.id) }
36
36
  : { ok: false, reason: "conflict" };
37
37
  }
38
- const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
38
+ const checked = checkTemplate(deps, parsed.data.templateId);
39
39
  if (!checked.ok)
40
40
  return checked;
41
41
  const topics = checkTopics(deps, parsed.data, []);
@@ -45,7 +45,8 @@ export function createSchedule(deps, input) {
45
45
  const next = nextOccurrence(parsed.data.cadence, parsed.data.timezone, now);
46
46
  if (next === null)
47
47
  return { ok: false, reason: "not-due" };
48
- const value = summary(parsed.data, next.toISOString(), now.toISOString());
48
+ // The version it was saved against, for the record; runs use the newest.
49
+ const value = summary({ ...parsed.data, templateVersion: checked.value }, next.toISOString(), now.toISOString());
49
50
  insertSchedule(deps.db, value, hash);
50
51
  return { ok: true, value };
51
52
  });
@@ -77,7 +78,7 @@ export function updateSchedule(deps, input) {
77
78
  return { ok: false, reason: "conflict" };
78
79
  if (previous.status === "canceled" || previous.status === "completed")
79
80
  return { ok: false, reason: "conflict" };
80
- const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
81
+ const checked = checkTemplate(deps, parsed.data.templateId);
81
82
  if (!checked.ok)
82
83
  return checked;
83
84
  const topics = checkTopics(deps, parsed.data, previous.items);
@@ -155,8 +156,9 @@ function control(deps, input, status) {
155
156
  return { ok: false, reason: "conflict" };
156
157
  return { ok: true, value: scheduleById(deps.db, previous.id) };
157
158
  }
158
- function checkTemplate(deps, id, version) {
159
- const template = deps.template(id, version);
159
+ // Answers the template's newest version when a schedule may use it.
160
+ function checkTemplate(deps, id) {
161
+ const template = deps.template(id);
160
162
  if (!template)
161
163
  return { ok: false, reason: "missing-template" };
162
164
  const sources = template.document.form.sources;
@@ -169,13 +171,13 @@ function checkTemplate(deps, id, version) {
169
171
  form.ambientBed?.source === "upload" ||
170
172
  (sources.images === "generate" && form.reference?.source === "provide"))
171
173
  return { ok: false, reason: "unsupported-media" };
172
- return { ok: true, value: true };
174
+ return { ok: true, value: template.version };
173
175
  }
174
176
  // The queue's keywords must be the template's: a topic (or an every-run value) naming a keyword
175
177
  // the template doesn't use would silently do nothing. Topics saved before, unchanged, are left
176
178
  // alone, so a queue from before this check still saves.
177
179
  function checkTopics(deps, input, kept) {
178
- const template = deps.template(input.templateId, input.templateVersion);
180
+ const template = deps.template(input.templateId);
179
181
  if (!template)
180
182
  return { ok: false, reason: "missing-template" };
181
183
  const keywords = templateKeywords(template.document.form);
@@ -101,7 +101,7 @@ async function ask(deps, schedule, options) {
101
101
  .run(failedAt.toISOString(), text, schedule.id);
102
102
  return { ok: false, reason: "failed", message: text };
103
103
  };
104
- const template = deps.template(schedule.templateId, schedule.templateVersion);
104
+ const template = deps.template(schedule.templateId);
105
105
  const llm = schedule.topicGeneration.llm ?? template?.document.form.llm;
106
106
  if (llm === undefined)
107
107
  return fail("the template this schedule uses was deleted, so there is no LLM to ask. Edit the schedule and choose a template, or pick an LLM under Topic generation.");
@@ -394,7 +394,7 @@ export function editHeldTopic(deps, scheduleId, topicId, input) {
394
394
  const schedule = scheduleById(deps.db, scheduleId);
395
395
  if (schedule === undefined)
396
396
  return { ok: false, reason: "not-found" };
397
- const template = deps.template(schedule.templateId, schedule.templateVersion);
397
+ const template = deps.template(schedule.templateId);
398
398
  if (template === undefined)
399
399
  return { ok: false, reason: "missing-template" };
400
400
  const problems = topicRowProblems([{ title: parsed.data, values }], {
@@ -463,7 +463,7 @@ export function replaceTopics(deps, scheduleId, input) {
463
463
  const schedule = open.value;
464
464
  if (schedule.version !== parsed.data.baseVersion)
465
465
  return { ok: false, reason: "conflict" };
466
- const template = deps.template(schedule.templateId, schedule.templateVersion);
466
+ const template = deps.template(schedule.templateId);
467
467
  if (!template)
468
468
  return { ok: false, reason: "missing-template" };
469
469
  const problems = topicRowProblems(parsed.data.items, {
@@ -58,7 +58,10 @@ export function findDownload(deps, projectId, asset) {
58
58
  if (stats === undefined) {
59
59
  return { ok: false, reason: "missing-file" };
60
60
  }
61
- const shown = output.role === "youtube_description" || output.role === "youtube_tags"
61
+ const shown = output.role === "youtube_description" ||
62
+ output.role === "youtube_tags" ||
63
+ output.role === "youtube_pinned_comment" ||
64
+ output.role === "youtube_titles"
62
65
  ? shownYoutubeText(deps, projectId, output.role)
63
66
  : undefined;
64
67
  return {
@@ -72,7 +75,7 @@ export function findDownload(deps, projectId, asset) {
72
75
  },
73
76
  };
74
77
  }
75
- // The description and tags download as the project page shows and copies them, and as Prepare
78
+ // The description, tags, pinned comment and other titles download as the project page shows and copies them, and as Prepare
76
79
  // upload hands them on: the user's hand edits over the written text, chapters fitted, and the
77
80
  // channel's and project's links filled in (`youtube/edits-repo.ts`). The file on disk stays
78
81
  // the generated text, so regeneration can tell the user's edits from its own.
@@ -92,11 +95,19 @@ function shownYoutubeText(deps, projectId, role) {
92
95
  const shown = effectiveDescription(deps.db, projectId, {
93
96
  description,
94
97
  tags: read("youtube_tags") ?? "",
98
+ pinnedComment: read("youtube_pinned_comment"),
99
+ titles: read("youtube_titles"),
95
100
  durationSeconds: video?.durationMs === null || video?.durationMs === undefined
96
101
  ? undefined
97
102
  : video.durationMs / 1000,
98
103
  });
99
- const text = role === "youtube_description" ? shown.description : shown.tags;
104
+ const text = role === "youtube_description"
105
+ ? shown.description
106
+ : role === "youtube_tags"
107
+ ? shown.tags
108
+ : role === "youtube_pinned_comment"
109
+ ? shown.pinnedComment
110
+ : shown.titles;
100
111
  return new TextEncoder().encode(`${text}\n`);
101
112
  }
102
113
  // "download all" images as `<title-slug>-images.zip`, thumbnail included.
@@ -68,6 +68,10 @@ export function outputFileName(role, index, extension, stageKind) {
68
68
  return "description.txt";
69
69
  case "youtube_tags":
70
70
  return "tags.txt";
71
+ case "youtube_pinned_comment":
72
+ return "pinned-comment.txt";
73
+ case "youtube_titles":
74
+ return "titles.txt";
71
75
  case "shorts":
72
76
  return "shorts.json";
73
77
  case "short_image":
@@ -27,6 +27,11 @@ export const outputRoles = [
27
27
  "document_pdf",
28
28
  "youtube_description",
29
29
  "youtube_tags",
30
+ // A comment to pin under the video, written with the description. Projects described before
31
+ // it existed have none until the description is written again.
32
+ "youtube_pinned_comment",
33
+ // Other titles for YouTube's title A/B test, one per line, written with the description.
34
+ "youtube_titles",
30
35
  // The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
31
36
  "shorts",
32
37
  "short_image",
@@ -23,8 +23,7 @@ import { appearances, providerById, providerIds } from "../settings/model.js";
23
23
  import { listVoices } from "../settings/repo.js";
24
24
  import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voices.js";
25
25
  import { whatsNewSeenKey } from "../settings/whats-new.js";
26
- import { studioPlaylistMax } from "../studio/model.js";
27
- import { studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioRealFootageKey, } from "../studio/settings.js";
26
+ import { storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
28
27
  import { channelLinksKey } from "../youtube/edits-repo.js";
29
28
  import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
30
29
  import { stageKinds } from "./model.js";
@@ -354,7 +353,6 @@ const storedAppearance = z.enum(appearances);
354
353
  const storedLoudness = z
355
354
  .object({ enabled: z.boolean(), videoLufs: z.number(), audioFilesLufs: z.number() })
356
355
  .strict();
357
- const storedPlaylist = z.string().max(studioPlaylistMax);
358
356
  // Settings → Channel links, and the providers a fresh Play form starts with.
359
357
  // The AI use marks: ticked voices, Image prompts and projects, by id.
360
358
  const storedIds = z.array(z.string().max(200)).max(100_000);
@@ -389,7 +387,9 @@ function portableSettings(settings) {
389
387
  else if (key === "loudness")
390
388
  storedLoudness.parse(parsed);
391
389
  else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
392
- storedPlaylist.parse(parsed);
390
+ storedPlaylistsSchema.parse(parsed);
391
+ else if (key === studioProjectPlaylistsKey)
392
+ storedProjectPlaylistsSchema.parse(parsed);
393
393
  else if (key === channelLinksKey)
394
394
  storedChannelLinks.parse(parsed);
395
395
  else if (key === realPersonVoicesKey ||
@@ -21,6 +21,8 @@ export const outputRoles = [
21
21
  "sources",
22
22
  "youtube_description",
23
23
  "youtube_tags",
24
+ "youtube_pinned_comment",
25
+ "youtube_titles",
24
26
  "document_pdf",
25
27
  "audio_export",
26
28
  "subtitles_srt",
@@ -17,7 +17,7 @@ import { usesVoices } from "../voices/model.js";
17
17
  import { effectiveDescription } from "../youtube/edits-repo.js";
18
18
  import { aiDisclosureOf } from "./disclosure.js";
19
19
  import { studioAudience, studioTitleMax, } from "./model.js";
20
- import { readRealFootage, readStudioPlaylist } from "./settings.js";
20
+ import { projectPlaylists, readRealFootage } from "./settings.js";
21
21
  // What `shorts.json` holds for each picked clip; only what the pack uses.
22
22
  const clipsSchema = z.object({
23
23
  shorts: z.array(z.object({
@@ -50,8 +50,10 @@ export function uploadPack(deps, projectId) {
50
50
  ? undefined
51
51
  : readFileSync(outputPath(deps.paths, projectId, output.path), "utf8").trim();
52
52
  const channelId = projectChannelId(deps.db, projectId);
53
- // The channel's own playlist, else the default (Settings → YouTube Studio).
54
- const playlist = readStudioPlaylist(deps.db, channelId);
53
+ // The channel's playlists (Settings → YouTube Studio), ticked as this project chose.
54
+ const playlistChoices = projectPlaylists(deps.db, projectId, channelId);
55
+ const playlists = playlistChoices.filter((one) => one.chosen).map((one) => one.name);
56
+ const playlist = playlists[0] ?? null;
55
57
  const missing = [];
56
58
  // The channel's setting, then what the project narrates and shows (`disclosure.ts`).
57
59
  const setting = channelById(deps.db, channelId)?.aiDisclosure ?? "auto";
@@ -84,6 +86,7 @@ export function uploadPack(deps, projectId) {
84
86
  : effectiveDescription(deps.db, projectId, {
85
87
  description: written,
86
88
  tags: writtenTags ?? "",
89
+ titles: text(outputs.find((output) => output.role === "youtube_titles")),
87
90
  // The last chapter is checked against the video's own length when it is known.
88
91
  durationSeconds: video?.durationMs === null || video?.durationMs === undefined
89
92
  ? undefined
@@ -107,18 +110,23 @@ export function uploadPack(deps, projectId) {
107
110
  : "The thumbnail isn't made yet. Let the Thumbnail stage finish, then open Prepare upload again.");
108
111
  else if (thumbnails.length < count)
109
112
  missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
110
- if (playlist === null)
111
- missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio → Playlist.");
113
+ if (playlistChoices.length === 0)
114
+ missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio → Playlists.");
112
115
  const items = [
113
116
  {
114
117
  kind: "video",
115
118
  video: video === undefined ? null : file(video),
116
119
  title: project.title.slice(0, studioTitleMax),
120
+ titles: (edited?.titles ?? "")
121
+ .split("\n")
122
+ .map((one) => one.trim().slice(0, studioTitleMax))
123
+ .filter((one) => one !== ""),
117
124
  description: description ?? "",
118
125
  tags: tagsOf(tagsFile),
119
126
  thumbnails: thumbnails.map(file),
120
127
  audience: studioAudience,
121
128
  alteredContent: disclosure("video"),
129
+ playlists,
122
130
  playlist,
123
131
  ...(edited?.chapterNotice === undefined ? {} : { chapterNotice: edited.chapterNotice }),
124
132
  },
@@ -140,6 +148,7 @@ export function uploadPack(deps, projectId) {
140
148
  short: clip.number,
141
149
  video: render === undefined ? null : file(render),
142
150
  title: clip.title.slice(0, studioTitleMax),
151
+ titles: [],
143
152
  description: [
144
153
  clip.description,
145
154
  fullVideoLine(config.shorts?.fullVideoLink),
@@ -152,6 +161,7 @@ export function uploadPack(deps, projectId) {
152
161
  thumbnails: [],
153
162
  audience: studioAudience,
154
163
  alteredContent: disclosure("short"),
164
+ playlists,
155
165
  playlist,
156
166
  });
157
167
  }
@@ -162,6 +172,7 @@ export function uploadPack(deps, projectId) {
162
172
  projectTitle: project.title,
163
173
  items,
164
174
  missing,
175
+ playlistChoices,
165
176
  ...(uploadedClips > 0 ? { footage: { clips: uploadedClips, real: realFootage } } : {}),
166
177
  },
167
178
  };