@gentbajko/slopify 3.0.0 → 3.0.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/README.md +6 -4
- package/dist/adapter-registry.js +28 -0
- package/dist/adapters/key-probes.js +57 -8
- package/dist/adapters/tts/gemini.js +206 -0
- package/dist/adapters/tts/pcm-mp3.js +62 -0
- package/dist/adapters/tts/system.js +147 -0
- package/dist/assets/models.yaml +38 -0
- package/dist/assets/style-preview/narration.mp3 +0 -0
- package/dist/assets/style-preview/tall-1.jpg +0 -0
- package/dist/assets/style-preview/tall-2.jpg +0 -0
- package/dist/assets/style-preview/tall-3.jpg +0 -0
- package/dist/assets/style-preview/wide-1.jpg +0 -0
- package/dist/assets/style-preview/wide-2.jpg +0 -0
- package/dist/assets/style-preview/wide-3.jpg +0 -0
- package/dist/catalog/registry.js +4 -1
- package/dist/catalog/validate.js +2 -2
- package/dist/edge/autostart/docker-record.js +2 -2
- package/dist/edge/autostart/docker.js +68 -0
- package/dist/edge/autostart/prompt.js +7 -1
- package/dist/edge/autostart/service.js +18 -9
- package/dist/edge/cli-args.js +71 -0
- package/dist/edge/cli.js +11 -16
- package/dist/edge/docker-install/run.js +7 -1
- package/dist/edge/events/hub.js +3 -1
- package/dist/edge/http/actions.js +6 -1
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/byte-range.js +42 -0
- package/dist/edge/http/diagnostics.js +1 -0
- package/dist/edge/http/draft-files.js +2 -4
- package/dist/edge/http/files.js +10 -6
- package/dist/edge/http/onboarding.js +64 -4
- package/dist/edge/http/project-templates.js +19 -2
- package/dist/edge/http/projects.js +6 -1
- package/dist/edge/http/providers.js +39 -9
- package/dist/edge/http/reviews.js +18 -8
- package/dist/edge/http/revision-files.js +2 -4
- package/dist/edge/http/schedules.js +9 -5
- package/dist/edge/http/storage.js +16 -2
- package/dist/edge/http/studio.js +111 -31
- package/dist/edge/http/style-preview.js +3 -42
- package/dist/edge/http/tutorials.js +59 -0
- package/dist/edge/http/youtube-edits.js +11 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/host-cli/gemini-login.js +83 -0
- package/dist/host-cli/status.js +4 -2
- package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
- package/dist/kernel/ports/model.js +5 -3
- package/dist/kernel/ports/system-speech.js +203 -0
- package/dist/kernel/runner/graph.js +17 -1
- package/dist/kernel/runner/index.js +4 -4
- package/dist/kernel/runner/providers.js +1 -1
- package/dist/main.js +5 -0
- package/dist/model-catalog.js +2 -0
- package/dist/patch-notes/3.0.1.md +105 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/rules.js +3 -3
- package/dist/slices/admission/start.js +1 -1
- package/dist/slices/channels/cast-match.js +1 -1
- package/dist/slices/channels/repo.js +3 -0
- package/dist/slices/channels/schema.js +23 -0
- package/dist/slices/channels/service.js +6 -6
- package/dist/slices/control/index.js +3 -1
- package/dist/slices/control/providers.js +4 -1
- package/dist/slices/episodes/related.js +2 -2
- package/dist/slices/estimate/requests.js +9 -1
- package/dist/slices/eta/history.js +56 -0
- package/dist/slices/eta/model.js +63 -0
- package/dist/slices/eta/view.js +25 -0
- package/dist/slices/fixes/rules.js +1 -1
- package/dist/slices/fonts/coverage.js +1 -1
- package/dist/slices/loudness/model.js +1 -1
- package/dist/slices/model-upkeep/usage.js +4 -2
- package/dist/slices/narration/pauses-model.js +1 -1
- package/dist/slices/narration/pronunciation.js +2 -2
- package/dist/slices/notifications/notifier.js +28 -2
- package/dist/slices/notifications/rules.js +26 -2
- package/dist/slices/onboarding/first-run.js +40 -14
- package/dist/slices/onboarding/full-video.js +81 -0
- package/dist/slices/onboarding/model.js +21 -0
- package/dist/slices/onboarding/quick-short.js +25 -10
- package/dist/slices/play-drafts/convert.js +3 -3
- package/dist/slices/play-drafts/readiness.js +1 -1
- package/dist/slices/project-templates/from-project.js +10 -3
- package/dist/slices/project-templates/next-chapter.js +94 -0
- package/dist/slices/project-templates/one-off.js +1 -1
- package/dist/slices/project-templates/schema.js +7 -1
- package/dist/slices/rebuild/recipe-exports.js +5 -0
- package/dist/slices/rebuild/recipe-voices.js +1 -1
- package/dist/slices/rebuild/review-redo.js +15 -0
- package/dist/slices/rebuild/runtime-export-bed.js +1 -1
- package/dist/slices/rebuild/runtime-review.js +35 -5
- package/dist/slices/rebuild/runtime-store.js +23 -1
- package/dist/slices/rebuild/runtime-voices.js +2 -2
- package/dist/slices/rebuild/service-readiness.js +3 -3
- package/dist/slices/reviews/repo.js +16 -4
- package/dist/slices/run-cost/panel.js +30 -8
- package/dist/slices/run-cost/pricing.js +4 -1
- package/dist/slices/schedules/agenda.js +66 -2
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/scheduler.js +1 -1
- package/dist/slices/schedules/schema.js +43 -2
- package/dist/slices/schedules/similar.js +41 -3
- package/dist/slices/schedules/topic-list.js +2 -2
- package/dist/slices/schedules/topics.js +118 -17
- package/dist/slices/settings/first-run.js +5 -1
- package/dist/slices/settings/health.js +64 -7
- package/dist/slices/settings/key-guides.js +17 -7
- package/dist/slices/settings/key-test.js +12 -4
- package/dist/slices/settings/keys.js +10 -5
- package/dist/slices/settings/model-reach.js +52 -0
- package/dist/slices/settings/model.js +49 -0
- package/dist/slices/settings/models.js +5 -1
- package/dist/slices/settings/readiness.js +14 -2
- package/dist/slices/settings/tutorial-schema.js +5 -0
- package/dist/slices/shorts/render.js +18 -17
- package/dist/slices/storage/documents.js +11 -3
- package/dist/slices/storage/downloads.js +33 -1
- package/dist/slices/storage/portable.js +4 -2
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +6 -4
- package/dist/slices/studio/queue.js +69 -0
- package/dist/slices/studio/settings.js +42 -5
- package/dist/slices/style-preview/narration.js +33 -0
- package/dist/slices/style-preview/render.js +43 -56
- package/dist/slices/style-preview/schema.js +17 -3
- package/dist/slices/style-preview/service.js +3 -4
- package/dist/slices/style-preview/settings.js +75 -25
- package/dist/slices/tutorials/anchors.js +57 -0
- package/dist/slices/tutorials/library.js +185 -0
- package/dist/slices/voices/audio-files.js +7 -2
- package/dist/slices/voices/cast.js +26 -8
- package/dist/slices/voices/grouping.js +10 -0
- package/dist/slices/voices/model.js +49 -10
- package/dist/slices/voices/script.js +1 -1
- package/dist/slices/youtube/edits-repo.js +19 -4
- package/dist/slices/youtube/placeholders.js +2 -2
- package/dist/tutorials/AI-CLIs.md +146 -0
- package/dist/tutorials/Backups.md +121 -0
- package/dist/tutorials/Calendar.md +103 -0
- package/dist/tutorials/Cast-Library.md +112 -0
- package/dist/tutorials/Channels.md +148 -0
- package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
- package/dist/tutorials/Docker.md +171 -0
- package/dist/tutorials/Document-Themes.md +207 -0
- package/dist/tutorials/Editing-a-Project.md +204 -0
- package/dist/tutorials/FAQ.md +134 -0
- package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
- package/dist/tutorials/Home-and-Projects.md +174 -0
- package/dist/tutorials/Home.md +119 -0
- package/dist/tutorials/Install.md +154 -0
- package/dist/tutorials/Intros-and-Outros.md +86 -0
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
- package/dist/tutorials/Library-Overview.md +75 -0
- package/dist/tutorials/Models.md +133 -0
- package/dist/tutorials/Multiple-Voices.md +161 -0
- package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
- package/dist/tutorials/Notifications.md +86 -0
- package/dist/tutorials/Other-Languages.md +110 -0
- package/dist/tutorials/PDF-Documents.md +100 -0
- package/dist/tutorials/Play-Images.md +135 -0
- package/dist/tutorials/Play-Narration.md +167 -0
- package/dist/tutorials/Play-Outputs.md +109 -0
- package/dist/tutorials/Play-Overview.md +216 -0
- package/dist/tutorials/Play-Title-and-Article.md +135 -0
- package/dist/tutorials/Play-Video-and-Style.md +143 -0
- package/dist/tutorials/Project-Page.md +228 -0
- package/dist/tutorials/Prompts.md +136 -0
- package/dist/tutorials/Providers-and-Keys.md +290 -0
- package/dist/tutorials/Publishing-to-YouTube.md +135 -0
- package/dist/tutorials/Recovery-and-Retries.md +141 -0
- package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
- package/dist/tutorials/Schedules.md +154 -0
- package/dist/tutorials/Settings-Reference.md +258 -0
- package/dist/tutorials/Shorts.md +116 -0
- package/dist/tutorials/Start-at-Login.md +126 -0
- package/dist/tutorials/Studio-Extension.md +146 -0
- package/dist/tutorials/Templates.md +146 -0
- package/dist/tutorials/Trash.md +87 -0
- package/dist/tutorials/Troubleshooting.md +149 -0
- package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
- package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
- package/dist/tutorials/Video-Editing.md +135 -0
- package/dist/tutorials/Where-Your-Files-Live.md +149 -0
- package/dist/tutorials/YouTube-Description.md +132 -0
- package/dist/tutorials/Your-First-Short.md +125 -0
- package/dist/tutorials/_Footer.md +1 -0
- package/dist/tutorials/_Sidebar.md +69 -0
- package/dist/web/assets/index-DBLSfqA-.css +1 -0
- package/dist/web/assets/index-DFdIhmm0.js +307 -0
- package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-B9OY1ioX.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CUA2sUxU.css +0 -1
- package/dist/web/assets/index-CX590kE-.js +0 -303
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { captionCues } from "../subtitles/captions.js";
|
|
3
|
-
import { transitionSecondsMax, transitionSecondsMin, videoEditOf } from "../video/edit-settings.js";
|
|
3
|
+
import { legacyVideoEdit, transitionSecondsMax, transitionSecondsMin, videoEditOf, } from "../video/edit-settings.js";
|
|
4
|
+
import { sampleNarrationText, sampleNarrationWords, sampleShortTitle } from "./narration.js";
|
|
4
5
|
import { defaultPreviewText, stylePreviewSeconds } from "./schema.js";
|
|
5
6
|
export function normalizeStylePreview(request,
|
|
6
7
|
// The sha256 of the picture the server found for request.image, if it found one.
|
|
7
8
|
image) {
|
|
8
9
|
const edit = videoEditOf(request);
|
|
9
10
|
const { subtitles } = request;
|
|
10
|
-
const
|
|
11
|
+
const typed = (request.previewText ?? "").replace(/\s+/g, " ").trim();
|
|
12
|
+
const text = typed === "" || typed === defaultPreviewText ? sampleNarrationText : typed;
|
|
13
|
+
const shorts = request.shorts;
|
|
14
|
+
if (shorts !== undefined) {
|
|
15
|
+
const title = shorts.title?.replace(/\s+/g, " ").trim() ?? "";
|
|
16
|
+
const none = videoEditOf({ videoEdit: legacyVideoEdit });
|
|
17
|
+
return {
|
|
18
|
+
version: 2,
|
|
19
|
+
format: "9:16",
|
|
20
|
+
captions: null,
|
|
21
|
+
look: {
|
|
22
|
+
vignette: none.vignette,
|
|
23
|
+
grain: none.grain,
|
|
24
|
+
grade: none.grade,
|
|
25
|
+
atmosphere: none.atmosphere,
|
|
26
|
+
},
|
|
27
|
+
transition: null,
|
|
28
|
+
chapterCard: null,
|
|
29
|
+
short: {
|
|
30
|
+
fontId: subtitles.fontId,
|
|
31
|
+
text,
|
|
32
|
+
title: shorts.titleOnScreen ? (title === "" ? sampleShortTitle : title) : null,
|
|
33
|
+
speed: Math.round(Math.min(1.25, Math.max(1, shorts.speed ?? 1)) * 100) / 100,
|
|
34
|
+
},
|
|
35
|
+
...(image === undefined ? {} : { image }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
11
38
|
return {
|
|
12
|
-
version:
|
|
39
|
+
version: 2,
|
|
13
40
|
format: request.format,
|
|
14
41
|
captions: subtitles.mode === "burn-in"
|
|
15
42
|
? {
|
|
16
43
|
fontId: subtitles.fontId,
|
|
17
44
|
fontSize: Math.round(subtitles.fontSize),
|
|
18
45
|
position: subtitles.position,
|
|
19
|
-
text
|
|
46
|
+
text,
|
|
20
47
|
}
|
|
21
48
|
: null,
|
|
22
49
|
look: {
|
|
@@ -51,38 +78,61 @@ export function stableJson(value) {
|
|
|
51
78
|
export function stylePreviewHash(settings) {
|
|
52
79
|
return createHash("sha256").update(stableJson(settings)).digest("hex");
|
|
53
80
|
}
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// a pause between
|
|
61
|
-
//
|
|
62
|
-
|
|
81
|
+
// How long the rendered preview runs: a Shorts preview at a higher speed is shorter.
|
|
82
|
+
export function previewSeconds(settings) {
|
|
83
|
+
return settings.short === undefined
|
|
84
|
+
? stylePreviewSeconds
|
|
85
|
+
: round(stylePreviewSeconds / settings.short.speed);
|
|
86
|
+
}
|
|
87
|
+
// ceiling: a pause this long between two words of the sample narration starts a new phrase,
|
|
88
|
+
// the same pause `captionCues` starts a new cue at.
|
|
89
|
+
const phraseGap = 0.7;
|
|
90
|
+
// The caption text as timed words. The sample narration's own text is its word timing as it
|
|
91
|
+
// was aligned; any other text is spread over the stretches the narration speaks, each phrase
|
|
92
|
+
// taking a share of the words by its length, the words of each evenly spaced. `captionCues`
|
|
93
|
+
// and the Shorts captions then group and wrap them exactly as they do real narration.
|
|
94
|
+
export function previewWords(text, narration = sampleNarrationWords) {
|
|
63
95
|
const words = text.split(/\s+/).filter((word) => word !== "");
|
|
64
96
|
if (words.length === 0)
|
|
65
97
|
return [];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
98
|
+
if (words.join(" ") === narration.map((word) => word.text).join(" "))
|
|
99
|
+
return narration;
|
|
100
|
+
const phrases = [];
|
|
101
|
+
for (const word of narration) {
|
|
102
|
+
const last = phrases.at(-1);
|
|
103
|
+
if (last !== undefined && word.start - last.end < phraseGap)
|
|
104
|
+
last.end = word.end;
|
|
105
|
+
else
|
|
106
|
+
phrases.push({ start: word.start, end: word.end });
|
|
107
|
+
}
|
|
108
|
+
if (phrases.length === 0)
|
|
109
|
+
phrases.push({ start: 0.4, end: stylePreviewSeconds - 0.4 });
|
|
110
|
+
const spoken = phrases.reduce((sum, phrase) => sum + (phrase.end - phrase.start), 0);
|
|
70
111
|
const timed = [];
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
112
|
+
let used = 0;
|
|
113
|
+
let before = 0;
|
|
114
|
+
for (const [at, phrase] of phrases.entries()) {
|
|
115
|
+
before += phrase.end - phrase.start;
|
|
116
|
+
// The words up to the end of this phrase, by its share of the speech; the last takes
|
|
117
|
+
// what is left, and none is left empty while words remain.
|
|
118
|
+
const upTo = at === phrases.length - 1
|
|
119
|
+
? words.length
|
|
120
|
+
: Math.min(words.length - (phrases.length - 1 - at), Math.max(used + 1, Math.round((words.length * before) / spoken)));
|
|
121
|
+
const chunk = words.slice(used, Math.max(used, upTo));
|
|
122
|
+
used += chunk.length;
|
|
123
|
+
const each = (phrase.end - phrase.start) / Math.max(1, chunk.length);
|
|
74
124
|
chunk.forEach((word, index) => {
|
|
75
125
|
timed.push({
|
|
76
126
|
text: word,
|
|
77
|
-
start: round(start + index * each),
|
|
78
|
-
end: round(start + (index + 1) * each),
|
|
127
|
+
start: round(phrase.start + index * each),
|
|
128
|
+
end: round(phrase.start + (index + 1) * each),
|
|
79
129
|
});
|
|
80
130
|
});
|
|
81
|
-
}
|
|
131
|
+
}
|
|
82
132
|
return timed;
|
|
83
133
|
}
|
|
84
|
-
export function
|
|
85
|
-
return captionCues(
|
|
134
|
+
export function previewCues(text) {
|
|
135
|
+
return captionCues(previewWords(text));
|
|
86
136
|
}
|
|
87
137
|
function round(value) {
|
|
88
138
|
return Math.round(value * 1000) / 1000;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The tutorials are the GitHub wiki's pages (docs/wiki), so their links name sections the way
|
|
2
|
+
// GitHub does: `[text](Page-Name#section-anchor)`. These helpers work out those anchors, for
|
|
3
|
+
// the server's search, the web page that scrolls to them and the test that checks every help
|
|
4
|
+
// entry's Learn more link. Pure: the web bundle imports this file.
|
|
5
|
+
export const tutorialPageIdPattern = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
|
|
6
|
+
// A heading's text as GitHub renders it: link targets, code ticks and emphasis marks dropped.
|
|
7
|
+
export function headingText(markdown) {
|
|
8
|
+
return markdown
|
|
9
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
10
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
11
|
+
.replace(/(\*\*|__)(.+?)\1/g, "$2")
|
|
12
|
+
.replace(/(^|[^\p{L}\p{N}])[*_]([^*_]+)[*_](?![\p{L}\p{N}])/gu, "$1$2")
|
|
13
|
+
.replace(/<[^>]+>/g, "")
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
// GitHub's anchor for a heading's text: lower case, punctuation dropped, each space a hyphen.
|
|
17
|
+
export function githubSlug(text) {
|
|
18
|
+
return text
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^\p{L}\p{N}\p{M} _-]/gu, "")
|
|
21
|
+
.replace(/ /g, "-");
|
|
22
|
+
}
|
|
23
|
+
const headingLine = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
24
|
+
// Every heading of a page in order, with GitHub's anchor; a repeated anchor gets `-1`, `-2`.
|
|
25
|
+
// `#` inside fenced code is not a heading.
|
|
26
|
+
export function tutorialHeadings(markdown) {
|
|
27
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
28
|
+
const seen = new Map();
|
|
29
|
+
const headings = [];
|
|
30
|
+
let fenced = false;
|
|
31
|
+
for (const [line, raw] of lines.entries()) {
|
|
32
|
+
if (/^\s*(```|~~~)/.test(raw))
|
|
33
|
+
fenced = !fenced;
|
|
34
|
+
const match = fenced ? null : headingLine.exec(raw);
|
|
35
|
+
if (match === null)
|
|
36
|
+
continue;
|
|
37
|
+
const text = headingText(match[2] ?? "");
|
|
38
|
+
const base = githubSlug(text);
|
|
39
|
+
const count = seen.get(base) ?? 0;
|
|
40
|
+
seen.set(base, count + 1);
|
|
41
|
+
headings.push({
|
|
42
|
+
level: (match[1] ?? "#").length,
|
|
43
|
+
text,
|
|
44
|
+
anchor: count === 0 ? base : `${base}-${String(count)}`,
|
|
45
|
+
line,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return headings;
|
|
49
|
+
}
|
|
50
|
+
// The anchors a page offers, for checking a link.
|
|
51
|
+
export function tutorialAnchors(markdown) {
|
|
52
|
+
return new Set(tutorialHeadings(markdown).map((heading) => heading.anchor));
|
|
53
|
+
}
|
|
54
|
+
// A page id as a title when the sidebar gives none: "Play-Overview" → "Play Overview".
|
|
55
|
+
export function pageTitleOf(id) {
|
|
56
|
+
return id.replace(/-/g, " ");
|
|
57
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { pageTitleOf, tutorialHeadings, tutorialPageIdPattern } from "./anchors.js";
|
|
6
|
+
// dist/tutorials when running the build; the repository's docs/wiki when running from source
|
|
7
|
+
// (tests, `tsx`).
|
|
8
|
+
export function bundledTutorialsDir() {
|
|
9
|
+
const candidates = ["../../tutorials/", "../../../../../docs/wiki/"].map((path) => fileURLToPath(new URL(path, import.meta.url)));
|
|
10
|
+
return candidates.find((dir) => existsSync(join(dir, "Home.md"))) ?? candidates[0];
|
|
11
|
+
}
|
|
12
|
+
export class TutorialsMissing extends Error {
|
|
13
|
+
}
|
|
14
|
+
// `[Title](Page-Name)` links of the sidebar, under its `**Group**` lines. The first link
|
|
15
|
+
// before any group is the home page.
|
|
16
|
+
export function parseSidebar(markdown, titleOf = () => undefined) {
|
|
17
|
+
const groups = [];
|
|
18
|
+
let home;
|
|
19
|
+
for (const line of markdown.replace(/\r\n?/g, "\n").split("\n")) {
|
|
20
|
+
const group = /^\s*\*\*([^*[\]]+)\*\*\s*$/.exec(line);
|
|
21
|
+
if (group !== null) {
|
|
22
|
+
groups.push({ title: (group[1] ?? "").trim(), pages: [] });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const link of line.matchAll(/\[([^\]]+)\]\(([^)#\s]+)\)/g)) {
|
|
26
|
+
const id = link[2] ?? "";
|
|
27
|
+
if (!tutorialPageIdPattern.test(id))
|
|
28
|
+
continue;
|
|
29
|
+
const page = { id, title: (link[1] ?? titleOf(id) ?? pageTitleOf(id)).trim() };
|
|
30
|
+
const last = groups[groups.length - 1];
|
|
31
|
+
if (last === undefined)
|
|
32
|
+
home ??= page;
|
|
33
|
+
else
|
|
34
|
+
last.pages.push(page);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { home, groups: groups.filter((group) => group.pages.length > 0) };
|
|
38
|
+
}
|
|
39
|
+
// Reads every page once; they never change while the app runs.
|
|
40
|
+
export async function loadTutorials(dir) {
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = await readdir(dir);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT")
|
|
47
|
+
throw new TutorialsMissing("This copy of Slopify has no tutorials: its docs/wiki folder is missing. Reinstall Slopify (or run npm run build) and reload the page.");
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
const pages = new Map();
|
|
51
|
+
for (const name of names.sort()) {
|
|
52
|
+
const id = name.replace(/\.md$/, "");
|
|
53
|
+
if (!name.endsWith(".md") || !tutorialPageIdPattern.test(id))
|
|
54
|
+
continue;
|
|
55
|
+
pages.set(id, await readFile(join(dir, name), "utf8"));
|
|
56
|
+
}
|
|
57
|
+
if (!pages.has("Home"))
|
|
58
|
+
throw new TutorialsMissing("This copy of Slopify has damaged tutorials: docs/wiki/Home.md is missing. Reinstall Slopify (or run npm run build) and reload the page.");
|
|
59
|
+
const extra = async (name) => readFile(join(dir, name), "utf8").catch((error) => {
|
|
60
|
+
if (error.code === "ENOENT")
|
|
61
|
+
return "";
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
const sidebar = parseSidebar(await extra("_Sidebar.md"));
|
|
65
|
+
const groups = sidebar.groups
|
|
66
|
+
.map((group) => ({ ...group, pages: group.pages.filter((page) => pages.has(page.id)) }))
|
|
67
|
+
.filter((group) => group.pages.length > 0);
|
|
68
|
+
const listed = new Map();
|
|
69
|
+
const home = sidebar.home ?? { id: "Home", title: "Home" };
|
|
70
|
+
listed.set(home.id, home);
|
|
71
|
+
for (const group of groups)
|
|
72
|
+
for (const page of group.pages)
|
|
73
|
+
listed.set(page.id, page);
|
|
74
|
+
for (const id of pages.keys())
|
|
75
|
+
if (!listed.has(id))
|
|
76
|
+
listed.set(id, { id, title: pageTitleOf(id) });
|
|
77
|
+
return {
|
|
78
|
+
index: {
|
|
79
|
+
home,
|
|
80
|
+
groups,
|
|
81
|
+
pages: [...listed.values()],
|
|
82
|
+
footer: (await extra("_Footer.md")).trim(),
|
|
83
|
+
},
|
|
84
|
+
pages,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Plain words of a Markdown line: links become their text, marks and tables' pipes go.
|
|
88
|
+
function plain(markdown) {
|
|
89
|
+
return markdown
|
|
90
|
+
.replace(/^\s*\|?\s*:?-{3,}[-|: ]*$/gm, "")
|
|
91
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
92
|
+
.replace(/[`*_>|]/g, " ")
|
|
93
|
+
.replace(/^\s*(?:[-+]|\d+\.)\s+/gm, "")
|
|
94
|
+
.replace(/\s+/g, " ")
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
function sectionsOf(markdown) {
|
|
98
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
99
|
+
const headings = tutorialHeadings(markdown);
|
|
100
|
+
const sections = [];
|
|
101
|
+
const first = headings[0]?.line ?? lines.length;
|
|
102
|
+
sections.push({ anchor: "", heading: undefined, text: plain(lines.slice(0, first).join("\n")) });
|
|
103
|
+
for (const [at, heading] of headings.entries()) {
|
|
104
|
+
const end = headings[at + 1]?.line ?? lines.length;
|
|
105
|
+
sections.push({
|
|
106
|
+
anchor: heading.anchor,
|
|
107
|
+
heading: heading.text,
|
|
108
|
+
text: plain(lines.slice(heading.line + 1, end).join("\n")),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return sections.filter((section) => section.heading !== undefined || section.text !== "");
|
|
112
|
+
}
|
|
113
|
+
function snippetOf(text, term) {
|
|
114
|
+
const at = term === "" ? -1 : text.toLowerCase().indexOf(term);
|
|
115
|
+
if (at < 0)
|
|
116
|
+
return text.length > 180 ? `${text.slice(0, 177).trimEnd()}…` : text;
|
|
117
|
+
// Whole words at both ends.
|
|
118
|
+
const from = Math.max(0, at - 60);
|
|
119
|
+
const start = from === 0 ? 0 : text.indexOf(" ", from) + 1 || from;
|
|
120
|
+
const to = Math.min(text.length, at + term.length + 120);
|
|
121
|
+
const end = to === text.length ? to : text.lastIndexOf(" ", to) > at ? text.lastIndexOf(" ", to) : to;
|
|
122
|
+
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
|
|
123
|
+
}
|
|
124
|
+
// The sections that hold every word typed, best first: a word in the page's title or the
|
|
125
|
+
// section's heading counts more than one in its text. The first heading (the page's own
|
|
126
|
+
// title) stands for the page.
|
|
127
|
+
export function searchTutorials(book, query, limit = 30) {
|
|
128
|
+
const terms = query
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.split(/\s+/)
|
|
131
|
+
.map((term) => term.trim())
|
|
132
|
+
.filter((term) => term.length > 0)
|
|
133
|
+
.slice(0, 8);
|
|
134
|
+
if (terms.length === 0)
|
|
135
|
+
return [];
|
|
136
|
+
const titles = new Map(book.index.pages.map((page) => [page.id, page.title]));
|
|
137
|
+
const scored = [];
|
|
138
|
+
let order = 0;
|
|
139
|
+
for (const summary of book.index.pages) {
|
|
140
|
+
const markdown = book.pages.get(summary.id);
|
|
141
|
+
if (markdown === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
const pageTitle = titles.get(summary.id) ?? pageTitleOf(summary.id);
|
|
144
|
+
for (const section of sectionsOf(markdown)) {
|
|
145
|
+
order += 1;
|
|
146
|
+
const heading = (section.heading ?? "").toLowerCase();
|
|
147
|
+
const text = section.text.toLowerCase();
|
|
148
|
+
const title = pageTitle.toLowerCase();
|
|
149
|
+
let score = 0;
|
|
150
|
+
let all = true;
|
|
151
|
+
let own = false;
|
|
152
|
+
for (const term of terms) {
|
|
153
|
+
const inHeading = heading.includes(term);
|
|
154
|
+
const inTitle = title.includes(term);
|
|
155
|
+
const inText = text.includes(term);
|
|
156
|
+
if (!inHeading && !inText && !inTitle) {
|
|
157
|
+
all = false;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
own ||= inHeading || inText;
|
|
161
|
+
score += (inHeading ? 6 : 0) + (inTitle ? 3 : 0) + (inText ? 1 : 0);
|
|
162
|
+
}
|
|
163
|
+
// A word only in the page's title would list every section of the page.
|
|
164
|
+
if (!all || !own)
|
|
165
|
+
continue;
|
|
166
|
+
if (heading === query.trim().toLowerCase())
|
|
167
|
+
score += 10;
|
|
168
|
+
scored.push({
|
|
169
|
+
hit: {
|
|
170
|
+
page: summary.id,
|
|
171
|
+
pageTitle,
|
|
172
|
+
anchor: section.anchor,
|
|
173
|
+
heading: section.heading,
|
|
174
|
+
snippet: snippetOf(section.text, terms.find((term) => text.includes(term)) ?? ""),
|
|
175
|
+
},
|
|
176
|
+
score,
|
|
177
|
+
order,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return scored
|
|
182
|
+
.sort((a, b) => b.score - a.score || a.order - b.order)
|
|
183
|
+
.slice(0, limit)
|
|
184
|
+
.map(({ hit }) => hit);
|
|
185
|
+
}
|
|
@@ -26,9 +26,14 @@ export function audioChapters(input) {
|
|
|
26
26
|
endMs: points[at + 1]?.startMs ?? totalMs,
|
|
27
27
|
}));
|
|
28
28
|
}
|
|
29
|
-
// FFMETADATA1: `=`, `;`, `#`, `\` and line breaks are escaped with a backslash.
|
|
30
|
-
|
|
29
|
+
// FFMETADATA1: `=`, `;`, `#`, `\` and line breaks are escaped with a backslash. A chapter of a
|
|
30
|
+
// book also carries the book as its album and the chapter number as its track (ID3 TALB and
|
|
31
|
+
// TRCK in the MP3, the album and track atoms in the M4B), so a player files the chapters together
|
|
32
|
+
// and in order.
|
|
33
|
+
export function ffmetadata(title, chapters, book) {
|
|
31
34
|
const lines = [";FFMETADATA1", `title=${escapeMeta(title)}`];
|
|
35
|
+
if (book !== undefined)
|
|
36
|
+
lines.push(`album=${escapeMeta(book.title.trim())}`, `track=${String(book.chapter)}`);
|
|
32
37
|
for (const chapter of chapters)
|
|
33
38
|
lines.push("", "[CHAPTER]", "TIMEBASE=1/1000", `START=${String(chapter.startMs)}`, `END=${String(chapter.endMs)}`, `title=${escapeMeta(chapter.title)}`);
|
|
34
39
|
return `${lines.join("\n")}\n`;
|
|
@@ -2,18 +2,36 @@
|
|
|
2
2
|
// cast in a run. A speaker picked from the cast keeps the member's id, and every run started
|
|
3
3
|
// later takes the member's voice as it is then, so a character or a host sounds the same in
|
|
4
4
|
// every episode; editing the cast later changes no project already made.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// The channel's hosts as speakers: every member marked as a host who has a voice, in the cast's
|
|
6
|
+
// order. A new podcast or interview starts with them (`defaultVoicesSettings`), so the channel's
|
|
7
|
+
// recurring voices are there without adding them each time.
|
|
8
|
+
export function castHosts(members) {
|
|
9
|
+
return members.flatMap((member) => member.host === true && member.voice !== undefined ? [speakerFromCast(member, "host")] : []);
|
|
10
|
+
}
|
|
11
|
+
// A speaker id no other member can share: the member's whole id (a UUID) rather than its first
|
|
12
|
+
// eight characters, which two members may have in common. An id the speaker schema cannot hold
|
|
13
|
+
// (never one the app made) falls back to a hash of it. A speaker already saved keeps its id.
|
|
14
|
+
export function castSpeakerId(memberId) {
|
|
15
|
+
const lower = memberId.toLowerCase();
|
|
16
|
+
return /^[a-z0-9-]{1,55}$/.test(lower) ? `cast-${lower}` : `cast-h${hashOf(memberId)}`;
|
|
17
|
+
}
|
|
18
|
+
// cyrb53: 53 bits, the same in the browser and on the server.
|
|
19
|
+
function hashOf(text) {
|
|
20
|
+
let h1 = 0xdeadbeef;
|
|
21
|
+
let h2 = 0x41c6ce57;
|
|
22
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
23
|
+
const code = text.charCodeAt(i);
|
|
24
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
25
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
26
|
+
}
|
|
27
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
28
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
29
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
9
30
|
}
|
|
10
31
|
export function speakerFromCast(member, role) {
|
|
11
32
|
const voice = member.voice;
|
|
12
33
|
return {
|
|
13
|
-
id:
|
|
14
|
-
.slice(0, 8)
|
|
15
|
-
.toLowerCase()
|
|
16
|
-
.replace(/[^a-z0-9-]/g, "")}`,
|
|
34
|
+
id: castSpeakerId(member.id),
|
|
17
35
|
name: member.name.slice(0, 40),
|
|
18
36
|
role,
|
|
19
37
|
voice: voice === undefined
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { speakerPace } from "./model.js";
|
|
2
2
|
export const dialogueCapabilities = [
|
|
3
3
|
{ provider: "elevenlabs", models: ["eleven_v3"], maxVoices: 10, maxCharacters: 2000 },
|
|
4
|
+
{
|
|
5
|
+
provider: "google-tts",
|
|
6
|
+
models: [
|
|
7
|
+
"gemini-2.5-flash-preview-tts",
|
|
8
|
+
"gemini-2.5-pro-preview-tts",
|
|
9
|
+
"gemini-3.1-flash-tts-preview",
|
|
10
|
+
],
|
|
11
|
+
maxVoices: 2,
|
|
12
|
+
maxCharacters: 3000,
|
|
13
|
+
},
|
|
4
14
|
];
|
|
5
15
|
export function dialogueCapability(provider, model) {
|
|
6
16
|
return dialogueCapabilities.find((capability) => capability.provider === provider && capability.models.includes(model));
|
|
@@ -47,6 +47,19 @@ export const speakerSchema = z.object({
|
|
|
47
47
|
.regex(/^[0-9a-f]{64}$/)
|
|
48
48
|
.optional(),
|
|
49
49
|
});
|
|
50
|
+
// A book: a series of audiobook projects, one per chapter, that share their speakers, voices,
|
|
51
|
+
// cast and settings. "Make the next chapter" on a finished chapter starts the next one in Play.
|
|
52
|
+
// The MP3 and M4B carry it as their album and track. Absent is a project on its own, which is
|
|
53
|
+
// every project made before this.
|
|
54
|
+
export const bookTitleMax = 200;
|
|
55
|
+
export const bookChapterMax = 9999;
|
|
56
|
+
export const bookSchema = z
|
|
57
|
+
.object({ title: z.string().max(bookTitleMax), chapter: z.number() })
|
|
58
|
+
.strict()
|
|
59
|
+
.readonly();
|
|
60
|
+
export function bookLabel(book) {
|
|
61
|
+
return `${book.title.trim()} · Chapter ${String(book.chapter)}`;
|
|
62
|
+
}
|
|
50
63
|
export const voicesSettingsSchema = z.object({
|
|
51
64
|
format: z.enum(voiceFormats),
|
|
52
65
|
source: z.enum(scriptSources),
|
|
@@ -58,18 +71,33 @@ export const voicesSettingsSchema = z.object({
|
|
|
58
71
|
nativeDialogue: z.boolean(),
|
|
59
72
|
// MP3 and M4B files with chapter markers beside the video.
|
|
60
73
|
audioFiles: z.boolean(),
|
|
74
|
+
// The book this project is a chapter of. Absent is none.
|
|
75
|
+
book: bookSchema.optional(),
|
|
61
76
|
});
|
|
62
|
-
|
|
77
|
+
// `hosts` are the channel's hosts as speakers (`castHosts`): a podcast or an interview starts
|
|
78
|
+
// with them in place of the placeholder hosts, and the other formats ignore them.
|
|
79
|
+
export function defaultVoicesSettings(format, hosts = []) {
|
|
63
80
|
return {
|
|
64
81
|
format,
|
|
65
82
|
source: "script",
|
|
66
|
-
speakers: starterSpeakers(format),
|
|
83
|
+
speakers: withHosts(format, starterSpeakers(format), hosts),
|
|
67
84
|
turnGapSeconds: defaultTurnGapSeconds,
|
|
68
85
|
nameTags: format === "podcast" || format === "interview",
|
|
69
86
|
nativeDialogue: true,
|
|
70
87
|
audioFiles: true,
|
|
71
88
|
};
|
|
72
89
|
}
|
|
90
|
+
// A podcast keeps a placeholder host only while it has fewer than two; an interview keeps its
|
|
91
|
+
// guest after the hosts.
|
|
92
|
+
function withHosts(format, starters, hosts) {
|
|
93
|
+
if (hosts.length === 0 || (format !== "podcast" && format !== "interview"))
|
|
94
|
+
return [...starters];
|
|
95
|
+
const cast = hosts.slice(0, speakersMax - 1).map((host) => ({ ...host, role: "host" }));
|
|
96
|
+
const fill = format === "podcast"
|
|
97
|
+
? starters.slice(0, Math.max(0, 2 - cast.length))
|
|
98
|
+
: starters.filter((one) => one.role !== "host");
|
|
99
|
+
return [...cast, ...fill.filter((one) => !cast.some((host) => host.id === one.id))];
|
|
100
|
+
}
|
|
73
101
|
function starterSpeakers(format) {
|
|
74
102
|
const blank = { provider: "", model: "", voice: "" };
|
|
75
103
|
const speaker = (id, name, role) => ({
|
|
@@ -99,9 +127,6 @@ export function usesVoices(draft) {
|
|
|
99
127
|
export function usesScriptPrompt(draft) {
|
|
100
128
|
return usesVoices(draft) && draft.voices?.source === "script";
|
|
101
129
|
}
|
|
102
|
-
export function runSpeakers(settings) {
|
|
103
|
-
return { speakers: () => settings.speakers };
|
|
104
|
-
}
|
|
105
130
|
// The rules a multi-voice run must pass before it starts, named for the control to fix.
|
|
106
131
|
export function voicesProblems(settings) {
|
|
107
132
|
const problems = [];
|
|
@@ -109,23 +134,23 @@ export function voicesProblems(settings) {
|
|
|
109
134
|
if (speakers.length === 0)
|
|
110
135
|
problems.push({
|
|
111
136
|
field: "voices.speakers",
|
|
112
|
-
message: "Add at least one speaker under Speakers (Play →
|
|
137
|
+
message: "Add at least one speaker under Speakers (Play → Narration, or Edit project → Providers).",
|
|
113
138
|
});
|
|
114
139
|
if (settings.source === "attribute" && settings.format !== "audiobook")
|
|
115
140
|
problems.push({
|
|
116
141
|
field: "voices.source",
|
|
117
|
-
message: "Only audiobooks can hand an existing text's dialogue to speakers. Choose Write a script under Speakers (Play →
|
|
142
|
+
message: "Only audiobooks can hand an existing text's dialogue to speakers. Choose Write a script under Speakers (Play → Narration, or Edit project → Providers).",
|
|
118
143
|
});
|
|
119
144
|
if ((settings.format === "podcast" || settings.format === "interview") && speakers.length < 2)
|
|
120
145
|
problems.push({
|
|
121
146
|
field: "voices.speakers",
|
|
122
|
-
message: `A ${voiceFormatLabels[settings.format].toLowerCase()} needs at least two speakers. Add one under Speakers (Play →
|
|
147
|
+
message: `A ${voiceFormatLabels[settings.format].toLowerCase()} needs at least two speakers. Add one under Speakers (Play → Narration, or Edit project → Providers).`,
|
|
123
148
|
});
|
|
124
149
|
if ((settings.format === "audiobook" || settings.format === "drama") &&
|
|
125
150
|
!speakers.some((speaker) => speaker.role === "narrator"))
|
|
126
151
|
problems.push({
|
|
127
152
|
field: "voices.speakers",
|
|
128
|
-
message: `An ${settings.format === "drama" ? "radio drama" : "audiobook"} needs a narrator. Set one speaker's role to Narrator under Speakers (Play →
|
|
153
|
+
message: `An ${settings.format === "drama" ? "radio drama" : "audiobook"} needs a narrator. Set one speaker's role to Narrator under Speakers (Play → Narration, or Edit project → Providers).`,
|
|
129
154
|
});
|
|
130
155
|
const names = new Set();
|
|
131
156
|
const ids = new Set();
|
|
@@ -167,10 +192,24 @@ export function voicesProblems(settings) {
|
|
|
167
192
|
message: `Pick a pace from the list for ${name || "this speaker"}.`,
|
|
168
193
|
});
|
|
169
194
|
}
|
|
195
|
+
if (settings.book !== undefined) {
|
|
196
|
+
if (settings.book.title.trim() === "")
|
|
197
|
+
problems.push({
|
|
198
|
+
field: "voices.book.title",
|
|
199
|
+
message: "Enter the book's title under Speakers (Play → Narration), or remove the book to make a project on its own.",
|
|
200
|
+
});
|
|
201
|
+
if (!Number.isInteger(settings.book.chapter) ||
|
|
202
|
+
settings.book.chapter < 1 ||
|
|
203
|
+
settings.book.chapter > bookChapterMax)
|
|
204
|
+
problems.push({
|
|
205
|
+
field: "voices.book.chapter",
|
|
206
|
+
message: `Enter a chapter number from 1 to ${String(bookChapterMax)} under Speakers (Play → Narration).`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
170
209
|
if (!turnGapSteps.includes(settings.turnGapSeconds))
|
|
171
210
|
problems.push({
|
|
172
211
|
field: "voices.turnGapSeconds",
|
|
173
|
-
message: "Pick a gap between turns from the list under Speakers (Play →
|
|
212
|
+
message: "Pick a gap between turns from the list under Speakers (Play → Narration, or Edit project → Providers).",
|
|
174
213
|
});
|
|
175
214
|
return problems;
|
|
176
215
|
}
|
|
@@ -42,7 +42,7 @@ export function parseScript(markdown, speakers, keepMarkdown = false) {
|
|
|
42
42
|
ok: false,
|
|
43
43
|
reason: turn === null
|
|
44
44
|
? `Line ${String(number)} of the script is not a turn. Start every turn with a speaker's name and a colon (${names}), one turn per paragraph.`
|
|
45
|
-
: `Line ${String(number)} of the script is spoken by "${(turn[1] ?? "").trim()}", who is not one of the speakers (${names}). Use only these names, or add the speaker under Speakers (Play →
|
|
45
|
+
: `Line ${String(number)} of the script is spoken by "${(turn[1] ?? "").trim()}", who is not one of the speakers (${names}). Use only these names, or add the speaker under Speakers (Play → Narration, or Edit project → Providers).`,
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
const spoken = [];
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Where the hand edits and the channel links are kept: the edits per project in
|
|
2
|
-
// `youtube_description_edits`,
|
|
2
|
+
// `youtube_description_edits`, each channel's links in its brand kit, and the older Settings list
|
|
3
|
+
// of links (read as the default channel's until its Brand tab saves its own) as one `settings` row.
|
|
3
4
|
import { z } from "zod";
|
|
5
|
+
import { defaultChannelId } from "../channels/model.js";
|
|
6
|
+
import { channelById, projectChannelId } from "../channels/repo.js";
|
|
4
7
|
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
5
8
|
import { chapterNotice, fitChapters } from "./chapters.js";
|
|
6
9
|
import { composeDescription, descriptionFields, resolveFields, shownFields, splitDescription, } from "./edits.js";
|
|
@@ -49,17 +52,29 @@ export function readChannelLinks(db) {
|
|
|
49
52
|
export function writeChannelLinks(db, links) {
|
|
50
53
|
writeSetting(db, channelLinksKey, JSON.stringify(links.map((link) => ({ name: link.name.trim(), url: link.url.trim() }))));
|
|
51
54
|
}
|
|
55
|
+
// A channel's links: the ones its Brand tab saved. The default channel, until its Brand tab
|
|
56
|
+
// saves a list of its own (even an empty one), reads the older Settings list, so links saved
|
|
57
|
+
// before channels had their own keep filling. A channel that is gone has none.
|
|
58
|
+
export function readChannelLinksFor(db, channelId) {
|
|
59
|
+
const channel = channelById(db, channelId);
|
|
60
|
+
if (channel === undefined)
|
|
61
|
+
return [];
|
|
62
|
+
if (channel.brand.links !== undefined)
|
|
63
|
+
return channel.brand.links;
|
|
64
|
+
return channel.id === defaultChannelId ? readChannelLinks(db) : [];
|
|
65
|
+
}
|
|
52
66
|
// The description and tags as the project page shows and copies them: the user's edits over
|
|
53
67
|
// the generated text, the chapters fitted to YouTube's rules (`chapters.ts`; the last one's
|
|
54
68
|
// length only checked when the video's length is given), placeholders filled from the
|
|
55
|
-
// project's and
|
|
56
|
-
// description on, such as Prepare upload. `chapterNotice` says what the
|
|
69
|
+
// project's and its channel's links (one with no link stays as typed). For anything that hands
|
|
70
|
+
// the description on, such as Prepare upload and the downloads. `chapterNotice` says what the
|
|
71
|
+
// fitting changed.
|
|
57
72
|
export function effectiveDescription(db, projectId, generated) {
|
|
58
73
|
const edits = readDescriptionEdits(db, projectId);
|
|
59
74
|
const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags), edits.fields));
|
|
60
75
|
const fitted = fitChapters(shown.chapters, generated.durationSeconds);
|
|
61
76
|
const notice = chapterNotice(fitted.adjustments);
|
|
62
|
-
const links = mergeLinks(
|
|
77
|
+
const links = mergeLinks(readChannelLinksFor(db, projectChannelId(db, projectId)), edits.links);
|
|
63
78
|
return {
|
|
64
79
|
description: fillPlaceholders(composeDescription({ ...shown, chapters: fitted.text }), links)
|
|
65
80
|
.text,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// A placeholder with no link of that name stays in the text as typed and is marked, never
|
|
6
6
|
// silently dropped. Browser-safe.
|
|
7
7
|
// The link YouTube descriptions most often point at, and the one that changes per project:
|
|
8
|
-
// the project page offers it by name, and a project's own value wins over
|
|
8
|
+
// the project page offers it by name, and a project's own value wins over its channel's list.
|
|
9
9
|
export const previousVideoLink = "Previous video";
|
|
10
10
|
export const channelLinkNameMax = 60;
|
|
11
11
|
export const channelLinkUrlMax = 2000;
|
|
@@ -63,7 +63,7 @@ export function fillPlaceholders(text, links) {
|
|
|
63
63
|
// Why a list of links cannot be saved, in the words Settings shows; undefined when it can.
|
|
64
64
|
export function channelLinksProblem(links) {
|
|
65
65
|
if (links.length > channelLinksMax)
|
|
66
|
-
return `Keep at most ${String(channelLinksMax)}
|
|
66
|
+
return `Keep at most ${String(channelLinksMax)} links. Remove some.`;
|
|
67
67
|
const seen = new Set();
|
|
68
68
|
for (const [index, link] of links.entries()) {
|
|
69
69
|
const row = `Link ${String(index + 1)}`;
|