@gentbajko/slopify 3.0.7 → 3.0.9
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/autostart/native.js +12 -3
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0042-narration-retries.sql +13 -0
- package/dist/main.js +14 -19
- package/dist/patch-notes/3.0.8.md +23 -0
- package/dist/patch-notes/3.0.9.md +27 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/rules.js +71 -2
- package/dist/slices/admission/schema.js +2 -0
- package/dist/slices/admission/substitute.js +5 -2
- package/dist/slices/estimate/index.js +3 -0
- package/dist/slices/images/scenes.js +103 -0
- package/dist/slices/play-drafts/convert.js +5 -1
- package/dist/slices/play-drafts/schema.js +2 -0
- package/dist/slices/project-templates/from-project.js +1 -0
- package/dist/slices/rebuild/narration-retry.js +128 -0
- package/dist/slices/rebuild/preview-details.js +3 -1
- package/dist/slices/rebuild/recipe-build.js +5 -1
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
- package/dist/slices/rebuild/recipe-save.js +3 -2
- package/dist/slices/rebuild/recipe-scenes.js +61 -0
- package/dist/slices/rebuild/recipe-text.js +4 -3
- package/dist/slices/rebuild/recipe-validation.js +3 -2
- package/dist/slices/rebuild/recipe-visual.js +46 -2
- package/dist/slices/rebuild/recipe-work.js +18 -0
- package/dist/slices/rebuild/recovery-selection.js +7 -0
- package/dist/slices/rebuild/runtime-document.js +1 -1
- package/dist/slices/rebuild/runtime-piece-label.js +2 -0
- package/dist/slices/rebuild/runtime-plan.js +1 -1
- package/dist/slices/rebuild/runtime-provider.js +16 -0
- package/dist/slices/rebuild/runtime-subtitles.js +53 -11
- package/dist/slices/revisions/mutation-prepare.js +1 -1
- package/dist/slices/revisions/view.js +3 -1
- package/dist/slices/storage/backup-format.js +1 -0
- package/dist/tutorials/Play-Images.md +16 -0
- package/dist/tutorials/Play-Title-and-Article.md +1 -0
- package/dist/updater/service.js +13 -2
- package/dist/updater/work-in-progress.js +59 -0
- package/dist/web/assets/{index-Cdb0Jh3P.js → index-CIXw6vpi.js} +115 -115
- package/dist/web/assets/{pdf-BhFuoBjr.js → pdf-B9CdIc1m.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -77,6 +77,10 @@ export function createNativeAutostart(o) {
|
|
|
77
77
|
const ours = text.includes(platform === "linux" ? desktopMarker : launcherMarker);
|
|
78
78
|
return { state: ours ? "ours" : "foreign", text };
|
|
79
79
|
}
|
|
80
|
+
const thisLauncher = launcherRef(platform, at.launcher);
|
|
81
|
+
function runsThisFolder(now) {
|
|
82
|
+
return now.state === "ours" && (now.text?.includes(thisLauncher) ?? false);
|
|
83
|
+
}
|
|
80
84
|
function state(enabled) {
|
|
81
85
|
return {
|
|
82
86
|
available: true,
|
|
@@ -113,7 +117,9 @@ export function createNativeAutostart(o) {
|
|
|
113
117
|
await o.fs.write(at.entry, entryText, 0o644);
|
|
114
118
|
}
|
|
115
119
|
return {
|
|
116
|
-
|
|
120
|
+
// On only when the entry starts this data folder: a second Slopify (another --data-dir,
|
|
121
|
+
// a test copy) sees the switch off, so turning it off there can't remove this one's entry.
|
|
122
|
+
status: async () => state(runsThisFolder(await entryNow())),
|
|
117
123
|
enable: async () => {
|
|
118
124
|
if (platform === "win32") {
|
|
119
125
|
const problem = windowsPathProblem(o.dataDir);
|
|
@@ -121,6 +127,8 @@ export function createNativeAutostart(o) {
|
|
|
121
127
|
throw new Error(problem);
|
|
122
128
|
}
|
|
123
129
|
const now = await entryNow();
|
|
130
|
+
if (now.state === "ours" && !runsThisFolder(now))
|
|
131
|
+
throw new Error(`Another Slopify, with a different data folder, already starts when you log in (${at.where}). Slopify left it alone. Turn it off in that Slopify's Settings → General, or delete ${platform === "win32" ? `the ${runValue} entry in Task Manager → Startup apps` : "that file"}, then turn the switch on here again.`);
|
|
124
132
|
if (now.state === "foreign")
|
|
125
133
|
throw new Error(platform === "win32"
|
|
126
134
|
? `Your start-up list already has an entry named ${runValue} that Slopify didn't make (${runKey}). Slopify left it alone. Remove it in Task Manager → Startup apps, or rename it, then turn the switch on again.`
|
|
@@ -131,7 +139,8 @@ export function createNativeAutostart(o) {
|
|
|
131
139
|
},
|
|
132
140
|
disable: async () => {
|
|
133
141
|
const now = await entryNow();
|
|
134
|
-
|
|
142
|
+
// Only this data folder's entry: another Slopify's is not this switch's to remove.
|
|
143
|
+
if (runsThisFolder(now)) {
|
|
135
144
|
if (platform === "win32") {
|
|
136
145
|
const result = await o.exec("reg", ["delete", runKey, "/v", runValue, "/f"]);
|
|
137
146
|
if (result.code !== 0)
|
|
@@ -151,7 +160,7 @@ export function createNativeAutostart(o) {
|
|
|
151
160
|
const now = await entryNow();
|
|
152
161
|
// Only the entry that runs this data folder's launcher: a Slopify started with another
|
|
153
162
|
// --data-dir must not take over the one the switch was turned on from.
|
|
154
|
-
if (
|
|
163
|
+
if (!runsThisFolder(now))
|
|
155
164
|
return;
|
|
156
165
|
await writeLauncher();
|
|
157
166
|
await writeEntry(now.text);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- A narration chunk whose audio stopped matching its text is recorded again by itself: the
|
|
2
|
+
-- caption step asks for it here, and the retry starts once that step's failure is saved. The
|
|
3
|
+
-- count caps it at two tries per chunk; `state` is what the last try did.
|
|
4
|
+
CREATE TABLE narration_retries (
|
|
5
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
6
|
+
chunk_key TEXT NOT NULL,
|
|
7
|
+
tries INTEGER NOT NULL CHECK (tries > 0),
|
|
8
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'started', 'failed')),
|
|
9
|
+
detail TEXT,
|
|
10
|
+
updated_at TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY(project_id, chunk_key)
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX narration_retries_pending ON narration_retries(state) WHERE state = 'pending';
|
package/dist/main.js
CHANGED
|
@@ -55,6 +55,7 @@ import { createNotificationSender } from "./slices/notifications/send.js";
|
|
|
55
55
|
import { readNotificationUrl } from "./slices/notifications/settings.js";
|
|
56
56
|
import { seedSamples } from "./slices/onboarding/sample.js";
|
|
57
57
|
import { templateById } from "./slices/project-templates/repo.js";
|
|
58
|
+
import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
|
|
58
59
|
import { recoverProject } from "./slices/rebuild/recovery.js";
|
|
59
60
|
import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
|
|
60
61
|
import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
|
|
@@ -88,6 +89,7 @@ import { isUpdateToken } from "./updater/model.js";
|
|
|
88
89
|
import { npmCommand, updateCommitted } from "./updater/plan.js";
|
|
89
90
|
import { publishedVersion } from "./updater/registry.js";
|
|
90
91
|
import { createUpdater } from "./updater/service.js";
|
|
92
|
+
import { workInProgress } from "./updater/work-in-progress.js";
|
|
91
93
|
// ceiling: a burst of finished stages coalesces into one delivery a second later, and the
|
|
92
94
|
// collector gets ten seconds to answer before the attempt is abandoned and the events
|
|
93
95
|
// stay queued.
|
|
@@ -261,8 +263,12 @@ export async function boot(config, options = {}) {
|
|
|
261
263
|
});
|
|
262
264
|
const audioPreviews = createAudioPreviewStore();
|
|
263
265
|
const reviewRedos = createReviewRedos();
|
|
266
|
+
const narrationRetries = createNarrationRetries();
|
|
264
267
|
const runner = wireRunner({
|
|
265
|
-
onFinished: (work) =>
|
|
268
|
+
onFinished: (work) => {
|
|
269
|
+
reviewRedos.kick(work.projectId);
|
|
270
|
+
narrationRetries.kick(work.projectId);
|
|
271
|
+
},
|
|
266
272
|
db,
|
|
267
273
|
paths,
|
|
268
274
|
clock,
|
|
@@ -332,24 +338,10 @@ export async function boot(config, options = {}) {
|
|
|
332
338
|
: npm === undefined
|
|
333
339
|
? "npm was not found, so in-app updates are off. Install Node.js with npm (https://nodejs.org), or update from the terminal: npx @gentbajko/slopify@latest"
|
|
334
340
|
: undefined,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
.all()
|
|
340
|
-
.some((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true),
|
|
341
|
-
busyWith: () => {
|
|
342
|
-
const running = updateDb
|
|
343
|
-
.prepare("SELECT p.title FROM stages s JOIN projects p ON p.id=s.project_id WHERE s.state='running' LIMIT 1")
|
|
344
|
-
.get();
|
|
345
|
-
if (typeof running?.title === "string")
|
|
346
|
-
return running.title;
|
|
347
|
-
const inflight = updateDb
|
|
348
|
-
.prepare("SELECT id,title FROM projects")
|
|
349
|
-
.all()
|
|
350
|
-
.find((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true);
|
|
351
|
-
return typeof inflight?.title === "string" ? inflight.title : undefined;
|
|
352
|
-
},
|
|
341
|
+
// An update never installs while a job is going (`updater/work-in-progress.ts`).
|
|
342
|
+
busy: () => workInProgress(updateDb, (id) => runner.hasInflight?.(id) === true, Date.now()) !==
|
|
343
|
+
undefined,
|
|
344
|
+
busyWith: () => workInProgress(updateDb, (id) => runner.hasInflight?.(id) === true, Date.now()),
|
|
353
345
|
report: (message) => log.write("warn", "update", { detail: message }),
|
|
354
346
|
install: async (next, restarting) => {
|
|
355
347
|
if (npm === undefined)
|
|
@@ -386,6 +378,9 @@ export async function boot(config, options = {}) {
|
|
|
386
378
|
// Redos a review asked for before the last shutdown start now.
|
|
387
379
|
reviewRedos.bind(rebuild);
|
|
388
380
|
reviewRedos.kick();
|
|
381
|
+
// So do narration chunks the captions asked to record again.
|
|
382
|
+
narrationRetries.bind(rebuild);
|
|
383
|
+
narrationRetries.kick();
|
|
389
384
|
// A stage that was waiting for a CLI's plan limits when the app stopped carries on waiting.
|
|
390
385
|
void resumeAfterRestart(db, async (projectId) => {
|
|
391
386
|
const baseRevisionId = currentRevisionId(runtimeDb, projectId);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Slopify 3.0.8
|
|
2
|
+
|
|
3
|
+
Released 29 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.8 fixes a garbled narration sentence by itself instead of stopping the run.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Garbled narration is recorded again by itself.** A run no longer stops for a sentence the voice mumbled or skipped.
|
|
10
|
+
|
|
11
|
+
## Narration
|
|
12
|
+
|
|
13
|
+
- When captions find the narration no longer matches the text (the voice garbled or skipped a sentence), Slopify records that narration chunk again by itself, rebuilds the narration, captions and video, and carries on. It used to stop the run until you pressed Regenerate on the chunk in Edit project → Narration and continued.
|
|
14
|
+
- Each chunk gets two tries. The step says which try is running; if the audio still doesn't match after both, the voice reads something its own way (a year, an abbreviation or a name), and the step says to reword that sentence in Edit project → Narration.
|
|
15
|
+
- Uploaded narration, captions you edited by hand and multi-voice turns aren't retried: they stop and ask, as before.
|
|
16
|
+
|
|
17
|
+
## Everywhere
|
|
18
|
+
|
|
19
|
+
- **Start Slopify when I log in** belongs to one data folder. A second Slopify on the same account (another `--data-dir`) sees the switch off and can no longer remove or take over the first one's login entry.
|
|
20
|
+
|
|
21
|
+
## Upgrading from 3.0.7
|
|
22
|
+
|
|
23
|
+
- **Nothing becomes outdated.** Retries start with the next run that finds a mismatch.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Slopify 3.0.9
|
|
2
|
+
|
|
3
|
+
Released 29 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.9 gives every image its own moment from the article, lets a project run without an article, and makes sure an update never lands in the middle of a job.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Scenes from the article:** images of one prompt stop being the same picture drawn again.
|
|
10
|
+
- **Article can be Off**, for projects of images or a thumbnail only.
|
|
11
|
+
- **Updates wait for every kind of job**, including a batch between two videos and a schedule about to run.
|
|
12
|
+
|
|
13
|
+
## Images
|
|
14
|
+
|
|
15
|
+
- **Scenes from the article** (Images, in Play and Edit project; off by default). Before the images are drawn, the project's AI model reads the article once and writes a short scene for each image, from the article's opening to its ending in the order the images are shown. Each image is drawn from its prompt with its scene added: where the prompt has `{{Scene}}`, there, otherwise after the prompt's first paragraph, so any prompt works unchanged. `{{Scene}}` is never a field to fill in; with the switch off, a `{{Scene}}` line is left out. One more AI call per run, shown on Review as "Image scenes".
|
|
16
|
+
|
|
17
|
+
## Article
|
|
18
|
+
|
|
19
|
+
- **Article: Off.** A project can have no article at all: for images or a thumbnail made from prompts, with no placeholder text. Choosing Off switches off what reads the article (research, narration, captions, the YouTube description, shorts, the PDF and Scenes from the article) and takes their sections out of Play and Edit project; the thumbnail offers From prompt and Provide only.
|
|
20
|
+
|
|
21
|
+
## Updates
|
|
22
|
+
|
|
23
|
+
- An update never installs while a job is going. Besides a running step, that now includes a step waiting to try again, a project between two steps, a batch with videos still queued, a narration chunk about to be recorded again, and a schedule writing topics or due to start within 10 minutes. A waiting update installs by itself once everything has been idle for two looks in a row. Updates still start only when you press Update.
|
|
24
|
+
|
|
25
|
+
## Upgrading from 3.0.8
|
|
26
|
+
|
|
27
|
+
- **Nothing becomes outdated.** Scenes from the article is off until you switch it on in a project or template.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.0.9",
|
|
4
|
+
"title": "Slopify 3.0.9",
|
|
5
|
+
"version": "3.0.9",
|
|
6
|
+
"date": "2026-09-29"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.0.8",
|
|
10
|
+
"title": "Slopify 3.0.8",
|
|
11
|
+
"version": "3.0.8",
|
|
12
|
+
"date": "2026-09-29"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.0.7",
|
|
4
16
|
"title": "Slopify 3.0.7",
|
|
@@ -58,7 +58,7 @@ export function edgeSilenceSecondsProblem(value) {
|
|
|
58
58
|
// drawn in; the rule itself only asks whether a source is on its stage's list.
|
|
59
59
|
export const allowedSources = {
|
|
60
60
|
research: ["off", "generate", "provide"],
|
|
61
|
-
article: ["generate", "provide"],
|
|
61
|
+
article: ["off", "generate", "provide"],
|
|
62
62
|
audio: ["off", "generate", "provide"],
|
|
63
63
|
images: ["off", "generate", "provide"],
|
|
64
64
|
thumbnail: ["off", "from_prompt", "prompt_by_llm", "provide"],
|
|
@@ -159,7 +159,8 @@ export function admit(input) {
|
|
|
159
159
|
// only generated narration uses separately selected intro/outro entries.
|
|
160
160
|
export function normaliseDraft(draft) {
|
|
161
161
|
const sources = { ...draft.sources };
|
|
162
|
-
|
|
162
|
+
// A pasted article, or none, is not researched.
|
|
163
|
+
if (sources.article !== "generate") {
|
|
163
164
|
sources.research = "off";
|
|
164
165
|
}
|
|
165
166
|
if (sources.images === "off" && sources.video === "generate") {
|
|
@@ -226,6 +227,7 @@ function checkProvided(draft, staged, fields) {
|
|
|
226
227
|
if (sources.article === "provide" && blank(provided.article)) {
|
|
227
228
|
fields.push({ field: "provided.article", message: "Paste the article." });
|
|
228
229
|
}
|
|
230
|
+
fields.push(...articleOffFields(draft));
|
|
229
231
|
if (sources.audio === "provide") {
|
|
230
232
|
checkFile(staged, provided.audio, "audio", "provided.audio", "Pick an audio file.", fields);
|
|
231
233
|
}
|
|
@@ -497,3 +499,70 @@ export function usesPronunciationGlossary(draft) {
|
|
|
497
499
|
draft.audio.provider === "inworld" &&
|
|
498
500
|
(draft.audio.model === "inworld-tts-2" || draft.audio.model === "inworld-tts-2-flash"));
|
|
499
501
|
}
|
|
502
|
+
// Article Off: a project of images or a thumbnail from prompts, with no text at all. What reads
|
|
503
|
+
// the article can't be on with it.
|
|
504
|
+
export function articleOffFields(draft) {
|
|
505
|
+
const { sources } = draft;
|
|
506
|
+
if (sources.article !== "off")
|
|
507
|
+
return [];
|
|
508
|
+
const fix = "Set Article to Generate or Provide";
|
|
509
|
+
return [
|
|
510
|
+
...(sources.audio === "generate"
|
|
511
|
+
? [
|
|
512
|
+
{
|
|
513
|
+
field: "sources.audio",
|
|
514
|
+
message: `Narration reads the article, and Article is Off. ${fix}, or set Narration to Off or Provide.`,
|
|
515
|
+
},
|
|
516
|
+
]
|
|
517
|
+
: []),
|
|
518
|
+
...(sources.document === "generate"
|
|
519
|
+
? [
|
|
520
|
+
{
|
|
521
|
+
field: "sources.document",
|
|
522
|
+
message: `The PDF is made from the article, and Article is Off. ${fix}, or set PDF to Off.`,
|
|
523
|
+
},
|
|
524
|
+
]
|
|
525
|
+
: []),
|
|
526
|
+
...(sources.thumbnail === "prompt_by_llm"
|
|
527
|
+
? [
|
|
528
|
+
{
|
|
529
|
+
field: "sources.thumbnail",
|
|
530
|
+
message: `The AI writes the thumbnail prompt from the article, and Article is Off. ${fix}, or pick From prompt for the thumbnail.`,
|
|
531
|
+
},
|
|
532
|
+
]
|
|
533
|
+
: []),
|
|
534
|
+
// Captions, the description and the shorts match the narration to the article's words.
|
|
535
|
+
...(sources.audio !== "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off"
|
|
536
|
+
? [
|
|
537
|
+
{
|
|
538
|
+
field: "subtitles.mode",
|
|
539
|
+
message: `Captions are matched to the article's words, and Article is Off. ${fix}, or turn captions off.`,
|
|
540
|
+
},
|
|
541
|
+
]
|
|
542
|
+
: []),
|
|
543
|
+
...(usesYoutubeDescription(draft)
|
|
544
|
+
? [
|
|
545
|
+
{
|
|
546
|
+
field: "youtubeDescription",
|
|
547
|
+
message: `The YouTube description is written from the narration's words, which come from the article, and Article is Off. ${fix}, or turn the YouTube description off.`,
|
|
548
|
+
},
|
|
549
|
+
]
|
|
550
|
+
: []),
|
|
551
|
+
...(usesShorts(draft)
|
|
552
|
+
? [
|
|
553
|
+
{
|
|
554
|
+
field: "shorts.enabled",
|
|
555
|
+
message: `Shorts are picked from the narration's words, which come from the article, and Article is Off. ${fix}, or turn Shorts off.`,
|
|
556
|
+
},
|
|
557
|
+
]
|
|
558
|
+
: []),
|
|
559
|
+
...(sources.images === "generate" && draft.imageScenes === true
|
|
560
|
+
? [
|
|
561
|
+
{
|
|
562
|
+
field: "imageScenes",
|
|
563
|
+
message: `Scenes from the article need the article, and Article is Off. ${fix}, or turn Scenes from the article off.`,
|
|
564
|
+
},
|
|
565
|
+
]
|
|
566
|
+
: []),
|
|
567
|
+
];
|
|
568
|
+
}
|
|
@@ -91,6 +91,8 @@ export const runDraftSchema = z.object({
|
|
|
91
91
|
imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
|
|
92
92
|
// The ranges are `slices/images/scale.ts`'s, checked by admission, not the schema's.
|
|
93
93
|
imageScale: z.object({ perHour: z.number(), words: z.number() }).optional(),
|
|
94
|
+
// Scenes from the article: each image drawn from its own scene (`images/scenes.ts`).
|
|
95
|
+
imageScenes: z.boolean().optional(),
|
|
94
96
|
thumbnailPrompt: z.string().optional(),
|
|
95
97
|
thumbnailCount: z.union([z.literal(1), z.literal(3)]).optional(),
|
|
96
98
|
intro: entryChoice.optional(),
|
|
@@ -37,15 +37,18 @@ export function detectSlots(body) {
|
|
|
37
37
|
}
|
|
38
38
|
return { names, errors };
|
|
39
39
|
}
|
|
40
|
+
// Filled by Slopify, never by the person: each image's own scene, which the project's AI model
|
|
41
|
+
// writes from the article when an image prompt asks for it (`images/scenes.ts`).
|
|
42
|
+
export const sceneKeyword = "Scene";
|
|
40
43
|
// One field per distinct name; Common when a name is used on both sides, otherwise Text or
|
|
41
44
|
// Image. The order is first appearance, which is why both sides arrive as ordered lists of
|
|
42
|
-
// bodies rather than as sets.
|
|
45
|
+
// bodies rather than as sets. The scene keyword is not a field: Slopify fills it.
|
|
43
46
|
export function collectFields(textBodies, imageBodies) {
|
|
44
47
|
const text = namesOf(textBodies);
|
|
45
48
|
const image = namesOf(imageBodies);
|
|
46
49
|
const fields = [];
|
|
47
50
|
for (const name of [...text, ...image]) {
|
|
48
|
-
if (fields.some((field) => field.name === name)) {
|
|
51
|
+
if (name === sceneKeyword || fields.some((field) => field.name === name)) {
|
|
49
52
|
continue;
|
|
50
53
|
}
|
|
51
54
|
const onText = text.includes(name);
|
|
@@ -176,6 +176,9 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
176
176
|
});
|
|
177
177
|
if (images === 0)
|
|
178
178
|
local("Images", "Provided or off.");
|
|
179
|
+
// Scenes from the article: one call reads the article and writes every image's scene.
|
|
180
|
+
if (images > 0 && draft.imageScenes === true)
|
|
181
|
+
text("Image scenes", articleChars + 1500, 120 * images);
|
|
179
182
|
// The establishing image is one more image when it is made from a prompt.
|
|
180
183
|
if (usesReference(draft) && draft.reference?.source === "prompt")
|
|
181
184
|
requests.push({
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { detectSlots, render, sceneKeyword } from "../admission/substitute.js";
|
|
3
|
+
// Scenes from the article (Images → Scenes from the article): each image is drawn from its
|
|
4
|
+
// own scene instead of the same text every time. One call to the project's AI model reads the
|
|
5
|
+
// article and writes every scene, following the article from its opening to its end in the
|
|
6
|
+
// order the images are shown.
|
|
7
|
+
export function usesScene(body) {
|
|
8
|
+
return body != null && detectSlots(body).names.includes(sceneKeyword);
|
|
9
|
+
}
|
|
10
|
+
// The prompt with its scene: where the prompt has `{{Scene}}`, there; otherwise as a line of
|
|
11
|
+
// its own after the prompt's first paragraph, so any prompt takes a scene without editing.
|
|
12
|
+
export function withScene(body, scene) {
|
|
13
|
+
if (usesScene(body))
|
|
14
|
+
return render(body, { [sceneKeyword]: scene });
|
|
15
|
+
const at = body.indexOf("\n\n");
|
|
16
|
+
return at === -1
|
|
17
|
+
? `${body}\n\nScene: ${scene}`
|
|
18
|
+
: `${body.slice(0, at)}\n\nScene: ${scene}${body.slice(at)}`;
|
|
19
|
+
}
|
|
20
|
+
// The prompt with the switch off: a line that holds `{{Scene}}` is left out, so the image is
|
|
21
|
+
// drawn from the rest of the prompt as it always was.
|
|
22
|
+
export function withoutScene(body) {
|
|
23
|
+
if (!usesScene(body))
|
|
24
|
+
return body;
|
|
25
|
+
return body
|
|
26
|
+
.split("\n")
|
|
27
|
+
.filter((line) => !usesScene(line))
|
|
28
|
+
.join("\n")
|
|
29
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
// What kind of picture each image is: the prompt's own "Composition:" paragraph when it has
|
|
33
|
+
// one (the wide shot, the close portrait), otherwise the prompt's name.
|
|
34
|
+
export function pictureKind(body, name) {
|
|
35
|
+
const composition = /^\s*Composition:\s*(.+)$/im.exec(body)?.[1]?.trim();
|
|
36
|
+
return composition === undefined || composition === "" ? name : composition;
|
|
37
|
+
}
|
|
38
|
+
// The count is written on a line of its own so the answer can be checked against it when the
|
|
39
|
+
// call runs (`sceneCountOf`), from the saved request alone.
|
|
40
|
+
const countLine = /^Pictures: (\d+)$/m;
|
|
41
|
+
export function sceneMessages(brief) {
|
|
42
|
+
const count = brief.pictures.length;
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
role: "system",
|
|
46
|
+
content: [
|
|
47
|
+
"You plan the pictures for a narrated video. Read the article and write one scene for each picture listed, in the listed order.",
|
|
48
|
+
`The pictures are shown in that order while the article is narrated from start to finish. Divide the article into ${String(count)} consecutive stretches of about equal length: scene 1 comes from the first stretch, scene 2 from the second, and so on, so the last scene comes from the ending. Keep to this order even when a later moment would suit a picture better.`,
|
|
49
|
+
"Each scene is one or two sentences saying what the picture shows: the subject, the place, what is happening, and the light and mood. Choose a moment its stretch of the article actually describes, and frame it the way the picture's kind asks (a wide view, a close figure, a moment of action, a mood).",
|
|
50
|
+
"Every scene shows something different: never the same subject or moment twice.",
|
|
51
|
+
"Describe only what can be seen. No text, lettering, captions, labels or logos in the picture, and nothing about art style or medium: the style is set elsewhere.",
|
|
52
|
+
`Answer with a JSON array of exactly ${String(count)} strings, one scene per picture, and nothing else.`,
|
|
53
|
+
`Pictures: ${String(count)}`,
|
|
54
|
+
].join("\n\n"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
role: "user",
|
|
58
|
+
content: [
|
|
59
|
+
`Video: ${brief.title}`,
|
|
60
|
+
`The ${String(count)} pictures, in order:\n${brief.pictures.map((kind, index) => `${String(index + 1)}. ${kind}`).join("\n")}`,
|
|
61
|
+
`Article:\n\n${brief.article}`,
|
|
62
|
+
].join("\n\n"),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
export function sceneCountOf(messages) {
|
|
67
|
+
for (const message of messages) {
|
|
68
|
+
const found = countLine.exec(message.content);
|
|
69
|
+
if (found?.[1] !== undefined)
|
|
70
|
+
return Number(found[1]);
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
export function checkScenes(text, count) {
|
|
75
|
+
const fix = "Use Try again; if it keeps happening, choose another model in the Providers section of Edit project.";
|
|
76
|
+
const parsed = z.array(z.string()).safeParse(jsonArrayOf(text));
|
|
77
|
+
if (!parsed.success)
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: `The AI model didn't answer with a list of image scenes. ${fix}`,
|
|
81
|
+
};
|
|
82
|
+
const scenes = parsed.data.map((scene) => scene.replace(/\s+/g, " ").trim());
|
|
83
|
+
if (scenes.length !== count)
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: `The AI model wrote ${String(scenes.length)} image scenes for ${String(count)} images. ${fix}`,
|
|
87
|
+
};
|
|
88
|
+
if (scenes.some((scene) => scene === ""))
|
|
89
|
+
return { ok: false, reason: `One of the AI model's image scenes is empty. ${fix}` };
|
|
90
|
+
return { ok: true, scenes };
|
|
91
|
+
}
|
|
92
|
+
function jsonArrayOf(text) {
|
|
93
|
+
const start = text.indexOf("[");
|
|
94
|
+
const end = text.lastIndexOf("]");
|
|
95
|
+
if (start === -1 || end <= start)
|
|
96
|
+
return undefined;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -31,7 +31,7 @@ export function toAdmissionDraft(input) {
|
|
|
31
31
|
const fields = [];
|
|
32
32
|
const sources = {
|
|
33
33
|
...form.sources,
|
|
34
|
-
...(form.sources.article
|
|
34
|
+
...(form.sources.article !== "generate" ? { research: "off" } : {}),
|
|
35
35
|
...(form.sources.images === "off" ? { video: "off" } : {}),
|
|
36
36
|
};
|
|
37
37
|
const number = (raw, field, max, min = 1) => {
|
|
@@ -217,6 +217,10 @@ export function toAdmissionDraft(input) {
|
|
|
217
217
|
...(sources.images === "generate" && form.imageScale !== undefined
|
|
218
218
|
? { imageScale: imageScaleOf(input.document, fields) }
|
|
219
219
|
: {}),
|
|
220
|
+
// Scenes are written from the article: with Article Off the switch is left out.
|
|
221
|
+
...(sources.images === "generate" && sources.article !== "off" && form.imageScenes === true
|
|
222
|
+
? { imageScenes: true }
|
|
223
|
+
: {}),
|
|
220
224
|
thumbnailPrompt: ["from_prompt", "prompt_by_llm"].includes(sources.thumbnail)
|
|
221
225
|
? form.thumbnailPrompt
|
|
222
226
|
: undefined,
|
|
@@ -238,6 +238,7 @@ export function documentFromProject(deps, revision) {
|
|
|
238
238
|
: { paragraphPause: String(config.paragraphPauseSeconds) }),
|
|
239
239
|
imagePrompts,
|
|
240
240
|
...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
|
|
241
|
+
...(config.imageScenes === true ? { imageScenes: true } : {}),
|
|
241
242
|
thumbnailPrompt: config.thumbnailPrompt ?? "",
|
|
242
243
|
intro: config.intro?.name ?? "",
|
|
243
244
|
outro: config.outro?.name ?? "",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
4
|
+
import { currentRevisionId } from "../revisions/repo.js";
|
|
5
|
+
import { recoverProject } from "./recovery.js";
|
|
6
|
+
// How many times one narration chunk is recorded again by itself. A voice that garbles a
|
|
7
|
+
// sentence rarely does it twice; one that reads a name its own way does it every time, so
|
|
8
|
+
// after this the run stops and asks the person.
|
|
9
|
+
export const narrationRetryLimit = 2;
|
|
10
|
+
const row = z.object({
|
|
11
|
+
project_id: z.string(),
|
|
12
|
+
chunk_key: z.string(),
|
|
13
|
+
tries: z.number(),
|
|
14
|
+
state: z.enum(["pending", "started", "failed"]),
|
|
15
|
+
});
|
|
16
|
+
// Asked by the caption step when the audio stopped matching the text in a chunk. Returns the
|
|
17
|
+
// try this is (1 or 2), or undefined once the chunk has had its tries.
|
|
18
|
+
export function requestNarrationRetry(db, input) {
|
|
19
|
+
return transact(db, () => {
|
|
20
|
+
const saved = db
|
|
21
|
+
.prepare("SELECT tries FROM narration_retries WHERE project_id=? AND chunk_key=?")
|
|
22
|
+
.get(input.projectId, input.chunkKey);
|
|
23
|
+
const tries = saved === undefined ? 0 : z.number().parse(saved.tries);
|
|
24
|
+
if (tries >= narrationRetryLimit)
|
|
25
|
+
return undefined;
|
|
26
|
+
db.prepare(`INSERT INTO narration_retries(project_id, chunk_key, tries, state, detail, updated_at)
|
|
27
|
+
VALUES (?, ?, 1, 'pending', NULL, ?)
|
|
28
|
+
ON CONFLICT(project_id, chunk_key)
|
|
29
|
+
DO UPDATE SET tries=tries+1, state='pending', detail=NULL, updated_at=excluded.updated_at`).run(input.projectId, input.chunkKey, input.now);
|
|
30
|
+
return tries + 1;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function pendingNarrationRetries(db, projectId) {
|
|
34
|
+
const rows = projectId === undefined
|
|
35
|
+
? db.prepare("SELECT * FROM narration_retries WHERE state='pending'").all()
|
|
36
|
+
: db
|
|
37
|
+
.prepare("SELECT * FROM narration_retries WHERE state='pending' AND project_id=?")
|
|
38
|
+
.all(projectId);
|
|
39
|
+
return rows.map((value) => {
|
|
40
|
+
const parsed = row.parse(value);
|
|
41
|
+
return { projectId: parsed.project_id, chunkKey: parsed.chunk_key, tries: parsed.tries };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function settle(db, retry, state, detail, now) {
|
|
45
|
+
db.prepare("UPDATE narration_retries SET state=?, detail=?, updated_at=? WHERE project_id=? AND chunk_key=? AND tries=?").run(state, detail, now, retry.projectId, retry.chunkKey, retry.tries);
|
|
46
|
+
}
|
|
47
|
+
// Starts the retries the caption step asked for, once its failure is saved (the runner's
|
|
48
|
+
// `onFinished`). The chunk is made again through Redo's path: a new regeneration token saved as
|
|
49
|
+
// a new project version, then the chunk and everything built on it (the joined narration,
|
|
50
|
+
// the captions, the video) rebuilt as ordinary work.
|
|
51
|
+
export function createNarrationRetries() {
|
|
52
|
+
let bound;
|
|
53
|
+
const running = new Set();
|
|
54
|
+
const kick = (projectId) => {
|
|
55
|
+
const deps = bound;
|
|
56
|
+
if (deps === undefined)
|
|
57
|
+
return;
|
|
58
|
+
for (const retry of pendingNarrationRetries(deps.db, projectId)) {
|
|
59
|
+
const id = `${retry.projectId}\n${retry.chunkKey}`;
|
|
60
|
+
if (running.has(id))
|
|
61
|
+
continue;
|
|
62
|
+
running.add(id);
|
|
63
|
+
void recordAgain(deps, retry)
|
|
64
|
+
.catch((error) => {
|
|
65
|
+
deps.log.write("error", "narration.retry", {
|
|
66
|
+
projectId: retry.projectId,
|
|
67
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
settle(deps.db, retry, "failed", "internal error", deps.clock.now().toISOString());
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
running.delete(id);
|
|
73
|
+
try {
|
|
74
|
+
deps.runner.tick(retry.projectId);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The next tick of this project picks the released work up.
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
bind: (deps) => {
|
|
84
|
+
bound = deps;
|
|
85
|
+
},
|
|
86
|
+
kick,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function recordAgain(deps, retry) {
|
|
90
|
+
const base = currentRevisionId(deps.db, retry.projectId);
|
|
91
|
+
if (base === undefined) {
|
|
92
|
+
settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const result = await recoverProject(deps, retry.projectId, {
|
|
96
|
+
baseRevisionId: base,
|
|
97
|
+
idempotencyKey: retryKey(retry),
|
|
98
|
+
action: { kind: "redo", item: retry.chunkKey },
|
|
99
|
+
}, { pendingSuperseded: true });
|
|
100
|
+
const now = deps.clock.now().toISOString();
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
settle(deps.db, retry, "started", null, now);
|
|
103
|
+
deps.log.write("info", "narration.retry", {
|
|
104
|
+
projectId: retry.projectId,
|
|
105
|
+
detail: `Recording ${retry.chunkKey} again (try ${String(retry.tries)} of ${String(narrationRetryLimit)}).`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
settle(deps.db, retry, "failed", refusal(result), now);
|
|
110
|
+
deps.log.write("warn", "narration.retry", {
|
|
111
|
+
projectId: retry.projectId,
|
|
112
|
+
detail: `Could not record ${retry.chunkKey} again: ${refusal(result)}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
|
|
116
|
+
}
|
|
117
|
+
function refusal(result) {
|
|
118
|
+
const detail = result.fields?.map((field) => field.message).join(" ");
|
|
119
|
+
return detail ? `${result.reason}: ${detail}` : result.reason;
|
|
120
|
+
}
|
|
121
|
+
// One request identity per try, so a restart that starts the same try again gets the first
|
|
122
|
+
// start's answer instead of a second recording.
|
|
123
|
+
export function retryKey(retry) {
|
|
124
|
+
const hex = createHash("sha256")
|
|
125
|
+
.update(`narration-retry:${retry.projectId}:${retry.chunkKey}:${String(retry.tries)}`)
|
|
126
|
+
.digest("hex");
|
|
127
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
128
|
+
}
|
|
@@ -82,7 +82,9 @@ export function previewDetails(deps, view, allRecipes, selected) {
|
|
|
82
82
|
? "Research"
|
|
83
83
|
: row.key.startsWith("shorts:")
|
|
84
84
|
? "Short image prompts"
|
|
85
|
-
:
|
|
85
|
+
: row.key === "images:scenes"
|
|
86
|
+
? "Image scenes"
|
|
87
|
+
: "Thumbnail prompt";
|
|
86
88
|
return [
|
|
87
89
|
{
|
|
88
90
|
key: row.key,
|