@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.
- package/dist/edge/http/schedules.js +27 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0043-prepared-videos.sql +13 -0
- package/dist/patch-notes/3.0.15.md +17 -0
- package/dist/patch-notes/3.1.0.md +28 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/play-drafts/review.js +8 -1
- package/dist/slices/play-drafts/schema.js +3 -0
- package/dist/slices/play-drafts/start.js +9 -5
- package/dist/slices/rebuild/narration-retry.js +10 -0
- package/dist/slices/schedules/agenda.js +7 -1
- package/dist/slices/schedules/prepare.js +147 -0
- package/dist/slices/schedules/scheduler.js +14 -25
- package/dist/slices/schedules/schema.js +3 -0
- package/dist/slices/storage/backup-format.js +1 -0
- package/dist/tutorials/Calendar.md +2 -0
- package/dist/tutorials/Play-Overview.md +12 -1
- package/dist/tutorials/Schedules.md +17 -0
- package/dist/web/assets/{index-aN9pfG3k.js → index-DBLI2o4h.js} +62 -62
- package/dist/web/assets/{pdf-D0quBJqy.js → pdf-CViHVF9X.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -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) => {
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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.
|