@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.
- package/dist/edge/http/projects.js +7 -1
- package/dist/edge/http/studio.js +32 -11
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +1 -1
- package/dist/patch-notes/3.0.5.md +30 -0
- package/dist/patch-notes/3.0.6.md +34 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/rebuild/activity.js +58 -0
- package/dist/slices/rebuild/recipe-legacy.js +2 -0
- package/dist/slices/rebuild/runtime-youtube.js +15 -4
- package/dist/slices/run-cost/panel.js +6 -23
- package/dist/slices/run-cost/timing.js +76 -0
- package/dist/slices/schedules/agenda.js +1 -1
- package/dist/slices/schedules/scheduler.js +3 -2
- package/dist/slices/schedules/service.js +9 -7
- package/dist/slices/schedules/topics.js +3 -3
- package/dist/slices/storage/downloads.js +14 -3
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/portable.js +4 -4
- package/dist/slices/storage/trim.js +2 -0
- package/dist/slices/studio/pack.js +16 -5
- package/dist/slices/studio/settings.js +89 -24
- package/dist/slices/youtube/answer.js +54 -5
- package/dist/slices/youtube/edits-repo.js +8 -2
- package/dist/slices/youtube/edits.js +18 -4
- package/dist/slices/youtube/model.js +8 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +3 -3
- package/dist/tutorials/Project-Page.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +4 -4
- package/dist/tutorials/Settings-Reference.md +3 -3
- package/dist/tutorials/Studio-Extension.md +2 -2
- package/dist/tutorials/YouTube-Description.md +13 -5
- package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
- package/dist/web/assets/index-CskH1GRY.js +310 -0
- package/dist/web/assets/{pdf-BEwm41kk.js → pdf-C5xctS6p.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- 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
|
})
|
package/dist/edge/http/studio.js
CHANGED
|
@@ -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,
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
101
|
+
playlists: readStudioPlaylists(deps.db),
|
|
93
102
|
channelPlaylists: readChannelPlaylists(deps.db),
|
|
94
103
|
pairing: studioPairing(deps.db),
|
|
95
104
|
});
|
|
96
105
|
})
|
|
97
|
-
.put("/settings/
|
|
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 {
|
|
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
|
|
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 =
|
|
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: "
|
|
123
|
+
extensions: { fields: [{ field: "playlists", message: invalid }] },
|
|
115
124
|
});
|
|
116
125
|
return c.json({
|
|
117
|
-
|
|
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);
|
|
Binary file
|
|
Binary file
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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" ||
|
|
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
|
|
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"
|
|
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",
|