@gentbajko/slopify 3.0.15 → 3.1.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.
@@ -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,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.
@@ -0,0 +1,18 @@
1
+ # Slopify 3.1.1
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.1.1 lets the shorts draw figures the way the video does.
6
+
7
+ ## Shorts
8
+
9
+ - The shorts' image prompt (**Shorts → Image prompt**) takes `{{Appearance}}`, like the video's image and thumbnail prompts. Each short gets the subject's researched look and those of the figures its clip names, so a figure looks the same in the shorts as in the video.
10
+ - A `{{Scene}}` line in the shorts' image prompt is left out: each short's images already show what is said at that moment.
11
+
12
+ ## Help
13
+
14
+ - The **Prompts** page of the wiki explains the two keywords Slopify fills itself, `{{Scene}}` and `{{Appearance}}`: what each becomes, where it works, and an example.
15
+
16
+ ## Upgrading from 3.1.0
17
+
18
+ - **Nothing becomes outdated.** Only a shorts image prompt that names `{{Appearance}}` changes, the next time its shorts are made.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.1.1",
4
+ "title": "Slopify 3.1.1",
5
+ "version": "3.1.1",
6
+ "date": "2026-09-30"
7
+ },
8
+ {
9
+ "id": "3.1.0",
10
+ "title": "Slopify 3.1.0",
11
+ "version": "3.1.0",
12
+ "date": "2026-09-30"
13
+ },
2
14
  {
3
15
  "id": "3.0.15",
4
16
  "title": "Slopify 3.0.15",
@@ -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);
@@ -4,7 +4,7 @@ import { recipe, selectedReference, } from "./recipe-model.js";
4
4
  import { llmInput, llmInputFingerprint } from "./recipe-text.js";
5
5
  export const imageAppearanceKey = "images:appearance";
6
6
  // The prompt bodies a run draws pictures from: the generated images', the thumbnail's when it
7
- // is drawn from a Library prompt, and the establishing image's.
7
+ // is drawn from a Library prompt, the establishing image's and the shorts' image prompt.
8
8
  function pictureBodies(context) {
9
9
  const { config, content } = context;
10
10
  const images = config.sources.images === "generate"
@@ -27,6 +27,9 @@ function pictureBodies(context) {
27
27
  ...(config.sources.images === "generate" && config.reference?.source === "prompt"
28
28
  ? [content.promptTemplates.referencePrompt ?? config.rendered.referencePrompt]
29
29
  : []),
30
+ ...(config.shorts?.enabled === true && config.shorts.imagePrompt?.trim()
31
+ ? [content.promptTemplates.shortsImage ?? config.rendered.shortsImage]
32
+ : []),
30
33
  ];
31
34
  }
32
35
  export function imageAppearance(context, text) {
@@ -43,7 +43,7 @@ export function buildRecipes(context) {
43
43
  ...exports,
44
44
  ...youtube,
45
45
  ...(reference === undefined ? [] : [reference]),
46
- ...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video")),
46
+ ...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video"), appearance),
47
47
  ...thumbnail,
48
48
  ...documentRecipes(context, text, thumbnail),
49
49
  ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images, audio.cards ?? []), drawnFrom, timing === undefined ? null : resourceIdentity(context, timing), withLines(masterPlan(context, audio.levels, "video"), linePlan(context, timing)), scenes, appearance)),
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { fingerprint } from "../../kernel/runner/work.js";
3
3
  import { usesShorts } from "../admission/rules.js";
4
+ import { withoutScene } from "../images/scenes.js";
4
5
  import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
5
6
  import { defaultShortsImagePrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
6
7
  import { imagePromptMessages } from "../shorts/prompts.js";
8
+ import { lookWait, withLooks } from "./recipe-appearance.js";
7
9
  import { castFor } from "./recipe-cast.js";
8
10
  import { noMaster } from "./recipe-loudness.js";
9
11
  import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
@@ -28,7 +30,9 @@ reference,
28
30
  // "Show tables and figures on screen": the cards a short shows where it describes one.
29
31
  cards = [],
30
32
  // Level the volume: the levelled narration and the master (`recipe-loudness.ts`).
31
- master = noMaster) {
33
+ master = noMaster,
34
+ // How the figures look, for a shorts image prompt with `{{Appearance}}` (`recipe-appearance.ts`).
35
+ appearance) {
32
36
  const { config } = context;
33
37
  const shorts = config.shorts;
34
38
  const timing = exports.find((value) => value.key === "subtitles:timing");
@@ -58,8 +62,10 @@ master = noMaster) {
58
62
  { kind: "provider" });
59
63
  const picked = savedPicked(context, pick);
60
64
  const images = config.images ?? { provider: "", model: "" };
65
+ // A shorts image prompt may name `{{Scene}}` like the video's: the prompts call already writes
66
+ // each image's moment from what the clip says, so that line is left out here.
61
67
  const style = shorts.imagePrompt?.trim()
62
- ? renderedPrompt(context, "shortsImage")
68
+ ? withoutScene(renderedPrompt(context, "shortsImage"))
63
69
  : defaultShortsImagePrompt;
64
70
  if (picked === undefined)
65
71
  return [
@@ -91,9 +97,22 @@ master = noMaster) {
91
97
  const prefix = `shorts:${String(clip.number)}`;
92
98
  const token = clipToken(context, clip);
93
99
  const count = shortImageCount(clip.end - clip.start, config.imageSeconds);
100
+ // The looks of the figures the clip names, and always the subject's: a short stands for
101
+ // the video. Until they are looked up the clip's prompts wait for them.
102
+ const looked = withLooks(style, appearance, clip.text, { subjectAlways: true });
103
+ if (looked === null) {
104
+ const wait = lookWait(style, appearance);
105
+ recipes.push(recipe(context, `${prefix}:prompts`, "video", {
106
+ kind: "deferred",
107
+ version: 1,
108
+ operation: "shorts",
109
+ template: [...wait.template, style, clip.text, count],
110
+ }, [pick.key, ...wait.dependsOn], { token }));
111
+ continue;
112
+ }
94
113
  const prompts = {
95
114
  ...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
96
- style,
115
+ style: looked,
97
116
  videoTitle: config.title,
98
117
  shortTitle: clip.title,
99
118
  text: clip.text,
@@ -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.
@@ -124,7 +124,7 @@ The style, composition and everything else in the prompt stay as written; only t
124
124
 
125
125
  ## Looks looked up: {{Appearance}}
126
126
 
127
- An image model draws a named figure from whatever it half remembers, so the same character can look different in every image. Put `{{Appearance}}` in an image, thumbnail or establishing prompt, for example a line `Looks: {{Appearance}}`, and Slopify looks the looks up instead.
127
+ An image model draws a named figure from whatever it half remembers, so the same character can look different in every image. Put `{{Appearance}}` in an image, thumbnail, establishing or shorts image prompt, for example a line `Looks: {{Appearance}}`, and Slopify looks the looks up instead.
128
128
 
129
129
  - Once the article exists, one call to the project's AI model **searches the web** for how the video's subject and every named character in the article look: build, face, clothing, colours and the marks that make them recognisable. Where depictions disagree, it describes the most iconic one.
130
130
  - With **Scenes from the article** on, each picture gets the looks of the figures its scene names (up to three besides the subject), so a scene of a place or of someone else isn't redrawn with the subject in it; a scene that names no one leaves the looks line out. A thumbnail always gets the subject's look. Without scenes, every picture gets the subject's look.
@@ -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
 
@@ -50,7 +50,7 @@ When the body already has text, Slopify asks "Replace this prompt body?" first.
50
50
 
51
51
  ## Keywords (slots)
52
52
 
53
- Every `{{keyword}}` in the body becomes one field on Play.
53
+ Every `{{keyword}}` in the body becomes one field on Play, except the two Slopify fills itself (below).
54
54
 
55
55
  - You type a keyword's value once per video, and it is filled into every prompt, intro and outro that names it.
56
56
  - Names are case-sensitive: `{{Topic}}` and `{{topic}}` are two fields.
@@ -63,6 +63,30 @@ Mistakes the editor refuses, with the line and column:
63
63
  - a keyword with no name,
64
64
  - a keyword placed inside another keyword.
65
65
 
66
+ ### Keywords Slopify fills: {{Scene}} and {{Appearance}}
67
+
68
+ Two keywords never become fields. The project's AI model writes them during the run, once the article exists, and the picture waits for them:
69
+
70
+ | Keyword | Filled with | Works in | Needs |
71
+ | --- | --- | --- | --- |
72
+ | `{{Scene}}` | This picture's moment from the article: for images, in order from the article's opening to its ending; for thumbnails, its most striking moment, a different one for each of three. | Image and thumbnail prompts | Images → **Scenes from the article** on |
73
+ | `{{Appearance}}` | How the figures look, looked up on the web (most iconic depiction): the ones the picture's scene names, or the video's subject; a thumbnail or a short always gets the subject. | Image, thumbnail, establishing and shorts image prompts | Nothing: the keyword turns it on |
74
+
75
+ Put each on a line of its own with a label, for example:
76
+
77
+ ```
78
+ Scene: {{Scene}}
79
+
80
+ Looks: {{Appearance}}
81
+ ```
82
+
83
+ - An image prompt without `{{Scene}}` still gets its scene, after its first paragraph, while Scenes from the article is on. A thumbnail prompt without it gets none.
84
+ - With Scenes from the article off, a line holding `{{Scene}}` is left out. A `{{Appearance}}` line whose scene names no one is left out too.
85
+ - Each costs one more AI call per video (the looks call searches the web).
86
+ - In the shorts' image prompt (**Shorts → Image prompt**), `{{Appearance}}` gives each short the subject's look and those of the figures its clip names. A `{{Scene}}` line is left out there: each short's images already show what is said at that moment.
87
+
88
+ See [Play Images](Play-Images#scenes-from-the-article) for how the scenes and looks are chosen.
89
+
66
90
  ## Mark an image prompt as photorealistic
67
91
 
68
92
  YouTube asks whether a video contains realistic-looking AI scenes. Slopify answers for you from your marks.