@gentbajko/slopify 3.0.11 → 3.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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.12.md +25 -0
- package/dist/patch-notes/3.0.13.md +43 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/substitute.js +7 -2
- package/dist/slices/images/appearance.js +107 -0
- package/dist/slices/images/scenes.js +50 -1
- package/dist/slices/rebuild/recipe-appearance.js +82 -0
- package/dist/slices/rebuild/recipe-build.js +13 -6
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -1
- package/dist/slices/rebuild/recipe-reference.js +22 -2
- package/dist/slices/rebuild/recipe-scenes.js +35 -1
- package/dist/slices/rebuild/recipe-validation.js +2 -2
- package/dist/slices/rebuild/recipe-visual.js +94 -38
- package/dist/slices/rebuild/recipe-work.js +5 -2
- package/dist/slices/rebuild/runtime-export.js +2 -1
- package/dist/slices/rebuild/runtime-provider.js +21 -3
- package/dist/slices/rebuild/transition-repo.js +13 -5
- package/dist/slices/revisions/publication-rules.js +11 -3
- 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 +10 -0
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/web/assets/index-CcJ6VLvA.css +1 -0
- package/dist/web/assets/{index-CT9_JJJS.js → index-aN9pfG3k.js} +120 -120
- package/dist/web/assets/{pdf-CNkg4tBo.js → pdf-D0quBJqy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cvuxycwt.css +0 -1
package/dist/edge/events/hub.js
CHANGED
|
@@ -7,6 +7,12 @@ export function createHub(deps) {
|
|
|
7
7
|
// changes, so the newest one is kept here and replayed to whoever opens next; a page
|
|
8
8
|
// that loads mid-render would otherwise be told nothing is running.
|
|
9
9
|
let tally = { type: "running.count", count: 0 };
|
|
10
|
+
const present = (event) => deps.presentEvent !== undefined
|
|
11
|
+
? deps.presentEvent(event)
|
|
12
|
+
: deps.acceptEvent?.(event) === false
|
|
13
|
+
? undefined
|
|
14
|
+
: event;
|
|
15
|
+
const heartbeatMs = deps.heartbeatMs ?? 20_000;
|
|
10
16
|
const send = (subscriber, event) => {
|
|
11
17
|
// writeSSE rejects on a socket that is already gone, asynchronously and long after
|
|
12
18
|
// emit() returned, so a dead subscriber is dropped here instead of at the call site.
|
|
@@ -38,6 +44,15 @@ export function createHub(deps) {
|
|
|
38
44
|
}
|
|
39
45
|
else {
|
|
40
46
|
signal.addEventListener("abort", subscriber.drop, { once: true });
|
|
47
|
+
if (heartbeatMs > 0) {
|
|
48
|
+
const beat = setInterval(() => {
|
|
49
|
+
subscriber.stream
|
|
50
|
+
.writeSSE({ event: "ping", data: "", id: deps.ids.next() })
|
|
51
|
+
.catch(() => subscriber.drop());
|
|
52
|
+
}, heartbeatMs);
|
|
53
|
+
beat.unref?.();
|
|
54
|
+
void done.then(() => clearInterval(beat));
|
|
55
|
+
}
|
|
41
56
|
}
|
|
42
57
|
return subscriber;
|
|
43
58
|
};
|
|
@@ -51,33 +66,38 @@ export function createHub(deps) {
|
|
|
51
66
|
}
|
|
52
67
|
});
|
|
53
68
|
if (set.has(subscriber))
|
|
54
|
-
for (const event of previews.snapshot(projectId))
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
for (const event of previews.snapshot(projectId)) {
|
|
70
|
+
const shown = present(event);
|
|
71
|
+
if (shown !== undefined)
|
|
72
|
+
send(subscriber, shown);
|
|
73
|
+
}
|
|
57
74
|
return subscriber.done;
|
|
58
75
|
},
|
|
59
76
|
subscribeGlobal: (stream, signal) => {
|
|
60
77
|
const subscriber = join(globals, stream, signal, () => { });
|
|
61
78
|
if (globals.has(subscriber)) {
|
|
62
79
|
send(subscriber, tally);
|
|
80
|
+
// What the AI models are writing right now, so a page opened mid-call shows it.
|
|
81
|
+
for (const event of previews.snapshotAll()) {
|
|
82
|
+
const shown = present(event);
|
|
83
|
+
if (shown !== undefined)
|
|
84
|
+
send(subscriber, shown);
|
|
85
|
+
}
|
|
63
86
|
}
|
|
64
87
|
return subscriber.done;
|
|
65
88
|
},
|
|
66
89
|
emit: (projectId, event) => {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
90
|
+
const shown = present(event);
|
|
91
|
+
if (shown !== undefined)
|
|
92
|
+
previews.observe(shown);
|
|
93
|
+
else if (event.type === "stage.state")
|
|
69
94
|
previews.observe(event);
|
|
70
|
-
if (
|
|
95
|
+
if (shown === undefined)
|
|
71
96
|
return;
|
|
72
|
-
for (const subscriber of projects.get(projectId) ?? [])
|
|
73
|
-
send(subscriber,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
event.type === "project.updated" ||
|
|
77
|
-
event.type === "review.flagged") {
|
|
78
|
-
for (const subscriber of globals)
|
|
79
|
-
send(subscriber, event);
|
|
80
|
-
}
|
|
97
|
+
for (const subscriber of projects.get(projectId) ?? [])
|
|
98
|
+
send(subscriber, shown);
|
|
99
|
+
for (const subscriber of globals)
|
|
100
|
+
send(subscriber, shown);
|
|
81
101
|
},
|
|
82
102
|
emitGlobal: (event) => {
|
|
83
103
|
if (event.type === "running.count") {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { currentRevisionId } from "../../slices/revisions/repo.js";
|
|
2
|
+
// Whether the work an event came from is the current revision's: made for it, or carried to it
|
|
3
|
+
// from an older revision by an edit that left it unchanged (a video still rendering after a
|
|
4
|
+
// thumbnail redo is still the project's video).
|
|
2
5
|
export function currentWorkOrigin(db, projectId, origin) {
|
|
3
6
|
if (currentRevisionId(db, projectId) === undefined)
|
|
4
7
|
return origin.revisionId === undefined && origin.workId === undefined;
|
|
@@ -13,21 +16,52 @@ export function currentWorkOrigin(db, projectId, origin) {
|
|
|
13
16
|
JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key)
|
|
14
17
|
AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint)
|
|
15
18
|
LEFT JOIN revision_work_pieces p ON p.id=r.piece_id AND p.work_id=w.id
|
|
16
|
-
WHERE w.project_id=? AND w.
|
|
19
|
+
WHERE w.project_id=? AND w.id=?
|
|
17
20
|
AND (? IS NULL OR r.piece_id=?)
|
|
18
21
|
AND (r.piece_id IS NULL OR (p.work_key=r.work_key AND p.fingerprint=r.fingerprint))
|
|
19
22
|
`)
|
|
20
|
-
.get(projectId, origin.
|
|
23
|
+
.get(projectId, origin.workId, origin.workPieceId ?? null, origin.workPieceId ?? null) !==
|
|
24
|
+
undefined);
|
|
21
25
|
}
|
|
22
|
-
|
|
26
|
+
// The event as the project's pages should see it, or undefined for one they must not: work the
|
|
27
|
+
// current revision no longer wants. Carried work is told under the current revision, since
|
|
28
|
+
// that is the revision the pages show.
|
|
29
|
+
export function currentEvent(db, event) {
|
|
23
30
|
if (event.type === "project.updated")
|
|
24
|
-
return
|
|
31
|
+
return event;
|
|
25
32
|
if (event.type === "project.state" &&
|
|
26
33
|
event.revisionId === undefined &&
|
|
27
34
|
event.workId === undefined)
|
|
28
|
-
return
|
|
35
|
+
return event;
|
|
29
36
|
const head = currentRevisionId(db, event.projectId);
|
|
30
|
-
if (head
|
|
31
|
-
return
|
|
32
|
-
|
|
37
|
+
if (head === undefined)
|
|
38
|
+
return currentWorkOrigin(db, event.projectId, event) ? event : undefined;
|
|
39
|
+
if (!currentWorkOrigin(db, event.projectId, event))
|
|
40
|
+
return undefined;
|
|
41
|
+
return event.revisionId === head ? event : { ...event, revisionId: head };
|
|
42
|
+
}
|
|
43
|
+
export function currentProjectEvent(db, event) {
|
|
44
|
+
return currentEvent(db, event) !== undefined;
|
|
45
|
+
}
|
|
46
|
+
// `currentEvent` for the hub. The streamed text (a token at a time) asks the same question many
|
|
47
|
+
// times a second, so its answer is kept for a second per piece of work.
|
|
48
|
+
export function eventPresenter(db, now = Date.now) {
|
|
49
|
+
const seen = new Map();
|
|
50
|
+
return (event) => {
|
|
51
|
+
if (event.type !== "llm.preview" && event.type !== "article.delta")
|
|
52
|
+
return currentEvent(db, event);
|
|
53
|
+
const key = [event.projectId, event.revisionId, event.workId, event.workPieceId].join("|");
|
|
54
|
+
const hit = seen.get(key);
|
|
55
|
+
if (hit !== undefined && now() - hit.at < 1000)
|
|
56
|
+
return !hit.shown
|
|
57
|
+
? undefined
|
|
58
|
+
: hit.revisionId === undefined || hit.revisionId === event.revisionId
|
|
59
|
+
? event
|
|
60
|
+
: { ...event, revisionId: hit.revisionId };
|
|
61
|
+
const shown = currentEvent(db, event);
|
|
62
|
+
if (seen.size > 512)
|
|
63
|
+
seen.clear();
|
|
64
|
+
seen.set(key, { at: now(), shown: shown !== undefined, revisionId: shown?.revisionId });
|
|
65
|
+
return shown;
|
|
66
|
+
};
|
|
33
67
|
}
|
|
Binary file
|
|
Binary file
|
package/dist/main.js
CHANGED
|
@@ -14,7 +14,7 @@ import { createCatalogueStore } from "./catalog/store.js";
|
|
|
14
14
|
import { createAutostart } from "./edge/autostart/index.js";
|
|
15
15
|
import { dockerActivationCommitted, dockerFilesLayout, dockerFolderConfiguration, } from "./edge/docker-install/activation.js";
|
|
16
16
|
import { createHub, observedHub } from "./edge/events/hub.js";
|
|
17
|
-
import {
|
|
17
|
+
import { eventPresenter } from "./edge/events/visibility.js";
|
|
18
18
|
import { createApp } from "./edge/http/app.js";
|
|
19
19
|
import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
|
|
20
20
|
import { limitRequestTimes } from "./edge/http/timeouts.js";
|
|
@@ -219,7 +219,7 @@ export async function boot(config, options = {}) {
|
|
|
219
219
|
const hub = observedHub(createHub({
|
|
220
220
|
ids,
|
|
221
221
|
log,
|
|
222
|
-
|
|
222
|
+
presentEvent: eventPresenter(eventDb),
|
|
223
223
|
}), (event) => {
|
|
224
224
|
notifier.observe(event);
|
|
225
225
|
episodes?.observe(event);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Slopify 3.0.12
|
|
2
|
+
|
|
3
|
+
Released 30 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.12 lets image and thumbnail prompts look things up: how the subject and characters look, and which moment a thumbnail shows. It also fixes a finished video that could be refused after a long render.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **`{{Appearance}}`: looks, looked up.** A figure is drawn the way people know it, and the same way in every picture.
|
|
10
|
+
- **Thumbnails take `{{Scene}}`.** Each thumbnail shows a striking moment from the article, a different one for each of three.
|
|
11
|
+
- **An edit during a render no longer fails the video.**
|
|
12
|
+
|
|
13
|
+
## Images and thumbnails
|
|
14
|
+
|
|
15
|
+
- Put `{{Appearance}}` in an image, thumbnail or establishing prompt, for example a line `Looks: {{Appearance}}`. Once the article is written, one call to the project's AI model searches the web for how the video's subject and every named character look, in their most iconic depiction. Each picture gets the looks of the figures its scene names; a thumbnail always gets the subject's. There is no switch: prompts without it are drawn as before. One more AI call per run.
|
|
16
|
+
- A thumbnail prompt with `{{Scene}}` gets a scene from Scenes from the article: the moment that would make someone click, taken from anywhere in the article, and a different one for each of three thumbnails. Before, `{{Scene}}` reached the image model as literal text.
|
|
17
|
+
|
|
18
|
+
## Fixes
|
|
19
|
+
|
|
20
|
+
- A video could render for hours and then stop with "Output fingerprint differs from its authorized recipe." when something in the project was changed while it rendered (a thumbnail made again, say). Its captions are now checked against the project as it is now. A video that failed this way renders again with **Try the video again**.
|
|
21
|
+
- Scroll bars are hidden everywhere; every area still scrolls.
|
|
22
|
+
|
|
23
|
+
## Upgrading from 3.0.11
|
|
24
|
+
|
|
25
|
+
- **Nothing becomes outdated.** Prompts without `{{Appearance}}` or a thumbnail `{{Scene}}` are drawn as before.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Slopify 3.0.13
|
|
2
|
+
|
|
3
|
+
Released 30 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.13 makes every page update live again, tightens the project page, adds run sounds, and fixes Restore and a thumbnail change that remade every image.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **Pages update live again.** Steps, images, progress and finished runs show up without refreshing, on every page and in every tab.
|
|
10
|
+
- **Run sounds.** A chime when a run starts and a different one when it ends.
|
|
11
|
+
- **History → Restore works again** on projects with scenes, looks, shorts or a YouTube description.
|
|
12
|
+
|
|
13
|
+
## Live updates
|
|
14
|
+
|
|
15
|
+
- Each page now keeps one live connection instead of one per project it shows. With a few tabs open, the connections ran out and the page's own requests waited forever: updates arrived but nothing could reload.
|
|
16
|
+
- A run that goes on after an edit (a video still rendering after a thumbnail redo) shows its progress again.
|
|
17
|
+
- The projects list, home and calendar move when steps start and finish, not only when a project changes.
|
|
18
|
+
- The video render's progress moves in tenths of a percent, so a two-hour render moves every few seconds.
|
|
19
|
+
- Pages showing a running project also look again every 15 seconds, in case an update was missed.
|
|
20
|
+
|
|
21
|
+
## Project page
|
|
22
|
+
|
|
23
|
+
- The header is one row: the way back, the title, the meta line and the buttons. Each stage's summary sits beside its title, and the run clock sits in the meta line instead of a row of its own, so the content starts much higher.
|
|
24
|
+
- The article shows its word and character count beside its title (the narrated text, without its sources and glossary).
|
|
25
|
+
- **Live** plays the finished narration, with its waveform, and plays the parts so far while it is spoken. Its length reads in hours and minutes.
|
|
26
|
+
|
|
27
|
+
## Run clock and cost
|
|
28
|
+
|
|
29
|
+
- The run clock keeps counting through work carried over from before an edit, instead of freezing.
|
|
30
|
+
- **Cost so far** shows the run's working time once it stops, for example "2 h 14 min of work".
|
|
31
|
+
|
|
32
|
+
## Notifications
|
|
33
|
+
|
|
34
|
+
- **Run sounds** (Settings → Notifications, on by default): two rising notes when a run starts, three brighter ones when it finishes, fails or waits for you. **Play start** and **Play end** let you hear them. With browser notifications on, a start also shows "Started: *title*".
|
|
35
|
+
|
|
36
|
+
## Fixes
|
|
37
|
+
|
|
38
|
+
- **History → Restore this revision** stopped with "unknown kind of step" on projects with Scenes from the article, `{{Appearance}}`, shorts, a YouTube description, levelled narration or animated images.
|
|
39
|
+
- Changing a project's thumbnail settings marked every image outdated. The thumbnails' scenes are now written by a step of their own in the Thumbnail stage.
|
|
40
|
+
|
|
41
|
+
## Upgrading from 3.0.12
|
|
42
|
+
|
|
43
|
+
- **Nothing becomes outdated.** A project whose thumbnail prompt uses `{{Scene}}` writes its thumbnail scenes in the new step the next time the thumbnail is made.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.0.13",
|
|
4
|
+
"title": "Slopify 3.0.13",
|
|
5
|
+
"version": "3.0.13",
|
|
6
|
+
"date": "2026-09-30"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.0.12",
|
|
10
|
+
"title": "Slopify 3.0.12",
|
|
11
|
+
"version": "3.0.12",
|
|
12
|
+
"date": "2026-09-30"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.0.11",
|
|
4
16
|
"title": "Slopify 3.0.11",
|
|
@@ -40,15 +40,20 @@ export function detectSlots(body) {
|
|
|
40
40
|
// Filled by Slopify, never by the person: each image's own scene, which the project's AI model
|
|
41
41
|
// writes from the article when an image prompt asks for it (`images/scenes.ts`).
|
|
42
42
|
export const sceneKeyword = "Scene";
|
|
43
|
+
// Filled by Slopify too: how the video's subject and the characters in an image look, which the
|
|
44
|
+
// project's AI model looks up on the web when a prompt asks for it (`images/appearance.ts`).
|
|
45
|
+
export const appearanceKeyword = "Appearance";
|
|
46
|
+
// The keywords Slopify fills itself, which are never fields for the person.
|
|
47
|
+
export const filledKeywords = [sceneKeyword, appearanceKeyword];
|
|
43
48
|
// One field per distinct name; Common when a name is used on both sides, otherwise Text or
|
|
44
49
|
// Image. The order is first appearance, which is why both sides arrive as ordered lists of
|
|
45
|
-
// bodies rather than as sets. The scene
|
|
50
|
+
// bodies rather than as sets. The scene and appearance keywords are not fields: Slopify fills them.
|
|
46
51
|
export function collectFields(textBodies, imageBodies) {
|
|
47
52
|
const text = namesOf(textBodies);
|
|
48
53
|
const image = namesOf(imageBodies);
|
|
49
54
|
const fields = [];
|
|
50
55
|
for (const name of [...text, ...image]) {
|
|
51
|
-
if (name
|
|
56
|
+
if (filledKeywords.includes(name) || fields.some((field) => field.name === name)) {
|
|
52
57
|
continue;
|
|
53
58
|
}
|
|
54
59
|
const onText = text.includes(name);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { appearanceKeyword, detectSlots, render } from "../admission/substitute.js";
|
|
3
|
+
// How things look, looked up rather than guessed: when an image or thumbnail prompt has
|
|
4
|
+
// `{{Appearance}}`, one call to the project's AI model searches the web for how the video's
|
|
5
|
+
// subject and every named character in the article look in their best-known depiction. Each
|
|
6
|
+
// picture then gets the subject's look and the looks of the characters its scene names, so a
|
|
7
|
+
// figure looks the same from image to image and like the one people know.
|
|
8
|
+
export function usesAppearance(body) {
|
|
9
|
+
return body != null && detectSlots(body).names.includes(appearanceKeyword);
|
|
10
|
+
}
|
|
11
|
+
const figureSchema = z.object({
|
|
12
|
+
name: z.string(),
|
|
13
|
+
aliases: z.array(z.string()).default([]),
|
|
14
|
+
look: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export const appearanceSchema = z.object({
|
|
17
|
+
subject: figureSchema,
|
|
18
|
+
characters: z.array(figureSchema).default([]),
|
|
19
|
+
});
|
|
20
|
+
export function appearanceMessages(brief) {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
role: "system",
|
|
24
|
+
content: [
|
|
25
|
+
"You research how things look, for the pictures of a narrated video. Search the web before you answer: official art and its descriptions, the sourcebooks and the wikis that describe them. Do not describe a figure from memory alone.",
|
|
26
|
+
"Describe the video's subject: the figure, creature, place or object the video is about. Then describe every other named character who could appear in a picture: rivals, servants, gods, companions. Leave out people who only made or published the work, and anyone the article only mentions in passing.",
|
|
27
|
+
"When depictions disagree (different editions, different artists), describe the most iconic one: the look most people picture, usually the best-known official art.",
|
|
28
|
+
"Each look is two to four sentences of what can be seen: build and size, face, skin or hide, hair, clothing or armour, colours, and the objects or marks that make the figure recognisable. Nothing about art style or medium, and no story: the style and the scene are set elsewhere.",
|
|
29
|
+
'Answer with JSON and nothing else: {"subject": {"name": "…", "aliases": ["…"], "look": "…"}, "characters": [{"name": "…", "aliases": ["…"], "look": "…"}]}. Aliases are other names or titles the article uses for the same figure.',
|
|
30
|
+
].join("\n\n"),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
role: "user",
|
|
34
|
+
content: [
|
|
35
|
+
`Video: ${brief.title}`,
|
|
36
|
+
brief.article.trim() === "" ? "" : `Article:\n\n${brief.article}`,
|
|
37
|
+
]
|
|
38
|
+
.filter((part) => part !== "")
|
|
39
|
+
.join("\n\n"),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
export function checkAppearance(text) {
|
|
44
|
+
const fix = "Use Try again; if it keeps happening, choose another model in the Providers section of Edit project.";
|
|
45
|
+
const parsed = appearanceSchema.safeParse(jsonObjectOf(text));
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
return { ok: false, reason: `The AI model didn't answer with the looks it looked up. ${fix}` };
|
|
48
|
+
const tidy = (figure) => ({
|
|
49
|
+
name: figure.name.trim(),
|
|
50
|
+
aliases: figure.aliases.map((alias) => alias.trim()).filter((alias) => alias !== ""),
|
|
51
|
+
look: figure.look.replace(/\s+/g, " ").trim(),
|
|
52
|
+
});
|
|
53
|
+
const subject = tidy(parsed.data.subject);
|
|
54
|
+
if (subject.name === "" || subject.look === "")
|
|
55
|
+
return { ok: false, reason: `The AI model described no look for the video's subject. ${fix}` };
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
appearance: {
|
|
59
|
+
subject,
|
|
60
|
+
characters: parsed.data.characters
|
|
61
|
+
.map(tidy)
|
|
62
|
+
.filter((figure) => figure.name !== "" && figure.look !== ""),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// The most characters besides the subject one picture describes: a crowded prompt is ignored.
|
|
67
|
+
const charactersPerPicture = 3;
|
|
68
|
+
// What `{{Appearance}}` becomes in one picture: the looks of the figures the picture's scene
|
|
69
|
+
// names (by name or alias, as a whole word), the subject first. Without a scene, the subject
|
|
70
|
+
// alone. The subject goes in only when the scene names it, unless it always does (a thumbnail
|
|
71
|
+
// stands for the subject): a scene of a place, or of another figure, is not redrawn with the
|
|
72
|
+
// subject in it. Empty when the scene names no one.
|
|
73
|
+
export function appearanceFor(appearance, scene, options = {}) {
|
|
74
|
+
const shown = (figure) => scene !== undefined && [figure.name, ...figure.aliases].some((name) => names(scene, name));
|
|
75
|
+
const subject = scene === undefined || options.subjectAlways === true || shown(appearance.subject)
|
|
76
|
+
? [appearance.subject]
|
|
77
|
+
: [];
|
|
78
|
+
const named = appearance.characters.filter(shown).slice(0, charactersPerPicture);
|
|
79
|
+
return [...subject, ...named].map((figure) => `${figure.name}: ${figure.look}`).join("\n");
|
|
80
|
+
}
|
|
81
|
+
// The prompt with its looks; a line asking for looks nobody in the picture has is left out.
|
|
82
|
+
export function withAppearance(body, text) {
|
|
83
|
+
if (text !== "")
|
|
84
|
+
return render(body, { [appearanceKeyword]: text });
|
|
85
|
+
return body
|
|
86
|
+
.split("\n")
|
|
87
|
+
.filter((line) => !usesAppearance(line))
|
|
88
|
+
.join("\n")
|
|
89
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
function names(text, name) {
|
|
93
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}($|[^\\p{L}\\p{N}])`, "iu").test(text);
|
|
95
|
+
}
|
|
96
|
+
function jsonObjectOf(text) {
|
|
97
|
+
const start = text.indexOf("{");
|
|
98
|
+
const end = text.lastIndexOf("}");
|
|
99
|
+
if (start === -1 || end <= start)
|
|
100
|
+
return undefined;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -38,8 +38,15 @@ export function pictureKind(body, name) {
|
|
|
38
38
|
// The count is written on a line of its own so the answer can be checked against it when the
|
|
39
39
|
// call runs (`sceneCountOf`), from the saved request alone.
|
|
40
40
|
const countLine = /^Pictures: (\d+)$/m;
|
|
41
|
+
// The thumbnails' scenes come after the images', one per thumbnail, when the thumbnail
|
|
42
|
+
// prompt asks for one with `{{Scene}}`. They are not tied to a stretch of the article: each is
|
|
43
|
+
// the moment that would make someone click, a different one for each thumbnail.
|
|
44
|
+
const thumbnailLine = /^Thumbnails: (\d+)$/m;
|
|
41
45
|
export function sceneMessages(brief) {
|
|
42
46
|
const count = brief.pictures.length;
|
|
47
|
+
const thumbnails = brief.thumbnails ?? 0;
|
|
48
|
+
if (thumbnails > 0)
|
|
49
|
+
return withThumbnails(brief, count, thumbnails);
|
|
43
50
|
return [
|
|
44
51
|
{
|
|
45
52
|
role: "system",
|
|
@@ -63,14 +70,56 @@ export function sceneMessages(brief) {
|
|
|
63
70
|
},
|
|
64
71
|
];
|
|
65
72
|
}
|
|
73
|
+
// How many scenes the answer must hold: the images' and then the thumbnails'.
|
|
66
74
|
export function sceneCountOf(messages) {
|
|
67
75
|
for (const message of messages) {
|
|
68
76
|
const found = countLine.exec(message.content);
|
|
69
77
|
if (found?.[1] !== undefined)
|
|
70
|
-
return Number(found[1]);
|
|
78
|
+
return Number(found[1]) + Number(thumbnailLine.exec(message.content)?.[1] ?? 0);
|
|
71
79
|
}
|
|
72
80
|
return undefined;
|
|
73
81
|
}
|
|
82
|
+
function withThumbnails(brief, count, thumbnails) {
|
|
83
|
+
const total = count + thumbnails;
|
|
84
|
+
const several = thumbnails > 1;
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
role: "system",
|
|
88
|
+
content: [
|
|
89
|
+
count === 0
|
|
90
|
+
? `You plan the thumbnail${several ? "s" : ""} for a narrated video. Read the article and write ${several ? `${String(thumbnails)} thumbnail scenes` : "one thumbnail scene"}.`
|
|
91
|
+
: "You plan the pictures for a narrated video. Read the article and write one scene for each picture listed, in the listed order, and then the thumbnail scenes.",
|
|
92
|
+
...(count === 0
|
|
93
|
+
? []
|
|
94
|
+
: [
|
|
95
|
+
`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 picture's scene comes from the ending. Keep to this order even when a later moment would suit a picture better.`,
|
|
96
|
+
"Each picture's 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).",
|
|
97
|
+
"Every picture's scene shows something different: never the same subject or moment twice.",
|
|
98
|
+
]),
|
|
99
|
+
`A thumbnail scene is one or two sentences saying what the thumbnail shows: the video's most recognisable subject in the most striking moment the article describes, the one that would make someone click. It comes from anywhere in the article, not from a stretch.${several ? " Each thumbnail scene shows a different moment or side of the subject, so the thumbnails can be tested against each other." : ""}`,
|
|
100
|
+
"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.",
|
|
101
|
+
count === 0
|
|
102
|
+
? `Answer with a JSON array of exactly ${String(total)} strings, one per thumbnail, and nothing else.`
|
|
103
|
+
: `Answer with a JSON array of exactly ${String(total)} strings, one scene per picture in order and then ${several ? `the ${String(thumbnails)} thumbnail scenes` : "the thumbnail scene"}, and nothing else.`,
|
|
104
|
+
`Pictures: ${String(count)}`,
|
|
105
|
+
`Thumbnails: ${String(thumbnails)}`,
|
|
106
|
+
].join("\n\n"),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
role: "user",
|
|
110
|
+
content: [
|
|
111
|
+
`Video: ${brief.title}`,
|
|
112
|
+
...(count === 0
|
|
113
|
+
? []
|
|
114
|
+
: [
|
|
115
|
+
`The ${String(count)} pictures, in order:\n${brief.pictures.map((kind, index) => `${String(index + 1)}. ${kind}`).join("\n")}`,
|
|
116
|
+
]),
|
|
117
|
+
`Thumbnails: ${String(thumbnails)}`,
|
|
118
|
+
`Article:\n\n${brief.article}`,
|
|
119
|
+
].join("\n\n"),
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
74
123
|
export function checkScenes(text, count) {
|
|
75
124
|
const fix = "Use Try again; if it keeps happening, choose another model in the Providers section of Edit project.";
|
|
76
125
|
const parsed = z.array(z.string()).safeParse(jsonArrayOf(text));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
2
|
+
import { appearanceFor, appearanceMessages, appearanceSchema, usesAppearance, withAppearance, } from "../images/appearance.js";
|
|
3
|
+
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
4
|
+
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
5
|
+
export const imageAppearanceKey = "images:appearance";
|
|
6
|
+
// The prompt bodies a run draws pictures from: the generated images', the thumbnail's when it
|
|
7
|
+
// is drawn from a Library prompt, and the establishing image's.
|
|
8
|
+
function pictureBodies(context) {
|
|
9
|
+
const { config, content } = context;
|
|
10
|
+
const images = config.sources.images === "generate"
|
|
11
|
+
? content.imageOrder.flatMap((key) => {
|
|
12
|
+
const image = content.imageDefinitions[key];
|
|
13
|
+
if (image?.source !== "generate")
|
|
14
|
+
return [];
|
|
15
|
+
return [
|
|
16
|
+
image.templateKey == null
|
|
17
|
+
? image.prompt
|
|
18
|
+
: (content.promptTemplates[image.templateKey] ?? image.prompt),
|
|
19
|
+
];
|
|
20
|
+
})
|
|
21
|
+
: [];
|
|
22
|
+
return [
|
|
23
|
+
...images,
|
|
24
|
+
...(config.sources.thumbnail === "from_prompt"
|
|
25
|
+
? [content.promptTemplates.thumbnailPrompt ?? config.rendered.thumbnailPrompt]
|
|
26
|
+
: []),
|
|
27
|
+
...(config.sources.images === "generate" && config.reference?.source === "prompt"
|
|
28
|
+
? [content.promptTemplates.referencePrompt ?? config.rendered.referencePrompt]
|
|
29
|
+
: []),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
export function imageAppearance(context, text) {
|
|
33
|
+
const { config } = context;
|
|
34
|
+
if (!pictureBodies(context).some(usesAppearance))
|
|
35
|
+
return undefined;
|
|
36
|
+
const messages = withLanguage(appearanceMessages({ title: config.title, article: text.articleText ?? "" }), config.language);
|
|
37
|
+
const value = recipe(context, imageAppearanceKey, config.sources.images === "generate" ? "images" : "thumbnail", text.articleText === null
|
|
38
|
+
? {
|
|
39
|
+
kind: "deferred",
|
|
40
|
+
version: 1,
|
|
41
|
+
operation: "image-appearance",
|
|
42
|
+
template: [llmInputFingerprint(context, messages), text.article.fingerprint],
|
|
43
|
+
}
|
|
44
|
+
: llmInput(context, messages, true), [text.article.key]);
|
|
45
|
+
return {
|
|
46
|
+
recipe: value,
|
|
47
|
+
value: text.articleText === null ? undefined : savedAppearance(context, value),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// The looks saved for this request, by either fingerprint (see `savedScenes`).
|
|
51
|
+
function savedAppearance(context, value) {
|
|
52
|
+
const logical = context.catalogue === undefined || value.input.kind !== "llm"
|
|
53
|
+
? value.fingerprint
|
|
54
|
+
: recipe({ content: context.content }, value.key, value.stage, { ...value.input, thinkingConfig: null }, value.dependsOn).fingerprint;
|
|
55
|
+
const piece = context.manifest.pieces.find((row) => row.key === value.key &&
|
|
56
|
+
selectedReference(row) &&
|
|
57
|
+
row.piece.state === "done" &&
|
|
58
|
+
(row.fingerprint === value.fingerprint || row.fingerprint === logical));
|
|
59
|
+
if (piece?.piece.payload == null)
|
|
60
|
+
return undefined;
|
|
61
|
+
const payload = JSON.parse(piece.piece.payload);
|
|
62
|
+
const parsed = appearanceSchema.safeParse(payload.appearance);
|
|
63
|
+
return parsed.success ? parsed.data : undefined;
|
|
64
|
+
}
|
|
65
|
+
// A prompt with its looks filled in: the subject's and those of the characters the scene names.
|
|
66
|
+
// Null while the looks are still being looked up, so the picture waits for them.
|
|
67
|
+
export function withLooks(body, appearance, scene, options) {
|
|
68
|
+
if (appearance === undefined || !usesAppearance(body))
|
|
69
|
+
return body;
|
|
70
|
+
if (appearance.value === undefined)
|
|
71
|
+
return null;
|
|
72
|
+
return withAppearance(body, appearanceFor(appearance.value, scene, options));
|
|
73
|
+
}
|
|
74
|
+
// What a picture waiting for the looks adds to its waiting request, and the step it waits on.
|
|
75
|
+
export function lookWait(body, appearance) {
|
|
76
|
+
return appearance === undefined || !usesAppearance(body)
|
|
77
|
+
? { template: [], dependsOn: [] }
|
|
78
|
+
: {
|
|
79
|
+
template: [["appearance", appearance.recipe.fingerprint]],
|
|
80
|
+
dependsOn: [appearance.recipe.key],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { referenceForThumbnail } from "../admission/rules.js";
|
|
2
|
+
import { imageAppearance } from "./recipe-appearance.js";
|
|
2
3
|
import { audioRecipes } from "./recipe-audio.js";
|
|
3
4
|
import { documentRecipes } from "./recipe-document.js";
|
|
4
5
|
import { editPlan } from "./recipe-edit.js";
|
|
@@ -8,7 +9,7 @@ import { masterPlan } from "./recipe-loudness.js";
|
|
|
8
9
|
import { resourceIdentity } from "./recipe-model.js";
|
|
9
10
|
import { imageReference, referenceRecipe } from "./recipe-reference.js";
|
|
10
11
|
import { withReviews } from "./recipe-reviews.js";
|
|
11
|
-
import { imageScenes } from "./recipe-scenes.js";
|
|
12
|
+
import { imageScenes, thumbnailScenes } from "./recipe-scenes.js";
|
|
12
13
|
import { shortsRecipes } from "./recipe-shorts.js";
|
|
13
14
|
import { textRecipes } from "./recipe-text.js";
|
|
14
15
|
import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
|
|
@@ -25,13 +26,17 @@ export function buildRecipes(context) {
|
|
|
25
26
|
const exports = exportRecipes(context, audio);
|
|
26
27
|
const captions = exports.find((value) => value.key === "subtitles:files");
|
|
27
28
|
const timing = exports.find((value) => value.key === "subtitles:timing");
|
|
29
|
+
// How the subject and characters look, looked up when a prompt has `{{Appearance}}`.
|
|
30
|
+
const appearance = imageAppearance(context, text);
|
|
28
31
|
// The establishing image, when it is on: every other image is drawn from it.
|
|
29
|
-
const reference = referenceRecipe(context);
|
|
32
|
+
const reference = referenceRecipe(context, appearance);
|
|
30
33
|
const drawnFrom = imageReference(context, reference);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Each image's scene, written from the article, for image prompts that ask for one.
|
|
34
|
+
// Each image's scene, written from the article, and the thumbnails' in a step of their own
|
|
35
|
+
// when their prompt asks.
|
|
34
36
|
const scenes = imageScenes(context, text);
|
|
37
|
+
const thumbnailScened = thumbnailScenes(context, text);
|
|
38
|
+
const thumbnail = thumbnailRecipes(context, text.recipes, referenceForThumbnail(context.config) ? drawnFrom : undefined, thumbnailScened, appearance);
|
|
39
|
+
const youtube = youtubeRecipes(context, exports);
|
|
35
40
|
return withReviews(context, [
|
|
36
41
|
...text.recipes,
|
|
37
42
|
...audio.recipes,
|
|
@@ -41,7 +46,9 @@ export function buildRecipes(context) {
|
|
|
41
46
|
...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video")),
|
|
42
47
|
...thumbnail,
|
|
43
48
|
...documentRecipes(context, text, thumbnail),
|
|
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)),
|
|
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)),
|
|
45
50
|
...(scenes === undefined ? [] : [scenes.recipe]),
|
|
51
|
+
...(thumbnailScened === undefined ? [] : [thumbnailScened.recipe]),
|
|
52
|
+
...(appearance === undefined ? [] : [appearance.recipe]),
|
|
46
53
|
]);
|
|
47
54
|
}
|
|
@@ -47,6 +47,9 @@ 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",
|
|
51
|
+
// How the subject and characters look (`recipe-appearance.ts`), looked up on the web.
|
|
52
|
+
"image-appearance",
|
|
50
53
|
];
|
|
51
54
|
export function recipe(context, key, stage, input, dependsOn = [], options = {}) {
|
|
52
55
|
const requestFingerprint = input.kind === "tts" && input.dialogue !== undefined
|
|
@@ -34,7 +34,9 @@ export function recipeProviderChoice(recipe, config) {
|
|
|
34
34
|
return undefined;
|
|
35
35
|
const family = input.operation === "narration-preparation" ||
|
|
36
36
|
input.operation === "narration-description" ||
|
|
37
|
-
input.operation === "image-scenes"
|
|
37
|
+
input.operation === "image-scenes" ||
|
|
38
|
+
input.operation === "thumbnail-scenes" ||
|
|
39
|
+
input.operation === "image-appearance"
|
|
38
40
|
? "llm"
|
|
39
41
|
: recipe.stage === "audio"
|
|
40
42
|
? "tts"
|