@gentbajko/slopify 3.0.12 → 3.0.14
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/events/hub.js +35 -15
- package/dist/edge/events/preview-cache.js +1 -0
- package/dist/edge/events/visibility.js +42 -8
- package/dist/edge/http/entries.js +2 -1
- package/dist/edge/http/prompts.js +2 -1
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +2 -2
- package/dist/patch-notes/3.0.13.md +43 -0
- package/dist/patch-notes/3.0.14.md +24 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/library/rename.js +94 -0
- package/dist/slices/library/save.js +5 -0
- package/dist/slices/library/used-by.js +95 -68
- package/dist/slices/rebuild/recipe-build.js +6 -3
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +1 -0
- package/dist/slices/rebuild/recipe-scenes.js +33 -20
- package/dist/slices/rebuild/recipe-visual.js +2 -4
- package/dist/slices/rebuild/recipe-work.js +1 -0
- package/dist/slices/rebuild/runtime-export.js +2 -1
- package/dist/slices/rebuild/runtime-provider.js +5 -3
- package/dist/slices/rebuild/transition-repo.js +13 -5
- package/dist/slices/revisions/restore.js +6 -1
- package/dist/slices/run-cost/timing.js +17 -5
- package/dist/tutorials/Notifications.md +9 -0
- package/dist/tutorials/Play-Images.md +1 -1
- package/dist/web/assets/{index-BbbbM56z.css → index-CcJ6VLvA.css} +1 -1
- package/dist/web/assets/{index-BPstEgjc.js → index-aN9pfG3k.js} +120 -120
- package/dist/web/assets/{pdf-DVDjUG4j.js → pdf-D0quBJqy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
|
@@ -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'
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
16
|
+
return undefined;
|
|
13
17
|
const body = content.promptTemplates.thumbnailPrompt ?? config.rendered.thumbnailPrompt;
|
|
14
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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" ||
|
|
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
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
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
|
|
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
|
|