@gentbajko/slopify 3.0.4 → 3.0.5

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.
@@ -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
  })
@@ -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.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.0.5",
4
+ "title": "Slopify 3.0.5",
5
+ "version": "3.0.5",
6
+ "date": "2026-09-28"
7
+ },
2
8
  {
3
9
  "id": "3.0.4",
4
10
  "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
+ }
@@ -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
+ }
@@ -49,7 +49,7 @@ When a CLI's plan is used up, the run waits instead of failing: the status reads
49
49
 
50
50
  ## After a run: the run cost line
51
51
 
52
- When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min end to end`. A failed or cancelled run says **Spent so far**, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the full breakdown below. The line doesn't show while the run is going, or when it spent nothing.
52
+ When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`. A failed or cancelled run says **Spent so far**, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the full breakdown below. The line doesn't show while the run is going, or when it spent nothing.
53
53
 
54
54
  ## After a run: the Run cost tab
55
55
 
@@ -65,7 +65,7 @@ If nothing has been spent yet, it says "Nothing has been spent yet. The cost of
65
65
  | **paid to providers** | The total cost of this project's calls. Reads "known cost, paid to providers" when some calls have no price, with a line "Plus N calls the model catalogue has no price for." |
66
66
  | **same work via API** | The API equivalent of the calls that ran on a CLI plan. |
67
67
  | Plan meters | One per plan window a CLI reported, for example "3% of weekly Codex limit". Hover or read the line below for the level it is at now. |
68
- | **end to end** | How long the whole run took. |
68
+ | **this run's working time** | How long some step of the run was working. Waiting on a review, a limit or a pause never counts, and steps that ran side by side count once. Each save that remakes something starts a new run. |
69
69
 
70
70
  Lines under the summary:
71
71
 
@@ -89,7 +89,7 @@ Lines under the summary:
89
89
 
90
90
  **Cost by model**: the same spending split by provider and model, with **Cost**, **Via API**, **Usage** and **Calls**. A CLI model shows the API model it was priced as, in brackets after "as". The CLI's own default model shows as "default model".
91
91
 
92
- The last lines give the total usage and stage time, and "Priced from the model catalogue of <date> when each call finished."
92
+ The last lines give the total usage and the working time over every run, and "Priced from the model catalogue of <date> when each call finished."
93
93
 
94
94
  ## Across all projects: Home → This week
95
95
 
@@ -20,7 +20,7 @@ The page opens where the next action points: the failed step, the held review or
20
20
 
21
21
  ### The run cost line
22
22
 
23
- When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min end to end`. A run that ended early says **Spent so far** instead, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the **Cost** section. The line is hidden while the run is going and when it spent nothing. See [Costs and Run Cost](Costs-and-Run-Cost).
23
+ When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`: only the time Slopify was working, never the time waiting on you, a review or a limit. A run that ended early says **Spent so far** instead, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the **Cost** section. The line is hidden while the run is going and when it spent nothing. While the run is going, a clock sits there instead: `Working for 9 min 30 s`, ticking while a step runs; while it is paused or waits on you it holds still and reads `9 min 30 s of work so far`. See [Costs and Run Cost](Costs-and-Run-Cost).
24
24
 
25
25
  ### Time left
26
26