@gentbajko/slopify 3.0.8 → 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/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +5 -18
- package/dist/patch-notes/3.0.9.md +27 -0
- package/dist/patch-notes/index.json +6 -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/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/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/revisions/mutation-prepare.js +1 -1
- package/dist/slices/revisions/view.js +3 -1
- 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
|
Binary file
|
|
Binary file
|
package/dist/main.js
CHANGED
|
@@ -89,6 +89,7 @@ import { isUpdateToken } from "./updater/model.js";
|
|
|
89
89
|
import { npmCommand, updateCommitted } from "./updater/plan.js";
|
|
90
90
|
import { publishedVersion } from "./updater/registry.js";
|
|
91
91
|
import { createUpdater } from "./updater/service.js";
|
|
92
|
+
import { workInProgress } from "./updater/work-in-progress.js";
|
|
92
93
|
// ceiling: a burst of finished stages coalesces into one delivery a second later, and the
|
|
93
94
|
// collector gets ten seconds to answer before the attempt is abandoned and the events
|
|
94
95
|
// stay queued.
|
|
@@ -337,24 +338,10 @@ export async function boot(config, options = {}) {
|
|
|
337
338
|
: npm === undefined
|
|
338
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"
|
|
339
340
|
: undefined,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
.all()
|
|
345
|
-
.some((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true),
|
|
346
|
-
busyWith: () => {
|
|
347
|
-
const running = updateDb
|
|
348
|
-
.prepare("SELECT p.title FROM stages s JOIN projects p ON p.id=s.project_id WHERE s.state='running' LIMIT 1")
|
|
349
|
-
.get();
|
|
350
|
-
if (typeof running?.title === "string")
|
|
351
|
-
return running.title;
|
|
352
|
-
const inflight = updateDb
|
|
353
|
-
.prepare("SELECT id,title FROM projects")
|
|
354
|
-
.all()
|
|
355
|
-
.find((row) => typeof row.id === "string" && runner.hasInflight?.(row.id) === true);
|
|
356
|
-
return typeof inflight?.title === "string" ? inflight.title : undefined;
|
|
357
|
-
},
|
|
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()),
|
|
358
345
|
report: (message) => log.write("warn", "update", { detail: message }),
|
|
359
346
|
install: async (next, restarting) => {
|
|
360
347
|
if (npm === undefined)
|
|
@@ -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.
|
|
@@ -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 ?? "",
|
|
@@ -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,
|
|
@@ -8,6 +8,7 @@ import { masterPlan } from "./recipe-loudness.js";
|
|
|
8
8
|
import { resourceIdentity } from "./recipe-model.js";
|
|
9
9
|
import { imageReference, referenceRecipe } from "./recipe-reference.js";
|
|
10
10
|
import { withReviews } from "./recipe-reviews.js";
|
|
11
|
+
import { imageScenes } from "./recipe-scenes.js";
|
|
11
12
|
import { shortsRecipes } from "./recipe-shorts.js";
|
|
12
13
|
import { textRecipes } from "./recipe-text.js";
|
|
13
14
|
import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
|
|
@@ -29,6 +30,8 @@ export function buildRecipes(context) {
|
|
|
29
30
|
const drawnFrom = imageReference(context, reference);
|
|
30
31
|
const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined);
|
|
31
32
|
const youtube = youtubeRecipes(context, exports);
|
|
33
|
+
// Each image's scene, written from the article, for image prompts that ask for one.
|
|
34
|
+
const scenes = imageScenes(context, text);
|
|
32
35
|
return withReviews(context, [
|
|
33
36
|
...text.recipes,
|
|
34
37
|
...audio.recipes,
|
|
@@ -38,6 +41,7 @@ export function buildRecipes(context) {
|
|
|
38
41
|
...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video")),
|
|
39
42
|
...thumbnail,
|
|
40
43
|
...documentRecipes(context, text, thumbnail),
|
|
41
|
-
...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)))),
|
|
44
|
+
...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)),
|
|
45
|
+
...(scenes === undefined ? [] : [scenes.recipe]),
|
|
42
46
|
]);
|
|
43
47
|
}
|
|
@@ -44,6 +44,9 @@ export const deferredOperations = [
|
|
|
44
44
|
"shorts",
|
|
45
45
|
"animate",
|
|
46
46
|
"script-attribution",
|
|
47
|
+
// Scenes from the article (`recipe-scenes.ts`): the scenes step and each image waiting on it.
|
|
48
|
+
"image-scenes",
|
|
49
|
+
"image-scene",
|
|
47
50
|
];
|
|
48
51
|
export function recipe(context, key, stage, input, dependsOn = [], options = {}) {
|
|
49
52
|
const requestFingerprint = input.kind === "tts" && input.dialogue !== undefined
|
|
@@ -32,7 +32,9 @@ export function recipeProviderChoice(recipe, config) {
|
|
|
32
32
|
};
|
|
33
33
|
if (input.kind !== "deferred")
|
|
34
34
|
return undefined;
|
|
35
|
-
const family = input.operation === "narration-preparation" ||
|
|
35
|
+
const family = input.operation === "narration-preparation" ||
|
|
36
|
+
input.operation === "narration-description" ||
|
|
37
|
+
input.operation === "image-scenes"
|
|
36
38
|
? "llm"
|
|
37
39
|
: recipe.stage === "audio"
|
|
38
40
|
? "tts"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
2
|
import { sourceOf } from "../admission/model.js";
|
|
3
|
-
import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, voicesFields, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
|
+
import { allowedSources, articleOffFields, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, voicesFields, zoomPercentProblem, } from "../admission/rules.js";
|
|
4
4
|
import { shortModeFields } from "../admission/short-mode.js";
|
|
5
5
|
import { render } from "../admission/substitute.js";
|
|
6
6
|
import { effectiveClips, pickedShortsOf, rangeProblem } from "../shorts/clips.js";
|
|
@@ -27,7 +27,7 @@ function shortRangeFields(base, config, content) {
|
|
|
27
27
|
}
|
|
28
28
|
export function normalizeArticleIntent(base, edit) {
|
|
29
29
|
const restart = edit.regenerate?.includes("article:body") === true ||
|
|
30
|
-
(base.revision.config.sources.article
|
|
30
|
+
(base.revision.config.sources.article !== "generate" &&
|
|
31
31
|
edit.config.sources.article === "generate");
|
|
32
32
|
const submittedText = edit.content.articleEdited === true ||
|
|
33
33
|
edit.content.articleMarkdown !== base.revision.content.articleMarkdown;
|
|
@@ -232,6 +232,7 @@ export function validateRevisionEdit(config, content) {
|
|
|
232
232
|
if (config.sources.article === "provide" &&
|
|
233
233
|
!(content.articleMarkdown ?? config.provided.article)?.trim())
|
|
234
234
|
fields.push({ field: "provided.article", message: "Paste the article." });
|
|
235
|
+
fields.push(...articleOffFields(config));
|
|
235
236
|
if (config.sources.audio === "off" &&
|
|
236
237
|
config.subtitles !== undefined &&
|
|
237
238
|
config.subtitles.mode !== "off")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
|
+
import { pictureKind, sceneMessages } from "../images/scenes.js";
|
|
4
|
+
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
5
|
+
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
6
|
+
export const imageScenesKey = "images:scenes";
|
|
7
|
+
export function imageScenes(context, text) {
|
|
8
|
+
const { config, content } = context;
|
|
9
|
+
if (config.sources.images !== "generate" || config.imageScenes !== true)
|
|
10
|
+
return undefined;
|
|
11
|
+
const keys = [];
|
|
12
|
+
const pictures = [];
|
|
13
|
+
for (const key of content.imageOrder) {
|
|
14
|
+
const image = content.imageDefinitions[key];
|
|
15
|
+
if (image?.source !== "generate")
|
|
16
|
+
continue;
|
|
17
|
+
const body = image.templateKey == null
|
|
18
|
+
? image.prompt
|
|
19
|
+
: (content.promptTemplates[image.templateKey] ?? image.prompt);
|
|
20
|
+
if (body == null || body.trim() === "")
|
|
21
|
+
continue;
|
|
22
|
+
const at = /^imagePrompts\.(\d+)$/.exec(image.templateKey ?? "")?.[1];
|
|
23
|
+
const name = at === undefined ? "Image" : (config.imagePrompts[Number(at)]?.name ?? "Image");
|
|
24
|
+
keys.push(key);
|
|
25
|
+
pictures.push(pictureKind(body, name));
|
|
26
|
+
}
|
|
27
|
+
if (keys.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const messages = withLanguage(sceneMessages({ title: config.title, article: text.articleText ?? "", pictures }), config.language);
|
|
30
|
+
const value = recipe(context, imageScenesKey, "images", text.articleText === null
|
|
31
|
+
? {
|
|
32
|
+
kind: "deferred",
|
|
33
|
+
version: 1,
|
|
34
|
+
operation: "image-scenes",
|
|
35
|
+
template: [llmInputFingerprint(context, messages), text.article.fingerprint],
|
|
36
|
+
}
|
|
37
|
+
: llmInput(context, messages), [text.article.key]);
|
|
38
|
+
return {
|
|
39
|
+
recipe: value,
|
|
40
|
+
keys,
|
|
41
|
+
scenes: text.articleText === null ? undefined : savedScenes(context, value, keys.length),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// The scenes saved for this request. The request carries the model's thinking settings from
|
|
45
|
+
// the catalogue, which the plan without a catalogue has not got, so either fingerprint
|
|
46
|
+
// matches (like the shorts' saved prompts).
|
|
47
|
+
function savedScenes(context, value, count) {
|
|
48
|
+
const logical = context.catalogue === undefined || value.input.kind !== "llm"
|
|
49
|
+
? value.fingerprint
|
|
50
|
+
: recipe({ content: context.content }, value.key, value.stage, { ...value.input, thinkingConfig: null }, value.dependsOn).fingerprint;
|
|
51
|
+
const piece = context.manifest.pieces.find((row) => row.key === value.key &&
|
|
52
|
+
selectedReference(row) &&
|
|
53
|
+
row.piece.state === "done" &&
|
|
54
|
+
(row.fingerprint === value.fingerprint || row.fingerprint === logical));
|
|
55
|
+
if (piece?.piece.payload == null)
|
|
56
|
+
return undefined;
|
|
57
|
+
const parsed = z
|
|
58
|
+
.object({ scenes: z.array(z.string()) })
|
|
59
|
+
.safeParse(JSON.parse(piece.piece.payload));
|
|
60
|
+
return parsed.success && parsed.data.scenes.length === count ? parsed.data.scenes : undefined;
|
|
61
|
+
}
|
|
@@ -124,7 +124,8 @@ export function textRecipes(context) {
|
|
|
124
124
|
speakers: voices.speakers.map((speaker) => ({ id: speaker.id, name: speaker.name })),
|
|
125
125
|
attribute: !writesScript,
|
|
126
126
|
};
|
|
127
|
-
|
|
127
|
+
// A pasted article, or with Article Off an empty one: nothing is written.
|
|
128
|
+
const article = config.sources.article !== "generate" || content.articleEdited === true
|
|
128
129
|
? recipe(context, "article:body", "article", {
|
|
129
130
|
kind: "local",
|
|
130
131
|
version: 1,
|
|
@@ -250,7 +251,7 @@ export function textRecipes(context) {
|
|
|
250
251
|
...(script === undefined ? {} : { script }),
|
|
251
252
|
};
|
|
252
253
|
}
|
|
253
|
-
function llmInputFingerprint(context, messages) {
|
|
254
|
+
export function llmInputFingerprint(context, messages) {
|
|
254
255
|
return fingerprint(JSON.parse(JSON.stringify(llmInput(context, messages))));
|
|
255
256
|
}
|
|
256
257
|
export function matchingText(context, value, field) {
|
|
@@ -265,7 +266,7 @@ export function matchingText(context, value, field) {
|
|
|
265
266
|
}
|
|
266
267
|
function articleMarkdownOf(context) {
|
|
267
268
|
const { config, content, resolved } = context;
|
|
268
|
-
return config.sources.article
|
|
269
|
+
return config.sources.article !== "generate" || content.articleEdited === true
|
|
269
270
|
? (content.articleMarkdown ?? config.provided.article ?? "")
|
|
270
271
|
: resolved.articleMarkdown;
|
|
271
272
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { narrationPreparationFields, shortsFields, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, videoEditFields, youtubeDescriptionFields, } from "../admission/rules.js";
|
|
2
|
-
import { detectSlots, render } from "../admission/substitute.js";
|
|
2
|
+
import { detectSlots, render, sceneKeyword } from "../admission/substitute.js";
|
|
3
3
|
import { imagesPerVideoMax } from "../images/scale.js";
|
|
4
4
|
import { loudnessFields } from "../loudness/model.js";
|
|
5
5
|
import { pauseFields } from "../narration/pauses-model.js";
|
|
@@ -143,7 +143,8 @@ export function validateRecipeInputs(config, content) {
|
|
|
143
143
|
if (slots.errors.length > 0)
|
|
144
144
|
fields.push({ field: prompt.field, message: "Fix the keyword placeholders in this prompt." });
|
|
145
145
|
for (const key of slots.names)
|
|
146
|
-
if (
|
|
146
|
+
if (key !== sceneKeyword &&
|
|
147
|
+
(!Object.hasOwn(config.values, key) || !config.values[key]?.trim()))
|
|
147
148
|
fields.push({ field: `values.${key}`, message: "Enter a value for this keyword." });
|
|
148
149
|
}
|
|
149
150
|
return fields;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { thumbnailCountOf, thumbnailKey } from "../admission/model.js";
|
|
2
2
|
import { usesShortMode } from "../admission/short-mode.js";
|
|
3
3
|
import { render } from "../admission/substitute.js";
|
|
4
|
+
import { withoutScene, withScene } from "../images/scenes.js";
|
|
4
5
|
import { usesAmbientBed } from "../video/ambient-bed.js";
|
|
5
6
|
import { castFor } from "./recipe-cast.js";
|
|
6
7
|
import { noMaster } from "./recipe-loudness.js";
|
|
@@ -15,7 +16,9 @@ reference,
|
|
|
15
16
|
// The word timing's identity, which a short's captions are drawn from.
|
|
16
17
|
timing = null,
|
|
17
18
|
// Level the volume: the levelled narration and the master (`recipe-loudness.ts`).
|
|
18
|
-
master = noMaster
|
|
19
|
+
master = noMaster,
|
|
20
|
+
// Each image's own scene, for the images whose prompt asks for one (`recipe-scenes.ts`).
|
|
21
|
+
scenes) {
|
|
19
22
|
const recipes = [];
|
|
20
23
|
const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
|
|
21
24
|
for (const key of imageKeys) {
|
|
@@ -25,7 +28,17 @@ master = noMaster) {
|
|
|
25
28
|
const raw = image.templateKey === undefined || image.templateKey === null
|
|
26
29
|
? undefined
|
|
27
30
|
: content.promptTemplates[image.templateKey];
|
|
28
|
-
const
|
|
31
|
+
const at = scenes?.keys.indexOf(key) ?? -1;
|
|
32
|
+
if (scenes !== undefined && at !== -1 && image.source === "generate") {
|
|
33
|
+
recipes.push(sceneImage(config, content, key, raw ?? image.prompt ?? "", scenes, at, reference));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// With Scenes from the article off, a prompt's `{{Scene}}` line is left out.
|
|
37
|
+
const prompt = raw === undefined || raw === null
|
|
38
|
+
? image.prompt === null || image.prompt === undefined
|
|
39
|
+
? image.prompt
|
|
40
|
+
: withoutScene(image.prompt)
|
|
41
|
+
: render(withoutScene(raw), config.values);
|
|
29
42
|
recipes.push(recipe({ content }, `image:${key}`, "images", image.source === "provide"
|
|
30
43
|
? { kind: "provided", version: 1, assetId: image.assetId, semantic: null }
|
|
31
44
|
: {
|
|
@@ -109,6 +122,37 @@ master = noMaster) {
|
|
|
109
122
|
}
|
|
110
123
|
return recipes;
|
|
111
124
|
}
|
|
125
|
+
// An image drawn from its own scene. Until the scenes are written it waits as a deferred
|
|
126
|
+
// request that names the scenes step and the image's place among them, so writing them turns
|
|
127
|
+
// it into its image request (materialization), and writing them again draws it again.
|
|
128
|
+
function sceneImage(config, content, key, body, scenes, at, reference) {
|
|
129
|
+
const scene = scenes.scenes?.[at];
|
|
130
|
+
const prompt = scene === undefined ? undefined : withScene(render(body, config.values), scene);
|
|
131
|
+
return recipe({ content }, `image:${key}`, "images", prompt === undefined
|
|
132
|
+
? {
|
|
133
|
+
kind: "deferred",
|
|
134
|
+
version: 1,
|
|
135
|
+
operation: "image-scene",
|
|
136
|
+
template: [
|
|
137
|
+
scenes.recipe.fingerprint,
|
|
138
|
+
at,
|
|
139
|
+
config.images?.provider ?? null,
|
|
140
|
+
config.images?.model ?? null,
|
|
141
|
+
config.format,
|
|
142
|
+
...(config.images?.thinking === undefined ? [] : [config.images.thinking]),
|
|
143
|
+
...(reference === undefined ? [] : [reference.input.fingerprint]),
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
: {
|
|
147
|
+
kind: "image",
|
|
148
|
+
version: 1,
|
|
149
|
+
...imageChoice(config),
|
|
150
|
+
aspect: config.format,
|
|
151
|
+
prompt,
|
|
152
|
+
...(reference === undefined ? {} : { reference: reference.input }),
|
|
153
|
+
...castField(castFor(config, prompt)),
|
|
154
|
+
}, [scenes.recipe.key, ...(reference === undefined ? [] : [reference.key])]);
|
|
155
|
+
}
|
|
112
156
|
// What the ambient bed adds to the render's fingerprint: its settings and, for the user's own
|
|
113
157
|
// file, the project asset it plays. Undefined without a bed.
|
|
114
158
|
export function ambientBedValues(config, content) {
|