@gentbajko/slopify 3.9.1 → 3.10.0
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/catalog/model-info.js +33 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/channels.js +6 -1
- package/dist/edge/http/document-themes.js +2 -2
- package/dist/edge/http/files.js +6 -2
- package/dist/edge/http/outputs.js +67 -0
- package/dist/edge/http/planning.js +18 -3
- package/dist/edge/http/projects.js +7 -4
- package/dist/edge/http/providers.js +4 -6
- package/dist/edge/http/revision-files.js +25 -7
- package/dist/edge/http/schedules.js +30 -0
- package/dist/edge/http/trash.js +51 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0052-cast-order.sql +11 -0
- package/dist/patch-notes/3.10.0.md +79 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/heads.js +59 -0
- package/dist/slices/admission/rules.js +6 -1
- package/dist/slices/batch/index.js +22 -0
- package/dist/slices/channels/rebrand.js +7 -1
- package/dist/slices/channels/repo.js +2 -1
- package/dist/slices/channels/schema.js +5 -0
- package/dist/slices/channels/service.js +23 -3
- package/dist/slices/estimate/index.js +2 -0
- package/dist/slices/library/transfer.js +164 -0
- package/dist/slices/narration/aliases-table.js +82 -0
- package/dist/slices/outputs/add.js +192 -0
- package/dist/slices/outputs/model.js +36 -0
- package/dist/slices/outputs/service.js +136 -0
- package/dist/slices/rebuild/model.js +1 -1
- package/dist/slices/rebuild/recipe-text.js +9 -4
- package/dist/slices/rebuild/recipe-visual.js +6 -0
- package/dist/slices/rebuild/runtime-local.js +10 -3
- package/dist/slices/rebuild/runtime-provider.js +7 -0
- package/dist/slices/revisions/downloads.js +38 -17
- package/dist/slices/revisions/model.js +3 -1
- package/dist/slices/revisions/mutation-assets.js +29 -2
- package/dist/slices/revisions/mutation-prepare.js +44 -9
- package/dist/slices/revisions/schema.js +4 -0
- package/dist/slices/schedules/agenda.js +21 -16
- package/dist/slices/schedules/held-bulk.js +66 -0
- package/dist/slices/schedules/topics.js +1 -1
- package/dist/slices/storage/backup-import.js +8 -4
- package/dist/slices/storage/downloads.js +42 -4
- package/dist/slices/voices/adaptation.js +19 -0
- package/dist/slices/voices/model.js +4 -2
- package/dist/slices/youtube/model.js +33 -2
- package/dist/tutorials/AI-CLIs.md +1 -1
- package/dist/tutorials/Backups.md +5 -5
- package/dist/tutorials/Calendar.md +2 -2
- package/dist/tutorials/Channels.md +1 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +1 -1
- package/dist/tutorials/Document-Themes.md +2 -2
- package/dist/tutorials/Editing-a-Project.md +2 -2
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Home-and-Projects.md +33 -22
- package/dist/tutorials/Intros-and-Outros.md +4 -4
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +4 -4
- package/dist/tutorials/Library-Overview.md +16 -15
- package/dist/tutorials/Multiple-Voices.md +3 -3
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +4 -4
- package/dist/tutorials/PDF-Documents.md +5 -5
- package/dist/tutorials/Play-Narration.md +3 -3
- package/dist/tutorials/Play-Outputs.md +1 -1
- package/dist/tutorials/Play-Overview.md +4 -4
- package/dist/tutorials/Play-Title-and-Article.md +3 -3
- package/dist/tutorials/Project-Page.md +7 -6
- package/dist/tutorials/Prompts.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +2 -2
- package/dist/tutorials/Recovery-and-Retries.md +1 -1
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +35 -31
- package/dist/tutorials/Templates.md +1 -1
- package/dist/tutorials/Trash.md +5 -5
- package/dist/tutorials/Troubleshooting.md +2 -2
- package/dist/tutorials/Where-Your-Files-Live.md +4 -4
- package/dist/web/assets/index-CW_6Ho-M.js +325 -0
- package/dist/web/assets/index-DmPZKDb1.css +1 -0
- package/dist/web/assets/{pdf-D04vvdOD.js → pdf-C4K73cRQ.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-IWb_SctN.js +0 -310
- package/dist/web/assets/index-_D68Tk8q.css +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { derive } from "../../kernel/runner/graph.js";
|
|
3
|
-
import { liveProject,
|
|
3
|
+
import { liveProject, stageStandingsByProject } from "../admission/repo.js";
|
|
4
4
|
import { queueEntries } from "../batch/index.js";
|
|
5
5
|
import { sampleIds } from "../onboarding/model.js";
|
|
6
6
|
import { readSampleRecord } from "../onboarding/state.js";
|
|
@@ -102,7 +102,9 @@ function upcomingRuns(deps, schedule, now, from, to) {
|
|
|
102
102
|
const projectRow = z.object({
|
|
103
103
|
id: z.string(),
|
|
104
104
|
title: z.string(),
|
|
105
|
-
|
|
105
|
+
// The stored `sources.video`, null when absent or the configuration is unreadable.
|
|
106
|
+
video_source: z.unknown(),
|
|
107
|
+
paused: z.number(),
|
|
106
108
|
created_at: z.string(),
|
|
107
109
|
finished_at: z.string().nullable(),
|
|
108
110
|
});
|
|
@@ -125,15 +127,8 @@ function awaitingReview(db) {
|
|
|
125
127
|
}
|
|
126
128
|
// Whether a finished project's settings make a video: projects stored before sources were
|
|
127
129
|
// recorded made one.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const parsed = videoSource.safeParse(JSON.parse(config));
|
|
132
|
-
return !parsed.success || parsed.data.sources?.video !== "off";
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
130
|
+
function makesVideo(videoSource) {
|
|
131
|
+
return videoSource !== "off";
|
|
137
132
|
}
|
|
138
133
|
function sampleProjectIds(db) {
|
|
139
134
|
return new Set(sampleIds.flatMap((id) => {
|
|
@@ -169,13 +164,23 @@ function projectsIn(deps, from, to) {
|
|
|
169
164
|
fromSchedule.set(parsed.project_id, parsed.schedule_id);
|
|
170
165
|
}
|
|
171
166
|
return deps.db
|
|
172
|
-
.prepare(
|
|
167
|
+
.prepare(
|
|
168
|
+
// A project with no stage pending or running, not paused and with stages is finished
|
|
169
|
+
// (`derive`), so one that ended before the range is left out here rather than read.
|
|
170
|
+
`SELECT projects.id,projects.title,projects.created_at,
|
|
171
|
+
CASE WHEN json_valid(projects.config) THEN json_extract(projects.config,'$.sources.video') END AS video_source,
|
|
172
|
+
COALESCE(project_controls.paused,0) AS paused,max(stages.finished_at) AS finished_at
|
|
173
173
|
FROM projects LEFT JOIN stages ON stages.project_id=projects.id
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
LEFT JOIN project_controls ON project_controls.project_id=projects.id
|
|
175
|
+
WHERE projects.created_at<? AND ${liveProject()} GROUP BY projects.id
|
|
176
|
+
HAVING COALESCE(project_controls.paused,0)=1 OR count(stages.id)=0
|
|
177
|
+
OR sum(stages.state IN ('pending','running'))>0
|
|
178
|
+
OR COALESCE(max(stages.finished_at),projects.created_at)>=?
|
|
179
|
+
ORDER BY projects.created_at,projects.id`)
|
|
180
|
+
.all(to.toISOString(), from.toISOString())
|
|
176
181
|
.flatMap((row) => {
|
|
177
182
|
const project = projectRow.parse(row);
|
|
178
|
-
const state = derive(standings.get(project.id) ?? [],
|
|
183
|
+
const state = derive(standings.get(project.id) ?? [], project.paused === 1);
|
|
179
184
|
const terminal = state === "done" || state === "partial" || state === "failed" || state === "canceled";
|
|
180
185
|
if (terminal) {
|
|
181
186
|
const ended = project.finished_at ?? project.created_at;
|
|
@@ -184,7 +189,7 @@ function projectsIn(deps, from, to) {
|
|
|
184
189
|
}
|
|
185
190
|
const needs = needsOf(state, reviewing.has(project.id));
|
|
186
191
|
const ready = (state === "done" || state === "partial") &&
|
|
187
|
-
makesVideo(project.
|
|
192
|
+
makesVideo(project.video_source) &&
|
|
188
193
|
!uploads.has(project.id) &&
|
|
189
194
|
!samples.has(project.id);
|
|
190
195
|
const waiting = waits.get(project.id);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { scheduleById } from "./repo.js";
|
|
3
|
+
import { writeHeldValues } from "./topics.js";
|
|
4
|
+
// Held topics decided several at a time: Reject selected (or Reject all, as the ids shown),
|
|
5
|
+
// and its Undo, which puts turned-down topics back to wait with the keywords they had.
|
|
6
|
+
function liveSchedule(deps, scheduleId) {
|
|
7
|
+
const schedule = scheduleById(deps.db, scheduleId);
|
|
8
|
+
if (schedule === undefined || schedule.deletedAt !== null)
|
|
9
|
+
return { ok: false, reason: "not-found" };
|
|
10
|
+
if (schedule.status === "canceled" || schedule.status === "completed")
|
|
11
|
+
return { ok: false, reason: "conflict" };
|
|
12
|
+
return { ok: true, value: schedule };
|
|
13
|
+
}
|
|
14
|
+
// Every id must still be waiting, or nothing is turned down: the list on screen was stale.
|
|
15
|
+
export function rejectHeldTopics(deps, scheduleId, ids) {
|
|
16
|
+
const unique = [...new Set(ids)];
|
|
17
|
+
if (unique.length === 0)
|
|
18
|
+
return { ok: false, reason: "invalid-input" };
|
|
19
|
+
return batch(deps, () => {
|
|
20
|
+
const open = liveSchedule(deps, scheduleId);
|
|
21
|
+
if (!open.ok)
|
|
22
|
+
return open;
|
|
23
|
+
const at = deps.clock.now().toISOString();
|
|
24
|
+
const reject = deps.db.prepare(`UPDATE schedule_topics SET state='rejected',decided_at=?
|
|
25
|
+
WHERE id=? AND schedule_id=? AND state='held'`);
|
|
26
|
+
for (const id of unique)
|
|
27
|
+
if (Number(reject.run(at, id, scheduleId).changes) !== 1)
|
|
28
|
+
throw new TopicGone();
|
|
29
|
+
writeHeldValues(deps.db);
|
|
30
|
+
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Turned-down topics wait again, each with the keywords it had (`values`, from the list the
|
|
34
|
+
// person saw), in its old place.
|
|
35
|
+
export function restoreRejectedTopics(deps, scheduleId, topics) {
|
|
36
|
+
if (topics.length === 0)
|
|
37
|
+
return { ok: false, reason: "invalid-input" };
|
|
38
|
+
return batch(deps, () => {
|
|
39
|
+
const open = liveSchedule(deps, scheduleId);
|
|
40
|
+
if (!open.ok)
|
|
41
|
+
return open;
|
|
42
|
+
const restore = deps.db.prepare(`UPDATE schedule_topics SET state='held',decided_at=NULL
|
|
43
|
+
WHERE id=? AND schedule_id=? AND state='rejected'`);
|
|
44
|
+
for (const topic of topics)
|
|
45
|
+
if (Number(restore.run(topic.id, scheduleId).changes) !== 1)
|
|
46
|
+
throw new TopicGone();
|
|
47
|
+
writeHeldValues(deps.db, (stored) => ({
|
|
48
|
+
...stored,
|
|
49
|
+
...Object.fromEntries(topics.flatMap((topic) => topic.values === undefined ? [] : [[topic.id, { ...topic.values }]])),
|
|
50
|
+
}));
|
|
51
|
+
return { ok: true, value: scheduleById(deps.db, scheduleId) };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Thrown inside the transaction so a half-done batch rolls back.
|
|
55
|
+
class TopicGone extends Error {
|
|
56
|
+
}
|
|
57
|
+
function batch(deps, run) {
|
|
58
|
+
try {
|
|
59
|
+
return transact(deps.db, run);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof TopicGone)
|
|
63
|
+
return { ok: false, reason: "topic-not-found" };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -299,7 +299,7 @@ function readHeldValues(db) {
|
|
|
299
299
|
}
|
|
300
300
|
// Entries of topics that are no longer held (approved, turned down, or gone with their
|
|
301
301
|
// schedule) are dropped on every write.
|
|
302
|
-
function writeHeldValues(db, change = (values) => values) {
|
|
302
|
+
export function writeHeldValues(db, change = (values) => values) {
|
|
303
303
|
const held = new Set(db
|
|
304
304
|
.prepare("SELECT id FROM schedule_topics WHERE state='held'")
|
|
305
305
|
.all()
|
|
@@ -433,7 +433,7 @@ function checkScratch(deps, scratch, library, parts) {
|
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
435
|
catch {
|
|
436
|
-
throw unreadable("a
|
|
436
|
+
throw unreadable("a PDF theme");
|
|
437
437
|
}
|
|
438
438
|
try {
|
|
439
439
|
for (const row of rowsOf(scratch, "SELECT id FROM channels")) {
|
|
@@ -489,7 +489,7 @@ function projectConflict(deps, scratch, id) {
|
|
|
489
489
|
if (deps.db.prepare("SELECT 1 FROM projects WHERE id=?").get(id) !== undefined)
|
|
490
490
|
return "It is already in this install.";
|
|
491
491
|
if (existsSync(projectDir(deps.paths, id)))
|
|
492
|
-
return "Its folder is already in the projects folder without a project. Press
|
|
492
|
+
return "Its folder is already in the projects folder without a project. Press Clear leftover files in Settings → Backup & storage, then import again.";
|
|
493
493
|
for (const table of projectTables)
|
|
494
494
|
for (const row of projectRows(scratch, table, id))
|
|
495
495
|
if (typeof row.id === "string" &&
|
|
@@ -854,11 +854,15 @@ function mergeChannels(db, scratch, work, images, now) {
|
|
|
854
854
|
db.prepare("INSERT INTO image_blobs(sha256,mime,bytes,created_at) VALUES (?,?,?,?) ON CONFLICT(sha256) DO NOTHING").run(image.sha256, image.mime, readFileSync(path), now);
|
|
855
855
|
}
|
|
856
856
|
const cast = { added: 0, renamed: 0, skipped: 0 };
|
|
857
|
-
|
|
857
|
+
// In the backup's order (an older backup was given its alphabetical order by migration 0052),
|
|
858
|
+
// after any members the channel here already has.
|
|
859
|
+
const lastPlace = db.prepare("SELECT coalesce(max(position)+1,0) AS next FROM cast_members WHERE channel_id=?");
|
|
860
|
+
for (const row of rowsOf(scratch, "SELECT * FROM cast_members ORDER BY channel_id, position, lower(name), id")) {
|
|
858
861
|
if (has("SELECT 1 FROM cast_members WHERE id=?", String(row.id)))
|
|
859
862
|
cast.skipped += 1;
|
|
860
863
|
else {
|
|
861
|
-
|
|
864
|
+
const next = lastPlace.get(String(row.channel_id))?.next;
|
|
865
|
+
insertRow(db, "cast_members", { ...row, position: typeof next === "number" ? next : 0 });
|
|
862
866
|
cast.added += 1;
|
|
863
867
|
}
|
|
864
868
|
}
|
|
@@ -44,6 +44,38 @@ export { assetOf };
|
|
|
44
44
|
export function downloadName(slug, output) {
|
|
45
45
|
return `${slug}-${assetOf(output)}${extname(output.path)}`;
|
|
46
46
|
}
|
|
47
|
+
// Which version of its place a file is: 1 for the first file ever saved there, 2 for the one
|
|
48
|
+
// that replaced it, and so on. Retained files keep their number across later versions of the
|
|
49
|
+
// project, so the same file always downloads under the same name.
|
|
50
|
+
export function fileVersion(deps, projectId, slot, assetId) {
|
|
51
|
+
const assets = deps.db
|
|
52
|
+
.prepare(`SELECT o.asset_id AS id, MIN(a.created_at) AS made FROM revision_outputs o
|
|
53
|
+
JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
|
|
54
|
+
WHERE o.project_id=? AND o.slot=? GROUP BY o.asset_id ORDER BY made, o.asset_id`)
|
|
55
|
+
.all(projectId, slot)
|
|
56
|
+
.map((row) => String(row.id));
|
|
57
|
+
return Math.max(1, assets.indexOf(assetId) + 1);
|
|
58
|
+
}
|
|
59
|
+
// "tides-image-3.png" for a first version, "tides-image-3-v2.png" once it was remade, so a
|
|
60
|
+
// newer download never silently takes an older one's name.
|
|
61
|
+
export function versionedName(name, version) {
|
|
62
|
+
if (version <= 1)
|
|
63
|
+
return name;
|
|
64
|
+
const extension = extname(name);
|
|
65
|
+
return `${name.slice(0, name.length - extension.length)}-v${String(version)}${extension}`;
|
|
66
|
+
}
|
|
67
|
+
// The version of the file a project's current output points at, so the current download and
|
|
68
|
+
// History name the same file the same way.
|
|
69
|
+
function currentVersion(deps, projectId, path) {
|
|
70
|
+
const row = deps.db
|
|
71
|
+
.prepare(`SELECT o.slot, o.asset_id FROM revision_outputs o
|
|
72
|
+
JOIN project_assets a ON a.project_id=o.project_id AND a.id=o.asset_id
|
|
73
|
+
WHERE o.project_id=? AND a.path=? LIMIT 1`)
|
|
74
|
+
.get(projectId, path);
|
|
75
|
+
return row === undefined
|
|
76
|
+
? 1
|
|
77
|
+
: fileVersion(deps, projectId, String(row.slot), String(row.asset_id));
|
|
78
|
+
}
|
|
47
79
|
export function findDownload(deps, projectId, asset) {
|
|
48
80
|
const title = projectTitle(deps.db, projectId);
|
|
49
81
|
if (title === undefined) {
|
|
@@ -68,7 +100,7 @@ export function findDownload(deps, projectId, asset) {
|
|
|
68
100
|
ok: true,
|
|
69
101
|
download: {
|
|
70
102
|
path,
|
|
71
|
-
filename: downloadName(slugOf(title), output),
|
|
103
|
+
filename: versionedName(downloadName(slugOf(title), output), currentVersion(deps, projectId, output.path)),
|
|
72
104
|
bytes: shown?.byteLength ?? stats,
|
|
73
105
|
contentType: contentTypeOf(path),
|
|
74
106
|
...(shown === undefined ? {} : { text: shown }),
|
|
@@ -111,7 +143,8 @@ function shownYoutubeText(deps, projectId, role) {
|
|
|
111
143
|
return new TextEncoder().encode(`${text}\n`);
|
|
112
144
|
}
|
|
113
145
|
// "download all" images as `<title-slug>-images.zip`, thumbnail included.
|
|
114
|
-
|
|
146
|
+
// `set`: every picture (Images' Download all), the thumbnails alone, or every rendered short.
|
|
147
|
+
export function imagesZip(deps, projectId, set = "images") {
|
|
115
148
|
const title = projectTitle(deps.db, projectId);
|
|
116
149
|
if (title === undefined) {
|
|
117
150
|
return { ok: false, reason: "unknown-project" };
|
|
@@ -119,7 +152,12 @@ export function imagesZip(deps, projectId) {
|
|
|
119
152
|
const slug = slugOf(title);
|
|
120
153
|
const entries = {};
|
|
121
154
|
for (const output of outputsOf(deps.db, projectId)) {
|
|
122
|
-
|
|
155
|
+
const member = set === "images"
|
|
156
|
+
? output.role === "image" || output.role === "thumbnail"
|
|
157
|
+
: set === "thumbnails"
|
|
158
|
+
? output.role === "thumbnail"
|
|
159
|
+
: output.role === "short_video";
|
|
160
|
+
if (!member) {
|
|
123
161
|
continue;
|
|
124
162
|
}
|
|
125
163
|
const path = outputPath(deps.paths, projectId, output.path);
|
|
@@ -138,7 +176,7 @@ export function imagesZip(deps, projectId) {
|
|
|
138
176
|
if (Object.keys(entries).length === 0) {
|
|
139
177
|
return { ok: false, reason: "no-images" };
|
|
140
178
|
}
|
|
141
|
-
return { ok: true, filename: `${slug}
|
|
179
|
+
return { ok: true, filename: `${slug}-${set}.zip`, bytes: zipSync(entries) };
|
|
142
180
|
}
|
|
143
181
|
function sizeOf(path) {
|
|
144
182
|
const stats = statSync(path, { throwIfNoEntry: false });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { scriptContract } from "./script.js";
|
|
2
|
+
// "Adapt into a conversation": the text model rewrites an existing article as speaker turns in
|
|
3
|
+
// the chosen format (a podcast's hosts talking it through, an interview). The article itself is
|
|
4
|
+
// left as written; the conversation is the narration's script, kept beside it. Unlike the
|
|
5
|
+
// audiobook's speaker split this is a rewrite, so the words are not held to the source - only
|
|
6
|
+
// its facts and order are asked for - and the script is checked as a script before it is used.
|
|
7
|
+
export function adaptationMessages(source, format, speakers) {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
role: "system",
|
|
11
|
+
content: [
|
|
12
|
+
scriptContract(format, speakers),
|
|
13
|
+
"Turn the text you are given into this conversation. Keep its facts, names and order, and say nothing the text does not support.",
|
|
14
|
+
"Keep the text's own chapter headings as the section headings.",
|
|
15
|
+
].join("\n"),
|
|
16
|
+
},
|
|
17
|
+
{ role: "user", content: source },
|
|
18
|
+
];
|
|
19
|
+
}
|
|
@@ -12,8 +12,10 @@ export const voiceFormatLabels = {
|
|
|
12
12
|
export const speakerRoles = ["narrator", "host", "guest", "character"];
|
|
13
13
|
// Where the script comes from. `script` asks the text model for speaker turns with a Script
|
|
14
14
|
// prompt; `attribute` (audiobooks) writes or takes the article as usual and has the text model
|
|
15
|
-
// hand its dialogue to the speakers
|
|
16
|
-
|
|
15
|
+
// hand its dialogue to the speakers; `adapt` writes or takes the article as usual and has the
|
|
16
|
+
// text model rewrite it as a conversation between the speakers (a podcast of an article). With
|
|
17
|
+
// `attribute` and `adapt` the article stays as written and the spoken script is kept beside it.
|
|
18
|
+
export const scriptSources = ["script", "attribute", "adapt"];
|
|
17
19
|
export const speakersMax = 10;
|
|
18
20
|
export const speakerNameMax = 40;
|
|
19
21
|
export const paceSteps = [0.8, 0.9, 1, 1.1, 1.2];
|
|
@@ -9,8 +9,10 @@ export const minChapterSeconds = 10;
|
|
|
9
9
|
// YouTube's limits on the Tags field: 500 characters in all, 100 for one tag.
|
|
10
10
|
export const tagsMaxCharacters = 500;
|
|
11
11
|
export const tagMaxCharacters = 100;
|
|
12
|
-
// YouTube ignores every hashtag of a description that has more than
|
|
13
|
-
|
|
12
|
+
// YouTube ignores every hashtag of a description that has more than 60 (YouTube Help,
|
|
13
|
+
// "Use hashtags for YouTube videos"); the first three show above the title.
|
|
14
|
+
export const hashtagsMax = 60;
|
|
15
|
+
export const hashtagsShownByTitle = 3;
|
|
14
16
|
// YouTube's limit on a description.
|
|
15
17
|
export const descriptionMaxCharacters = 5000;
|
|
16
18
|
// YouTube's title A/B test ("Test & compare") takes up to three titles: the video's own and
|
|
@@ -33,3 +35,32 @@ export const defaultDescriptionPrompt = [
|
|
|
33
35
|
// The name Play, Edit project and the review summary show for the prompt used when none is
|
|
34
36
|
// picked.
|
|
35
37
|
export const defaultDescriptionPromptName = "Built-in";
|
|
38
|
+
// YouTube's API refuses < and > in a title or a description.
|
|
39
|
+
const forbidden = /[<>]/;
|
|
40
|
+
// Why YouTube would refuse a title as typed, or undefined when it takes it: at most 100
|
|
41
|
+
// characters, no < or >. The project's own title is not this; only the title sent to YouTube.
|
|
42
|
+
export function youtubeTitleProblem(title) {
|
|
43
|
+
const trimmed = title.trim();
|
|
44
|
+
if (trimmed.length > titleMaxCharacters)
|
|
45
|
+
return `YouTube takes titles of up to ${String(titleMaxCharacters)} characters; this one has ${String(trimmed.length)}. Shorten it.`;
|
|
46
|
+
if (forbidden.test(trimmed))
|
|
47
|
+
return "YouTube doesn't allow < or > in a title. Remove them.";
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// The same for a description: at most 5,000 characters, no < or >.
|
|
51
|
+
export function youtubeDescriptionProblem(description) {
|
|
52
|
+
if (description.length > descriptionMaxCharacters)
|
|
53
|
+
return `YouTube takes descriptions of up to ${descriptionMaxCharacters.toLocaleString("en-US")} characters; this one has ${description.length.toLocaleString("en-US")}. Shorten it.`;
|
|
54
|
+
if (forbidden.test(description))
|
|
55
|
+
return "YouTube doesn't allow < or > in a description. Remove them.";
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
// The hashtags of a hashtag line, and a warning when YouTube would ignore them all.
|
|
59
|
+
export function hashtagNote(line) {
|
|
60
|
+
const count = line.split(/\s+/u).filter((word) => word.startsWith("#") && word.length > 1).length;
|
|
61
|
+
if (count === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (count > hashtagsMax)
|
|
64
|
+
return `${String(count)} hashtags: YouTube ignores every hashtag when there are more than ${String(hashtagsMax)}. Remove ${String(count - hashtagsMax)}.`;
|
|
65
|
+
return `${String(count)} hashtag${count === 1 ? "" : "s"}; the first ${String(Math.min(count, hashtagsShownByTitle))} show above the title.`;
|
|
66
|
+
}
|
|
@@ -102,7 +102,7 @@ When a tool says its allowance is used up, the run does not fail:
|
|
|
102
102
|
3. The project's status reads **Waiting for limits**, with a line such as "Waiting for Codex limits (resets at 14:00)." The same words show on Home's Running now, the Projects row and the Calendar.
|
|
103
103
|
4. When the reset passes, the run continues by itself.
|
|
104
104
|
|
|
105
|
-
Other providers are not held up: a project whose narration uses a key carries on while its Codex images wait. The wait survives a restart; a project that was waiting when Slopify stopped resumes on the next start and waits again. A project you paused or
|
|
105
|
+
Other providers are not held up: a project whose narration uses a key carries on while its Codex images wait. The wait survives a restart; a project that was waiting when Slopify stopped resumes on the next start and waits again. A project you paused or canceled meanwhile is left alone.
|
|
106
106
|
|
|
107
107
|
Gemini's daily quota is handled the same way ("Slopify waits and tries again later by itself").
|
|
108
108
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A backup is one `.tar` file with everything you made in Slopify: every project with its files and history, your library, templates, schedules, channels and settings. Make one by hand at any time with **Export everything**, or let Slopify write one every day. Bring one back with **Import a backup**. Provider keys are never included.
|
|
4
4
|
|
|
5
|
-
**Where to find it:** **Settings → Backup & storage
|
|
5
|
+
**Where to find it:** **Settings → Backup & storage**: daily backups and Back up now, Export everything and Import a backup.
|
|
6
6
|
|
|
7
7
|
## What a backup holds
|
|
8
8
|
|
|
@@ -54,7 +54,7 @@ If the import fails, Slopify says: "The backup wasn't imported. Check it is a fi
|
|
|
54
54
|
|
|
55
55
|
Daily backups are off by default, because each backup holds every project's video and can fill a disk.
|
|
56
56
|
|
|
57
|
-
1. Open **Settings →
|
|
57
|
+
1. Open **Settings → Backup & storage**.
|
|
58
58
|
2. Turn **Back up automatically** on.
|
|
59
59
|
3. Set **Time of day**, **Keep last** and optionally **Folder** (see below).
|
|
60
60
|
4. Press **Save**.
|
|
@@ -78,7 +78,7 @@ The screen shows the last backup's time and size, the result of the last attempt
|
|
|
78
78
|
|
|
79
79
|
## Back up now
|
|
80
80
|
|
|
81
|
-
1. Open **Settings →
|
|
81
|
+
1. Open **Settings → Backup & storage**.
|
|
82
82
|
2. Press **Back up now**. Slopify says "Backup started. Its result shows here when it finishes."
|
|
83
83
|
|
|
84
84
|
It writes the same file into the backup folder. It says which projects it is waiting for, if any, and is refused while another backup is being written.
|
|
@@ -97,11 +97,11 @@ It writes the same file into the backup folder. It says which projects it is wai
|
|
|
97
97
|
- Free space is checked first. A backup that would not fit fails with the sizes.
|
|
98
98
|
- Backup files are readable by your user only.
|
|
99
99
|
- An update waits for a backup that is being written.
|
|
100
|
-
- Every failure is explained on **Settings →
|
|
100
|
+
- Every failure is explained on **Settings → Backup & storage** with what to change, for example free space, lower **Keep last**, or pick another **Folder**.
|
|
101
101
|
|
|
102
102
|
## Other storage tools on Backup & storage
|
|
103
103
|
|
|
104
|
-
- **
|
|
104
|
+
- **Clear leftover files** deletes files that no project, draft or template uses any more. Slopify also does this each time it starts.
|
|
105
105
|
- **Keep outputs only** on a finished project deletes its working files and keeps the finished outputs.
|
|
106
106
|
|
|
107
107
|
Both are explained in [Where your files live](Where-Your-Files-Live).
|
|
@@ -10,7 +10,7 @@ The calendar starts on this week's Monday and shows four weeks, Monday to Sunday
|
|
|
10
10
|
|
|
11
11
|
- **Scheduled runs**, each with the project title it will make. A run shows **Queued** when a topic is lined up, **Needs a topic** when its topic is still waiting for your approval or for generation, and **Paused** when its schedule is paused.
|
|
12
12
|
- **Needs you**, above the weeks: every project that is waiting on you or ready to upload (see below).
|
|
13
|
-
- **Projects** running or finished on their day, with their state: **Running**, **Paused**, **
|
|
13
|
+
- **Projects** running or finished on their day, with their state: **Running**, **Paused**, **Queued**, **Failed**, **Done with problems**, **Done** or **Canceled**. A project waiting for a CLI plan says so, for example **Waiting for Codex limits (resets at 14:00)**.
|
|
14
14
|
- **Batch queue**: projects waiting to start (see below).
|
|
15
15
|
- **Suggested topics**, beside the weeks, for schedules that hold generated topics for approval.
|
|
16
16
|
|
|
@@ -33,7 +33,7 @@ The bundled samples never show here. A press anywhere on an entry opens its proj
|
|
|
33
33
|
|
|
34
34
|
## The batch queue
|
|
35
35
|
|
|
36
|
-
Videos started together, such as extra videos queued from Play, run one at a time. **Batch queue** lists them in the order they run, numbered (**1 in line**, **2 in line**, …), each marked **Running now**, **Waiting its turn** or **Paused**. Its heading says how many are waiting to start. Pausing a queued project holds the whole queue; when one fails or is
|
|
36
|
+
Videos started together, such as extra videos queued from Play, run one at a time. **Batch queue** lists them in the order they run, numbered (**1 in line**, **2 in line**, …), each marked **Running now**, **Waiting its turn** or **Paused**. Its heading says how many are waiting to start. Pausing a queued project holds the whole queue; when one fails or is canceled, the next one starts. The batch queue is only shown on the calendar; Projects links here and has a **Queued** filter. It is hidden when nothing is queued.
|
|
37
37
|
|
|
38
38
|
## Switch between weeks and a list
|
|
39
39
|
|
|
@@ -61,7 +61,7 @@ Mark voices in **Settings → Voices** and Image prompts in **Library → Prompt
|
|
|
61
61
|
| **Title font** | The font of chapter cards and the end screen, for videos whose template sets no title style. |
|
|
62
62
|
| **Title colour** | The text colour of chapter cards and the end screen, such as `#FFD700`. |
|
|
63
63
|
| **End screen text** | A line shown centred over the last 5 seconds of every video, such as "Subscribe for more". Up to 200 characters. Blank shows none. |
|
|
64
|
-
| **Intro** / **Outro** | An entry from **Library → Intros &
|
|
64
|
+
| **Intro** / **Outro** | An entry from **Library → Intros & outros**, narrated before or after the body of every video whose template has none. Each is narrated with the video's voice, so each costs one voice request per video (plus a text call for an LLM entry), and it shows in the estimate. See [Intros and outros](Intros-and-Outros). |
|
|
65
65
|
| **Document theme** | The look of the PDF for videos whose template picks no theme. A saved theme that was deleted is skipped. |
|
|
66
66
|
| **Ambient sound** | Rain, Fireplace or Wind under the long video's narration, with its level (−40 to −6 dB, default −18), fade-in (0 to 30 s, default 3) and tail (0 to 30 s, default 6). Used when the template leaves its own ambient sound on "The channel's". A channel can't hold your own audio file. |
|
|
67
67
|
|
|
@@ -49,7 +49,7 @@ When a CLI's plan is used up, the run waits instead of failing: the status reads
|
|
|
49
49
|
|
|
50
50
|
## After a run: the run cost line
|
|
51
51
|
|
|
52
|
-
When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`. A failed or
|
|
52
|
+
When a run has ended, one line at the top of its project page, under the title, sums it up: for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`. A failed or canceled run says **Spent so far**, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the full breakdown below. The line doesn't show while the run is going, or when it spent nothing.
|
|
53
53
|
|
|
54
54
|
## After a run: the Run cost tab
|
|
55
55
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A document theme is the look of the PDF Slopify makes from the article: paper size, colours, fonts, spacing, the title page, contents, sources and closing pages, headers and page numbers. Slopify ships a built-in **Plain** theme. Copy it to make themes of your own, with a live preview as you edit.
|
|
4
4
|
|
|
5
|
-
**Where to find it:** **Library →
|
|
5
|
+
**Where to find it:** **Library → PDF themes**. Pick a theme on Play's **Document** row, in **Edit project**, or as a channel's default in its brand kit.
|
|
6
6
|
|
|
7
7
|
## Built-in and your own themes
|
|
8
8
|
|
|
@@ -15,7 +15,7 @@ A project keeps its own copy of the theme's settings. Editing or deleting a them
|
|
|
15
15
|
|
|
16
16
|
## Make a theme
|
|
17
17
|
|
|
18
|
-
1. Open **Library →
|
|
18
|
+
1. Open **Library → PDF themes**.
|
|
19
19
|
2. Press **New theme** (it starts from Plain), or pick a built-in theme and press **Copy theme**, or press **Duplicate** on one of your themes.
|
|
20
20
|
3. Type a **Name** (up to 80 characters). This is what the Document row on Play and Edit project shows.
|
|
21
21
|
4. Open the groups on the left and change what you like (every setting is listed below). The first two groups start open.
|
|
@@ -31,7 +31,7 @@ The form has its own section list on the left. Some sections appear only when th
|
|
|
31
31
|
|
|
32
32
|
At the bottom: **Save changes** saves a revision, **Discard changes** drops the draft. While a file is uploading the bar says "Waiting for uploads to finish…" and saving waits.
|
|
33
33
|
|
|
34
|
-
If someone (or another tab) saved the project while you were editing, the bar says "A newer revision is available. Your unsaved changes are kept below." Press **Reload
|
|
34
|
+
If someone (or another tab) saved the project while you were editing, the bar says "A newer revision is available. Your unsaved changes are kept below and in this browser." Press **Reload latest and re-apply my edit** to lay your changes over the newer revision (check them, then **Save changes**), or **Copy my edit** to keep a copy on the clipboard first.
|
|
35
35
|
|
|
36
36
|
## Change the article text
|
|
37
37
|
|
|
@@ -119,7 +119,7 @@ The project keeps its own copy of each prompt, so later Library edits reach it o
|
|
|
119
119
|
| Control | What it does |
|
|
120
120
|
| --- | --- |
|
|
121
121
|
| **Also use pronunciations from my other projects** → **Update from other projects** | Copies your other projects' Pronunciation Glossary terms into this project again, picking up any added since the last copy. After you save, only the chunks whose words those terms change are spoken again, paid per character. |
|
|
122
|
-
| **Use narration aliases** → **Update from Library** | Copies Library →
|
|
122
|
+
| **Use narration aliases** → **Update from Library** | Copies Library → Narration aliases into this project again. A Library edit reaches the project only when you press this. After you save, only the chunks the changed aliases touch are spoken again. |
|
|
123
123
|
| **Describe tables and figures in the narration** | The text model writes a short spoken passage for each table, figure, equation and code block. Turning it on costs one text call per block, and after you save the chunks that contain a block are spoken again (the whole narration when Chunking is the whole article). **Leave code out** skips code blocks. |
|
|
124
124
|
|
|
125
125
|
**Show tables and figures on screen** (in **Inputs → Cuts and look**, when Describe tables and figures is on) shows each described block in the video while its description is spoken, as the article's picture or a card drawn on your computer at no cost. Turning it on or off renders the video again, and any Shorts that include a card; the narration is kept.
|
package/dist/tutorials/FAQ.md
CHANGED
|
@@ -86,7 +86,7 @@ The main output was made, but another step failed, for example the thumbnail. Th
|
|
|
86
86
|
|
|
87
87
|
### I deleted a project by mistake. Can I get it back?
|
|
88
88
|
|
|
89
|
-
Yes, within 30 days. Open Settings → **Trash** and press **Restore**. See [Trash](Trash).
|
|
89
|
+
Yes, within 30 days. Open Settings → **Backup & storage** → **Trash** and press **Restore**. See [Trash](Trash).
|
|
90
90
|
|
|
91
91
|
### Are captions free?
|
|
92
92
|
|