@gentbajko/slopify 3.0.14 → 3.1.0

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.
@@ -3,6 +3,7 @@ import { Hono } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
4
  import { z } from "zod";
5
5
  import { calendarRange } from "../../slices/schedules/agenda.js";
6
+ import { preparedTopics, prepareTopic } from "../../slices/schedules/prepare.js";
6
7
  import { calendarMaxDays, calendarSchema, heldTopicEditSchema, heldTopicSchema, queueMax, scheduleCreateSchema, scheduleRunSchema, scheduleSummarySchema, scheduleUpdateSchema, topicMoveSchema, topicQueueSchema, topicTransferSchema, } from "../../slices/schedules/schema.js";
7
8
  import { cancelSchedule, createSchedule, deleteSchedule, listScheduleRuns, listSchedules, pauseSchedule, resumeSchedule, updateSchedule, } from "../../slices/schedules/service.js";
8
9
  import { approveHeldTopics, editHeldTopic, heldTopics, moveTopic, rejectHeldTopic, replaceTopics, transferTopic, } from "../../slices/schedules/topics.js";
@@ -118,6 +119,32 @@ function routes(deps) {
118
119
  return c.json({
119
120
  topics: heldTopics(service(), scheduleId).map((topic) => heldTopicSchema.parse(topic)),
120
121
  });
122
+ })
123
+ // Which queued topics are prepared ahead, and their projects.
124
+ .get("/:id/prepared", zValidator("param", id, onInvalid), (c) => c.json({ prepared: preparedTopics(service(), c.req.valid("param").id) }))
125
+ // Prepare one queued topic now: everything but the video, which its scheduled day renders.
126
+ .post("/:id/topics/prepare", zValidator("param", id, onInvalid), zValidator("json", z.object({ topic: z.string().min(1).max(500) }).strict(), onInvalid), async (c) => {
127
+ const result = await prepareTopic(service(), {
128
+ scheduleId: c.req.valid("param").id,
129
+ title: c.req.valid("json").topic,
130
+ });
131
+ if (result.ok)
132
+ return c.json({ projectId: result.projectId, title: result.title }, 201);
133
+ const detail = {
134
+ "not-found": "This topic is no longer in the schedule's queue. Reload the page to see the queue as it is now.",
135
+ "already-prepared": "This topic is already prepared: its project is in Projects under the same title.",
136
+ "missing-template": "This schedule's template was deleted, so there is nothing to prepare from. Choose another template under Edit.",
137
+ "spend-limit": "Preparing it would go over this schedule's spend limit. Raise the limit under Edit, or prepare it from Play instead.",
138
+ };
139
+ return problem(c, {
140
+ status: result.reason === "not-found" ? 404 : 409,
141
+ title: titleOf(result.reason === "not-found" ? 404 : 409),
142
+ detail: detail[result.reason] ??
143
+ (result.detail === undefined
144
+ ? `This topic couldn't be prepared (${result.reason}). Open Play with the schedule's template to see what the setup needs, then try again.`
145
+ : `This topic couldn't be prepared: ${result.detail} Fix it in the schedule's template (Library → Templates), then try again.`),
146
+ extensions: { reason: result.reason },
147
+ });
121
148
  })
122
149
  // Starts a generation in the background; the schedule's `topics` says when it is done.
123
150
  .post("/:id/topics/generate", zValidator("param", id, onInvalid), (c) => {
@@ -0,0 +1,13 @@
1
+ -- A schedule's topic prepared ahead (Schedules → a topic → Prepare): its project runs everything
2
+ -- but the video now and holds at a Before Video checkpoint; on the scheduled day the schedule
3
+ -- continues it instead of starting another. `added_checkpoint` says the hold is the preparation's
4
+ -- own, so the day releases it; a Before Video checkpoint the template already had stays for the
5
+ -- person to approve.
6
+ CREATE TABLE prepared_videos (
7
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
8
+ schedule_id TEXT NOT NULL,
9
+ title TEXT NOT NULL,
10
+ added_checkpoint INTEGER NOT NULL CHECK (added_checkpoint IN (0, 1)),
11
+ prepared_at TEXT NOT NULL
12
+ );
13
+ CREATE INDEX prepared_videos_title ON prepared_videos(schedule_id, title);
@@ -0,0 +1,17 @@
1
+ # Slopify 3.0.15
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.0.15 makes a garbled narration chunk get recorded again even when the project is busy.
6
+
7
+ ## Fixes
8
+
9
+ - When the captions caught a garbled sentence, Slopify said it was recording that narration chunk again, but if another step of the project was still running at that moment (its PDF, say), the retry gave up and the video stayed failed. The retry now waits for that step and starts as soon as it finishes.
10
+
11
+ ## Under the hood
12
+
13
+ - Faster CI and releases: Docker images build beside the npm publish, the Windows install check uses one warm cache, and unchanged Windows and Docker checks are skipped.
14
+
15
+ ## Upgrading from 3.0.14
16
+
17
+ - **Nothing becomes outdated.**
@@ -0,0 +1,28 @@
1
+ # Slopify 3.1.0
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.1.0 lets a schedule's videos be made ahead of their day, and several videos from one Start run together.
6
+
7
+ ## Highlights
8
+
9
+ - **Prepare a scheduled video ahead.** Everything but the video is made now; its scheduled day only renders.
10
+ - **Run several videos at once.** Play's Queue switch: on, one after another; off, all together.
11
+
12
+ ## Schedules and the calendar
13
+
14
+ - Every queued run on the calendar has **Prepare**. Slopify makes the project now, exactly as the schedule would on the day (the template as it is now, the topic and the schedule's values), and runs research, the article, narration, images, the thumbnail and the PDF. It holds before the video, and the run shows **Prepared** with **Open**.
15
+ - On the scheduled day the schedule continues that project instead of making another: the video, its captions, shorts and YouTube description are made. An unfinished project you made yourself under the same title is continued the same way; a finished or canceled one is not.
16
+ - Open a prepared project to read the article or listen to the narration, and change anything before its day. A Before Video checkpoint the template already has still waits for you.
17
+
18
+ ## Play
19
+
20
+ - With more than one video, the Start box has a **Queue** switch (on by default). Off, they all start at once as separate runs, and the Play key reads **Start N videos**. At once finishes sooner while steps wait on providers, but the renders share your processor and plan limits are reached sooner.
21
+
22
+ ## Fixes
23
+
24
+ - **Review** refused any setup with burned-in captions and a Before Video checkpoint ("Something changed since you reviewed this video"), however often you pressed it.
25
+
26
+ ## Upgrading from 3.0.15
27
+
28
+ - **Nothing becomes outdated.** The database gains one table, for prepared videos.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.1.0",
4
+ "title": "Slopify 3.1.0",
5
+ "version": "3.1.0",
6
+ "date": "2026-09-30"
7
+ },
8
+ {
9
+ "id": "3.0.15",
10
+ "title": "Slopify 3.0.15",
11
+ "version": "3.0.15",
12
+ "date": "2026-09-30"
13
+ },
2
14
  {
3
15
  "id": "3.0.14",
4
16
  "title": "Slopify 3.0.14",
@@ -51,7 +51,14 @@ export async function resolvePlayReview(deps, view) {
51
51
  }
52
52
  if (fields.length)
53
53
  return reviewRefusal(view, fields);
54
- return reviewBinding(before.value) === reviewBinding(after.value)
54
+ // Did anything change while the font and the models were looked up? Both sides are resolved
55
+ // without the font: `before` had none yet, and a Before Video checkpoint covers the render
56
+ // that burns the captions in, so comparing it with `after` always differed and refused every
57
+ // such review.
58
+ const unchanged = resolveReviewInputs(deps, parsed.data, null);
59
+ if (!unchanged.ok)
60
+ return unchanged;
61
+ return reviewBinding(before.value) === reviewBinding(unchanged.value)
55
62
  ? after
56
63
  : reviewRefusal(view, [], "stale-review");
57
64
  }
@@ -226,6 +226,9 @@ export const playDraftDocumentSchema = z
226
226
  form: playDraftFormSchema,
227
227
  section: z.enum(["content", "outputs", "style", "review"]),
228
228
  variants: z.array(z.object({ id, title: text, values }).strict().readonly()).readonly(),
229
+ // Several videos from one Start: queued (one after another, the default) or all started
230
+ // together. Absent reads as queued, as every draft saved before the choice was.
231
+ queue: z.boolean().optional(),
229
232
  expectedWords: text,
230
233
  previewText: text,
231
234
  fontUpload: z.object({ operationId: id, name: text }).strict().readonly().nullable(),
@@ -137,10 +137,14 @@ function createReviewedRuns(deps, input, captured, checked, catalogue) {
137
137
  const first = runs[0];
138
138
  if (!first)
139
139
  throw new Error("Reviewed run list was empty");
140
- const queue = runs.length === 1 ? [] : enqueueBatch(captured, input.reviewId, runs, true);
141
- const projectIds = runs.length === 1
142
- ? [startRun(captured, first.draft, first.rendered, true, first.templates).project.id]
143
- : queue.map((e) => e.projectId);
140
+ // Several videos run one after another (a batch queue) unless the draft asks for them
141
+ // all at once (Play → Queue off): then each starts as its own run.
142
+ const view = readDraft(deps, input.draftId);
143
+ const queued = runs.length > 1 && (!view.ok || view.value.draft.document.queue !== false);
144
+ const queue = queued ? enqueueBatch(captured, input.reviewId, runs, true) : [];
145
+ const projectIds = queued
146
+ ? queue.map((e) => e.projectId)
147
+ : runs.map((run) => startRun(captured, run.draft, run.rendered, true, run.templates).project.id);
144
148
  const checkpointSet = admitReviewedCheckpoints(captured, projectIds, starting.value.checkpointSet ?? [], catalogue);
145
149
  const value = {
146
150
  requestId: input.reviewId,
@@ -149,7 +153,7 @@ function createReviewedRuns(deps, input, captured, checked, catalogue) {
149
153
  replayed: false,
150
154
  ...(checkpointSet.length ? { checkpointSet } : {}),
151
155
  };
152
- if (projectIds.length !== runs.length || queue.length !== (runs.length === 1 ? 0 : runs.length))
156
+ if (projectIds.length !== runs.length || queue.length !== (queued ? runs.length : 0))
153
157
  throw new StartedRunMismatch(`Start would create ${projectIds.length} of ${runs.length} reviewed videos (${queue.length} queued).`);
154
158
  insertStartReceipt(deps, input, value);
155
159
  markDraftStartedAndReleaseRefs(deps.db, input);
@@ -105,6 +105,16 @@ async function recordAgain(deps, retry) {
105
105
  detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
106
106
  });
107
107
  }
108
+ else if (result.reason === "running") {
109
+ // Another step of the project is still going (its PDF, say): the retry waits for it and
110
+ // starts when that step finishes, which kicks this again (`onFinished`). Giving up here
111
+ // left the video failed with a message promising a retry that never came.
112
+ deps.log.write("info", "narration.retry", {
113
+ projectId: retry.projectId,
114
+ detail: `Recording ${retry.chunkKey} again once the project's running steps finish.`,
115
+ });
116
+ return;
117
+ }
108
118
  else {
109
119
  settle(deps.db, retry, "failed", refusal(result), now);
110
120
  deps.log.write("warn", "narration.retry", {
@@ -7,6 +7,7 @@ import { readSampleRecord } from "../onboarding/state.js";
7
7
  import { limitWaitsByProject, listingWait } from "../run-cost/panel.js";
8
8
  import { uploadedProjects } from "../uploads/repo.js";
9
9
  import { nextOccurrence } from "./calendar.js";
10
+ import { preparedProject } from "./prepare.js";
10
11
  import { scheduleRows } from "./repo.js";
11
12
  import { calendarMaxDays, } from "./schema.js";
12
13
  import { renderedTitle } from "./topic-list.js";
@@ -66,6 +67,7 @@ function upcomingRuns(deps, schedule, now, from, to) {
66
67
  // Known for a queued topic, and for a run of the template as saved; a held or generated
67
68
  // topic has no text yet.
68
69
  const known = queuedTopic !== undefined || generation === "off";
70
+ const title = form !== undefined && known ? renderedTitle(form, schedule, queuedTopic) : null;
69
71
  if (at >= from)
70
72
  runs.push({
71
73
  at: at.toISOString(),
@@ -85,7 +87,11 @@ function upcomingRuns(deps, schedule, now, from, to) {
85
87
  : k < items.length + held
86
88
  ? "held"
87
89
  : "generated",
88
- renderedTitle: form !== undefined && known ? renderedTitle(form, schedule, queuedTopic) : null,
90
+ renderedTitle: title,
91
+ // Prepared ahead (`prepare.ts`): the project the day continues.
92
+ prepared: title === null || queuedTopic === undefined
93
+ ? null
94
+ : (preparedProject(deps.db, schedule.id, title)?.projectId ?? null),
89
95
  });
90
96
  if (schedule.cadence.kind === "once")
91
97
  break;
@@ -0,0 +1,147 @@
1
+ import { derive } from "../../kernel/runner/graph.js";
2
+ import { projectPaused, stagesOf } from "../admission/repo.js";
3
+ import { changeCheckpoints } from "../checkpoints/change.js";
4
+ import { listCheckpoints } from "../checkpoints/repo.js";
5
+ import { reviewDraft } from "../play-drafts/review.js";
6
+ import { createDraft } from "../play-drafts/service.js";
7
+ import { startPlayDraft } from "../play-drafts/start.js";
8
+ import { freshTemplateDraft } from "../project-templates/setup.js";
9
+ import { currentRevisionId } from "../revisions/repo.js";
10
+ import { scheduleById } from "./repo.js";
11
+ import { renderedTitle, scheduledValues } from "./topic-list.js";
12
+ // The draft a schedule's run starts for one topic: the template as it is now, the topic in its
13
+ // keyword, the schedule's values over the template's, and the title filled from them.
14
+ export function scheduledDocument(deps, schedule, topic) {
15
+ const template = deps.template(schedule.templateId);
16
+ if (template === undefined)
17
+ return undefined;
18
+ const document = freshTemplateDraft(deps, template.document, {
19
+ id: schedule.templateId,
20
+ version: template.version,
21
+ });
22
+ return {
23
+ ...document,
24
+ form: {
25
+ ...document.form,
26
+ title: renderedTitle(document.form, schedule, topic),
27
+ values: scheduledValues(document.form, schedule, topic),
28
+ },
29
+ variants: [],
30
+ };
31
+ }
32
+ export async function prepareTopic(deps, input) {
33
+ const schedule = scheduleById(deps.db, input.scheduleId);
34
+ if (schedule === undefined)
35
+ return { ok: false, reason: "not-found" };
36
+ const topic = schedule.items.find((item) => item.title === input.title);
37
+ if (topic === undefined)
38
+ return { ok: false, reason: "not-found" };
39
+ const document = scheduledDocument(deps, schedule, topic);
40
+ if (document === undefined)
41
+ return { ok: false, reason: "missing-template" };
42
+ const title = document.form.title;
43
+ if (preparedProject(deps.db, schedule.id, title) !== undefined)
44
+ return { ok: false, reason: "already-prepared" };
45
+ const { document: held, added } = heldBeforeVideo(document);
46
+ const draft = createDraft(deps, { id: deps.uuid(), document: held });
47
+ if (!draft.ok)
48
+ return { ok: false, reason: "conflict" };
49
+ const review = await reviewDraft(deps, { id: draft.value.draft.id, baseVersion: 1 });
50
+ if (!review.ok)
51
+ return {
52
+ ok: false,
53
+ reason: review.reason,
54
+ // What the setup needs, as Play would say it ("This font was deleted. Choose another font.").
55
+ ...(review.fields.length === 0
56
+ ? {}
57
+ : { detail: review.fields.map((field) => field.message).join(" ") }),
58
+ };
59
+ const high = review.value.estimates.reduce((sum, estimate) => sum + estimate.high, 0);
60
+ if (schedule.spendLimitCents !== null &&
61
+ (review.value.estimates.some((estimate) => estimate.unknown > 0) ||
62
+ high * 100 > schedule.spendLimitCents))
63
+ return { ok: false, reason: "spend-limit" };
64
+ const started = await startPlayDraft(deps, {
65
+ draftId: draft.value.draft.id,
66
+ baseVersion: 1,
67
+ reviewId: review.value.id,
68
+ });
69
+ if (!started.ok)
70
+ return { ok: false, reason: started.reason };
71
+ const projectId = started.value.projectIds[0];
72
+ if (projectId === undefined)
73
+ return { ok: false, reason: "conflict" };
74
+ deps.db
75
+ .prepare("INSERT INTO prepared_videos(project_id,schedule_id,title,added_checkpoint,prepared_at) VALUES (?,?,?,?,?)")
76
+ .run(projectId, schedule.id, title, added ? 1 : 0, deps.clock.now().toISOString());
77
+ return { ok: true, projectId, title };
78
+ }
79
+ // The draft with a Before Video checkpoint, and whether it is the preparation's own. A project
80
+ // that makes no video has nothing to hold back: it runs to the end when prepared.
81
+ export function heldBeforeVideo(document) {
82
+ const had = document.form.checkpoints ?? [];
83
+ if (document.form.sources.video !== "generate" || had.includes("video"))
84
+ return { document, added: false };
85
+ return {
86
+ document: { ...document, form: { ...document.form, checkpoints: [...had, "video"] } },
87
+ added: true,
88
+ };
89
+ }
90
+ const ended = new Set(["done", "partial", "failed", "canceled"]);
91
+ // The project a scheduled run of this title continues: one prepared for it, or else, for a run
92
+ // with a topic, an unfinished project of the same title (made by hand, say). A finished one is
93
+ // a video already made, and a new run makes a new one. A schedule without topics gives every
94
+ // run the same title, so only a prepared project counts there.
95
+ export function preparedProject(db, scheduleId, title, byTitle = true) {
96
+ const live = "project_id NOT IN (SELECT project_id FROM project_trash)";
97
+ const prepared = db
98
+ .prepare(`SELECT project_id, added_checkpoint FROM prepared_videos
99
+ WHERE schedule_id=? AND title=? AND ${live} ORDER BY prepared_at DESC LIMIT 1`)
100
+ .get(scheduleId, title);
101
+ // A prepared project canceled since is no video to continue: the day makes a new one.
102
+ if (typeof prepared?.project_id === "string" &&
103
+ derive(stagesOf(db, prepared.project_id), projectPaused(db, prepared.project_id)) !== "canceled")
104
+ return { projectId: prepared.project_id, release: prepared.added_checkpoint === 1 };
105
+ if (!byTitle)
106
+ return undefined;
107
+ for (const row of db
108
+ .prepare("SELECT id FROM projects WHERE title=? AND id NOT IN (SELECT project_id FROM project_trash) ORDER BY created_at DESC")
109
+ .all(title)) {
110
+ const id = String(row.id);
111
+ if (!ended.has(derive(stagesOf(db, id), projectPaused(db, id))))
112
+ return { projectId: id, release: false };
113
+ }
114
+ return undefined;
115
+ }
116
+ // The scheduled day for a prepared video: its own Before Video hold is taken away, so the
117
+ // video renders as soon as what it needs is done. A Before Video checkpoint the template had
118
+ // stays for the person to approve.
119
+ export function continuePrepared(deps, prepared) {
120
+ if (prepared.release) {
121
+ const head = currentRevisionId(deps.db, prepared.projectId);
122
+ if (head !== undefined) {
123
+ const stages = listCheckpoints(deps.db, prepared.projectId, head)
124
+ .map((row) => row.stage)
125
+ .filter((stage) => stage !== "video");
126
+ changeCheckpoints(deps, { projectId: prepared.projectId, revisionId: head, stages });
127
+ }
128
+ }
129
+ // Continued once: the topic is used, and the project is now the run's.
130
+ deps.db.prepare("DELETE FROM prepared_videos WHERE project_id=?").run(prepared.projectId);
131
+ deps.runner.tick(prepared.projectId);
132
+ }
133
+ // Which of a schedule's topics are prepared, and their projects, for the topic list.
134
+ export function preparedTopics(deps, scheduleId) {
135
+ const schedule = scheduleById(deps.db, scheduleId);
136
+ if (schedule === undefined)
137
+ return [];
138
+ return schedule.items.flatMap((topic) => {
139
+ const document = scheduledDocument(deps, schedule, topic);
140
+ if (document === undefined)
141
+ return [];
142
+ const found = preparedProject(deps.db, scheduleId, document.form.title);
143
+ return found === undefined
144
+ ? []
145
+ : [{ title: document.form.title, topic: topic.title, projectId: found.projectId }];
146
+ });
147
+ }
@@ -2,10 +2,9 @@ import { transact } from "../../kernel/db/tx.js";
2
2
  import { reviewDraft } from "../play-drafts/review.js";
3
3
  import { createDraft } from "../play-drafts/service.js";
4
4
  import { startPlayDraft } from "../play-drafts/start.js";
5
- import { freshTemplateDraft } from "../project-templates/setup.js";
6
5
  import { nextOccurrence } from "./calendar.js";
6
+ import { continuePrepared, preparedProject, scheduledDocument } from "./prepare.js";
7
7
  import { activeRun, dueSchedules, insertRun, recordRunDispatch, recordRunStartIdentity, recoverRunningRuns, scheduleById, scheduleRows, updateRun, } from "./repo.js";
8
- import { renderedTitle, scheduledValues } from "./topic-list.js";
9
8
  import { generateTopics, generationDue, releaseTopicLeases } from "./topics.js";
10
9
  const missedGraceMs = 60_000;
11
10
  export function createScheduleRunner(deps) {
@@ -134,19 +133,20 @@ async function execute(deps, claimed) {
134
133
  let run = claimed.run;
135
134
  try {
136
135
  // The template as it is now, so an edit to it reaches the next run.
137
- const template = deps.template(claimed.schedule.templateId);
138
- if (template === undefined)
139
- throw new ScheduleDispatchError("missing-template");
140
- const document = freshTemplateDraft(deps, template.document, {
141
- id: claimed.schedule.templateId,
142
- version: template.version,
143
- });
144
136
  const topic = claimed.schedule.items[0];
145
- const fresh = {
146
- ...document,
147
- form: runForm(document.form, claimed.schedule, topic),
148
- variants: [],
149
- };
137
+ const fresh = scheduledDocument(deps, claimed.schedule, topic);
138
+ if (fresh === undefined)
139
+ throw new ScheduleDispatchError("missing-template");
140
+ // Prepared ahead (`prepare.ts`), or made by hand under the same title: continue it rather
141
+ // than make the same video twice.
142
+ const prepared = preparedProject(deps.db, claimed.schedule.id, fresh.form.title, topic !== undefined);
143
+ if (prepared !== undefined) {
144
+ continuePrepared(deps, prepared);
145
+ run = { ...run, projectIds: [prepared.projectId] };
146
+ recordRunDispatch(deps.db, run, deps.clock.now().toISOString(), topic);
147
+ updateRun(deps.db, { ...run, status: "succeeded", endedAt: deps.clock.now().toISOString() });
148
+ return;
149
+ }
150
150
  const draft = createDraft(deps, { id: deps.uuid(), document: fresh });
151
151
  if (!draft.ok)
152
152
  throw new ScheduleDispatchError("conflict");
@@ -193,17 +193,6 @@ async function execute(deps, claimed) {
193
193
  }
194
194
  updateRun(deps.db, run);
195
195
  }
196
- // One project per run. The first queued topic fills the chosen keyword, the schedule's fixed
197
- // values fill the rest over the template's own, and the template's title is filled from the
198
- // same keywords, so "History: {{Topic}}" names the project after the topic. A topic saved
199
- // before topics had a keyword brings its own title and values instead.
200
- function runForm(form, schedule, topic) {
201
- return {
202
- ...form,
203
- title: renderedTitle(form, schedule, topic),
204
- values: scheduledValues(form, schedule, topic),
205
- };
206
- }
207
196
  class ScheduleDispatchError extends Error {
208
197
  reason;
209
198
  constructor(reason) {
@@ -207,6 +207,9 @@ export const calendarRunSchema = z
207
207
  // be known yet (a topic still to be approved or generated, or a deleted template). Older
208
208
  // servers leave it out.
209
209
  renderedTitle: z.string().nullable().default(null),
210
+ // The project prepared ahead for this run (Prepare), which its day continues. Older servers
211
+ // leave it out.
212
+ prepared: z.string().nullable().default(null),
210
213
  })
211
214
  .strict()
212
215
  .readonly();
@@ -58,6 +58,7 @@ export const projectTables = [
58
58
  // Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
59
59
  // current wait, a lease of this install) and prompt_softening (a pending one-off request).
60
60
  // Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
61
+ // Since 3.1.0, prepared_videos too: a schedule's hold on this install's next run.
61
62
  "review_verdicts",
62
63
  "provider_usage",
63
64
  "plan_limit_readings",
@@ -16,6 +16,8 @@ The calendar starts on this week's Monday and shows four weeks, Monday to Sunday
16
16
 
17
17
  The calendar follows the channel picked in the sidebar, or shows every channel. See [Channels](Channels).
18
18
 
19
+ Each queued run has **Prepare**, which makes its video ahead of the day and leaves only the render for the day; a prepared run shows **Prepared** and **Open**. See [Schedules](Schedules#prepare-a-video-ahead).
20
+
19
21
  ## Needs you
20
22
 
21
23
  Above the weeks, **Needs you** lists what cannot move on without you, then what is ready to upload. Its heading counts them, for example "2 waiting for you · 1 ready to upload". It is hidden when there is nothing.
@@ -75,7 +75,18 @@ Keywords live in the **Title and keywords** row. Topic keywords (the ones the ti
75
75
  3. Each extra video shows as a chip. Its name is a button with a pencil (**Change the keywords of …**): press it to open a side panel where you can change that video's **Title** and keywords. Everything else comes from the setup of the first video.
76
76
  4. Press the **x** on a chip to remove that video.
77
77
 
78
- One Start queues at most 50 videos: this setup and 49 more. The Play key reads **Queue N videos**, and the videos run one at a time, in order.
78
+ One Start makes at most 50 videos: this setup and 49 more.
79
+
80
+ ### Queue or all at once
81
+
82
+ With more than one video, the Start box has a **Queue** switch (on by default):
83
+
84
+ | Queue | What happens | The Play key reads |
85
+ | --- | --- | --- |
86
+ | **On** | The videos run one after another, in order, each starting when the one before finishes. | **Queue N videos** |
87
+ | **Off** | They all start at once, as separate runs. | **Start N videos** |
88
+
89
+ All at once finishes sooner while the steps wait on providers (research, narration, images), but the video renders share your computer's processor, so each renders slower, and Claude or Codex plan limits are reached sooner (Slopify waits and goes on when they are).
79
90
 
80
91
  ### The video queue
81
92
 
@@ -83,6 +83,23 @@ Pick a schedule in the list to see its **Queued topics · N**. You can change th
83
83
 
84
84
  Each change saves at once and says so, for example "Renamed to “Hypatia of Alexandria”.", with **Undo** on the notice. Only the queue changes; the next run and the other settings stay as they are. If the schedule changed in another tab meanwhile, the change is refused with the reason; reload and try again.
85
85
 
86
+ ## Prepare a video ahead
87
+
88
+ A scheduled video can be made ahead of its day, so the day only renders it: useful before a trip, or to read the article and listen to the narration first.
89
+
90
+ 1. Open **Calendar** and find the topic's run (either view).
91
+ 2. Press **Prepare**.
92
+
93
+ Slopify makes the project now, exactly as the schedule would on the day (the template as it is now, the topic and the schedule's values), and runs everything but the video: research, the article, narration, images, the thumbnail and the PDF. It then holds at a **Before Video** checkpoint, and the run shows **Prepared** with **Open**.
94
+
95
+ On the scheduled day the schedule finds that project by its title and continues it instead of making another: the hold is lifted and the video, its captions, shorts and YouTube description are made. The same goes for an unfinished project you made yourself under the same title. A finished one counts as a video already made, and the day makes a new one.
96
+
97
+ - You can open the prepared project and change anything before its day, for example with Edit project.
98
+ - A **Before Video** checkpoint the template already has stays: the day waits for you to approve it.
99
+ - A setup that makes no video has nothing to hold back, so Prepare runs it to the end.
100
+ - A schedule without topics gives every run the same title, so only a prepared project continues there.
101
+ - The spend limit applies to Prepare as it does to a scheduled run.
102
+
86
103
  ## Topics that find themselves
87
104
 
88
105
  Under **Topic generation**, a schedule can ask an LLM for its next topics so it never runs dry.