@gentbajko/slopify 3.11.0 → 3.11.1
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/kernel/db/migrations/0053-local-work-times.sql +14 -0
- package/dist/patch-notes/3.11.1.md +8 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/document/fonts.js +5 -1
- package/dist/slices/document/pages.js +20 -7
- package/dist/slices/document/sources.js +93 -1
- package/dist/slices/rebuild/recipe-audio.js +23 -3
- package/dist/slices/rebuild/recipe-save.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +4 -3
- package/dist/slices/rebuild/runtime-run.js +25 -0
- package/dist/slices/run-cost/timing.js +7 -2
- package/dist/slices/storage/backup-format.js +2 -0
- package/dist/web/assets/{index-CASczIQ9.js → index-CmQ2so2R.js} +2 -2
- package/dist/web/assets/{pdf-DWBP2BjM.js → pdf-B0AMuc3F.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- How long each local step ran (3.11.1): levelling, renders, captions, documents. Only provider
|
|
2
|
+
-- calls left a record (`attempts`), so a project's working time missed every render and its
|
|
3
|
+
-- clock stood still while one ran. Kept apart from attempts, whose presence says a step has
|
|
4
|
+
-- started submitting and changes how a pause or an edit treats it.
|
|
5
|
+
CREATE TABLE local_work_times (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
9
|
+
revision_id TEXT,
|
|
10
|
+
work_id TEXT,
|
|
11
|
+
started_at TEXT NOT NULL,
|
|
12
|
+
ended_at TEXT
|
|
13
|
+
);
|
|
14
|
+
CREATE INDEX local_work_times_project ON local_work_times(project_id);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Slopify 3.11.1
|
|
2
|
+
|
|
3
|
+
Released 6 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Working time counts renders.** Only the steps that call an AI model recorded how long they ran, so a project's working time left out every render, levelling and document, and while one ran the clock counted up and dropped back every 20 seconds. Slopify's own steps now record their time too, and the clock only moves while a step actually runs. Time from renders made before this update can't be recovered.
|
|
6
|
+
- **No more words run together in the PDF.** A word starting with a letter like V or T was measured narrower than it is drawn, so the next word ran into it ("Vecna.Later"). Words are now measured exactly as they are drawn.
|
|
7
|
+
- **A readable Sources page.** A source's long address is shown as the site's name, still linking to the page; a bare link gets a title read from its address ("Who is Vecna and why must he die — dndbeyond.com"); a site cited several times in one source is numbered.
|
|
8
|
+
- **Fixing the article by hand no longer remakes the narration it didn't change.** Dropping a line from the article that the narration had already dropped (by editing that chunk's narration) gave the chunk a new identity, so it was voiced again and the captions, shorts, description and video all followed. Such a chunk now keeps its narration. An outro or intro the AI model wrote from the article also keeps the article it was written from, as the pictures do since 3.11.0, so it isn't written again either.
|
|
@@ -52,9 +52,13 @@ export function useFace(doc, face, size) {
|
|
|
52
52
|
doc.setFontSize(size);
|
|
53
53
|
doc.setCharSpace(face.letterSpacing);
|
|
54
54
|
}
|
|
55
|
+
// Measured as drawn: `getTextWidth` takes off the kerning between pairs like "Ve" for the
|
|
56
|
+
// standard fonts, but `text` draws without it, so a word starting "V" or "T" came out wider than
|
|
57
|
+
// measured and ran into the next one ("Vecna.Later").
|
|
55
58
|
export function faceWidth(doc, face, size, text) {
|
|
56
59
|
useFace(doc, face, size);
|
|
57
|
-
|
|
60
|
+
const width = (doc.getStringUnitWidth(text, { doKerning: false }) * size) / doc.internal.scaleFactor;
|
|
61
|
+
return width + face.letterSpacing * [...text].length;
|
|
58
62
|
}
|
|
59
63
|
function styleFor(face) {
|
|
60
64
|
if (face.family === "Cinzel") {
|
|
@@ -127,14 +127,27 @@ export function sourcesPage(w, items) {
|
|
|
127
127
|
const lines = doc.splitTextToSize(item.text, w.contentWidth - bullet - 5);
|
|
128
128
|
w.ensure(sources.line);
|
|
129
129
|
w.write("•", w.left, w.y, fonts.body, sizes.body, "muted");
|
|
130
|
-
// The entry reads as text; only its
|
|
131
|
-
//
|
|
130
|
+
// The entry reads as text; only its site names (or addresses, when it wasn't tidied) are
|
|
131
|
+
// links, each in the link colour and pointing at its page. An entry with neither is
|
|
132
132
|
// clickable as a whole, still in text colour.
|
|
133
|
-
const addresses = [
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
const addresses = [];
|
|
134
|
+
if (item.links !== undefined) {
|
|
135
|
+
let from = 0;
|
|
136
|
+
for (const link of item.links) {
|
|
137
|
+
const at = item.text.indexOf(link.label, from);
|
|
138
|
+
if (at < 0)
|
|
139
|
+
continue;
|
|
140
|
+
addresses.push({ from: at, to: at + link.label.length, url: link.href });
|
|
141
|
+
from = at + link.label.length;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
for (const match of item.text.matchAll(/https?:\/\/[^\s)>\]]+/g))
|
|
146
|
+
addresses.push({
|
|
147
|
+
from: match.index,
|
|
148
|
+
to: match.index + match[0].replace(/[.,;:]+$/, "").length,
|
|
149
|
+
url: match[0].replace(/[.,;:]+$/, ""),
|
|
150
|
+
});
|
|
138
151
|
const width = (text) => faceWidth(doc, fonts.body, sizes.body, text);
|
|
139
152
|
let cursor = 0;
|
|
140
153
|
for (const line of lines) {
|
|
@@ -14,7 +14,99 @@ export function documentText(articleMarkdown, researchNotes) {
|
|
|
14
14
|
seen.add(link.href);
|
|
15
15
|
cited.push(link.text === link.href ? { text: link.href, href: link.href } : link);
|
|
16
16
|
}
|
|
17
|
-
return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
|
|
17
|
+
return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited].map(tidySource) };
|
|
18
|
+
}
|
|
19
|
+
const addressPattern = /https?:\/\/[^\s)>\]]+/g;
|
|
20
|
+
// A source as a reader wants it: an entry's long address replaced by the site's name (the entry
|
|
21
|
+
// still links to it), and a bare address given a title read from the address itself.
|
|
22
|
+
export function tidySource(item) {
|
|
23
|
+
const bare = item.text.trim();
|
|
24
|
+
if (item.href !== null && (bare === item.href || /^https?:\/\/\S+$/.test(bare))) {
|
|
25
|
+
const site = siteOf(bare);
|
|
26
|
+
const title = titleOf(bare);
|
|
27
|
+
return {
|
|
28
|
+
text: title === undefined ? site : `${title} — ${site}`,
|
|
29
|
+
href: item.href,
|
|
30
|
+
links: [{ label: site, href: item.href }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const addresses = [...item.text.matchAll(addressPattern)].map((match) => match[0].replace(/[.,;:]+$/, ""));
|
|
34
|
+
if (addresses.length === 0)
|
|
35
|
+
return item;
|
|
36
|
+
// A site cited more than once in an entry is numbered, so each name still says which page.
|
|
37
|
+
const seen = new Map();
|
|
38
|
+
const links = addresses.map((address) => {
|
|
39
|
+
const site = siteOf(address);
|
|
40
|
+
const count = (seen.get(site) ?? 0) + 1;
|
|
41
|
+
seen.set(site, count);
|
|
42
|
+
return { label: count === 1 ? site : `${site} (${String(count)})`, href: address };
|
|
43
|
+
});
|
|
44
|
+
let text = item.text;
|
|
45
|
+
for (const [at, address] of addresses.entries())
|
|
46
|
+
text = text.replace(address, links[at]?.label ?? address);
|
|
47
|
+
return { text, href: item.href ?? addresses[0] ?? null, links };
|
|
48
|
+
}
|
|
49
|
+
// "en.wikipedia.org" from an address; the address itself when it isn't one.
|
|
50
|
+
function siteOf(address) {
|
|
51
|
+
try {
|
|
52
|
+
return new URL(address).hostname.replace(/^www\./, "");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return address;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Path parts that name a kind of page rather than the page.
|
|
59
|
+
const genericParts = new Set([
|
|
60
|
+
"article",
|
|
61
|
+
"articles",
|
|
62
|
+
"blog",
|
|
63
|
+
"card",
|
|
64
|
+
"cards",
|
|
65
|
+
"claim",
|
|
66
|
+
"index",
|
|
67
|
+
"item",
|
|
68
|
+
"news",
|
|
69
|
+
"page",
|
|
70
|
+
"pages",
|
|
71
|
+
"post",
|
|
72
|
+
"posts",
|
|
73
|
+
"product",
|
|
74
|
+
"products",
|
|
75
|
+
"source",
|
|
76
|
+
"view",
|
|
77
|
+
"watch",
|
|
78
|
+
"wiki",
|
|
79
|
+
]);
|
|
80
|
+
// A page's title as its address spells it: the last path part that reads as words
|
|
81
|
+
// ("1700-who-is-vecna-and-why-must-he-die" → "Who is vecna and why must he die").
|
|
82
|
+
function titleOf(address) {
|
|
83
|
+
let parts;
|
|
84
|
+
try {
|
|
85
|
+
parts = new URL(address).pathname.split("/").filter((part) => part !== "");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
for (const part of parts.toReversed()) {
|
|
91
|
+
let decoded = part;
|
|
92
|
+
try {
|
|
93
|
+
decoded = decodeURIComponent(part);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A malformed escape: the part as written.
|
|
97
|
+
}
|
|
98
|
+
const words = decoded
|
|
99
|
+
.replace(/\.(html?|php|aspx?|pdf)$/i, "")
|
|
100
|
+
.replace(/[-_+]+/g, " ")
|
|
101
|
+
.replace(/^\d+\s+/, "")
|
|
102
|
+
.trim();
|
|
103
|
+
if (!/[a-z]{3}/i.test(words) ||
|
|
104
|
+
/^[a-z]{1,3}\d*$/i.test(words) ||
|
|
105
|
+
genericParts.has(words.toLowerCase()))
|
|
106
|
+
continue;
|
|
107
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
18
110
|
}
|
|
19
111
|
function sourceItems(section) {
|
|
20
112
|
return sourceEntries(section).flatMap((entry) => {
|
|
@@ -12,9 +12,29 @@ import { pauseValues } from "./recipe-pauses.js";
|
|
|
12
12
|
import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
|
|
13
13
|
import { voiceBodyRecipes } from "./recipe-voices.js";
|
|
14
14
|
export function bodyNarrationGroups(context, text) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
if (text.narrationText === null)
|
|
16
|
+
return [];
|
|
17
|
+
const groups = pronunciationChunks(normalizeNarrationText(text.narrationText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources);
|
|
18
|
+
// A chunk's key is its text's, so an article edit that makes a chunk say what its narration
|
|
19
|
+
// edit already said (a line dropped from both) would make a new chunk to voice again. It
|
|
20
|
+
// keeps the edited chunk's key instead, and with it the narration already made.
|
|
21
|
+
const overrides = context.content.narrationOverrides;
|
|
22
|
+
const present = new Set(groups.map((group) => group.key));
|
|
23
|
+
const orphans = new Map();
|
|
24
|
+
for (const [key, override] of Object.entries(overrides))
|
|
25
|
+
if (override.kind === "text" && key.startsWith("audio:body:") && !present.has(key))
|
|
26
|
+
orphans.set(normalizeNarrationText(override.text), key);
|
|
27
|
+
if (orphans.size === 0)
|
|
28
|
+
return groups;
|
|
29
|
+
return groups.map((group) => {
|
|
30
|
+
if (overrides[group.key] !== undefined || group.source !== undefined)
|
|
31
|
+
return group;
|
|
32
|
+
const key = orphans.get(group.text);
|
|
33
|
+
if (key === undefined)
|
|
34
|
+
return group;
|
|
35
|
+
orphans.delete(group.text);
|
|
36
|
+
return { ...group, key };
|
|
37
|
+
});
|
|
18
38
|
}
|
|
19
39
|
export function audioRecipes(context, text) {
|
|
20
40
|
const { config, content } = context;
|
|
@@ -44,16 +44,18 @@ export function normalizeArticleIntent(base, edit) {
|
|
|
44
44
|
: (base.revision.content.articleEdited ?? false),
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
-
// The steps that
|
|
48
|
-
//
|
|
47
|
+
// The steps that are made from the article: each image's scene, the looks, the thumbnails'
|
|
48
|
+
// scenes, the thumbnail prompt and an intro or outro the AI model writes from it.
|
|
49
49
|
export const pictureWorkKeys = [
|
|
50
50
|
"images:scenes",
|
|
51
51
|
"thumbnail:scenes",
|
|
52
52
|
"images:appearance",
|
|
53
53
|
"thumbnail:prompt",
|
|
54
|
+
"entry:intro:text",
|
|
55
|
+
"entry:outro:text",
|
|
54
56
|
];
|
|
55
|
-
// An article edited by hand keeps the pictures already drawn
|
|
56
|
-
// they were
|
|
57
|
+
// An article edited by hand keeps the pictures already drawn, and an intro or outro already
|
|
58
|
+
// written: the version remembers the article they were made from and those steps go on reading it, until the edit asks for them again or
|
|
57
59
|
// the article is written anew. Before any picture is drawn there is nothing to keep.
|
|
58
60
|
function picturesArticleOf(base, edit, changed) {
|
|
59
61
|
const keys = pictureWorkKeys;
|
|
@@ -152,7 +152,8 @@ export function textRecipes(context) {
|
|
|
152
152
|
recipes.push(recipe(context, "article:continuation", "article", llmInput(context, continuationMessages(messages, resolved.articleContinuation), false, documents), [article.key]));
|
|
153
153
|
const endMatter = articleMarkdown === null ? null : splitEndMatter(articleMarkdown);
|
|
154
154
|
const articleText = endMatter === null ? null : plainText(endMatter.body);
|
|
155
|
-
// The pictures
|
|
155
|
+
// The pictures, and an intro or outro written from the article, go on reading the article
|
|
156
|
+
// they were made from while the version keeps it (`picturesArticle`).
|
|
156
157
|
const pictureText = content.picturesArticle === undefined
|
|
157
158
|
? articleText
|
|
158
159
|
: plainText(splitEndMatter(content.picturesArticle).body);
|
|
@@ -213,7 +214,7 @@ export function textRecipes(context) {
|
|
|
213
214
|
const key = `entry:${category}:text`;
|
|
214
215
|
const input = choice.mode === "text"
|
|
215
216
|
? { kind: "local", version: 1, operation: "entry-text", values: prompt }
|
|
216
|
-
:
|
|
217
|
+
: pictureText === null
|
|
217
218
|
? {
|
|
218
219
|
kind: "deferred",
|
|
219
220
|
version: 1,
|
|
@@ -223,7 +224,7 @@ export function textRecipes(context) {
|
|
|
223
224
|
article.fingerprint,
|
|
224
225
|
],
|
|
225
226
|
}
|
|
226
|
-
: llmInput(context, withLanguage(segmentMessages(prompt, config,
|
|
227
|
+
: llmInput(context, withLanguage(segmentMessages(prompt, config, pictureText), config.language));
|
|
227
228
|
const value = recipe(context, key, "article", input, choice.mode === "llm" ? [article.key] : []);
|
|
228
229
|
recipes.push(value);
|
|
229
230
|
entries[category] = {
|
|
@@ -17,6 +17,15 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
17
17
|
continue;
|
|
18
18
|
if (piece.input.kind === "deferred")
|
|
19
19
|
return "held";
|
|
20
|
+
// A step Slopify does itself (no provider call) records how long it ran, for the working
|
|
21
|
+
// time; a provider call records its own attempts.
|
|
22
|
+
const local = /^(subtitles|export|document|voices):/.test(piece.key) ||
|
|
23
|
+
/^shorts:\d+:render$/.test(piece.key) ||
|
|
24
|
+
(!/^(review|youtube|shorts|animate):/.test(piece.key) &&
|
|
25
|
+
piece.input.kind !== "llm" &&
|
|
26
|
+
piece.input.kind !== "tts" &&
|
|
27
|
+
piece.input.kind !== "image");
|
|
28
|
+
const timed = local ? startLocalTime(deps, context) : undefined;
|
|
20
29
|
try {
|
|
21
30
|
const outcome = piece.key.startsWith("subtitles:")
|
|
22
31
|
? await executeSubtitleRecipe(deps, context, piece)
|
|
@@ -57,9 +66,25 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
57
66
|
}
|
|
58
67
|
throw label === undefined ? error : new Error(`${label}: ${error.message}`, { cause: error });
|
|
59
68
|
}
|
|
69
|
+
finally {
|
|
70
|
+
if (timed !== undefined)
|
|
71
|
+
endLocalTime(deps, timed);
|
|
72
|
+
}
|
|
60
73
|
}
|
|
61
74
|
deps.db
|
|
62
75
|
.prepare("UPDATE revision_work_pieces SET state='done' WHERE work_id=? AND result_json IS NOT NULL")
|
|
63
76
|
.run(context.work.workId);
|
|
64
77
|
return "done";
|
|
65
78
|
}
|
|
79
|
+
function startLocalTime(deps, context) {
|
|
80
|
+
const id = deps.ids.next();
|
|
81
|
+
deps.db
|
|
82
|
+
.prepare("INSERT INTO local_work_times(id,project_id,stage_id,revision_id,work_id,started_at) VALUES (?,?,?,?,?,?)")
|
|
83
|
+
.run(id, context.work.projectId, context.work.stageId, context.work.revisionId, context.work.workId, deps.clock.now().toISOString());
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
function endLocalTime(deps, id) {
|
|
87
|
+
deps.db
|
|
88
|
+
.prepare("UPDATE local_work_times SET ended_at=? WHERE id=?")
|
|
89
|
+
.run(deps.clock.now().toISOString(), id);
|
|
90
|
+
}
|
|
@@ -31,8 +31,13 @@ export function projectTiming(db, projectId, now) {
|
|
|
31
31
|
a.started_at AS started_at, a.ended_at AS ended_at, w.state AS work_state
|
|
32
32
|
FROM attempts a JOIN stages s ON s.id = a.stage_id
|
|
33
33
|
LEFT JOIN revision_work w ON w.id = a.work_id
|
|
34
|
-
WHERE s.project_id =
|
|
35
|
-
|
|
34
|
+
WHERE s.project_id = ?
|
|
35
|
+
UNION ALL
|
|
36
|
+
SELECT s.kind, t.revision_id, t.work_id, t.started_at, t.ended_at, w.state
|
|
37
|
+
FROM local_work_times t JOIN stages s ON s.id = t.stage_id
|
|
38
|
+
LEFT JOIN revision_work w ON w.id = t.work_id
|
|
39
|
+
WHERE t.project_id = ?`)
|
|
40
|
+
.all(projectId, projectId)
|
|
36
41
|
.map((row) => attemptRow.parse(row));
|
|
37
42
|
const head = currentRevisionId(db, projectId);
|
|
38
43
|
// Work an edit carried over to the current revision is still its run's work: a video
|