@gentbajko/slopify 3.0.12 → 3.0.13

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,12 @@ export function createHub(deps) {
7
7
  // changes, so the newest one is kept here and replayed to whoever opens next; a page
8
8
  // that loads mid-render would otherwise be told nothing is running.
9
9
  let tally = { type: "running.count", count: 0 };
10
+ const present = (event) => deps.presentEvent !== undefined
11
+ ? deps.presentEvent(event)
12
+ : deps.acceptEvent?.(event) === false
13
+ ? undefined
14
+ : event;
15
+ const heartbeatMs = deps.heartbeatMs ?? 20_000;
10
16
  const send = (subscriber, event) => {
11
17
  // writeSSE rejects on a socket that is already gone, asynchronously and long after
12
18
  // emit() returned, so a dead subscriber is dropped here instead of at the call site.
@@ -38,6 +44,15 @@ export function createHub(deps) {
38
44
  }
39
45
  else {
40
46
  signal.addEventListener("abort", subscriber.drop, { once: true });
47
+ if (heartbeatMs > 0) {
48
+ const beat = setInterval(() => {
49
+ subscriber.stream
50
+ .writeSSE({ event: "ping", data: "", id: deps.ids.next() })
51
+ .catch(() => subscriber.drop());
52
+ }, heartbeatMs);
53
+ beat.unref?.();
54
+ void done.then(() => clearInterval(beat));
55
+ }
41
56
  }
42
57
  return subscriber;
43
58
  };
@@ -51,33 +66,38 @@ export function createHub(deps) {
51
66
  }
52
67
  });
53
68
  if (set.has(subscriber))
54
- for (const event of previews.snapshot(projectId))
55
- if (deps.acceptEvent?.(event) !== false)
56
- send(subscriber, event);
69
+ for (const event of previews.snapshot(projectId)) {
70
+ const shown = present(event);
71
+ if (shown !== undefined)
72
+ send(subscriber, shown);
73
+ }
57
74
  return subscriber.done;
58
75
  },
59
76
  subscribeGlobal: (stream, signal) => {
60
77
  const subscriber = join(globals, stream, signal, () => { });
61
78
  if (globals.has(subscriber)) {
62
79
  send(subscriber, tally);
80
+ // What the AI models are writing right now, so a page opened mid-call shows it.
81
+ for (const event of previews.snapshotAll()) {
82
+ const shown = present(event);
83
+ if (shown !== undefined)
84
+ send(subscriber, shown);
85
+ }
63
86
  }
64
87
  return subscriber.done;
65
88
  },
66
89
  emit: (projectId, event) => {
67
- const accepted = deps.acceptEvent?.(event) !== false;
68
- if (accepted || event.type === "stage.state")
90
+ const shown = present(event);
91
+ if (shown !== undefined)
92
+ previews.observe(shown);
93
+ else if (event.type === "stage.state")
69
94
  previews.observe(event);
70
- if (!accepted)
95
+ if (shown === undefined)
71
96
  return;
72
- for (const subscriber of projects.get(projectId) ?? []) {
73
- send(subscriber, event);
74
- }
75
- if (event.type === "project.state" ||
76
- event.type === "project.updated" ||
77
- event.type === "review.flagged") {
78
- for (const subscriber of globals)
79
- send(subscriber, event);
80
- }
97
+ for (const subscriber of projects.get(projectId) ?? [])
98
+ send(subscriber, shown);
99
+ for (const subscriber of globals)
100
+ send(subscriber, shown);
81
101
  },
82
102
  emitGlobal: (event) => {
83
103
  if (event.type === "running.count") {
@@ -39,5 +39,6 @@ export function createPreviewCache() {
39
39
  }
40
40
  },
41
41
  snapshot: (projectId) => [...(projects.get(projectId)?.values() ?? [])],
42
+ snapshotAll: () => [...projects.values()].flatMap((calls) => [...calls.values()]),
42
43
  };
43
44
  }
@@ -1,4 +1,7 @@
1
1
  import { currentRevisionId } from "../../slices/revisions/repo.js";
2
+ // Whether the work an event came from is the current revision's: made for it, or carried to it
3
+ // from an older revision by an edit that left it unchanged (a video still rendering after a
4
+ // thumbnail redo is still the project's video).
2
5
  export function currentWorkOrigin(db, projectId, origin) {
3
6
  if (currentRevisionId(db, projectId) === undefined)
4
7
  return origin.revisionId === undefined && origin.workId === undefined;
@@ -13,21 +16,52 @@ export function currentWorkOrigin(db, projectId, origin) {
13
16
  JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key)
14
17
  AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint)
15
18
  LEFT JOIN revision_work_pieces p ON p.id=r.piece_id AND p.work_id=w.id
16
- WHERE w.project_id=? AND w.revision_id=? AND w.id=?
19
+ WHERE w.project_id=? AND w.id=?
17
20
  AND (? IS NULL OR r.piece_id=?)
18
21
  AND (r.piece_id IS NULL OR (p.work_key=r.work_key AND p.fingerprint=r.fingerprint))
19
22
  `)
20
- .get(projectId, origin.revisionId, origin.workId, origin.workPieceId ?? null, origin.workPieceId ?? null) !== undefined);
23
+ .get(projectId, origin.workId, origin.workPieceId ?? null, origin.workPieceId ?? null) !==
24
+ undefined);
21
25
  }
22
- export function currentProjectEvent(db, event) {
26
+ // The event as the project's pages should see it, or undefined for one they must not: work the
27
+ // current revision no longer wants. Carried work is told under the current revision, since
28
+ // that is the revision the pages show.
29
+ export function currentEvent(db, event) {
23
30
  if (event.type === "project.updated")
24
- return true;
31
+ return event;
25
32
  if (event.type === "project.state" &&
26
33
  event.revisionId === undefined &&
27
34
  event.workId === undefined)
28
- return true;
35
+ return event;
29
36
  const head = currentRevisionId(db, event.projectId);
30
- if (head !== event.revisionId)
31
- return false;
32
- return currentWorkOrigin(db, event.projectId, event);
37
+ if (head === undefined)
38
+ return currentWorkOrigin(db, event.projectId, event) ? event : undefined;
39
+ if (!currentWorkOrigin(db, event.projectId, event))
40
+ return undefined;
41
+ return event.revisionId === head ? event : { ...event, revisionId: head };
42
+ }
43
+ export function currentProjectEvent(db, event) {
44
+ return currentEvent(db, event) !== undefined;
45
+ }
46
+ // `currentEvent` for the hub. The streamed text (a token at a time) asks the same question many
47
+ // times a second, so its answer is kept for a second per piece of work.
48
+ export function eventPresenter(db, now = Date.now) {
49
+ const seen = new Map();
50
+ return (event) => {
51
+ if (event.type !== "llm.preview" && event.type !== "article.delta")
52
+ return currentEvent(db, event);
53
+ const key = [event.projectId, event.revisionId, event.workId, event.workPieceId].join("|");
54
+ const hit = seen.get(key);
55
+ if (hit !== undefined && now() - hit.at < 1000)
56
+ return !hit.shown
57
+ ? undefined
58
+ : hit.revisionId === undefined || hit.revisionId === event.revisionId
59
+ ? event
60
+ : { ...event, revisionId: hit.revisionId };
61
+ const shown = currentEvent(db, event);
62
+ if (seen.size > 512)
63
+ seen.clear();
64
+ seen.set(key, { at: now(), shown: shown !== undefined, revisionId: shown?.revisionId });
65
+ return shown;
66
+ };
33
67
  }
package/dist/main.js CHANGED
@@ -14,7 +14,7 @@ import { createCatalogueStore } from "./catalog/store.js";
14
14
  import { createAutostart } from "./edge/autostart/index.js";
15
15
  import { dockerActivationCommitted, dockerFilesLayout, dockerFolderConfiguration, } from "./edge/docker-install/activation.js";
16
16
  import { createHub, observedHub } from "./edge/events/hub.js";
17
- import { currentProjectEvent } from "./edge/events/visibility.js";
17
+ import { eventPresenter } from "./edge/events/visibility.js";
18
18
  import { createApp } from "./edge/http/app.js";
19
19
  import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
20
20
  import { limitRequestTimes } from "./edge/http/timeouts.js";
@@ -219,7 +219,7 @@ export async function boot(config, options = {}) {
219
219
  const hub = observedHub(createHub({
220
220
  ids,
221
221
  log,
222
- acceptEvent: (event) => currentProjectEvent(eventDb, event),
222
+ presentEvent: eventPresenter(eventDb),
223
223
  }), (event) => {
224
224
  notifier.observe(event);
225
225
  episodes?.observe(event);
@@ -0,0 +1,43 @@
1
+ # Slopify 3.0.13
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.0.13 makes every page update live again, tightens the project page, adds run sounds, and fixes Restore and a thumbnail change that remade every image.
6
+
7
+ ## Highlights
8
+
9
+ - **Pages update live again.** Steps, images, progress and finished runs show up without refreshing, on every page and in every tab.
10
+ - **Run sounds.** A chime when a run starts and a different one when it ends.
11
+ - **History → Restore works again** on projects with scenes, looks, shorts or a YouTube description.
12
+
13
+ ## Live updates
14
+
15
+ - Each page now keeps one live connection instead of one per project it shows. With a few tabs open, the connections ran out and the page's own requests waited forever: updates arrived but nothing could reload.
16
+ - A run that goes on after an edit (a video still rendering after a thumbnail redo) shows its progress again.
17
+ - The projects list, home and calendar move when steps start and finish, not only when a project changes.
18
+ - The video render's progress moves in tenths of a percent, so a two-hour render moves every few seconds.
19
+ - Pages showing a running project also look again every 15 seconds, in case an update was missed.
20
+
21
+ ## Project page
22
+
23
+ - The header is one row: the way back, the title, the meta line and the buttons. Each stage's summary sits beside its title, and the run clock sits in the meta line instead of a row of its own, so the content starts much higher.
24
+ - The article shows its word and character count beside its title (the narrated text, without its sources and glossary).
25
+ - **Live** plays the finished narration, with its waveform, and plays the parts so far while it is spoken. Its length reads in hours and minutes.
26
+
27
+ ## Run clock and cost
28
+
29
+ - The run clock keeps counting through work carried over from before an edit, instead of freezing.
30
+ - **Cost so far** shows the run's working time once it stops, for example "2 h 14 min of work".
31
+
32
+ ## Notifications
33
+
34
+ - **Run sounds** (Settings → Notifications, on by default): two rising notes when a run starts, three brighter ones when it finishes, fails or waits for you. **Play start** and **Play end** let you hear them. With browser notifications on, a start also shows "Started: *title*".
35
+
36
+ ## Fixes
37
+
38
+ - **History → Restore this revision** stopped with "unknown kind of step" on projects with Scenes from the article, `{{Appearance}}`, shorts, a YouTube description, levelled narration or animated images.
39
+ - Changing a project's thumbnail settings marked every image outdated. The thumbnails' scenes are now written by a step of their own in the Thumbnail stage.
40
+
41
+ ## Upgrading from 3.0.12
42
+
43
+ - **Nothing becomes outdated.** A project whose thumbnail prompt uses `{{Scene}}` writes its thumbnail scenes in the new step the next time the thumbnail is made.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.0.13",
4
+ "title": "Slopify 3.0.13",
5
+ "version": "3.0.13",
6
+ "date": "2026-09-30"
7
+ },
2
8
  {
3
9
  "id": "3.0.12",
4
10
  "title": "Slopify 3.0.12",
@@ -9,7 +9,7 @@ import { masterPlan } from "./recipe-loudness.js";
9
9
  import { resourceIdentity } from "./recipe-model.js";
10
10
  import { imageReference, referenceRecipe } from "./recipe-reference.js";
11
11
  import { withReviews } from "./recipe-reviews.js";
12
- import { imageScenes } from "./recipe-scenes.js";
12
+ import { imageScenes, thumbnailScenes } from "./recipe-scenes.js";
13
13
  import { shortsRecipes } from "./recipe-shorts.js";
14
14
  import { textRecipes } from "./recipe-text.js";
15
15
  import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
@@ -31,9 +31,11 @@ export function buildRecipes(context) {
31
31
  // The establishing image, when it is on: every other image is drawn from it.
32
32
  const reference = referenceRecipe(context, appearance);
33
33
  const drawnFrom = imageReference(context, reference);
34
- // Each image's scene, written from the article, and the thumbnails' when their prompt asks.
34
+ // Each image's scene, written from the article, and the thumbnails' in a step of their own
35
+ // when their prompt asks.
35
36
  const scenes = imageScenes(context, text);
36
- const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined, scenes, appearance);
37
+ const thumbnailScened = thumbnailScenes(context, text);
38
+ const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined, thumbnailScened, appearance);
37
39
  const youtube = youtubeRecipes(context, exports);
38
40
  return withReviews(context, [
39
41
  ...text.recipes,
@@ -46,6 +48,7 @@ export function buildRecipes(context) {
46
48
  ...documentRecipes(context, text, thumbnail),
47
49
  ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images, audio.cards ?? []), drawnFrom, timing === undefined ? null : resourceIdentity(context, timing), withLines(masterPlan(context, audio.levels, "video"), linePlan(context, timing)), scenes, appearance)),
48
50
  ...(scenes === undefined ? [] : [scenes.recipe]),
51
+ ...(thumbnailScened === undefined ? [] : [thumbnailScened.recipe]),
49
52
  ...(appearance === undefined ? [] : [appearance.recipe]),
50
53
  ]);
51
54
  }
@@ -47,6 +47,7 @@ export const deferredOperations = [
47
47
  // Scenes from the article (`recipe-scenes.ts`): the scenes step and each image waiting on it.
48
48
  "image-scenes",
49
49
  "image-scene",
50
+ "thumbnail-scenes",
50
51
  // How the subject and characters look (`recipe-appearance.ts`), looked up on the web.
51
52
  "image-appearance",
52
53
  ];
@@ -35,6 +35,7 @@ export function recipeProviderChoice(recipe, config) {
35
35
  const family = input.operation === "narration-preparation" ||
36
36
  input.operation === "narration-description" ||
37
37
  input.operation === "image-scenes" ||
38
+ input.operation === "thumbnail-scenes" ||
38
39
  input.operation === "image-appearance"
39
40
  ? "llm"
40
41
  : recipe.stage === "audio"
@@ -4,16 +4,40 @@ import { thumbnailCountOf } from "../admission/model.js";
4
4
  import { pictureKind, sceneMessages, usesScene } from "../images/scenes.js";
5
5
  import { recipe, selectedReference, } from "./recipe-model.js";
6
6
  import { llmInput, llmInputFingerprint } from "./recipe-text.js";
7
- // A thumbnail drawn from a Library prompt whose text asks for `{{Scene}}` takes one scene per
8
- // thumbnail; any other thumbnail is drawn as it always was.
9
- export function thumbnailScenes(context) {
7
+ export const imageScenesKey = "images:scenes";
8
+ export const thumbnailScenesKey = "thumbnail:scenes";
9
+ // With Scenes from the article on, a thumbnail drawn from a Library prompt whose text asks for
10
+ // `{{Scene}}` takes one scene per thumbnail; any other thumbnail is drawn as it always was.
11
+ export function thumbnailScenes(context, text) {
10
12
  const { config, content } = context;
13
+ if (config.sources.images !== "generate" || config.imageScenes !== true)
14
+ return undefined;
11
15
  if (config.sources.thumbnail !== "from_prompt")
12
- return 0;
16
+ return undefined;
13
17
  const body = content.promptTemplates.thumbnailPrompt ?? config.rendered.thumbnailPrompt;
14
- return usesScene(body) ? thumbnailCountOf(config) : 0;
18
+ if (!usesScene(body))
19
+ return undefined;
20
+ const count = thumbnailCountOf(config);
21
+ const messages = withLanguage(sceneMessages({
22
+ title: config.title,
23
+ article: text.articleText ?? "",
24
+ pictures: [],
25
+ thumbnails: count,
26
+ }), config.language);
27
+ const value = recipe(context, thumbnailScenesKey, "thumbnail", text.articleText === null
28
+ ? {
29
+ kind: "deferred",
30
+ version: 1,
31
+ operation: "thumbnail-scenes",
32
+ template: [llmInputFingerprint(context, messages), text.article.fingerprint],
33
+ }
34
+ : llmInput(context, messages), [text.article.key]);
35
+ return {
36
+ recipe: value,
37
+ count,
38
+ scenes: text.articleText === null ? undefined : savedScenes(context, value, count),
39
+ };
15
40
  }
16
- export const imageScenesKey = "images:scenes";
17
41
  export function imageScenes(context, text) {
18
42
  const { config, content } = context;
19
43
  if (config.sources.images !== "generate" || config.imageScenes !== true)
@@ -34,15 +58,9 @@ export function imageScenes(context, text) {
34
58
  keys.push(key);
35
59
  pictures.push(pictureKind(body, name));
36
60
  }
37
- const thumbnailCount = thumbnailScenes(context);
38
- if (keys.length === 0 && thumbnailCount === 0)
61
+ if (keys.length === 0)
39
62
  return undefined;
40
- const messages = withLanguage(sceneMessages({
41
- title: config.title,
42
- article: text.articleText ?? "",
43
- pictures,
44
- ...(thumbnailCount === 0 ? {} : { thumbnails: thumbnailCount }),
45
- }), config.language);
63
+ const messages = withLanguage(sceneMessages({ title: config.title, article: text.articleText ?? "", pictures }), config.language);
46
64
  const value = recipe(context, imageScenesKey, "images", text.articleText === null
47
65
  ? {
48
66
  kind: "deferred",
@@ -51,15 +69,10 @@ export function imageScenes(context, text) {
51
69
  template: [llmInputFingerprint(context, messages), text.article.fingerprint],
52
70
  }
53
71
  : llmInput(context, messages), [text.article.key]);
54
- const saved = text.articleText === null
55
- ? undefined
56
- : savedScenes(context, value, keys.length + thumbnailCount);
57
72
  return {
58
73
  recipe: value,
59
74
  keys,
60
- scenes: saved?.slice(0, keys.length),
61
- thumbnailCount,
62
- thumbnails: saved?.slice(keys.length),
75
+ scenes: text.articleText === null ? undefined : savedScenes(context, value, keys.length),
63
76
  };
64
77
  }
65
78
  // The scenes saved for this request. The request carries the model's thinking settings from
@@ -220,10 +220,8 @@ appearance) {
220
220
  const written = config.sources.thumbnail === "prompt_by_llm" && promptRecipe !== undefined
221
221
  ? matchingText(context, promptRecipe, "prompt")
222
222
  : renderedPrompt(context, "thumbnailPrompt");
223
- const sceneFor = (variant) => scenes === undefined || scenes.thumbnailCount === 0
224
- ? undefined
225
- : scenes.thumbnails?.[variant - 1];
226
- const withScenes = scenes !== undefined && scenes.thumbnailCount > 0;
223
+ const sceneFor = (variant) => scenes?.scenes?.[variant - 1];
224
+ const withScenes = scenes !== undefined;
227
225
  const variants = Array.from({ length: thumbnailCountOf(config) }, (_, index) => index + 1);
228
226
  return variants.map((variant) => {
229
227
  const scene = sceneFor(variant);
@@ -283,6 +283,7 @@ export function priceRecipe(work, value, config) {
283
283
  if (input.kind === "deferred" &&
284
284
  (input.operation === "image-scene" ||
285
285
  input.operation === "image-scenes" ||
286
+ input.operation === "thumbnail-scenes" ||
286
287
  input.operation === "image-appearance")) {
287
288
  const choice = config === undefined ? undefined : recipeProviderChoice(value, config);
288
289
  if (choice !== undefined && input.operation === "image-scene")
@@ -123,7 +123,8 @@ export async function executeExportRecipe(deps, context, piece) {
123
123
  type: "stage.progress",
124
124
  projectId: context.work.projectId,
125
125
  stage: "video",
126
- current: Math.min(100, Math.round(elapsedMs / (totalSeconds * 10))),
126
+ // A tenth of a percent, so a two-hour render moves every few seconds, not minutes.
127
+ current: Math.min(100, Math.round(elapsedMs / totalSeconds) / 10),
127
128
  total: 100,
128
129
  });
129
130
  // Level the volume: the audio-only file is mastered to the audio files' target, the video to
@@ -17,7 +17,7 @@ import { probeDurationMs } from "../video/ffmpeg.js";
17
17
  import { parseAttribution } from "../voices/attribution.js";
18
18
  import { parseScript } from "../voices/script.js";
19
19
  import { imageAppearanceKey } from "./recipe-appearance.js";
20
- import { imageScenesKey } from "./recipe-scenes.js";
20
+ import { imageScenesKey, thumbnailScenesKey } from "./recipe-scenes.js";
21
21
  import { executeArticleRequests } from "./runtime-article.js";
22
22
  import { imageCall } from "./runtime-image.js";
23
23
  import { frozenInstructions } from "./runtime-instructions.js";
@@ -198,7 +198,8 @@ function checkAnswer(piece, answer) {
198
198
  ? "The AI model's description had no words a narrator could say. Use Try again; if it keeps happening, choose another model in the Providers section of Edit project, or turn off Describe tables and figures there."
199
199
  : undefined;
200
200
  // Scenes from the article: one scene per image, as many as the request lists.
201
- if (piece.key === imageScenesKey && piece.input.kind === "llm") {
201
+ if ((piece.key === imageScenesKey || piece.key === thumbnailScenesKey) &&
202
+ piece.input.kind === "llm") {
202
203
  const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
203
204
  return checked.ok ? undefined : checked.reason;
204
205
  }
@@ -224,7 +225,8 @@ function attributionSource(messages) {
224
225
  return messages.at(-1)?.content ?? "";
225
226
  }
226
227
  async function publishText(deps, context, piece, answer) {
227
- if (piece.key === imageScenesKey && piece.input.kind === "llm") {
228
+ if ((piece.key === imageScenesKey || piece.key === thumbnailScenesKey) &&
229
+ piece.input.kind === "llm") {
228
230
  const checked = checkScenes(answer.text, sceneCountOf(piece.input.messages) ?? 0);
229
231
  if (!checked.ok)
230
232
  throw new Error(checked.reason);
@@ -79,7 +79,7 @@ export function transitionRevisionWork(deps, input) {
79
79
  db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint) VALUES (?,?,?,?,?,?)`).run(input.projectId, input.revisionId, key, existing.workId, existing.pieceId, fp);
80
80
  continue;
81
81
  }
82
- const kind = exact?.stage ?? stageForKey(key);
82
+ const kind = exact?.stage ?? input.stages?.[key] ?? stageForKey(key);
83
83
  const stage = db
84
84
  .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
85
85
  .get(input.projectId, kind);
@@ -129,7 +129,7 @@ function existingWork(db, projectId, key, fingerprint) {
129
129
  ? undefined
130
130
  : { workId: z.string().parse(row.work_id), pieceId: z.string().parse(row.piece_id) };
131
131
  }
132
- function stageForKey(key) {
132
+ export function stageForKey(key) {
133
133
  // A review runs in its item's stage; the narration's review in Audio.
134
134
  if (key === "review:narration")
135
135
  return "audio";
@@ -142,13 +142,21 @@ function stageForKey(key) {
142
142
  return "research";
143
143
  if (prefix === "article" || prefix === "entry" || prefix === "script")
144
144
  return "article";
145
- if (prefix === "audio" || prefix === "narration")
145
+ if (prefix === "audio" || prefix === "narration" || prefix === "level")
146
146
  return "audio";
147
- if (prefix === "image" || prefix === "reference")
147
+ // `images:scenes` and `images:appearance` (the latter is Thumbnail's in a project that draws
148
+ // no images, which the plan's own stages say).
149
+ if (prefix === "image" || prefix === "images" || prefix === "reference" || prefix === "figure")
148
150
  return "images";
149
151
  if (prefix === "thumbnail")
150
152
  return "thumbnail";
151
- if (prefix === "export" || prefix === "subtitles" || prefix === "video" || prefix === "voices")
153
+ if (prefix === "export" ||
154
+ prefix === "subtitles" ||
155
+ prefix === "video" ||
156
+ prefix === "voices" ||
157
+ prefix === "shorts" ||
158
+ prefix === "youtube" ||
159
+ prefix === "animate")
152
160
  return "video";
153
161
  if (prefix === "document")
154
162
  return "document";
@@ -48,7 +48,12 @@ export async function restoreRevision(deps, input) {
48
48
  baseRevisionId: input.baseRevisionId,
49
49
  revisionId: revision.id,
50
50
  fingerprints: revision.fingerprints,
51
- ...(comparison.ok ? { baseFingerprints: comparison.baseFingerprints } : {}),
51
+ ...(comparison.ok
52
+ ? {
53
+ baseFingerprints: comparison.baseFingerprints,
54
+ stages: Object.fromEntries(comparison.recipes.map((row) => [row.key, row.stage])),
55
+ }
56
+ : {}),
52
57
  logicalKeys: logicalKeys(deps, input.projectId, input.baseRevisionId, revision.fingerprints),
53
58
  });
54
59
  carryCheckpointGates(deps, input.baseRevisionId, revision);
@@ -4,6 +4,7 @@ import { currentRevisionId } from "../revisions/repo.js";
4
4
  const attemptRow = z.object({
5
5
  kind: z.enum(stageKinds),
6
6
  revision_id: z.string().nullable(),
7
+ work_id: z.string().nullable(),
7
8
  started_at: z.string(),
8
9
  ended_at: z.string().nullable(),
9
10
  work_state: z.string().nullable(),
@@ -26,14 +27,22 @@ export function coveredMs(intervals) {
26
27
  }
27
28
  export function projectTiming(db, projectId, now) {
28
29
  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
30
+ .prepare(`SELECT s.kind AS kind, a.revision_id AS revision_id, a.work_id AS work_id,
31
+ a.started_at AS started_at, a.ended_at AS ended_at, w.state AS work_state
31
32
  FROM attempts a JOIN stages s ON s.id = a.stage_id
32
33
  LEFT JOIN revision_work w ON w.id = a.work_id
33
34
  WHERE s.project_id = ?`)
34
35
  .all(projectId)
35
36
  .map((row) => attemptRow.parse(row));
36
37
  const head = currentRevisionId(db, projectId);
38
+ // Work an edit carried over to the current revision is still its run's work: a video
39
+ // retried after a thumbnail redo renders on the revision it started on, and its hours count.
40
+ const carried = new Set(head === undefined
41
+ ? []
42
+ : db
43
+ .prepare("SELECT work_id FROM revision_work_reservations WHERE revision_id=?")
44
+ .all(head)
45
+ .flatMap((row) => (typeof row.work_id === "string" ? [row.work_id] : [])));
37
46
  const all = [];
38
47
  const runs = new Map();
39
48
  const stages = new Map();
@@ -50,12 +59,15 @@ export function projectTiming(db, projectId, now) {
50
59
  const interval = [start, end];
51
60
  all.push(interval);
52
61
  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 };
62
+ const owner = head !== undefined && row.work_id !== null && carried.has(row.work_id)
63
+ ? head
64
+ : row.revision_id;
65
+ if (owner !== null) {
66
+ const run = runs.get(owner) ?? { intervals: [], running: false, last: start };
55
67
  run.intervals.push(interval);
56
68
  run.running ||= live;
57
69
  run.last = Math.max(run.last, start);
58
- runs.set(row.revision_id, run);
70
+ runs.set(owner, run);
59
71
  }
60
72
  }
61
73
  const latest = (head === undefined ? undefined : runs.get(head)) ??
@@ -42,6 +42,15 @@ If you blocked notifications for Slopify earlier, Slopify says "Notifications ar
42
42
 
43
43
  Some browsers only allow notifications from installed apps. If the test says so, use a Notification URL instead.
44
44
 
45
+ ## Run sounds
46
+
47
+ **Settings → Notifications → Run sounds** (on by default) plays a chime when a run starts and a different, longer one when it finishes, fails or stops to wait for you, like a message arriving on a phone. **Play start** and **Play end** let you hear them.
48
+
49
+ - Works while any Slopify tab is open; only one tab plays each chime.
50
+ - Needs no permission, but browsers keep a page silent until it has been clicked once, so click anywhere in Slopify after opening it.
51
+ - With **Browser notifications** on too, a run's start also shows "Started: *title*".
52
+ - It is this browser's own setting. Your computer's volume sets how loud.
53
+
45
54
  ## Get notifications on your phone (Notification URL)
46
55
 
47
56
  A Notification URL works with no Slopify tab open. When a run finishes, fails or waits, or a review needs a decision, Slopify sends that address one `POST` with a short plain-text body. Its last line is the project's link, so you can open it straight from the message, for example:
@@ -118,7 +118,7 @@ Where the scene goes:
118
118
  - A prompt with `{{Scene}}` gets the scene there, for example a line `Scene: {{Scene}}` under the prompt's first sentence. `{{Scene}}` is never a field to fill in: Slopify writes it.
119
119
  - A prompt without it gets the scene as a line of its own after its first paragraph, so any prompt works unchanged.
120
120
  - With the switch off, a line holding `{{Scene}}` is left out and the prompt is drawn as it always was.
121
- - A **thumbnail** prompt with `{{Scene}}` gets a scene from the same call: the article's most striking moment, the one that would make someone click, taken from anywhere in the article. With **Thumbnails** set to 3, each thumbnail gets a different moment, so the A/B test compares ideas as well as compositions. A thumbnail prompt without `{{Scene}}` is drawn as it always was.
121
+ - A **thumbnail** prompt with `{{Scene}}` gets a scene from a call of its own (one more AI call, in the Thumbnail stage, so changing the thumbnail never remakes the images): the article's most striking moment, the one that would make someone click, taken from anywhere in the article. With **Thumbnails** set to 3, each thumbnail gets a different moment, so the A/B test compares ideas as well as compositions. A thumbnail prompt without `{{Scene}}` is drawn as it always was.
122
122
 
123
123
  The style, composition and everything else in the prompt stay as written; only the scene changes from image to image. The scenes are written once the article exists, and each image waits for them. Switching it on or off in **Edit project** remakes the images.
124
124