@gentbajko/slopify 3.0.4 → 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 (40) hide show
  1. package/dist/edge/http/projects.js +7 -1
  2. package/dist/edge/http/studio.js +32 -11
  3. package/dist/extension/slopify-studio-chrome.zip +0 -0
  4. package/dist/extension/slopify-studio-firefox.zip +0 -0
  5. package/dist/main.js +1 -1
  6. package/dist/patch-notes/3.0.5.md +30 -0
  7. package/dist/patch-notes/3.0.6.md +34 -0
  8. package/dist/patch-notes/index.json +12 -0
  9. package/dist/slices/rebuild/activity.js +58 -0
  10. package/dist/slices/rebuild/recipe-legacy.js +2 -0
  11. package/dist/slices/rebuild/runtime-youtube.js +15 -4
  12. package/dist/slices/run-cost/panel.js +6 -23
  13. package/dist/slices/run-cost/timing.js +76 -0
  14. package/dist/slices/schedules/agenda.js +1 -1
  15. package/dist/slices/schedules/scheduler.js +3 -2
  16. package/dist/slices/schedules/service.js +9 -7
  17. package/dist/slices/schedules/topics.js +3 -3
  18. package/dist/slices/storage/downloads.js +14 -3
  19. package/dist/slices/storage/layout.js +4 -0
  20. package/dist/slices/storage/model.js +5 -0
  21. package/dist/slices/storage/portable.js +4 -4
  22. package/dist/slices/storage/trim.js +2 -0
  23. package/dist/slices/studio/pack.js +16 -5
  24. package/dist/slices/studio/settings.js +89 -24
  25. package/dist/slices/youtube/answer.js +54 -5
  26. package/dist/slices/youtube/edits-repo.js +8 -2
  27. package/dist/slices/youtube/edits.js +18 -4
  28. package/dist/slices/youtube/model.js +8 -1
  29. package/dist/tutorials/Costs-and-Run-Cost.md +3 -3
  30. package/dist/tutorials/Project-Page.md +1 -1
  31. package/dist/tutorials/Publishing-to-YouTube.md +4 -4
  32. package/dist/tutorials/Settings-Reference.md +3 -3
  33. package/dist/tutorials/Studio-Extension.md +2 -2
  34. package/dist/tutorials/YouTube-Description.md +13 -5
  35. package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
  36. package/dist/web/assets/index-CskH1GRY.js +310 -0
  37. package/dist/web/assets/{pdf-BEwm41kk.js → pdf-C5xctS6p.js} +1 -1
  38. package/dist/web/index.html +2 -2
  39. package/package.json +1 -1
  40. package/dist/web/assets/index-haZKPEuq.js +0 -307
@@ -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: thank the viewer in one line, ask one question a viewer can answer from their own experience of the subject, point to the chapters in the description, and end by inviting a suggestion for the next video. Two to four short paragraphs, in the same voice as the summary.",
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,12 @@ 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 far shorter.
21
+ export const pinnedCommentMaxCharacters = 1500;
15
22
  // What a project uses when no Description prompt from the library is picked. No keywords,
16
23
  // so it never asks Play for a field.
17
24
  export const defaultDescriptionPrompt = [
@@ -49,7 +49,7 @@ When a CLI's plan is used up, the run waits instead of failing: the status reads
49
49
 
50
50
  ## After a run: the run cost line
51
51
 
52
- When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min end to end`. A failed or cancelled run says **Spent so far**, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the full breakdown below. The line doesn't show while the run is going, or when it spent nothing.
52
+ When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`. A failed or cancelled run says **Spent so far**, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the full breakdown below. The line doesn't show while the run is going, or when it spent nothing.
53
53
 
54
54
  ## After a run: the Run cost tab
55
55
 
@@ -65,7 +65,7 @@ If nothing has been spent yet, it says "Nothing has been spent yet. The cost of
65
65
  | **paid to providers** | The total cost of this project's calls. Reads "known cost, paid to providers" when some calls have no price, with a line "Plus N calls the model catalogue has no price for." |
66
66
  | **same work via API** | The API equivalent of the calls that ran on a CLI plan. |
67
67
  | Plan meters | One per plan window a CLI reported, for example "3% of weekly Codex limit". Hover or read the line below for the level it is at now. |
68
- | **end to end** | How long the whole run took. |
68
+ | **this run's working time** | How long some step of the run was working. Waiting on a review, a limit or a pause never counts, and steps that ran side by side count once. Each save that remakes something starts a new run. |
69
69
 
70
70
  Lines under the summary:
71
71
 
@@ -89,7 +89,7 @@ Lines under the summary:
89
89
 
90
90
  **Cost by model**: the same spending split by provider and model, with **Cost**, **Via API**, **Usage** and **Calls**. A CLI model shows the API model it was priced as, in brackets after "as". The CLI's own default model shows as "default model".
91
91
 
92
- The last lines give the total usage and stage time, and "Priced from the model catalogue of <date> when each call finished."
92
+ The last lines give the total usage and the working time over every run, and "Priced from the model catalogue of <date> when each call finished."
93
93
 
94
94
  ## Across all projects: Home → This week
95
95
 
@@ -20,7 +20,7 @@ The page opens where the next action points: the failed step, the held review or
20
20
 
21
21
  ### The run cost line
22
22
 
23
- When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min end to end`. A run that ended early says **Spent so far** instead, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the **Cost** section. The line is hidden while the run is going and when it spent nothing. See [Costs and Run Cost](Costs-and-Run-Cost).
23
+ When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`: only the time Slopify was working, never the time waiting on you, a review or a limit. A run that ended early says **Spent so far** instead, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the **Cost** section. The line is hidden while the run is going and when it spent nothing. While the run is going, a clock sits there instead: `Working for 9 min 30 s`, ticking while a step runs; while it is paused or waits on you it holds still and reads `9 min 30 s of work so far`. See [Costs and Run Cost](Costs-and-Run-Cost).
24
24
 
25
25
  ### Time left
26
26
 
@@ -2,11 +2,11 @@
2
2
 
3
3
  Slopify never uploads or publishes for you: there is no YouTube sign-in and no upload API. Instead, **Prepare upload** lists everything YouTube Studio asks for, in the order it asks, with a Copy button for each value, for the long video and for every short. You upload in Studio in your own browser and press Publish yourself. The optional [Studio-Extension](Studio-Extension) can fill Studio's upload dialog from the same list.
4
4
 
5
- **Where to find it:** a finished project → **Prepare upload** (the next action, and in the **YouTube** section), Home → **Ready to upload** → **Prepare upload**, or the Calendar's **Needs you** → **Prepare upload**. Settings → **YouTube Studio** holds the playlist, and each channel's **Brand** tab holds the links it uses.
5
+ **Where to find it:** a finished project → **Prepare upload** (the next action, and in the **YouTube** section), Home → **Ready to upload** → **Prepare upload**, or the Calendar's **Needs you** → **Prepare upload**. Settings → **YouTube Studio** holds the playlists, and each channel's **Brand** tab holds the links it uses.
6
6
 
7
7
  ## Before your first upload
8
8
 
9
- 1. Open **Settings → YouTube Studio** and type your **Playlist** name exactly as it is called in Studio (up to 150 characters), then press **Save**. It is the default for every channel; pick a channel in the list above the field to give that channel its own playlist. Leave it empty for no playlist.
9
+ 1. Open **Settings → YouTube Studio**, press **Add playlist** and type each playlist exactly as it is called in Studio (up to 150 characters). Tick **On by default** for the ones every video goes into, then press **Save playlists**. The list is the default for every channel; pick a channel above it to give that channel its own list.
10
10
  2. Open **Channels** → your channel → **Brand** and, under **Channel links**, add any links your descriptions use, such as `Patreon` or `Discord`. See [Channels](Channels#channel-links).
11
11
  3. In **Settings → Voices**, turn on **Real person** for any voice cloned from, or made to sound like, a real person.
12
12
  4. In **Library → Prompts**, turn on **Draws photorealistic pictures** for any Image prompt whose style looks like real photos or film.
@@ -27,7 +27,7 @@ Steps 3 and 4 decide the AI use answer; see [AI use disclosure](#ai-use-disclosu
27
27
  | **Title** | The project's title (up to 100 characters), or the short's title. **Copy**. |
28
28
  | **Description** | The description with chapters and hashtags, links filled in. For a short: its description, the "Watch the full video:" line and its hashtags. **Copy**. |
29
29
  | **Thumbnail** | The thumbnail file, with **Open folder**. With three thumbnails: "The first under Thumbnail; all 3 in A/B Testing (beside the title)." Shorts have no thumbnail step, since Studio shows a frame of a short. |
30
- | **Playlist** | The project's channel's playlist from Settings → YouTube Studio (or the default one), or "None set". **Copy**. |
30
+ | **Playlist** | The project's channel's playlists from Settings → YouTube Studio (or the default list), or "None set". With more than one, each has a tick: tick the ones this project goes into (saved for this project; the others keep the defaults). **Copy**. |
31
31
  | **Audience** | "No, it's not made for kids". |
32
32
  | **AI use (under Show more)** | **Yes** or **No**, with the reason. |
33
33
  | **Tags (under Show more)** | The tags, comma-separated. For a short, its hashtags without the `#`. **Copy**. |
@@ -119,7 +119,7 @@ Slopify does not check YouTube, so it can't know when you've published. After yo
119
119
 
120
120
  ## Tips
121
121
 
122
- - Set the playlist once in Settings (per channel if your channels upload to different playlists); every upload pack names it.
122
+ - Set the playlists once in Settings (per channel if your channels upload to different playlists), and tick the few a single video joins in its Prepare upload.
123
123
  - Upload the long video first, then paste its link as the shorts' **Full video link** so each short's description points to it.
124
124
  - If the AI use answer surprises you, read its reason: it names the voice, footage or prompt that made it Yes.
125
125
 
@@ -15,7 +15,7 @@ Every section of Settings, what each control does and its default. Sections with
15
15
  | [Playback & appearance](#playback--appearance) | Silence gap, light or dark, volume levelling | This page |
16
16
  | [Notifications](#notifications) | Browser and phone notifications | [Notifications](Notifications) |
17
17
  | [Channel links](#channel-links) | A pointer to each channel's own links | [Channels](Channels#channel-links) |
18
- | [YouTube Studio](#youtube-studio) | Playlist per channel, the Studio extension's download and pairing | [Studio-Extension](Studio-Extension) |
18
+ | [YouTube Studio](#youtube-studio) | Playlists per channel, the Studio extension's download and pairing | [Studio-Extension](Studio-Extension) |
19
19
  | [Backup & storage](#backup--storage) | Your files folder, export and import, disk space, samples | [Where-Your-Files-Live](Where-Your-Files-Live) |
20
20
  | [Backups](#backups) | The daily automatic backup | [Backups](Backups) |
21
21
  | [Trash](#trash) | Deleted items, kept 30 days | [Trash](Trash) |
@@ -140,12 +140,12 @@ Each channel now keeps its own links, on its **Brand** tab under **Channel links
140
140
 
141
141
  ## YouTube Studio
142
142
 
143
- "The playlist upload packs name, and the Studio extension's pairing."
143
+ "The playlists upload packs offer, and the Studio extension's pairing."
144
144
 
145
145
  | Control | What it does | Default / limit |
146
146
  |---|---|---|
147
147
  | **Upload pack and extension** | Explains that Slopify never uploads or publishes: **Prepare upload** on a finished project lists everything Studio asks for, and the optional extension fills Studio's upload dialog from it. | |
148
- | **Playlist** | The YouTube playlist upload packs name, typed exactly as it is called in Studio. Pick **Every channel (default)** or one channel first: the default is used by every channel without its own. **Save** stores it. Empty means no playlist (or, for a channel, the default). | Empty; up to 150 characters |
148
+ | **Playlists** | The YouTube playlists upload packs offer, each typed exactly as it is called in Studio. Pick **Every channel (default)** or one channel first: the default list is used by every channel without its own. **Add playlist** adds a row; **On by default** puts every project into it; **Remove** drops it; **Save playlists** stores the list. A project's **Prepare upload** changes which ones it goes into. | None; up to 20, each up to 150 characters |
149
149
  | **Extension pairing token** | A secret the Slopify Studio extension needs before it may read your upload packs. **Copy** it into the extension's options and press Pair there. | |
150
150
  | **New pairing token** | Makes a fresh token, unpairs the extension until you paste the new one, and clears the uploads waiting for Studio. | |
151
151
  | **Install the Studio extension** | **Chrome, Edge, Brave** or **Firefox**, then **Download for Chrome** or **Download for Firefox**, with the three install steps. | |
@@ -58,7 +58,7 @@ The section is headed **Upload pack and extension**.
58
58
 
59
59
  | Option | What it does | Default / range |
60
60
  |---|---|---|
61
- | **Playlist** | The YouTube playlist upload packs name, so the extension ticks it in Studio's dialog. Pick **Every channel (default)** or one channel in the channel list, type the playlist exactly as it is called in Studio, and press **Save**. The default is used by every channel without its own; a channel's own playlist wins for its videos. Leave it empty for no playlist. | Empty; up to 150 characters |
61
+ | **Playlists** | The YouTube playlists upload packs offer, so the extension ticks them in Studio's dialog. Pick **Every channel (default)** or one channel, **Add playlist** for each, type it exactly as it is called in Studio, tick **On by default** for the ones every video joins, and press **Save playlists**. A channel's own list wins for its videos; Prepare upload changes the ticks for one project. | None; up to 20, each up to 150 characters |
62
62
  | **Extension pairing token** | The secret the extension needs before it may read your upload packs. **Copy** copies it; **New pairing token** makes a new one and unpairs the extension. | Made for you |
63
63
  | **Install the Studio extension** | The download and install steps (see [Get the extension](#get-the-extension)). | |
64
64
 
@@ -89,7 +89,7 @@ Each press of **Fill in YouTube Studio** adds the item to **Waiting for Studio**
89
89
  | **Title** | Types the title (up to 100 characters for the video, the short's own title for a short). |
90
90
  | **Description** | Types the description line by line, so line breaks are kept. |
91
91
  | **Thumbnail** | Sets the first thumbnail. With three thumbnails, it looks for Studio's **A/B Testing** button beside the title and puts all three in. If the button isn't there or its dialog can't be filled, it sets the first and asks you to add the others in A/B Testing by hand. Shorts get no thumbnail. |
92
- | **Playlists** | Opens the dropdown, ticks the playlist named in Settings → YouTube Studio, and closes it. |
92
+ | **Playlists** | Opens the dropdown, ticks every playlist the project goes into (Settings → YouTube Studio, and the project's own ticks in Prepare upload), and closes it. |
93
93
  | **Audience** | Picks "No, it's not made for kids". |
94
94
  | **Show more** | Presses it once, to reach the advanced settings. |
95
95
  | **AI use** | Picks Yes or No, as Prepare upload worked it out. See [AI use disclosure](Publishing-to-YouTube#ai-use-disclosure). |