@gentbajko/slopify 3.0.4 → 3.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/edge/http/projects.js +7 -1
  2. package/dist/edge/http/studio.js +32 -11
  3. package/dist/extension/slopify-studio-chrome.zip +0 -0
  4. package/dist/extension/slopify-studio-firefox.zip +0 -0
  5. package/dist/main.js +1 -1
  6. package/dist/patch-notes/3.0.5.md +30 -0
  7. package/dist/patch-notes/3.0.6.md +34 -0
  8. package/dist/patch-notes/index.json +12 -0
  9. package/dist/slices/rebuild/activity.js +58 -0
  10. package/dist/slices/rebuild/recipe-legacy.js +2 -0
  11. package/dist/slices/rebuild/runtime-youtube.js +15 -4
  12. package/dist/slices/run-cost/panel.js +6 -23
  13. package/dist/slices/run-cost/timing.js +76 -0
  14. package/dist/slices/schedules/agenda.js +1 -1
  15. package/dist/slices/schedules/scheduler.js +3 -2
  16. package/dist/slices/schedules/service.js +9 -7
  17. package/dist/slices/schedules/topics.js +3 -3
  18. package/dist/slices/storage/downloads.js +14 -3
  19. package/dist/slices/storage/layout.js +4 -0
  20. package/dist/slices/storage/model.js +5 -0
  21. package/dist/slices/storage/portable.js +4 -4
  22. package/dist/slices/storage/trim.js +2 -0
  23. package/dist/slices/studio/pack.js +16 -5
  24. package/dist/slices/studio/settings.js +89 -24
  25. package/dist/slices/youtube/answer.js +54 -5
  26. package/dist/slices/youtube/edits-repo.js +8 -2
  27. package/dist/slices/youtube/edits.js +18 -4
  28. package/dist/slices/youtube/model.js +8 -1
  29. package/dist/tutorials/Costs-and-Run-Cost.md +3 -3
  30. package/dist/tutorials/Project-Page.md +1 -1
  31. package/dist/tutorials/Publishing-to-YouTube.md +4 -4
  32. package/dist/tutorials/Settings-Reference.md +3 -3
  33. package/dist/tutorials/Studio-Extension.md +2 -2
  34. package/dist/tutorials/YouTube-Description.md +13 -5
  35. package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
  36. package/dist/web/assets/index-CskH1GRY.js +310 -0
  37. package/dist/web/assets/{pdf-BEwm41kk.js → pdf-C5xctS6p.js} +1 -1
  38. package/dist/web/index.html +2 -2
  39. package/package.json +1 -1
  40. package/dist/web/assets/index-haZKPEuq.js +0 -307
@@ -7,6 +7,7 @@ import { defaultChannelId } from "../../slices/channels/model.js";
7
7
  import { projectChannels } from "../../slices/channels/repo.js";
8
8
  import { withProjectControl } from "../../slices/control/lock.js";
9
9
  import { stagesWithEta } from "../../slices/eta/view.js";
10
+ import { videoActivity } from "../../slices/rebuild/activity.js";
10
11
  import { resumable } from "../../slices/rebuild/recovery-repo.js";
11
12
  import { adoptBaseline } from "../../slices/revisions/adopt.js";
12
13
  import { currentRevisionId } from "../../slices/revisions/repo.js";
@@ -90,7 +91,12 @@ export function projectRoutes(deps) {
90
91
  revisionId: currentRevisionId(deps.db, project.id) ?? null,
91
92
  resumable: resumable(deps.db, project.id),
92
93
  project: summarise(project),
93
- stages: stagesWithEta(deps.db, stagesOf(deps.db, project.id), project.config, deps.clock.now()),
94
+ stages: stagesWithEta(deps.db, stagesOf(deps.db, project.id), project.config, deps.clock.now()).map((stage) => {
95
+ if (stage.kind !== "video" || stage.state !== "running")
96
+ return stage;
97
+ const activity = videoActivity(deps.db, project.id);
98
+ return activity === undefined ? stage : { ...stage, activity };
99
+ }),
94
100
  outputs: outputsOf(deps.db, project.id),
95
101
  });
96
102
  })
@@ -10,7 +10,7 @@ import { findDownload } from "../../slices/storage/downloads.js";
10
10
  import { studioPlaylistMax } from "../../slices/studio/model.js";
11
11
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
12
12
  import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
- import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylist, resetStudioPairing, saveRealFootage, saveStudioPlaylist, studioPairing, studioPlaylistProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
13
+ import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
14
14
  import { onInvalid, problem, titleOf } from "./problem.js";
15
15
  const id = z
16
16
  .string()
@@ -30,11 +30,20 @@ const queueItemBody = z.object({
30
30
  projectId: id,
31
31
  short: z.number().int().min(1).max(99).nullable().optional(),
32
32
  });
33
- const playlistBody = z.object({
34
- playlist: z.string().max(studioPlaylistMax * 2),
35
- // A channel's own playlist; absent is the default every other channel uses.
33
+ const playlistsBody = z.object({
34
+ playlists: z
35
+ .array(z.object({
36
+ name: z.string().max(studioPlaylistMax * 2),
37
+ byDefault: z.boolean(),
38
+ }))
39
+ .max(studioPlaylistsMax),
40
+ // A channel's own list; absent is the default every other channel uses.
36
41
  channelId: id.optional(),
37
42
  });
43
+ // The names this project's uploads go into; null goes back to the channel's defaults.
44
+ const projectPlaylistsBody = z.object({
45
+ playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
46
+ });
38
47
  const realFootageBody = z.object({ realFootage: z.boolean() });
39
48
  // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
40
49
  const extensionFiles = {
@@ -89,32 +98,32 @@ export function studioRoutes(deps) {
89
98
  if (denied !== undefined)
90
99
  return denied;
91
100
  return c.json({
92
- playlist: readStudioPlaylist(deps.db),
101
+ playlists: readStudioPlaylists(deps.db),
93
102
  channelPlaylists: readChannelPlaylists(deps.db),
94
103
  pairing: studioPairing(deps.db),
95
104
  });
96
105
  })
97
- .put("/settings/playlist", zValidator("json", playlistBody, onInvalid), (c) => {
106
+ .put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
98
107
  const denied = samePage(c);
99
108
  if (denied !== undefined)
100
109
  return denied;
101
- const { playlist: raw, channelId } = c.req.valid("json");
110
+ const { playlists: raw, channelId } = c.req.valid("json");
102
111
  if (channelId !== undefined && channelById(deps.db, channelId) === undefined)
103
112
  return problem(c, {
104
113
  status: 404,
105
114
  title: titleOf(404),
106
- detail: "The playlist wasn't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
115
+ detail: "The playlists weren't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
107
116
  });
108
- const invalid = studioPlaylistProblem(raw);
117
+ const invalid = studioPlaylistsProblem(raw);
109
118
  if (invalid !== undefined)
110
119
  return problem(c, {
111
120
  status: 400,
112
121
  title: titleOf(400),
113
122
  detail: invalid,
114
- extensions: { fields: [{ field: "playlist", message: invalid }] },
123
+ extensions: { fields: [{ field: "playlists", message: invalid }] },
115
124
  });
116
125
  return c.json({
117
- playlist: saveStudioPlaylist(deps.db, raw, channelId),
126
+ playlists: saveStudioPlaylists(deps.db, raw, channelId),
118
127
  channelId: channelId ?? null,
119
128
  });
120
129
  })
@@ -141,6 +150,18 @@ export function studioRoutes(deps) {
141
150
  saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
142
151
  const result = uploadPack(deps, projectId);
143
152
  return result.ok ? c.json(result.pack) : unknownProject(c);
153
+ })
154
+ // Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
155
+ .put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
156
+ const denied = samePage(c);
157
+ if (denied !== undefined)
158
+ return denied;
159
+ const { projectId } = c.req.valid("param");
160
+ if (!uploadPack(deps, projectId).ok)
161
+ return unknownProject(c);
162
+ saveProjectPlaylists(deps.db, projectId, c.req.valid("json").playlists);
163
+ const result = uploadPack(deps, projectId);
164
+ return result.ok ? c.json(result.pack) : unknownProject(c);
144
165
  })
145
166
  .post("/packs/:projectId/choose", zValidator("param", projectParam, onInvalid), zValidator("json", chooseBody, onInvalid), (c) => {
146
167
  const denied = samePage(c);
package/dist/main.js CHANGED
@@ -419,7 +419,7 @@ export async function boot(config, options = {}) {
419
419
  };
420
420
  const scheduleRunnerDeps = {
421
421
  ...draftDeps,
422
- template: (id, templateVersion) => templateById(runtimeDb, id, templateVersion),
422
+ template: (id) => templateById(runtimeDb, id),
423
423
  topicLlm: (call) => standaloneLlm(standalone, call),
424
424
  topicsWaiting: (event) => {
425
425
  hub.emitGlobal(event);
@@ -0,0 +1,30 @@
1
+ # Slopify 3.0.5
2
+
3
+ Released 28 September 2026.
4
+
5
+ 3.0.5 says what a running video is doing in plain words, adds a clock for how long Slopify worked on a project, and gives the browser extension Slopify's own icon.
6
+
7
+ ## Highlights
8
+
9
+ - **Clear video progress**: "Rendering the video (82%)" instead of "10.159999999999998 of 50".
10
+ - **A work clock** on every project: how long Slopify spent working, never the time spent waiting on you.
11
+ - **Slopify's icon** in the browser extension.
12
+
13
+ ## Project page
14
+
15
+ - A running Video stage names the step it is on and counts that step alone: "Timing the captions (30%)", "Drawing the shorts' pictures (12 of 38)", "Rendering the video (82%)". The count used to add every short's pictures and renders once the shorts were picked, so it jumped from 9 of 11 to 10 of 50 and the bar went backwards.
16
+ - While a project runs, the top of its page shows **Working for 9 min 30 s**, ticking while a step runs. While it is paused or waits on a review or a plan limit, the clock holds still and reads "9 min 30 s of work so far".
17
+ - When a run ends, the cost line adds how long it worked: "This run cost $2.73 · 22 min 15 s of work". Steps that run side by side, such as narration and images, count once.
18
+ - The **Cost** section shows this run's working time, each stage's working time and the total over every run. The old "end to end" time was often wrong: it added up stages that ran at the same time.
19
+
20
+ ## Home
21
+
22
+ - Running projects show whole numbers in their progress, never long decimals.
23
+
24
+ ## Browser extension
25
+
26
+ - The toolbar button and the browser's extensions page show Slopify's icon, not a plain letter.
27
+
28
+ ## Upgrading from 3.0.4
29
+
30
+ - **Nothing becomes outdated.** Past projects get their working time from the steps they already ran.
@@ -0,0 +1,34 @@
1
+ # Slopify 3.0.6
2
+
3
+ Released 28 September 2026.
4
+
5
+ 3.0.6 writes more of what YouTube asks for: a comment to pin and two more titles to test, alongside the description and tags. A channel can send its videos to several playlists, and schedules follow the latest version of their template.
6
+
7
+ ## Highlights
8
+
9
+ - **A pinned comment** written with every description.
10
+ - **Two more titles** for YouTube's title A/B test.
11
+ - **Several playlists** per channel, ticked per project.
12
+ - **Schedules use the template as it is now.**
13
+
14
+ ## YouTube description
15
+
16
+ - The description step also writes a comment to pin under the video: a thank-you, a question viewers can answer from their own experience, a pointer to the chapters and an ask for the next topic. It shows under Tags in the project's YouTube section with **Copy**, and **Edit** keeps your changes through a rewrite. Your Description prompt can say how you want it.
17
+ - The same step writes two **Other titles** for YouTube's A/B Testing, next to the title in Studio. Each takes a different angle on the same video, within YouTube's 100 characters. They show in the YouTube section and in Prepare upload's Title step, with **Copy others**.
18
+ - **Write again**, next to Description, writes the description, tags, pinned comment and titles anew after asking. It no longer takes a trip through Edit settings and a remake.
19
+
20
+ ## Publishing
21
+
22
+ - Settings → YouTube Studio → **Playlists** is a list for each channel (or the default list for all channels). Each playlist is either **On by default** or not.
23
+ - Prepare upload's Playlist step has a tick for each playlist, saved for that project, and the Studio extension ticks every chosen playlist in Studio.
24
+ - **Select all** above Prepare upload's steps ticks them all, or clears them.
25
+
26
+ ## Schedules
27
+
28
+ - A schedule runs, previews and checks its topics against its template as it is now. It used to keep the template version it was made with, so edits to the template never reached its runs, and the Calendar showed the template's old name.
29
+
30
+ ## Upgrading from 3.0.5
31
+
32
+ - **Nothing becomes outdated.** A description written before shows no pinned comment or other titles until you press **Write again**.
33
+ - A playlist you set before becomes the first entry of the channel's list, on by default.
34
+ - Update the Studio extension to have it tick more than one playlist. An older extension still ticks the first.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.0.6",
4
+ "title": "Slopify 3.0.6",
5
+ "version": "3.0.6",
6
+ "date": "2026-09-28"
7
+ },
8
+ {
9
+ "id": "3.0.5",
10
+ "title": "Slopify 3.0.5",
11
+ "version": "3.0.5",
12
+ "date": "2026-09-28"
13
+ },
2
14
  {
3
15
  "id": "3.0.4",
4
16
  "title": "Slopify 3.0.4",
@@ -0,0 +1,58 @@
1
+ import { currentRevisionId } from "../revisions/repo.js";
2
+ function percentOf(row) {
3
+ if (row?.current === null || row?.total === null || row === undefined || !row.total)
4
+ return undefined;
5
+ return Math.max(0, Math.min(99, Math.floor((row.current / row.total) * 100)));
6
+ }
7
+ // The steps of a kind, done of all, while one of them runs.
8
+ function counted(rows, match) {
9
+ const own = rows.filter((row) => match.test(row.key));
10
+ if (!own.some((row) => row.state === "running"))
11
+ return undefined;
12
+ return { done: own.filter((row) => row.state === "done").length, total: own.length };
13
+ }
14
+ export function videoActivity(db, projectId) {
15
+ const head = currentRevisionId(db, projectId);
16
+ if (head === undefined)
17
+ return undefined;
18
+ const rows = db
19
+ .prepare(`SELECT r.work_key AS key, w.state AS state, w.progress_current AS current, w.progress_total AS total
20
+ FROM revision_work_reservations r JOIN revision_work w ON w.id = r.work_id
21
+ WHERE r.revision_id = ? AND w.project_id = ? AND w.kind = 'video'`)
22
+ .all(head, projectId)
23
+ .map((row) => ({
24
+ key: String(row.key),
25
+ state: String(row.state),
26
+ current: typeof row.current === "number" ? row.current : null,
27
+ total: typeof row.total === "number" ? row.total : null,
28
+ }));
29
+ const running = (key) => rows.find((row) => row.key === key && row.state === "running");
30
+ const percent = (label, row) => {
31
+ const value = percentOf(row);
32
+ return value === undefined ? { label } : { label, percent: value };
33
+ };
34
+ if (running("export:video"))
35
+ return percent("rendering the video", running("export:video"));
36
+ if (running("export:wav"))
37
+ return percent("exporting the audio", running("export:wav"));
38
+ if (running("voices:files"))
39
+ return { label: "making the MP3 and M4B" };
40
+ const renders = counted(rows, /^shorts:\d+:render$/);
41
+ if (renders !== undefined)
42
+ return { label: "rendering the shorts", ...renders };
43
+ const pictures = counted(rows, /^shorts:\d+:image:\d+$/);
44
+ if (pictures !== undefined)
45
+ return { label: "drawing the shorts' pictures", ...pictures };
46
+ const prompts = counted(rows, /^shorts:\d+:prompts$/);
47
+ if (prompts !== undefined)
48
+ return { label: "planning the shorts' pictures", ...prompts };
49
+ if (running("shorts:pick"))
50
+ return { label: "picking the shorts" };
51
+ if (running("youtube:description"))
52
+ return { label: "writing the YouTube description" };
53
+ if (running("subtitles:timing"))
54
+ return percent("timing the captions", running("subtitles:timing"));
55
+ if (running("subtitles:cues") || running("subtitles:files"))
56
+ return { label: "writing the captions" };
57
+ return undefined;
58
+ }
@@ -68,6 +68,8 @@ export function legacyOutputWorkKey(output, config) {
68
68
  return "document:pdf";
69
69
  case "youtube_description":
70
70
  case "youtube_tags":
71
+ case "youtube_pinned_comment":
72
+ case "youtube_titles":
71
73
  return "youtube:description";
72
74
  case "shorts":
73
75
  return "shorts:pick";
@@ -8,7 +8,7 @@ import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
8
8
  import { savedCatalogue } from "./runtime-plan.js";
9
9
  import { preparedTexts, publishResult } from "./runtime-publication.js";
10
10
  import { wordsSchema } from "./runtime-subtitles.js";
11
- // Writes the YouTube description and tags from the saved word timing: the transcript's
11
+ // Writes the YouTube description, tags, pinned comment and other titles from the saved word timing: the transcript's
12
12
  // passages carry their start in the final video, so the chapters the model picks are real
13
13
  // times. An answer that breaks YouTube's chapter or tag rules is a failed attempt, which the
14
14
  // provider wrapper asks again while attempts remain.
@@ -66,21 +66,32 @@ export async function executeYoutubeRecipe(deps, context, providers, piece) {
66
66
  webSearch: false,
67
67
  previewLabel: piece.key,
68
68
  check: (value) => {
69
- const checked = checkDescriptionAnswer(value.text, durationSeconds);
69
+ const checked = checkDescriptionAnswer(value.text, durationSeconds, config.title);
70
70
  return checked.ok ? undefined : checked.reason;
71
71
  },
72
72
  });
73
73
  if (!answer.ok)
74
74
  return "held";
75
- const checked = checkDescriptionAnswer(answer.value.text, durationSeconds);
75
+ const checked = checkDescriptionAnswer(answer.value.text, durationSeconds, config.title);
76
76
  if (!checked.ok)
77
77
  throw new Error(checked.reason);
78
78
  const description = assembleDescription(checked.value);
79
79
  const tags = tagsText(checked.value.tags);
80
+ const pinnedComment = checked.value.pinnedComment;
81
+ const titles = checked.value.titles.join("\n");
80
82
  await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
81
83
  ["youtube_description", "description.txt", description],
82
84
  ["youtube_tags", "tags.txt", tags],
83
- ]), { description, tags: checked.value.tags, chapters: checked.value.chapters, durationSeconds });
85
+ ["youtube_pinned_comment", "pinned-comment.txt", pinnedComment],
86
+ ["youtube_titles", "titles.txt", titles],
87
+ ]), {
88
+ description,
89
+ tags: checked.value.tags,
90
+ chapters: checked.value.chapters,
91
+ pinnedComment,
92
+ titles: checked.value.titles,
93
+ durationSeconds,
94
+ });
84
95
  deps.count?.("stage.completed", {
85
96
  provider: llm.provider,
86
97
  model: llm.model,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds } from "../../kernel/pipeline.js";
3
3
  import { planAccountNames, planAccountOf, planAccounts, } from "../../kernel/ports/plan-limits.js";
4
+ import { projectTiming } from "./timing.js";
4
5
  const usageRow = z.object({
5
6
  stage: z.enum(stageKinds),
6
7
  kind: z.enum(["llm", "tts", "image", "video"]),
@@ -59,12 +60,13 @@ function add(into, row) {
59
60
  seconds: into.seconds + (row.seconds ?? 0),
60
61
  };
61
62
  }
62
- export function runCostOf(db, projectId) {
63
+ export function runCostOf(db, projectId, now = Date.now()) {
63
64
  const rows = db
64
65
  .prepare("SELECT stage, kind, provider, model, tokens_in, tokens_out, tokens_cached, characters, images, seconds, on_plan, cost, api_model, api_cost, price_json FROM provider_usage WHERE project_id = ? ORDER BY created_at, rowid")
65
66
  .all(projectId)
66
67
  .map((row) => usageRow.parse(row));
67
- const walls = stageWalls(db, projectId);
68
+ const timing = projectTiming(db, projectId, now);
69
+ const walls = timing.byStage;
68
70
  const stages = new Map();
69
71
  const models = new Map();
70
72
  let total = { ...emptyLine, ...emptyTotals };
@@ -104,8 +106,9 @@ export function runCostOf(db, projectId) {
104
106
  characters: total.characters,
105
107
  images: total.images,
106
108
  seconds: total.seconds,
107
- wallMs: [...walls.values()].reduce((sum, ms) => sum + ms, 0),
109
+ wallMs: timing.workingMs,
108
110
  },
111
+ run: timing.run,
109
112
  byStage: stageKinds.flatMap((kind) => {
110
113
  const found = stages.get(kind);
111
114
  return found === undefined ? [] : [found];
@@ -125,26 +128,6 @@ function catalogueOf(json) {
125
128
  const parsed = z.object({ catalogue: z.string() }).safeParse(JSON.parse(json));
126
129
  return parsed.success ? parsed.data.catalogue : null;
127
130
  }
128
- function stageWalls(db, projectId) {
129
- const walls = new Map();
130
- for (const raw of db
131
- .prepare("SELECT kind, started_at, finished_at FROM stages WHERE project_id = ?")
132
- .all(projectId)) {
133
- const row = z
134
- .object({
135
- kind: z.enum(stageKinds),
136
- started_at: z.string().nullable(),
137
- finished_at: z.string().nullable(),
138
- })
139
- .parse(raw);
140
- if (row.started_at === null || row.finished_at === null)
141
- continue;
142
- const ms = Date.parse(row.finished_at) - Date.parse(row.started_at);
143
- if (Number.isFinite(ms) && ms >= 0)
144
- walls.set(row.kind, ms);
145
- }
146
- return walls;
147
- }
148
131
  // Two readings belong to the same window when their resets agree to within a few minutes;
149
132
  // a reset moving on means the window rolled over between them.
150
133
  const sameWindowMs = 5 * 60_000;
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { stageKinds } from "../../kernel/pipeline.js";
3
+ import { currentRevisionId } from "../revisions/repo.js";
4
+ const attemptRow = z.object({
5
+ kind: z.enum(stageKinds),
6
+ revision_id: z.string().nullable(),
7
+ started_at: z.string(),
8
+ ended_at: z.string().nullable(),
9
+ work_state: z.string().nullable(),
10
+ });
11
+ // The time any of the intervals covers.
12
+ export function coveredMs(intervals) {
13
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
14
+ let total = 0;
15
+ let open;
16
+ for (const [start, end] of sorted) {
17
+ if (open === undefined || start > open[1]) {
18
+ if (open !== undefined)
19
+ total += open[1] - open[0];
20
+ open = [start, end];
21
+ }
22
+ else
23
+ open[1] = Math.max(open[1], end);
24
+ }
25
+ return open === undefined ? total : total + open[1] - open[0];
26
+ }
27
+ export function projectTiming(db, projectId, now) {
28
+ const rows = db
29
+ .prepare(`SELECT s.kind AS kind, a.revision_id AS revision_id, a.started_at AS started_at,
30
+ a.ended_at AS ended_at, w.state AS work_state
31
+ FROM attempts a JOIN stages s ON s.id = a.stage_id
32
+ LEFT JOIN revision_work w ON w.id = a.work_id
33
+ WHERE s.project_id = ?`)
34
+ .all(projectId)
35
+ .map((row) => attemptRow.parse(row));
36
+ const head = currentRevisionId(db, projectId);
37
+ const all = [];
38
+ const runs = new Map();
39
+ const stages = new Map();
40
+ for (const row of rows) {
41
+ const start = Date.parse(row.started_at);
42
+ if (!Number.isFinite(start))
43
+ continue;
44
+ // An attempt with no end is still running only while its step is; one a crash left open
45
+ // counts as nothing, so no clock ticks on forever.
46
+ const live = row.ended_at === null && row.work_state === "running";
47
+ const end = row.ended_at === null ? (live ? now : start) : Date.parse(row.ended_at);
48
+ if (!Number.isFinite(end) || end < start)
49
+ continue;
50
+ const interval = [start, end];
51
+ all.push(interval);
52
+ stages.set(row.kind, [...(stages.get(row.kind) ?? []), interval]);
53
+ if (row.revision_id !== null) {
54
+ const run = runs.get(row.revision_id) ?? { intervals: [], running: false, last: start };
55
+ run.intervals.push(interval);
56
+ run.running ||= live;
57
+ run.last = Math.max(run.last, start);
58
+ runs.set(row.revision_id, run);
59
+ }
60
+ }
61
+ const latest = (head === undefined ? undefined : runs.get(head)) ??
62
+ [...runs.values()].sort((a, b) => b.last - a.last)[0];
63
+ const byStage = new Map();
64
+ for (const [kind, intervals] of stages)
65
+ byStage.set(kind, coveredMs(intervals));
66
+ return {
67
+ run: latest === undefined
68
+ ? null
69
+ : runOf(latest.intervals, latest.running, latest === runs.get(head ?? "")),
70
+ workingMs: coveredMs(all),
71
+ byStage,
72
+ };
73
+ }
74
+ function runOf(intervals, running, current) {
75
+ return { current, running, workingMs: coveredMs(intervals) };
76
+ }
@@ -52,7 +52,7 @@ function upcomingRuns(deps, schedule, now, from, to) {
52
52
  : paused
53
53
  ? nextOccurrence(schedule.cadence, schedule.timezone, now)
54
54
  : new Date(schedule.nextRunAt);
55
- const template = deps.template(schedule.templateId, schedule.templateVersion);
55
+ const template = deps.template(schedule.templateId);
56
56
  const templateName = template?.name ?? null;
57
57
  const form = template?.document.form;
58
58
  const { items } = schedule;
@@ -133,12 +133,13 @@ function noTopicReason(schedule) {
133
133
  async function execute(deps, claimed) {
134
134
  let run = claimed.run;
135
135
  try {
136
- const template = deps.template(claimed.schedule.templateId, claimed.schedule.templateVersion);
136
+ // The template as it is now, so an edit to it reaches the next run.
137
+ const template = deps.template(claimed.schedule.templateId);
137
138
  if (template === undefined)
138
139
  throw new ScheduleDispatchError("missing-template");
139
140
  const document = freshTemplateDraft(deps, template.document, {
140
141
  id: claimed.schedule.templateId,
141
- version: claimed.schedule.templateVersion,
142
+ version: template.version,
142
143
  });
143
144
  const topic = claimed.schedule.items[0];
144
145
  const fresh = {
@@ -35,7 +35,7 @@ export function createSchedule(deps, input) {
35
35
  ? { ok: true, value: scheduleById(deps.db, parsed.data.id) }
36
36
  : { ok: false, reason: "conflict" };
37
37
  }
38
- const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
38
+ const checked = checkTemplate(deps, parsed.data.templateId);
39
39
  if (!checked.ok)
40
40
  return checked;
41
41
  const topics = checkTopics(deps, parsed.data, []);
@@ -45,7 +45,8 @@ export function createSchedule(deps, input) {
45
45
  const next = nextOccurrence(parsed.data.cadence, parsed.data.timezone, now);
46
46
  if (next === null)
47
47
  return { ok: false, reason: "not-due" };
48
- const value = summary(parsed.data, next.toISOString(), now.toISOString());
48
+ // The version it was saved against, for the record; runs use the newest.
49
+ const value = summary({ ...parsed.data, templateVersion: checked.value }, next.toISOString(), now.toISOString());
49
50
  insertSchedule(deps.db, value, hash);
50
51
  return { ok: true, value };
51
52
  });
@@ -77,7 +78,7 @@ export function updateSchedule(deps, input) {
77
78
  return { ok: false, reason: "conflict" };
78
79
  if (previous.status === "canceled" || previous.status === "completed")
79
80
  return { ok: false, reason: "conflict" };
80
- const checked = checkTemplate(deps, parsed.data.templateId, parsed.data.templateVersion);
81
+ const checked = checkTemplate(deps, parsed.data.templateId);
81
82
  if (!checked.ok)
82
83
  return checked;
83
84
  const topics = checkTopics(deps, parsed.data, previous.items);
@@ -155,8 +156,9 @@ function control(deps, input, status) {
155
156
  return { ok: false, reason: "conflict" };
156
157
  return { ok: true, value: scheduleById(deps.db, previous.id) };
157
158
  }
158
- function checkTemplate(deps, id, version) {
159
- const template = deps.template(id, version);
159
+ // Answers the template's newest version when a schedule may use it.
160
+ function checkTemplate(deps, id) {
161
+ const template = deps.template(id);
160
162
  if (!template)
161
163
  return { ok: false, reason: "missing-template" };
162
164
  const sources = template.document.form.sources;
@@ -169,13 +171,13 @@ function checkTemplate(deps, id, version) {
169
171
  form.ambientBed?.source === "upload" ||
170
172
  (sources.images === "generate" && form.reference?.source === "provide"))
171
173
  return { ok: false, reason: "unsupported-media" };
172
- return { ok: true, value: true };
174
+ return { ok: true, value: template.version };
173
175
  }
174
176
  // The queue's keywords must be the template's: a topic (or an every-run value) naming a keyword
175
177
  // the template doesn't use would silently do nothing. Topics saved before, unchanged, are left
176
178
  // alone, so a queue from before this check still saves.
177
179
  function checkTopics(deps, input, kept) {
178
- const template = deps.template(input.templateId, input.templateVersion);
180
+ const template = deps.template(input.templateId);
179
181
  if (!template)
180
182
  return { ok: false, reason: "missing-template" };
181
183
  const keywords = templateKeywords(template.document.form);
@@ -101,7 +101,7 @@ async function ask(deps, schedule, options) {
101
101
  .run(failedAt.toISOString(), text, schedule.id);
102
102
  return { ok: false, reason: "failed", message: text };
103
103
  };
104
- const template = deps.template(schedule.templateId, schedule.templateVersion);
104
+ const template = deps.template(schedule.templateId);
105
105
  const llm = schedule.topicGeneration.llm ?? template?.document.form.llm;
106
106
  if (llm === undefined)
107
107
  return fail("the template this schedule uses was deleted, so there is no LLM to ask. Edit the schedule and choose a template, or pick an LLM under Topic generation.");
@@ -394,7 +394,7 @@ export function editHeldTopic(deps, scheduleId, topicId, input) {
394
394
  const schedule = scheduleById(deps.db, scheduleId);
395
395
  if (schedule === undefined)
396
396
  return { ok: false, reason: "not-found" };
397
- const template = deps.template(schedule.templateId, schedule.templateVersion);
397
+ const template = deps.template(schedule.templateId);
398
398
  if (template === undefined)
399
399
  return { ok: false, reason: "missing-template" };
400
400
  const problems = topicRowProblems([{ title: parsed.data, values }], {
@@ -463,7 +463,7 @@ export function replaceTopics(deps, scheduleId, input) {
463
463
  const schedule = open.value;
464
464
  if (schedule.version !== parsed.data.baseVersion)
465
465
  return { ok: false, reason: "conflict" };
466
- const template = deps.template(schedule.templateId, schedule.templateVersion);
466
+ const template = deps.template(schedule.templateId);
467
467
  if (!template)
468
468
  return { ok: false, reason: "missing-template" };
469
469
  const problems = topicRowProblems(parsed.data.items, {
@@ -58,7 +58,10 @@ export function findDownload(deps, projectId, asset) {
58
58
  if (stats === undefined) {
59
59
  return { ok: false, reason: "missing-file" };
60
60
  }
61
- const shown = output.role === "youtube_description" || output.role === "youtube_tags"
61
+ const shown = output.role === "youtube_description" ||
62
+ output.role === "youtube_tags" ||
63
+ output.role === "youtube_pinned_comment" ||
64
+ output.role === "youtube_titles"
62
65
  ? shownYoutubeText(deps, projectId, output.role)
63
66
  : undefined;
64
67
  return {
@@ -72,7 +75,7 @@ export function findDownload(deps, projectId, asset) {
72
75
  },
73
76
  };
74
77
  }
75
- // The description and tags download as the project page shows and copies them, and as Prepare
78
+ // The description, tags, pinned comment and other titles download as the project page shows and copies them, and as Prepare
76
79
  // upload hands them on: the user's hand edits over the written text, chapters fitted, and the
77
80
  // channel's and project's links filled in (`youtube/edits-repo.ts`). The file on disk stays
78
81
  // the generated text, so regeneration can tell the user's edits from its own.
@@ -92,11 +95,19 @@ function shownYoutubeText(deps, projectId, role) {
92
95
  const shown = effectiveDescription(deps.db, projectId, {
93
96
  description,
94
97
  tags: read("youtube_tags") ?? "",
98
+ pinnedComment: read("youtube_pinned_comment"),
99
+ titles: read("youtube_titles"),
95
100
  durationSeconds: video?.durationMs === null || video?.durationMs === undefined
96
101
  ? undefined
97
102
  : video.durationMs / 1000,
98
103
  });
99
- const text = role === "youtube_description" ? shown.description : shown.tags;
104
+ const text = role === "youtube_description"
105
+ ? shown.description
106
+ : role === "youtube_tags"
107
+ ? shown.tags
108
+ : role === "youtube_pinned_comment"
109
+ ? shown.pinnedComment
110
+ : shown.titles;
100
111
  return new TextEncoder().encode(`${text}\n`);
101
112
  }
102
113
  // "download all" images as `<title-slug>-images.zip`, thumbnail included.
@@ -68,6 +68,10 @@ export function outputFileName(role, index, extension, stageKind) {
68
68
  return "description.txt";
69
69
  case "youtube_tags":
70
70
  return "tags.txt";
71
+ case "youtube_pinned_comment":
72
+ return "pinned-comment.txt";
73
+ case "youtube_titles":
74
+ return "titles.txt";
71
75
  case "shorts":
72
76
  return "shorts.json";
73
77
  case "short_image":
@@ -27,6 +27,11 @@ export const outputRoles = [
27
27
  "document_pdf",
28
28
  "youtube_description",
29
29
  "youtube_tags",
30
+ // A comment to pin under the video, written with the description. Projects described before
31
+ // it existed have none until the description is written again.
32
+ "youtube_pinned_comment",
33
+ // Other titles for YouTube's title A/B test, one per line, written with the description.
34
+ "youtube_titles",
30
35
  // The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
31
36
  "shorts",
32
37
  "short_image",