@gentbajko/slopify 3.9.1 → 3.10.1

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 (86) hide show
  1. package/dist/catalog/model-info.js +33 -0
  2. package/dist/edge/http/app.js +2 -0
  3. package/dist/edge/http/channels.js +6 -1
  4. package/dist/edge/http/document-themes.js +2 -2
  5. package/dist/edge/http/files.js +6 -2
  6. package/dist/edge/http/outputs.js +67 -0
  7. package/dist/edge/http/planning.js +18 -3
  8. package/dist/edge/http/projects.js +7 -4
  9. package/dist/edge/http/providers.js +4 -6
  10. package/dist/edge/http/revision-files.js +25 -7
  11. package/dist/edge/http/schedules.js +30 -0
  12. package/dist/edge/http/trash.js +51 -2
  13. package/dist/extension/slopify-studio-chrome.zip +0 -0
  14. package/dist/extension/slopify-studio-firefox.zip +0 -0
  15. package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
  16. package/dist/patch-notes/3.10.0.md +79 -0
  17. package/dist/patch-notes/3.10.1.md +18 -0
  18. package/dist/patch-notes/index.json +12 -0
  19. package/dist/slices/admission/heads.js +59 -0
  20. package/dist/slices/admission/rules.js +6 -1
  21. package/dist/slices/batch/index.js +22 -0
  22. package/dist/slices/channels/rebrand.js +7 -1
  23. package/dist/slices/channels/repo.js +2 -1
  24. package/dist/slices/channels/schema.js +5 -0
  25. package/dist/slices/channels/service.js +23 -3
  26. package/dist/slices/estimate/index.js +2 -0
  27. package/dist/slices/library/transfer.js +164 -0
  28. package/dist/slices/narration/aliases-table.js +82 -0
  29. package/dist/slices/outputs/add.js +192 -0
  30. package/dist/slices/outputs/model.js +36 -0
  31. package/dist/slices/outputs/service.js +136 -0
  32. package/dist/slices/rebuild/model.js +1 -1
  33. package/dist/slices/rebuild/recipe-text.js +9 -4
  34. package/dist/slices/rebuild/recipe-visual.js +6 -0
  35. package/dist/slices/rebuild/runtime-local.js +10 -3
  36. package/dist/slices/rebuild/runtime-provider.js +7 -0
  37. package/dist/slices/revisions/downloads.js +38 -17
  38. package/dist/slices/revisions/model.js +3 -1
  39. package/dist/slices/revisions/mutation-assets.js +29 -2
  40. package/dist/slices/revisions/mutation-prepare.js +44 -9
  41. package/dist/slices/revisions/schema.js +4 -0
  42. package/dist/slices/schedules/agenda.js +21 -16
  43. package/dist/slices/schedules/held-bulk.js +66 -0
  44. package/dist/slices/schedules/topics.js +1 -1
  45. package/dist/slices/storage/backup-import.js +8 -4
  46. package/dist/slices/storage/downloads.js +42 -4
  47. package/dist/slices/studio/backfill.js +8 -1
  48. package/dist/slices/voices/adaptation.js +19 -0
  49. package/dist/slices/voices/model.js +4 -2
  50. package/dist/slices/youtube/model.js +33 -2
  51. package/dist/tutorials/AI-CLIs.md +1 -1
  52. package/dist/tutorials/Backups.md +5 -5
  53. package/dist/tutorials/Calendar.md +2 -2
  54. package/dist/tutorials/Channels.md +1 -1
  55. package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
  56. package/dist/tutorials/Document-Themes.md +2 -2
  57. package/dist/tutorials/Editing-a-Project.md +2 -2
  58. package/dist/tutorials/FAQ.md +1 -1
  59. package/dist/tutorials/Home-and-Projects.md +33 -22
  60. package/dist/tutorials/Intros-and-Outros.md +4 -4
  61. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
  62. package/dist/tutorials/Library-Overview.md +16 -15
  63. package/dist/tutorials/Multiple-Voices.md +3 -3
  64. package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
  65. package/dist/tutorials/PDF-Documents.md +5 -5
  66. package/dist/tutorials/Play-Narration.md +3 -3
  67. package/dist/tutorials/Play-Outputs.md +1 -1
  68. package/dist/tutorials/Play-Overview.md +4 -4
  69. package/dist/tutorials/Play-Title-and-Article.md +3 -3
  70. package/dist/tutorials/Project-Page.md +7 -6
  71. package/dist/tutorials/Prompts.md +1 -1
  72. package/dist/tutorials/Publishing-to-YouTube.md +2 -2
  73. package/dist/tutorials/Recovery-and-Retries.md +1 -1
  74. package/dist/tutorials/Schedules.md +1 -1
  75. package/dist/tutorials/Settings-Reference.md +35 -31
  76. package/dist/tutorials/Templates.md +1 -1
  77. package/dist/tutorials/Trash.md +5 -5
  78. package/dist/tutorials/Troubleshooting.md +2 -2
  79. package/dist/tutorials/Where-Your-Files-Live.md +4 -4
  80. package/dist/web/assets/index-CGwlNEwD.js +325 -0
  81. package/dist/web/assets/index-DmPZKDb1.css +1 -0
  82. package/dist/web/assets/{pdf-D04vvdOD.js → pdf-3nEY4HYq.js} +1 -1
  83. package/dist/web/index.html +2 -2
  84. package/package.json +1 -1
  85. package/dist/web/assets/index-IWb_SctN.js +0 -310
  86. package/dist/web/assets/index-_D68Tk8q.css +0 -1
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { derive } from "../../kernel/runner/graph.js";
3
- import { liveProject, projectPaused, stageStandingsByProject } from "../admission/repo.js";
3
+ import { liveProject, stageStandingsByProject } from "../admission/repo.js";
4
4
  import { queueEntries } from "../batch/index.js";
5
5
  import { sampleIds } from "../onboarding/model.js";
6
6
  import { readSampleRecord } from "../onboarding/state.js";
@@ -102,7 +102,9 @@ function upcomingRuns(deps, schedule, now, from, to) {
102
102
  const projectRow = z.object({
103
103
  id: z.string(),
104
104
  title: z.string(),
105
- config: z.string(),
105
+ // The stored `sources.video`, null when absent or the configuration is unreadable.
106
+ video_source: z.unknown(),
107
+ paused: z.number(),
106
108
  created_at: z.string(),
107
109
  finished_at: z.string().nullable(),
108
110
  });
@@ -125,15 +127,8 @@ function awaitingReview(db) {
125
127
  }
126
128
  // Whether a finished project's settings make a video: projects stored before sources were
127
129
  // recorded made one.
128
- const videoSource = z.object({ sources: z.object({ video: z.string() }).partial() }).partial();
129
- function makesVideo(config) {
130
- try {
131
- const parsed = videoSource.safeParse(JSON.parse(config));
132
- return !parsed.success || parsed.data.sources?.video !== "off";
133
- }
134
- catch {
135
- return true;
136
- }
130
+ function makesVideo(videoSource) {
131
+ return videoSource !== "off";
137
132
  }
138
133
  function sampleProjectIds(db) {
139
134
  return new Set(sampleIds.flatMap((id) => {
@@ -169,13 +164,23 @@ function projectsIn(deps, from, to) {
169
164
  fromSchedule.set(parsed.project_id, parsed.schedule_id);
170
165
  }
171
166
  return deps.db
172
- .prepare(`SELECT projects.id,projects.title,projects.config,projects.created_at,max(stages.finished_at) AS finished_at
167
+ .prepare(
168
+ // A project with no stage pending or running, not paused and with stages is finished
169
+ // (`derive`), so one that ended before the range is left out here rather than read.
170
+ `SELECT projects.id,projects.title,projects.created_at,
171
+ CASE WHEN json_valid(projects.config) THEN json_extract(projects.config,'$.sources.video') END AS video_source,
172
+ COALESCE(project_controls.paused,0) AS paused,max(stages.finished_at) AS finished_at
173
173
  FROM projects LEFT JOIN stages ON stages.project_id=projects.id
174
- WHERE projects.created_at<? AND ${liveProject()} GROUP BY projects.id ORDER BY projects.created_at,projects.id`)
175
- .all(to.toISOString())
174
+ LEFT JOIN project_controls ON project_controls.project_id=projects.id
175
+ WHERE projects.created_at<? AND ${liveProject()} GROUP BY projects.id
176
+ HAVING COALESCE(project_controls.paused,0)=1 OR count(stages.id)=0
177
+ OR sum(stages.state IN ('pending','running'))>0
178
+ OR COALESCE(max(stages.finished_at),projects.created_at)>=?
179
+ ORDER BY projects.created_at,projects.id`)
180
+ .all(to.toISOString(), from.toISOString())
176
181
  .flatMap((row) => {
177
182
  const project = projectRow.parse(row);
178
- const state = derive(standings.get(project.id) ?? [], projectPaused(deps.db, project.id));
183
+ const state = derive(standings.get(project.id) ?? [], project.paused === 1);
179
184
  const terminal = state === "done" || state === "partial" || state === "failed" || state === "canceled";
180
185
  if (terminal) {
181
186
  const ended = project.finished_at ?? project.created_at;
@@ -184,7 +189,7 @@ function projectsIn(deps, from, to) {
184
189
  }
185
190
  const needs = needsOf(state, reviewing.has(project.id));
186
191
  const ready = (state === "done" || state === "partial") &&
187
- makesVideo(project.config) &&
192
+ makesVideo(project.video_source) &&
188
193
  !uploads.has(project.id) &&
189
194
  !samples.has(project.id);
190
195
  const waiting = waits.get(project.id);
@@ -0,0 +1,66 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { scheduleById } from "./repo.js";
3
+ import { writeHeldValues } from "./topics.js";
4
+ // Held topics decided several at a time: Reject selected (or Reject all, as the ids shown),
5
+ // and its Undo, which puts turned-down topics back to wait with the keywords they had.
6
+ function liveSchedule(deps, scheduleId) {
7
+ const schedule = scheduleById(deps.db, scheduleId);
8
+ if (schedule === undefined || schedule.deletedAt !== null)
9
+ return { ok: false, reason: "not-found" };
10
+ if (schedule.status === "canceled" || schedule.status === "completed")
11
+ return { ok: false, reason: "conflict" };
12
+ return { ok: true, value: schedule };
13
+ }
14
+ // Every id must still be waiting, or nothing is turned down: the list on screen was stale.
15
+ export function rejectHeldTopics(deps, scheduleId, ids) {
16
+ const unique = [...new Set(ids)];
17
+ if (unique.length === 0)
18
+ return { ok: false, reason: "invalid-input" };
19
+ return batch(deps, () => {
20
+ const open = liveSchedule(deps, scheduleId);
21
+ if (!open.ok)
22
+ return open;
23
+ const at = deps.clock.now().toISOString();
24
+ const reject = deps.db.prepare(`UPDATE schedule_topics SET state='rejected',decided_at=?
25
+ WHERE id=? AND schedule_id=? AND state='held'`);
26
+ for (const id of unique)
27
+ if (Number(reject.run(at, id, scheduleId).changes) !== 1)
28
+ throw new TopicGone();
29
+ writeHeldValues(deps.db);
30
+ return { ok: true, value: scheduleById(deps.db, scheduleId) };
31
+ });
32
+ }
33
+ // Turned-down topics wait again, each with the keywords it had (`values`, from the list the
34
+ // person saw), in its old place.
35
+ export function restoreRejectedTopics(deps, scheduleId, topics) {
36
+ if (topics.length === 0)
37
+ return { ok: false, reason: "invalid-input" };
38
+ return batch(deps, () => {
39
+ const open = liveSchedule(deps, scheduleId);
40
+ if (!open.ok)
41
+ return open;
42
+ const restore = deps.db.prepare(`UPDATE schedule_topics SET state='held',decided_at=NULL
43
+ WHERE id=? AND schedule_id=? AND state='rejected'`);
44
+ for (const topic of topics)
45
+ if (Number(restore.run(topic.id, scheduleId).changes) !== 1)
46
+ throw new TopicGone();
47
+ writeHeldValues(deps.db, (stored) => ({
48
+ ...stored,
49
+ ...Object.fromEntries(topics.flatMap((topic) => topic.values === undefined ? [] : [[topic.id, { ...topic.values }]])),
50
+ }));
51
+ return { ok: true, value: scheduleById(deps.db, scheduleId) };
52
+ });
53
+ }
54
+ // Thrown inside the transaction so a half-done batch rolls back.
55
+ class TopicGone extends Error {
56
+ }
57
+ function batch(deps, run) {
58
+ try {
59
+ return transact(deps.db, run);
60
+ }
61
+ catch (error) {
62
+ if (error instanceof TopicGone)
63
+ return { ok: false, reason: "topic-not-found" };
64
+ throw error;
65
+ }
66
+ }
@@ -299,7 +299,7 @@ function readHeldValues(db) {
299
299
  }
300
300
  // Entries of topics that are no longer held (approved, turned down, or gone with their
301
301
  // schedule) are dropped on every write.
302
- function writeHeldValues(db, change = (values) => values) {
302
+ export function writeHeldValues(db, change = (values) => values) {
303
303
  const held = new Set(db
304
304
  .prepare("SELECT id FROM schedule_topics WHERE state='held'")
305
305
  .all()
@@ -433,7 +433,7 @@ function checkScratch(deps, scratch, library, parts) {
433
433
  }
434
434
  }
435
435
  catch {
436
- throw unreadable("a document theme");
436
+ throw unreadable("a PDF theme");
437
437
  }
438
438
  try {
439
439
  for (const row of rowsOf(scratch, "SELECT id FROM channels")) {
@@ -489,7 +489,7 @@ function projectConflict(deps, scratch, id) {
489
489
  if (deps.db.prepare("SELECT 1 FROM projects WHERE id=?").get(id) !== undefined)
490
490
  return "It is already in this install.";
491
491
  if (existsSync(projectDir(deps.paths, id)))
492
- return "Its folder is already in the projects folder without a project. Press Clean orphan files in Settings → Backup & storage, then import again.";
492
+ return "Its folder is already in the projects folder without a project. Press Clear leftover files in Settings → Backup & storage, then import again.";
493
493
  for (const table of projectTables)
494
494
  for (const row of projectRows(scratch, table, id))
495
495
  if (typeof row.id === "string" &&
@@ -854,11 +854,15 @@ function mergeChannels(db, scratch, work, images, now) {
854
854
  db.prepare("INSERT INTO image_blobs(sha256,mime,bytes,created_at) VALUES (?,?,?,?) ON CONFLICT(sha256) DO NOTHING").run(image.sha256, image.mime, readFileSync(path), now);
855
855
  }
856
856
  const cast = { added: 0, renamed: 0, skipped: 0 };
857
- for (const row of rowsOf(scratch, "SELECT * FROM cast_members ORDER BY rowid")) {
857
+ // In the backup's order (an older backup was given its alphabetical order by migration 0052),
858
+ // after any members the channel here already has.
859
+ const lastPlace = db.prepare("SELECT coalesce(max(position)+1,0) AS next FROM cast_members WHERE channel_id=?");
860
+ for (const row of rowsOf(scratch, "SELECT * FROM cast_members ORDER BY channel_id, position, lower(name), id")) {
858
861
  if (has("SELECT 1 FROM cast_members WHERE id=?", String(row.id)))
859
862
  cast.skipped += 1;
860
863
  else {
861
- insertRow(db, "cast_members", row);
864
+ const next = lastPlace.get(String(row.channel_id))?.next;
865
+ insertRow(db, "cast_members", { ...row, position: typeof next === "number" ? next : 0 });
862
866
  cast.added += 1;
863
867
  }
864
868
  }
@@ -44,6 +44,38 @@ export { assetOf };
44
44
  export function downloadName(slug, output) {
45
45
  return `${slug}-${assetOf(output)}${extname(output.path)}`;
46
46
  }
47
+ // Which version of its place a file is: 1 for the first file ever saved there, 2 for the one
48
+ // that replaced it, and so on. Retained files keep their number across later versions of the
49
+ // project, so the same file always downloads under the same name.
50
+ export function fileVersion(deps, projectId, slot, assetId) {
51
+ const assets = deps.db
52
+ .prepare(`SELECT o.asset_id AS id, MIN(a.created_at) AS made FROM revision_outputs o
53
+ JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
54
+ WHERE o.project_id=? AND o.slot=? GROUP BY o.asset_id ORDER BY made, o.asset_id`)
55
+ .all(projectId, slot)
56
+ .map((row) => String(row.id));
57
+ return Math.max(1, assets.indexOf(assetId) + 1);
58
+ }
59
+ // "tides-image-3.png" for a first version, "tides-image-3-v2.png" once it was remade, so a
60
+ // newer download never silently takes an older one's name.
61
+ export function versionedName(name, version) {
62
+ if (version <= 1)
63
+ return name;
64
+ const extension = extname(name);
65
+ return `${name.slice(0, name.length - extension.length)}-v${String(version)}${extension}`;
66
+ }
67
+ // The version of the file a project's current output points at, so the current download and
68
+ // History name the same file the same way.
69
+ function currentVersion(deps, projectId, path) {
70
+ const row = deps.db
71
+ .prepare(`SELECT o.slot, o.asset_id FROM revision_outputs o
72
+ JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
73
+ WHERE o.project_id=? AND a.path=? LIMIT 1`)
74
+ .get(projectId, path);
75
+ return row === undefined
76
+ ? 1
77
+ : fileVersion(deps, projectId, String(row.slot), String(row.asset_id));
78
+ }
47
79
  export function findDownload(deps, projectId, asset) {
48
80
  const title = projectTitle(deps.db, projectId);
49
81
  if (title === undefined) {
@@ -68,7 +100,7 @@ export function findDownload(deps, projectId, asset) {
68
100
  ok: true,
69
101
  download: {
70
102
  path,
71
- filename: downloadName(slugOf(title), output),
103
+ filename: versionedName(downloadName(slugOf(title), output), currentVersion(deps, projectId, output.path)),
72
104
  bytes: shown?.byteLength ?? stats,
73
105
  contentType: contentTypeOf(path),
74
106
  ...(shown === undefined ? {} : { text: shown }),
@@ -111,7 +143,8 @@ function shownYoutubeText(deps, projectId, role) {
111
143
  return new TextEncoder().encode(`${text}\n`);
112
144
  }
113
145
  // "download all" images as `<title-slug>-images.zip`, thumbnail included.
114
- export function imagesZip(deps, projectId) {
146
+ // `set`: every picture (Images' Download all), the thumbnails alone, or every rendered short.
147
+ export function imagesZip(deps, projectId, set = "images") {
115
148
  const title = projectTitle(deps.db, projectId);
116
149
  if (title === undefined) {
117
150
  return { ok: false, reason: "unknown-project" };
@@ -119,7 +152,12 @@ export function imagesZip(deps, projectId) {
119
152
  const slug = slugOf(title);
120
153
  const entries = {};
121
154
  for (const output of outputsOf(deps.db, projectId)) {
122
- if (output.role !== "image" && output.role !== "thumbnail") {
155
+ const member = set === "images"
156
+ ? output.role === "image" || output.role === "thumbnail"
157
+ : set === "thumbnails"
158
+ ? output.role === "thumbnail"
159
+ : output.role === "short_video";
160
+ if (!member) {
123
161
  continue;
124
162
  }
125
163
  const path = outputPath(deps.paths, projectId, output.path);
@@ -138,7 +176,7 @@ export function imagesZip(deps, projectId) {
138
176
  if (Object.keys(entries).length === 0) {
139
177
  return { ok: false, reason: "no-images" };
140
178
  }
141
- return { ok: true, filename: `${slug}-images.zip`, bytes: zipSync(entries) };
179
+ return { ok: true, filename: `${slug}-${set}.zip`, bytes: zipSync(entries) };
142
180
  }
143
181
  function sizeOf(path) {
144
182
  const stats = statSync(path, { throwIfNoEntry: false });
@@ -8,6 +8,12 @@ export function backfillVideos(deps, rows, at) {
8
8
  byTitle.set(norm(row.title), row.videoId);
9
9
  if (byTitle.size === 0)
10
10
  return 0;
11
+ // A video already linked to an upload is never claimed by another: two projects can have a
12
+ // short with the same title (a remake of a topic), and only one of them made that video.
13
+ const taken = new Set(deps.db
14
+ .prepare("SELECT video_id FROM youtube_videos")
15
+ .all()
16
+ .map((row) => String(row.video_id)));
11
17
  let found = 0;
12
18
  for (const project of listProjects(deps.db)) {
13
19
  const known = projectVideos(deps.db, project.id).filter((one) => one.uploadState === "done");
@@ -20,9 +26,10 @@ export function backfillVideos(deps, rows, at) {
20
26
  continue;
21
27
  const titles = item.pickable?.titles ?? [item.title, ...item.titles];
22
28
  const videoId = titles.map((one) => byTitle.get(norm(one))).find((one) => one !== undefined);
23
- if (videoId === undefined)
29
+ if (videoId === undefined || taken.has(videoId))
24
30
  continue;
25
31
  recordVideo(deps.db, project.id, short, videoId, at);
32
+ taken.add(videoId);
26
33
  found += 1;
27
34
  }
28
35
  }
@@ -0,0 +1,19 @@
1
+ import { scriptContract } from "./script.js";
2
+ // "Adapt into a conversation": the text model rewrites an existing article as speaker turns in
3
+ // the chosen format (a podcast's hosts talking it through, an interview). The article itself is
4
+ // left as written; the conversation is the narration's script, kept beside it. Unlike the
5
+ // audiobook's speaker split this is a rewrite, so the words are not held to the source - only
6
+ // its facts and order are asked for - and the script is checked as a script before it is used.
7
+ export function adaptationMessages(source, format, speakers) {
8
+ return [
9
+ {
10
+ role: "system",
11
+ content: [
12
+ scriptContract(format, speakers),
13
+ "Turn the text you are given into this conversation. Keep its facts, names and order, and say nothing the text does not support.",
14
+ "Keep the text's own chapter headings as the section headings.",
15
+ ].join("\n"),
16
+ },
17
+ { role: "user", content: source },
18
+ ];
19
+ }
@@ -12,8 +12,10 @@ export const voiceFormatLabels = {
12
12
  export const speakerRoles = ["narrator", "host", "guest", "character"];
13
13
  // Where the script comes from. `script` asks the text model for speaker turns with a Script
14
14
  // prompt; `attribute` (audiobooks) writes or takes the article as usual and has the text model
15
- // hand its dialogue to the speakers.
16
- export const scriptSources = ["script", "attribute"];
15
+ // hand its dialogue to the speakers; `adapt` writes or takes the article as usual and has the
16
+ // text model rewrite it as a conversation between the speakers (a podcast of an article). With
17
+ // `attribute` and `adapt` the article stays as written and the spoken script is kept beside it.
18
+ export const scriptSources = ["script", "attribute", "adapt"];
17
19
  export const speakersMax = 10;
18
20
  export const speakerNameMax = 40;
19
21
  export const paceSteps = [0.8, 0.9, 1, 1.1, 1.2];
@@ -9,8 +9,10 @@ export const minChapterSeconds = 10;
9
9
  // YouTube's limits on the Tags field: 500 characters in all, 100 for one tag.
10
10
  export const tagsMaxCharacters = 500;
11
11
  export const tagMaxCharacters = 100;
12
- // YouTube ignores every hashtag of a description that has more than 15.
13
- export const hashtagsMax = 15;
12
+ // YouTube ignores every hashtag of a description that has more than 60 (YouTube Help,
13
+ // "Use hashtags for YouTube videos"); the first three show above the title.
14
+ export const hashtagsMax = 60;
15
+ export const hashtagsShownByTitle = 3;
14
16
  // YouTube's limit on a description.
15
17
  export const descriptionMaxCharacters = 5000;
16
18
  // YouTube's title A/B test ("Test & compare") takes up to three titles: the video's own and
@@ -33,3 +35,32 @@ export const defaultDescriptionPrompt = [
33
35
  // The name Play, Edit project and the review summary show for the prompt used when none is
34
36
  // picked.
35
37
  export const defaultDescriptionPromptName = "Built-in";
38
+ // YouTube's API refuses < and > in a title or a description.
39
+ const forbidden = /[<>]/;
40
+ // Why YouTube would refuse a title as typed, or undefined when it takes it: at most 100
41
+ // characters, no < or >. The project's own title is not this; only the title sent to YouTube.
42
+ export function youtubeTitleProblem(title) {
43
+ const trimmed = title.trim();
44
+ if (trimmed.length > titleMaxCharacters)
45
+ return `YouTube takes titles of up to ${String(titleMaxCharacters)} characters; this one has ${String(trimmed.length)}. Shorten it.`;
46
+ if (forbidden.test(trimmed))
47
+ return "YouTube doesn't allow < or > in a title. Remove them.";
48
+ return undefined;
49
+ }
50
+ // The same for a description: at most 5,000 characters, no < or >.
51
+ export function youtubeDescriptionProblem(description) {
52
+ if (description.length > descriptionMaxCharacters)
53
+ return `YouTube takes descriptions of up to ${descriptionMaxCharacters.toLocaleString("en-US")} characters; this one has ${description.length.toLocaleString("en-US")}. Shorten it.`;
54
+ if (forbidden.test(description))
55
+ return "YouTube doesn't allow < or > in a description. Remove them.";
56
+ return undefined;
57
+ }
58
+ // The hashtags of a hashtag line, and a warning when YouTube would ignore them all.
59
+ export function hashtagNote(line) {
60
+ const count = line.split(/\s+/u).filter((word) => word.startsWith("#") && word.length > 1).length;
61
+ if (count === 0)
62
+ return undefined;
63
+ if (count > hashtagsMax)
64
+ return `${String(count)} hashtags: YouTube ignores every hashtag when there are more than ${String(hashtagsMax)}. Remove ${String(count - hashtagsMax)}.`;
65
+ return `${String(count)} hashtag${count === 1 ? "" : "s"}; the first ${String(Math.min(count, hashtagsShownByTitle))} show above the title.`;
66
+ }
@@ -102,7 +102,7 @@ When a tool says its allowance is used up, the run does not fail:
102
102
  3. The project's status reads **Waiting for limits**, with a line such as "Waiting for Codex limits (resets at 14:00)." The same words show on Home's Running now, the Projects row and the Calendar.
103
103
  4. When the reset passes, the run continues by itself.
104
104
 
105
- Other providers are not held up: a project whose narration uses a key carries on while its Codex images wait. The wait survives a restart; a project that was waiting when Slopify stopped resumes on the next start and waits again. A project you paused or cancelled meanwhile is left alone.
105
+ Other providers are not held up: a project whose narration uses a key carries on while its Codex images wait. The wait survives a restart; a project that was waiting when Slopify stopped resumes on the next start and waits again. A project you paused or canceled meanwhile is left alone.
106
106
 
107
107
  Gemini's daily quota is handled the same way ("Slopify waits and tries again later by itself").
108
108
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A backup is one `.tar` file with everything you made in Slopify: every project with its files and history, your library, templates, schedules, channels and settings. Make one by hand at any time with **Export everything**, or let Slopify write one every day. Bring one back with **Import a backup**. Provider keys are never included.
4
4
 
5
- **Where to find it:** **Settings → Backup & storage** (Export everything, Import a backup) and **Settings → Backups** (daily backups, Back up now).
5
+ **Where to find it:** **Settings → Backup & storage**: daily backups and Back up now, Export everything and Import a backup.
6
6
 
7
7
  ## What a backup holds
8
8
 
@@ -54,7 +54,7 @@ If the import fails, Slopify says: "The backup wasn't imported. Check it is a fi
54
54
 
55
55
  Daily backups are off by default, because each backup holds every project's video and can fill a disk.
56
56
 
57
- 1. Open **Settings → Backups**.
57
+ 1. Open **Settings → Backup & storage**.
58
58
  2. Turn **Back up automatically** on.
59
59
  3. Set **Time of day**, **Keep last** and optionally **Folder** (see below).
60
60
  4. Press **Save**.
@@ -78,7 +78,7 @@ The screen shows the last backup's time and size, the result of the last attempt
78
78
 
79
79
  ## Back up now
80
80
 
81
- 1. Open **Settings → Backups**.
81
+ 1. Open **Settings → Backup & storage**.
82
82
  2. Press **Back up now**. Slopify says "Backup started. Its result shows here when it finishes."
83
83
 
84
84
  It writes the same file into the backup folder. It says which projects it is waiting for, if any, and is refused while another backup is being written.
@@ -97,11 +97,11 @@ It writes the same file into the backup folder. It says which projects it is wai
97
97
  - Free space is checked first. A backup that would not fit fails with the sizes.
98
98
  - Backup files are readable by your user only.
99
99
  - An update waits for a backup that is being written.
100
- - Every failure is explained on **Settings → Backups** with what to change, for example free space, lower **Keep last**, or pick another **Folder**.
100
+ - Every failure is explained on **Settings → Backup & storage** with what to change, for example free space, lower **Keep last**, or pick another **Folder**.
101
101
 
102
102
  ## Other storage tools on Backup & storage
103
103
 
104
- - **Clean orphan files** deletes files that no project, draft or template uses any more. Slopify also does this each time it starts.
104
+ - **Clear leftover files** deletes files that no project, draft or template uses any more. Slopify also does this each time it starts.
105
105
  - **Keep outputs only** on a finished project deletes its working files and keeps the finished outputs.
106
106
 
107
107
  Both are explained in [Where your files live](Where-Your-Files-Live).
@@ -10,7 +10,7 @@ The calendar starts on this week's Monday and shows four weeks, Monday to Sunday
10
10
 
11
11
  - **Scheduled runs**, each with the project title it will make. A run shows **Queued** when a topic is lined up, **Needs a topic** when its topic is still waiting for your approval or for generation, and **Paused** when its schedule is paused.
12
12
  - **Needs you**, above the weeks: every project that is waiting on you or ready to upload (see below).
13
- - **Projects** running or finished on their day, with their state: **Running**, **Paused**, **Waiting**, **Failed**, **Done with problems**, **Done** or **Canceled**. A project waiting for a CLI plan says so, for example **Waiting for Codex limits (resets at 14:00)**.
13
+ - **Projects** running or finished on their day, with their state: **Running**, **Paused**, **Queued**, **Failed**, **Done with problems**, **Done** or **Canceled**. A project waiting for a CLI plan says so, for example **Waiting for Codex limits (resets at 14:00)**.
14
14
  - **Batch queue**: projects waiting to start (see below).
15
15
  - **Suggested topics**, beside the weeks, for schedules that hold generated topics for approval.
16
16
 
@@ -33,7 +33,7 @@ The bundled samples never show here. A press anywhere on an entry opens its proj
33
33
 
34
34
  ## The batch queue
35
35
 
36
- Videos started together, such as extra videos queued from Play, run one at a time. **Batch queue** lists them in the order they run, numbered (**1 in line**, **2 in line**, …), each marked **Running now**, **Waiting its turn** or **Paused**. Its heading says how many are waiting to start. Pausing a queued project holds the whole queue; when one fails or is cancelled, the next one starts. The batch queue is only shown on the calendar; Projects links here and has a **Queued** filter. It is hidden when nothing is queued.
36
+ Videos started together, such as extra videos queued from Play, run one at a time. **Batch queue** lists them in the order they run, numbered (**1 in line**, **2 in line**, …), each marked **Running now**, **Waiting its turn** or **Paused**. Its heading says how many are waiting to start. Pausing a queued project holds the whole queue; when one fails or is canceled, the next one starts. The batch queue is only shown on the calendar; Projects links here and has a **Queued** filter. It is hidden when nothing is queued.
37
37
 
38
38
  ## Switch between weeks and a list
39
39
 
@@ -61,7 +61,7 @@ Mark voices in **Settings → Voices** and Image prompts in **Library → Prompt
61
61
  | **Title font** | The font of chapter cards and the end screen, for videos whose template sets no title style. |
62
62
  | **Title colour** | The text colour of chapter cards and the end screen, such as `#FFD700`. |
63
63
  | **End screen text** | A line shown centred over the last 5 seconds of every video, such as "Subscribe for more". Up to 200 characters. Blank shows none. |
64
- | **Intro** / **Outro** | An entry from **Library → Intros & Outros**, narrated before or after the body of every video whose template has none. Each is narrated with the video's voice, so each costs one voice request per video (plus a text call for an LLM entry), and it shows in the estimate. See [Intros and outros](Intros-and-Outros). |
64
+ | **Intro** / **Outro** | An entry from **Library → Intros & outros**, narrated before or after the body of every video whose template has none. Each is narrated with the video's voice, so each costs one voice request per video (plus a text call for an LLM entry), and it shows in the estimate. See [Intros and outros](Intros-and-Outros). |
65
65
  | **Document theme** | The look of the PDF for videos whose template picks no theme. A saved theme that was deleted is skipped. |
66
66
  | **Ambient sound** | Rain, Fireplace or Wind under the long video's narration, with its level (−40 to −6 dB, default −18), fade-in (0 to 30 s, default 3) and tail (0 to 30 s, default 6). Used when the template leaves its own ambient sound on "The channel's". A channel can't hold your own audio file. |
67
67
 
@@ -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 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.
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 canceled 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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A document theme is the look of the PDF Slopify makes from the article: paper size, colours, fonts, spacing, the title page, contents, sources and closing pages, headers and page numbers. Slopify ships a built-in **Plain** theme. Copy it to make themes of your own, with a live preview as you edit.
4
4
 
5
- **Where to find it:** **Library → Documents**. Pick a theme on Play's **Document** row, in **Edit project**, or as a channel's default in its brand kit.
5
+ **Where to find it:** **Library → PDF themes**. Pick a theme on Play's **Document** row, in **Edit project**, or as a channel's default in its brand kit.
6
6
 
7
7
  ## Built-in and your own themes
8
8
 
@@ -15,7 +15,7 @@ A project keeps its own copy of the theme's settings. Editing or deleting a them
15
15
 
16
16
  ## Make a theme
17
17
 
18
- 1. Open **Library → Documents**.
18
+ 1. Open **Library → PDF themes**.
19
19
  2. Press **New theme** (it starts from Plain), or pick a built-in theme and press **Copy theme**, or press **Duplicate** on one of your themes.
20
20
  3. Type a **Name** (up to 80 characters). This is what the Document row on Play and Edit project shows.
21
21
  4. Open the groups on the left and change what you like (every setting is listed below). The first two groups start open.
@@ -31,7 +31,7 @@ The form has its own section list on the left. Some sections appear only when th
31
31
 
32
32
  At the bottom: **Save changes** saves a revision, **Discard changes** drops the draft. While a file is uploading the bar says "Waiting for uploads to finish…" and saving waits.
33
33
 
34
- If someone (or another tab) saved the project while you were editing, the bar says "A newer revision is available. Your unsaved changes are kept below." Press **Reload current revision and discard my draft** to start from the newer one.
34
+ If someone (or another tab) saved the project while you were editing, the bar says "A newer revision is available. Your unsaved changes are kept below and in this browser." Press **Reload latest and re-apply my edit** to lay your changes over the newer revision (check them, then **Save changes**), or **Copy my edit** to keep a copy on the clipboard first.
35
35
 
36
36
  ## Change the article text
37
37
 
@@ -119,7 +119,7 @@ The project keeps its own copy of each prompt, so later Library edits reach it o
119
119
  | Control | What it does |
120
120
  | --- | --- |
121
121
  | **Also use pronunciations from my other projects** → **Update from other projects** | Copies your other projects' Pronunciation Glossary terms into this project again, picking up any added since the last copy. After you save, only the chunks whose words those terms change are spoken again, paid per character. |
122
- | **Use narration aliases** → **Update from Library** | Copies Library → Aliases into this project again. A Library edit reaches the project only when you press this. After you save, only the chunks the changed aliases touch are spoken again. |
122
+ | **Use narration aliases** → **Update from Library** | Copies Library → Narration aliases into this project again. A Library edit reaches the project only when you press this. After you save, only the chunks the changed aliases touch are spoken again. |
123
123
  | **Describe tables and figures in the narration** | The text model writes a short spoken passage for each table, figure, equation and code block. Turning it on costs one text call per block, and after you save the chunks that contain a block are spoken again (the whole narration when Chunking is the whole article). **Leave code out** skips code blocks. |
124
124
 
125
125
  **Show tables and figures on screen** (in **Inputs → Cuts and look**, when Describe tables and figures is on) shows each described block in the video while its description is spoken, as the article's picture or a card drawn on your computer at no cost. Turning it on or off renders the video again, and any Shorts that include a card; the narration is kept.
@@ -86,7 +86,7 @@ The main output was made, but another step failed, for example the thumbnail. Th
86
86
 
87
87
  ### I deleted a project by mistake. Can I get it back?
88
88
 
89
- Yes, within 30 days. Open Settings → **Trash** and press **Restore**. See [Trash](Trash).
89
+ Yes, within 30 days. Open Settings → **Backup & storage** → **Trash** and press **Restore**. See [Trash](Trash).
90
90
 
91
91
  ### Are captions free?
92
92