@gentbajko/slopify 3.0.5 → 3.0.7

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 (50) hide show
  1. package/dist/adapters/tts/elevenlabs.js +2 -0
  2. package/dist/assets/document/fonts/Literata-Italic.ttf +0 -0
  3. package/dist/assets/document/fonts/Literata-OFL.txt +93 -0
  4. package/dist/assets/document/fonts/Literata-Regular.ttf +0 -0
  5. package/dist/assets/document/fonts/Literata-SemiBold.ttf +0 -0
  6. package/dist/assets/document/fonts/Literata-SemiBoldItalic.ttf +0 -0
  7. package/dist/assets/document/fonts/SOURCE.txt +13 -1
  8. package/dist/assets/models.yaml +26 -0
  9. package/dist/edge/http/studio.js +32 -11
  10. package/dist/extension/slopify-studio-chrome.zip +0 -0
  11. package/dist/extension/slopify-studio-firefox.zip +0 -0
  12. package/dist/main.js +1 -1
  13. package/dist/patch-notes/3.0.6.md +34 -0
  14. package/dist/patch-notes/3.0.7.md +33 -0
  15. package/dist/patch-notes/index.json +12 -0
  16. package/dist/slices/document/fonts.js +29 -11
  17. package/dist/slices/document/render.js +1 -1
  18. package/dist/slices/document/theme.js +3 -3
  19. package/dist/slices/rebuild/recipe-legacy.js +2 -0
  20. package/dist/slices/rebuild/runtime-youtube.js +15 -4
  21. package/dist/slices/schedules/agenda.js +1 -1
  22. package/dist/slices/schedules/scheduler.js +3 -2
  23. package/dist/slices/schedules/service.js +11 -8
  24. package/dist/slices/schedules/topics.js +3 -3
  25. package/dist/slices/storage/downloads.js +14 -3
  26. package/dist/slices/storage/layout.js +4 -0
  27. package/dist/slices/storage/model.js +5 -0
  28. package/dist/slices/storage/portable.js +4 -4
  29. package/dist/slices/storage/trim.js +2 -0
  30. package/dist/slices/studio/pack.js +16 -5
  31. package/dist/slices/studio/settings.js +89 -24
  32. package/dist/slices/youtube/answer.js +54 -5
  33. package/dist/slices/youtube/edits-repo.js +8 -2
  34. package/dist/slices/youtube/edits.js +18 -4
  35. package/dist/slices/youtube/model.js +9 -1
  36. package/dist/tutorials/Document-Themes.md +1 -1
  37. package/dist/tutorials/Home-and-Projects.md +4 -4
  38. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +3 -3
  39. package/dist/tutorials/Play-Overview.md +1 -1
  40. package/dist/tutorials/Publishing-to-YouTube.md +4 -4
  41. package/dist/tutorials/Schedules.md +2 -2
  42. package/dist/tutorials/Settings-Reference.md +3 -3
  43. package/dist/tutorials/Studio-Extension.md +2 -2
  44. package/dist/tutorials/YouTube-Description.md +13 -5
  45. package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
  46. package/dist/web/assets/index-Cdb0Jh3P.js +310 -0
  47. package/dist/web/assets/{pdf-CkHBkfTB.js → pdf-BhFuoBjr.js} +1 -1
  48. package/dist/web/index.html +2 -2
  49. package/package.json +1 -1
  50. package/dist/web/assets/index-QDLAjCSW.js +0 -307
@@ -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
  };
@@ -2,7 +2,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { readSetting, writeSetting } from "../settings/repo.js";
4
4
  import { studioPlaylistMax } from "./model.js";
5
- // Two rows of the key/value `settings` table. The playlist travels with a backup; the
5
+ // Rows of the key/value `settings` table. The playlists travel with a backup; the
6
6
  // pairing stays on this machine (`slices/storage/portable.ts`): its token is what lets the
7
7
  // browser extension read a project's upload pack.
8
8
  export const studioPlaylistKey = "studio.playlist";
@@ -12,23 +12,37 @@ const pairingSchema = z.object({
12
12
  origin: z.string().nullable(),
13
13
  pairedAt: z.string().nullable(),
14
14
  });
15
- // A channel's own playlist is a row of its own, `studio.playlist.<channelId>`; a channel
16
- // without one uses the default above.
15
+ // A channel's own playlists are a row of their own, `studio.playlist.<channelId>`; a channel
16
+ // without one uses the default list above. Each playlist is ticked by default or not; a
17
+ // project's Prepare upload can change which it goes into. A row saved before lists existed
18
+ // holds one name, read as one playlist ticked by default.
17
19
  export const studioChannelPlaylistPrefix = `${studioPlaylistKey}.`;
18
20
  function playlistKey(channelId) {
19
21
  return channelId === undefined ? studioPlaylistKey : `${studioChannelPlaylistPrefix}${channelId}`;
20
22
  }
21
- // The playlist an upload pack names: the channel's own, else the default. Without a channel,
22
- // the default alone.
23
- export function readStudioPlaylist(db, channelId) {
23
+ // Up to 20 playlists a list: Studio's own dialog shows every playlist of the channel, and a
24
+ // video in more than a handful of them is rare.
25
+ export const studioPlaylistsMax = 20;
26
+ export const studioPlaylistSchema = z.object({
27
+ name: z.string().trim().min(1).max(studioPlaylistMax),
28
+ byDefault: z.boolean(),
29
+ });
30
+ // What a row holds: the list, or the one name a row held before lists.
31
+ export const storedPlaylistsSchema = z.union([
32
+ z.string().max(studioPlaylistMax),
33
+ z.array(studioPlaylistSchema).max(studioPlaylistsMax),
34
+ ]);
35
+ // The playlists a channel's upload packs offer: the channel's own list, else the default.
36
+ // Without a channel, the default alone.
37
+ export function readStudioPlaylists(db, channelId) {
24
38
  if (channelId !== undefined) {
25
- const own = storedPlaylist(db, playlistKey(channelId));
39
+ const own = storedPlaylists(db, playlistKey(channelId));
26
40
  if (own !== null)
27
41
  return own;
28
42
  }
29
- return storedPlaylist(db, studioPlaylistKey);
43
+ return storedPlaylists(db, studioPlaylistKey) ?? [];
30
44
  }
31
- // Every channel's own playlist, by channel id.
45
+ // Every channel's own list, by channel id.
32
46
  export function readChannelPlaylists(db) {
33
47
  const prefix = studioChannelPlaylistPrefix;
34
48
  const rows = db
@@ -36,41 +50,92 @@ export function readChannelPlaylists(db) {
36
50
  .all(prefix.length, prefix);
37
51
  const out = {};
38
52
  for (const { key } of rows) {
39
- const value = storedPlaylist(db, key);
53
+ const value = storedPlaylists(db, key);
40
54
  if (value !== null)
41
55
  out[key.slice(prefix.length)] = value;
42
56
  }
43
57
  return out;
44
58
  }
45
- function storedPlaylist(db, key) {
59
+ function storedPlaylists(db, key) {
46
60
  const stored = readSetting(db, key);
47
61
  if (stored === undefined)
48
62
  return null;
49
63
  try {
50
- const value = JSON.parse(stored);
51
- return typeof value === "string" && value.trim() !== "" ? value : null;
64
+ const parsed = storedPlaylistsSchema.safeParse(JSON.parse(stored));
65
+ if (!parsed.success)
66
+ return null;
67
+ if (typeof parsed.data === "string")
68
+ return parsed.data.trim() === "" ? null : [{ name: parsed.data.trim(), byDefault: true }];
69
+ return parsed.data.length === 0 ? null : parsed.data;
52
70
  }
53
71
  catch {
54
72
  return null;
55
73
  }
56
74
  }
57
- export function studioPlaylistProblem(raw) {
58
- return raw.trim().length > studioPlaylistMax
59
- ? `The playlist name is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`
60
- : undefined;
61
- }
62
- // Saves the default playlist, or a channel's own; empty clears it (the channel then uses the
63
- // default again).
64
- export function saveStudioPlaylist(db, raw, channelId) {
65
- const value = raw.trim();
75
+ // Why a list can't be saved, in words for Settings → YouTube Studio; undefined when it can.
76
+ export function studioPlaylistsProblem(list) {
77
+ const long = list.find((one) => one.name.trim().length > studioPlaylistMax);
78
+ if (long !== undefined)
79
+ return `The playlist name "${long.name.slice(0, 40)}…" is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`;
80
+ const seen = new Set();
81
+ for (const one of list) {
82
+ const key = one.name.trim().toLowerCase();
83
+ if (seen.has(key))
84
+ return `"${one.name.trim()}" is listed twice. Remove one in Settings → YouTube Studio.`;
85
+ seen.add(key);
86
+ }
87
+ return undefined;
88
+ }
89
+ // Saves the default list, or a channel's own; an empty list clears it (the channel then uses
90
+ // the default again). Blank names are dropped.
91
+ export function saveStudioPlaylists(db, list, channelId) {
92
+ const value = list
93
+ .map((one) => ({ name: one.name.trim(), byDefault: one.byDefault }))
94
+ .filter((one) => one.name !== "");
66
95
  const key = playlistKey(channelId);
67
- if (value === "") {
96
+ if (value.length === 0) {
68
97
  db.prepare("DELETE FROM settings WHERE key = ?").run(key);
69
- return null;
98
+ return [];
70
99
  }
71
100
  writeSetting(db, key, JSON.stringify(value));
72
101
  return value;
73
102
  }
103
+ // The playlists a project's uploads go into, when its Prepare upload changed them from the
104
+ // channel's defaults: one row, project id to names.
105
+ export const studioProjectPlaylistsKey = "studio.projectPlaylists";
106
+ export const storedProjectPlaylistsSchema = z.record(z.string().max(64), z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax));
107
+ function projectChoices(db) {
108
+ const stored = readSetting(db, studioProjectPlaylistsKey);
109
+ if (stored === undefined)
110
+ return {};
111
+ try {
112
+ const parsed = storedProjectPlaylistsSchema.safeParse(JSON.parse(stored));
113
+ return parsed.success ? parsed.data : {};
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ // The channel's playlists, each ticked as this project goes into it: the project's own choice
120
+ // when it made one (a playlist the channel no longer lists is dropped), else the defaults.
121
+ export function projectPlaylists(db, projectId, channelId) {
122
+ const list = readStudioPlaylists(db, channelId);
123
+ const own = projectChoices(db)[projectId];
124
+ return list.map((one) => ({
125
+ name: one.name,
126
+ chosen: own === undefined
127
+ ? one.byDefault
128
+ : own.some((name) => name.toLowerCase() === one.name.toLowerCase()),
129
+ }));
130
+ }
131
+ export function saveProjectPlaylists(db, projectId, names) {
132
+ const { [projectId]: _previous, ...others } = projectChoices(db);
133
+ const next = names === null ? others : { ...others, [projectId]: [...names] };
134
+ if (Object.keys(next).length === 0)
135
+ db.prepare("DELETE FROM settings WHERE key = ?").run(studioProjectPlaylistsKey);
136
+ else
137
+ writeSetting(db, studioProjectPlaylistsKey, JSON.stringify(next));
138
+ }
74
139
  // The projects whose uploaded clips are real footage (filmed, not generated): ticked in the
75
140
  // project's Prepare upload, under AI use. One row holding their ids; YouTube's second AI use
76
141
  // case is footage of a real event or place altered by AI (`disclosure.ts`).
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { descriptionMaxCharacters, hashtagsMax, minChapterSeconds, minChapters, tagMaxCharacters, tagsMaxCharacters, } from "./model.js";
2
+ import { alternativeTitles, descriptionMaxCharacters, hashtagsMax, minChapterSeconds, minChapters, pinnedCommentMaxCharacters, tagMaxCharacters, tagsMaxCharacters, titleMaxCharacters, } from "./model.js";
3
3
  import { parseTimestamp, youtubeTimestamp } from "./timestamps.js";
4
4
  export function descriptionMessages(brief) {
5
5
  const end = youtubeTimestamp(brief.durationSeconds);
@@ -8,7 +8,7 @@ export function descriptionMessages(brief) {
8
8
  role: "system",
9
9
  content: [
10
10
  "You write YouTube descriptions. Answer with one JSON object and nothing else, in this shape:",
11
- '{"summary": "...", "chapters": [{"start": "0:00", "title": "..."}], "hashtags": ["#Example"], "tags": ["example tag"]}',
11
+ '{"summary": "...", "chapters": [{"start": "0:00", "title": "..."}], "hashtags": ["#Example"], "tags": ["example tag"], "pinnedComment": "...", "titles": ["...", "..."]}',
12
12
  "",
13
13
  "Rules YouTube enforces, which the answer must follow:",
14
14
  '- The first chapter starts at exactly "0:00".',
@@ -20,6 +20,11 @@ export function descriptionMessages(brief) {
20
20
  `- Tags are plain search terms without # or commas, each at most ${String(tagMaxCharacters)} characters, no tag repeated, and all tags together at most ${String(tagsMaxCharacters)} characters.`,
21
21
  "- No < or > anywhere.",
22
22
  "- The summary is plain text without chapters, hashtags or links.",
23
+ `- The pinned comment is plain text without links, at most ${String(pinnedCommentMaxCharacters)} characters.`,
24
+ `- Titles are exactly ${String(alternativeTitles)} other titles for this video, for YouTube's title A/B test beside the video's own title: each at most ${String(titleMaxCharacters)} characters, one line, different from the video title and from each other.`,
25
+ "",
26
+ "The pinned comment is what the channel pins under the video, unless the instructions below say otherwise: one or two short sentences, at most 200 characters, in the same voice as the summary. Ask one question a viewer can answer from their own experience of the subject, and invite a suggestion for the next video.",
27
+ "The other titles try different angles on the same video (a question, a stake, a surprising fact) while staying true to it and in the video title's language and tone.",
23
28
  ].join("\n"),
24
29
  },
25
30
  {
@@ -42,16 +47,20 @@ const answerSchema = z.object({
42
47
  chapters: z.array(z.object({ start: z.union([z.string(), z.number()]), title: z.string() })),
43
48
  hashtags: z.array(z.string()),
44
49
  tags: z.array(z.string()),
50
+ pinnedComment: z.string(),
51
+ titles: z.array(z.string()),
45
52
  });
46
53
  const fix = "Try again, or choose another model in Edit project → Providers.";
47
54
  // Reads and checks the model's answer. The reason is the sentence the stage shows; the
48
55
  // provider wrapper's `check` asks the model again while attempts remain.
49
- export function checkDescriptionAnswer(text, durationSeconds) {
56
+ export function checkDescriptionAnswer(text, durationSeconds,
57
+ // The video's own title, which the other titles must differ from.
58
+ title = "") {
50
59
  const parsed = answerSchema.safeParse(jsonOf(text));
51
60
  if (!parsed.success)
52
61
  return {
53
62
  ok: false,
54
- reason: `The AI model's YouTube description didn't come back in the expected format (a JSON object with a summary, chapters, hashtags and tags). ${fix}`,
63
+ reason: `The AI model's YouTube description didn't come back in the expected format (a JSON object with a summary, chapters, hashtags, tags, a pinned comment and titles). ${fix}`,
55
64
  };
56
65
  const summary = parsed.data.summary.trim();
57
66
  if (summary === "")
@@ -65,7 +74,25 @@ export function checkDescriptionAnswer(text, durationSeconds) {
65
74
  const tags = checkTags(parsed.data.tags);
66
75
  if (!tags.ok)
67
76
  return tags;
68
- const value = { summary, chapters: chapters.value, hashtags: hashtags.value, tags: tags.value };
77
+ const pinnedComment = parsed.data.pinnedComment.trim();
78
+ if (pinnedComment === "")
79
+ return { ok: false, reason: `The AI model wrote no pinned comment. ${fix}` };
80
+ if (pinnedComment.length > pinnedCommentMaxCharacters)
81
+ return {
82
+ ok: false,
83
+ reason: `The AI model's pinned comment is ${String(pinnedComment.length)} characters, over the ${String(pinnedCommentMaxCharacters)} Slopify allows. ${fix}`,
84
+ };
85
+ const titles = checkTitles(parsed.data.titles, title);
86
+ if (!titles.ok)
87
+ return titles;
88
+ const value = {
89
+ titles: titles.value,
90
+ summary,
91
+ chapters: chapters.value,
92
+ hashtags: hashtags.value,
93
+ tags: tags.value,
94
+ pinnedComment,
95
+ };
69
96
  const description = assembleDescription(value);
70
97
  if (/[<>]/.test(description))
71
98
  return {
@@ -139,6 +166,28 @@ function checkChapters(raw, durationSeconds) {
139
166
  }
140
167
  return { ok: true, value: chapters };
141
168
  }
169
+ function checkTitles(raw, title) {
170
+ const broke = (rule) => ({
171
+ ok: false,
172
+ reason: `The AI model's other titles broke YouTube's rules (${rule}). ${fix}`,
173
+ });
174
+ const titles = raw.map((one) => one.trim().replace(/\s+/g, " ")).filter((one) => one !== "");
175
+ if (titles.length !== alternativeTitles)
176
+ return broke(`there must be ${String(alternativeTitles)} other titles, and it wrote ${String(titles.length)}`);
177
+ const long = titles.find((one) => one.length > titleMaxCharacters);
178
+ if (long !== undefined)
179
+ return broke(`"${long.slice(0, 40)}…" is over ${String(titleMaxCharacters)} characters`);
180
+ const odd = titles.find((one) => /[<>]/.test(one));
181
+ if (odd !== undefined)
182
+ return broke(`"${odd}" contains < or >`);
183
+ const seen = new Set([title.trim().toLowerCase()]);
184
+ for (const one of titles) {
185
+ if (seen.has(one.toLowerCase()))
186
+ return broke(`"${one}" repeats a title`);
187
+ seen.add(one.toLowerCase());
188
+ }
189
+ return { ok: true, value: titles };
190
+ }
142
191
  function checkHashtags(raw) {
143
192
  const broke = (rule) => ({
144
193
  ok: false,
@@ -63,7 +63,8 @@ export function readChannelLinksFor(db, channelId) {
63
63
  return channel.brand.links;
64
64
  return channel.id === defaultChannelId ? readChannelLinks(db) : [];
65
65
  }
66
- // The description and tags as the project page shows and copies them: the user's edits over
66
+ // The description, tags, pinned comment and other titles as the project page shows and copies
67
+ // them: the user's edits over
67
68
  // the generated text, the chapters fitted to YouTube's rules (`chapters.ts`; the last one's
68
69
  // length only checked when the video's length is given), placeholders filled from the
69
70
  // project's and its channel's links (one with no link stays as typed). For anything that hands
@@ -71,7 +72,10 @@ export function readChannelLinksFor(db, channelId) {
71
72
  // fitting changed.
72
73
  export function effectiveDescription(db, projectId, generated) {
73
74
  const edits = readDescriptionEdits(db, projectId);
74
- const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags), edits.fields));
75
+ const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags, {
76
+ pinnedComment: generated.pinnedComment,
77
+ titles: generated.titles,
78
+ }), edits.fields));
75
79
  const fitted = fitChapters(shown.chapters, generated.durationSeconds);
76
80
  const notice = chapterNotice(fitted.adjustments);
77
81
  const links = mergeLinks(readChannelLinksFor(db, projectChannelId(db, projectId)), edits.links);
@@ -79,6 +83,8 @@ export function effectiveDescription(db, projectId, generated) {
79
83
  description: fillPlaceholders(composeDescription({ ...shown, chapters: fitted.text }), links)
80
84
  .text,
81
85
  tags: fillPlaceholders(shown.tags, links).text,
86
+ pinnedComment: fillPlaceholders(shown.pinnedComment, links).text,
87
+ titles: shown.titles,
82
88
  ...(notice === undefined ? {} : { chapterNotice: notice }),
83
89
  };
84
90
  }
@@ -1,14 +1,22 @@
1
1
  // Hand edits to the YouTube description. The project page lets the user edit the summary,
2
- // the chapters, the hashtags and the tags in place; each edit is saved with the generated
2
+ // the chapters, the hashtags, the tags and the pinned comment in place; each edit is saved with the generated
3
3
  // text it was made from (`base`), so when the description is written again the page can tell
4
4
  // a field the user changed from one they left alone. Browser-safe: the project page applies
5
5
  // these rules to what it shows, and the server only stores the edits.
6
6
  import { parseTimestamp } from "./timestamps.js";
7
- export const descriptionFields = ["summary", "chapters", "hashtags", "tags"];
8
- // Splits a written description back into its three parts. `assembleDescription` (answer.ts) lays it out as
7
+ export const descriptionFields = [
8
+ "summary",
9
+ "chapters",
10
+ "hashtags",
11
+ "tags",
12
+ "pinnedComment",
13
+ "titles",
14
+ ];
15
+ // Splits a written description back into its three parts, beside the tags, the pinned comment
16
+ // and the other titles (each empty for a description written before it existed). `assembleDescription` (answer.ts) lays it out as
9
17
  // the summary, a blank line, one chapter per line, a blank line and the hashtags; reading it
10
18
  // from the end keeps a summary with blank lines of its own whole.
11
- export function splitDescription(description, tags) {
19
+ export function splitDescription(description, tags, extras = {}) {
12
20
  const lines = description.replace(/\r\n?/gu, "\n").split("\n");
13
21
  while (lines.length > 0 && lines.at(-1)?.trim() === "")
14
22
  lines.pop();
@@ -29,6 +37,8 @@ export function splitDescription(description, tags) {
29
37
  chapters: chapters.join("\n"),
30
38
  hashtags,
31
39
  tags: tags.trim(),
40
+ pinnedComment: (extras.pinnedComment ?? "").trim(),
41
+ titles: (extras.titles ?? "").trim(),
32
42
  };
33
43
  }
34
44
  function isChapterLine(line) {
@@ -65,6 +75,8 @@ export function resolveFields(generated, edits) {
65
75
  chapters: one("chapters"),
66
76
  hashtags: one("hashtags"),
67
77
  tags: one("tags"),
78
+ pinnedComment: one("pinnedComment"),
79
+ titles: one("titles"),
68
80
  };
69
81
  }
70
82
  export function shownFields(resolved) {
@@ -73,6 +85,8 @@ export function shownFields(resolved) {
73
85
  chapters: resolved.chapters.text,
74
86
  hashtags: resolved.hashtags.text,
75
87
  tags: resolved.tags.text,
88
+ pinnedComment: resolved.pinnedComment.text,
89
+ titles: resolved.titles.text,
76
90
  };
77
91
  }
78
92
  // Saving an edit: text equal to the generated text is no edit at all, so the field follows the
@@ -1,5 +1,6 @@
1
1
  // The optional YouTube description step of the Video stage: a short summary, a chapter list
2
- // YouTube turns into chapters, hashtags, and a separate list for YouTube's Tags field.
2
+ // YouTube turns into chapters, hashtags, a separate list for YouTube's Tags field, a comment
3
+ // to pin under the video, and two more titles for YouTube's title A/B test.
3
4
  // Browser-safe: Play, Edit project and the project page read these names and limits too.
4
5
  // YouTube's own rules for chapters in a description: the first starts at 0:00, there are at
5
6
  // least three, and each lasts at least ten seconds.
@@ -12,6 +13,13 @@ export const tagMaxCharacters = 100;
12
13
  export const hashtagsMax = 15;
13
14
  // YouTube's limit on a description.
14
15
  export const descriptionMaxCharacters = 5000;
16
+ // YouTube's title A/B test ("Test & compare") takes up to three titles: the video's own and
17
+ // these alternatives. YouTube's limit on a title.
18
+ export const alternativeTitles = 2;
19
+ export const titleMaxCharacters = 100;
20
+ // YouTube's limit on a comment is 10,000; a pinned comment people read in full is a line or
21
+ // two. The model is asked for 200; this is the most a hand-picked prompt may make it.
22
+ export const pinnedCommentMaxCharacters = 500;
15
23
  // What a project uses when no Description prompt from the library is picked. No keywords,
16
24
  // so it never asks Play for a field.
17
25
  export const defaultDescriptionPrompt = [
@@ -57,7 +57,7 @@ Sizes are in millimetres (mm) unless marked pt (points; 1 pt is about 0.35 mm).
57
57
 
58
58
  ### Fonts
59
59
 
60
- Each font has three controls: the family, its weight or italic, and **Letter spacing (mm)** from −0.5 to 2. The families are **Cinzel** (bundled; four weights, no italics) and **Times**, **Helvetica** and **Courier** (built into every PDF reader).
60
+ Each font has three controls: the family, its weight or italic, and **Letter spacing (mm)** from −0.5 to 2. The families are **Cinzel** (bundled; four weights, no italics), **Literata** (bundled; a text face made for long reading, in regular, bold, italic and bold italic) and **Times**, **Helvetica** and **Courier** (built into every PDF reader).
61
61
 
62
62
  | Setting | Where it is used | Default |
63
63
  |---|---|---|
@@ -19,7 +19,7 @@ From top to bottom:
19
19
  | Destinations | **Home**, **Projects**, **Calendar** (also lit on Schedules), **Channels**, **Library** (Prompts, Intros & Outros, Templates, Document themes, Narration aliases), **Settings** (also lit on Usage). |
20
20
  | **N running** | Shown while runs are going. It links to Running now on Home. |
21
21
  | **Channel** | The channel picker (see below). |
22
- | **New video** | Opens Play (`C` from anywhere). |
22
+ | **New project** | Opens Play (`C` from anywhere). |
23
23
  | Links | **GitHub**, **Patreon** and **Buy Me a Coffee**, each opening in a new tab. |
24
24
  | Foot | "Free. Your keys, your machine.", the updates button (circular arrows, see [Updating and Patch Notes](Updating-and-Patch-Notes)) and the tutorial button (question mark, **Start interactive tutorial**). |
25
25
 
@@ -33,7 +33,7 @@ In every list, a press anywhere on a row or tile opens or picks it, not only on
33
33
 
34
34
  ### The command palette
35
35
 
36
- Press `Ctrl+K`, or click **Search or run a command**, to jump anywhere or run an action by typing. The **Go to** group has **Open home**, **Open projects**, **Open calendar**, **Open schedules**, **Open channels**, **Open library**, **Open settings**, **Open usage and costs** and **Show patch notes**. **New video** opens Play. The **Channel** group has **Show all channels** and **Switch to <channel>**. See [Keyboard Shortcuts and Command Palette](Keyboard-Shortcuts-and-Command-Palette).
36
+ Press `Ctrl+K`, or click **Search or run a command**, to jump anywhere or run an action by typing. The **Go to** group has **Open home**, **Open projects**, **Open calendar**, **Open schedules**, **Open channels**, **Open library**, **Open settings**, **Open usage and costs** and **Show patch notes**. **New project** opens Play. The **Channel** group has **Show all channels** and **Switch to <channel>**. See [Keyboard Shortcuts and Command Palette](Keyboard-Shortcuts-and-Command-Palette).
37
37
 
38
38
  ### The Channel picker
39
39
 
@@ -43,7 +43,7 @@ The **Channel** picker near the foot of the sidebar (at the top of Home on a pho
43
43
 
44
44
  **Where to find it:** the sidebar → **Home**.
45
45
 
46
- The page title is the channel you picked (or **Home**), under today's date. At the top right are **Open calendar** and **New video**.
46
+ The page title is the channel you picked (or **Home**), under today's date. At the top right are **Open calendar** and **New project**.
47
47
 
48
48
  On a fresh install, Home sends you to the first-run screen once. See [First Launch and Welcome](First-Launch-and-Welcome).
49
49
 
@@ -98,7 +98,7 @@ See [Costs and Run Cost](Costs-and-Run-Cost).
98
98
 
99
99
  **Where to find it:** the sidebar → **Projects**.
100
100
 
101
- Every run ever started, newest first, for the channel picked in the sidebar. The heading says how many projects there are and for which channel. **New video** opens Play.
101
+ Every run ever started, newest first, for the channel picked in the sidebar. The heading says how many projects there are and for which channel. **New project** opens Play.
102
102
 
103
103
  ### Search and filter
104
104
 
@@ -7,7 +7,7 @@ The command palette lets you reach every screen, every project and the frequent
7
7
  ## Use the command palette
8
8
 
9
9
  1. Press `Ctrl+K`.
10
- 2. Type a few letters of what you want, such as `set` for **Open settings** or `back` for **Back up now**. Letters only need to appear in order, so `nwv` finds **New video**. Commands also match other words, for example `keys` finds **Open settings**. Several words match in any order across the command's name, the project it acts on and its other words: `cleopatra regenerate image 3` finds **Regenerate image 3 in Cleopatra**.
10
+ 2. Type a few letters of what you want, such as `set` for **Open settings** or `back` for **Back up now**. Letters only need to appear in order, so `nwv` finds **New project**. Commands also match other words, for example `keys` finds **Open settings**. Several words match in any order across the command's name, the project it acts on and its other words: `cleopatra regenerate image 3` finds **Regenerate image 3 in Cleopatra**.
11
11
  3. Use `Up` and `Down` to move through the list, `Home` and `End` to jump to the first or last, and `Enter` to run the highlighted command. You can also click a command.
12
12
  4. Press `Esc` (or `Ctrl+K` again) to close the palette without running anything.
13
13
 
@@ -23,7 +23,7 @@ Press `?` anywhere (outside a text field) to see **Keyboard shortcuts**: every k
23
23
  |---|---|---|
24
24
  | `Ctrl+K` / `Cmd+K` | Anywhere | Opens or closes the command palette. |
25
25
  | `?` | Anywhere | Shows the keyboard shortcuts. |
26
- | `C` | Anywhere | **New video** (opens Play). |
26
+ | `C` | Anywhere | **New project** (opens Play). |
27
27
  | `G` then `H` | Anywhere | Go to Home. |
28
28
  | `G` then `P` | Anywhere | Go to Projects. |
29
29
  | `G` then `C` | Anywhere | Go to the Calendar. |
@@ -70,7 +70,7 @@ Commands are grouped under headings in the palette. Group names below are the he
70
70
 
71
71
  | Command | What it does |
72
72
  |---|---|
73
- | **New video** (`C`) | Opens Play. |
73
+ | **New project** (`C`) | Opens Play. |
74
74
  | **Show all channels** | Shows every channel's projects, schedules and costs. |
75
75
  | **Switch to <channel>** | Shows one channel only. There is one command per channel. |
76
76