@gentbajko/slopify 3.0.5 → 3.0.7
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/adapters/tts/elevenlabs.js +2 -0
- package/dist/assets/document/fonts/Literata-Italic.ttf +0 -0
- package/dist/assets/document/fonts/Literata-OFL.txt +93 -0
- package/dist/assets/document/fonts/Literata-Regular.ttf +0 -0
- package/dist/assets/document/fonts/Literata-SemiBold.ttf +0 -0
- package/dist/assets/document/fonts/Literata-SemiBoldItalic.ttf +0 -0
- package/dist/assets/document/fonts/SOURCE.txt +13 -1
- package/dist/assets/models.yaml +26 -0
- package/dist/edge/http/studio.js +32 -11
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/main.js +1 -1
- package/dist/patch-notes/3.0.6.md +34 -0
- package/dist/patch-notes/3.0.7.md +33 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/document/fonts.js +29 -11
- package/dist/slices/document/render.js +1 -1
- package/dist/slices/document/theme.js +3 -3
- package/dist/slices/rebuild/recipe-legacy.js +2 -0
- package/dist/slices/rebuild/runtime-youtube.js +15 -4
- package/dist/slices/schedules/agenda.js +1 -1
- package/dist/slices/schedules/scheduler.js +3 -2
- package/dist/slices/schedules/service.js +11 -8
- package/dist/slices/schedules/topics.js +3 -3
- package/dist/slices/storage/downloads.js +14 -3
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/portable.js +4 -4
- package/dist/slices/storage/trim.js +2 -0
- package/dist/slices/studio/pack.js +16 -5
- package/dist/slices/studio/settings.js +89 -24
- package/dist/slices/youtube/answer.js +54 -5
- package/dist/slices/youtube/edits-repo.js +8 -2
- package/dist/slices/youtube/edits.js +18 -4
- package/dist/slices/youtube/model.js +9 -1
- package/dist/tutorials/Document-Themes.md +1 -1
- package/dist/tutorials/Home-and-Projects.md +4 -4
- package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +3 -3
- package/dist/tutorials/Play-Overview.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +4 -4
- package/dist/tutorials/Schedules.md +2 -2
- package/dist/tutorials/Settings-Reference.md +3 -3
- package/dist/tutorials/Studio-Extension.md +2 -2
- package/dist/tutorials/YouTube-Description.md +13 -5
- package/dist/web/assets/{index-KWtMGqKB.css → index-C-Ib18Ec.css} +1 -1
- package/dist/web/assets/index-Cdb0Jh3P.js +310 -0
- package/dist/web/assets/{pdf-CkHBkfTB.js → pdf-BhFuoBjr.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-QDLAjCSW.js +0 -307
|
@@ -58,7 +58,10 @@ export function findDownload(deps, projectId, asset) {
|
|
|
58
58
|
if (stats === undefined) {
|
|
59
59
|
return { ok: false, reason: "missing-file" };
|
|
60
60
|
}
|
|
61
|
-
const shown = output.role === "youtube_description" ||
|
|
61
|
+
const shown = output.role === "youtube_description" ||
|
|
62
|
+
output.role === "youtube_tags" ||
|
|
63
|
+
output.role === "youtube_pinned_comment" ||
|
|
64
|
+
output.role === "youtube_titles"
|
|
62
65
|
? shownYoutubeText(deps, projectId, output.role)
|
|
63
66
|
: undefined;
|
|
64
67
|
return {
|
|
@@ -72,7 +75,7 @@ export function findDownload(deps, projectId, asset) {
|
|
|
72
75
|
},
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
|
-
// The description and
|
|
78
|
+
// The description, tags, pinned comment and other titles download as the project page shows and copies them, and as Prepare
|
|
76
79
|
// upload hands them on: the user's hand edits over the written text, chapters fitted, and the
|
|
77
80
|
// channel's and project's links filled in (`youtube/edits-repo.ts`). The file on disk stays
|
|
78
81
|
// the generated text, so regeneration can tell the user's edits from its own.
|
|
@@ -92,11 +95,19 @@ function shownYoutubeText(deps, projectId, role) {
|
|
|
92
95
|
const shown = effectiveDescription(deps.db, projectId, {
|
|
93
96
|
description,
|
|
94
97
|
tags: read("youtube_tags") ?? "",
|
|
98
|
+
pinnedComment: read("youtube_pinned_comment"),
|
|
99
|
+
titles: read("youtube_titles"),
|
|
95
100
|
durationSeconds: video?.durationMs === null || video?.durationMs === undefined
|
|
96
101
|
? undefined
|
|
97
102
|
: video.durationMs / 1000,
|
|
98
103
|
});
|
|
99
|
-
const text = role === "youtube_description"
|
|
104
|
+
const text = role === "youtube_description"
|
|
105
|
+
? shown.description
|
|
106
|
+
: role === "youtube_tags"
|
|
107
|
+
? shown.tags
|
|
108
|
+
: role === "youtube_pinned_comment"
|
|
109
|
+
? shown.pinnedComment
|
|
110
|
+
: shown.titles;
|
|
100
111
|
return new TextEncoder().encode(`${text}\n`);
|
|
101
112
|
}
|
|
102
113
|
// "download all" images as `<title-slug>-images.zip`, thumbnail included.
|
|
@@ -68,6 +68,10 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
68
68
|
return "description.txt";
|
|
69
69
|
case "youtube_tags":
|
|
70
70
|
return "tags.txt";
|
|
71
|
+
case "youtube_pinned_comment":
|
|
72
|
+
return "pinned-comment.txt";
|
|
73
|
+
case "youtube_titles":
|
|
74
|
+
return "titles.txt";
|
|
71
75
|
case "shorts":
|
|
72
76
|
return "shorts.json";
|
|
73
77
|
case "short_image":
|
|
@@ -27,6 +27,11 @@ export const outputRoles = [
|
|
|
27
27
|
"document_pdf",
|
|
28
28
|
"youtube_description",
|
|
29
29
|
"youtube_tags",
|
|
30
|
+
// A comment to pin under the video, written with the description. Projects described before
|
|
31
|
+
// it existed have none until the description is written again.
|
|
32
|
+
"youtube_pinned_comment",
|
|
33
|
+
// Other titles for YouTube's title A/B test, one per line, written with the description.
|
|
34
|
+
"youtube_titles",
|
|
30
35
|
// The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
|
|
31
36
|
"shorts",
|
|
32
37
|
"short_image",
|
|
@@ -23,8 +23,7 @@ import { appearances, providerById, providerIds } from "../settings/model.js";
|
|
|
23
23
|
import { listVoices } from "../settings/repo.js";
|
|
24
24
|
import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voices.js";
|
|
25
25
|
import { whatsNewSeenKey } from "../settings/whats-new.js";
|
|
26
|
-
import {
|
|
27
|
-
import { studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioRealFootageKey, } from "../studio/settings.js";
|
|
26
|
+
import { storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
|
|
28
27
|
import { channelLinksKey } from "../youtube/edits-repo.js";
|
|
29
28
|
import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
|
|
30
29
|
import { stageKinds } from "./model.js";
|
|
@@ -354,7 +353,6 @@ const storedAppearance = z.enum(appearances);
|
|
|
354
353
|
const storedLoudness = z
|
|
355
354
|
.object({ enabled: z.boolean(), videoLufs: z.number(), audioFilesLufs: z.number() })
|
|
356
355
|
.strict();
|
|
357
|
-
const storedPlaylist = z.string().max(studioPlaylistMax);
|
|
358
356
|
// Settings → Channel links, and the providers a fresh Play form starts with.
|
|
359
357
|
// The AI use marks: ticked voices, Image prompts and projects, by id.
|
|
360
358
|
const storedIds = z.array(z.string().max(200)).max(100_000);
|
|
@@ -389,7 +387,9 @@ function portableSettings(settings) {
|
|
|
389
387
|
else if (key === "loudness")
|
|
390
388
|
storedLoudness.parse(parsed);
|
|
391
389
|
else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
|
|
392
|
-
|
|
390
|
+
storedPlaylistsSchema.parse(parsed);
|
|
391
|
+
else if (key === studioProjectPlaylistsKey)
|
|
392
|
+
storedProjectPlaylistsSchema.parse(parsed);
|
|
393
393
|
else if (key === channelLinksKey)
|
|
394
394
|
storedChannelLinks.parse(parsed);
|
|
395
395
|
else if (key === realPersonVoicesKey ||
|
|
@@ -17,7 +17,7 @@ import { usesVoices } from "../voices/model.js";
|
|
|
17
17
|
import { effectiveDescription } from "../youtube/edits-repo.js";
|
|
18
18
|
import { aiDisclosureOf } from "./disclosure.js";
|
|
19
19
|
import { studioAudience, studioTitleMax, } from "./model.js";
|
|
20
|
-
import {
|
|
20
|
+
import { projectPlaylists, readRealFootage } from "./settings.js";
|
|
21
21
|
// What `shorts.json` holds for each picked clip; only what the pack uses.
|
|
22
22
|
const clipsSchema = z.object({
|
|
23
23
|
shorts: z.array(z.object({
|
|
@@ -50,8 +50,10 @@ export function uploadPack(deps, projectId) {
|
|
|
50
50
|
? undefined
|
|
51
51
|
: readFileSync(outputPath(deps.paths, projectId, output.path), "utf8").trim();
|
|
52
52
|
const channelId = projectChannelId(deps.db, projectId);
|
|
53
|
-
// The channel's
|
|
54
|
-
const
|
|
53
|
+
// The channel's playlists (Settings → YouTube Studio), ticked as this project chose.
|
|
54
|
+
const playlistChoices = projectPlaylists(deps.db, projectId, channelId);
|
|
55
|
+
const playlists = playlistChoices.filter((one) => one.chosen).map((one) => one.name);
|
|
56
|
+
const playlist = playlists[0] ?? null;
|
|
55
57
|
const missing = [];
|
|
56
58
|
// The channel's setting, then what the project narrates and shows (`disclosure.ts`).
|
|
57
59
|
const setting = channelById(deps.db, channelId)?.aiDisclosure ?? "auto";
|
|
@@ -84,6 +86,7 @@ export function uploadPack(deps, projectId) {
|
|
|
84
86
|
: effectiveDescription(deps.db, projectId, {
|
|
85
87
|
description: written,
|
|
86
88
|
tags: writtenTags ?? "",
|
|
89
|
+
titles: text(outputs.find((output) => output.role === "youtube_titles")),
|
|
87
90
|
// The last chapter is checked against the video's own length when it is known.
|
|
88
91
|
durationSeconds: video?.durationMs === null || video?.durationMs === undefined
|
|
89
92
|
? undefined
|
|
@@ -107,18 +110,23 @@ export function uploadPack(deps, projectId) {
|
|
|
107
110
|
: "The thumbnail isn't made yet. Let the Thumbnail stage finish, then open Prepare upload again.");
|
|
108
111
|
else if (thumbnails.length < count)
|
|
109
112
|
missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
|
|
110
|
-
if (
|
|
111
|
-
missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio →
|
|
113
|
+
if (playlistChoices.length === 0)
|
|
114
|
+
missing.push("No playlist is set for this project's channel, so the playlist step is left to you. Set one in Settings → YouTube Studio → Playlists.");
|
|
112
115
|
const items = [
|
|
113
116
|
{
|
|
114
117
|
kind: "video",
|
|
115
118
|
video: video === undefined ? null : file(video),
|
|
116
119
|
title: project.title.slice(0, studioTitleMax),
|
|
120
|
+
titles: (edited?.titles ?? "")
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((one) => one.trim().slice(0, studioTitleMax))
|
|
123
|
+
.filter((one) => one !== ""),
|
|
117
124
|
description: description ?? "",
|
|
118
125
|
tags: tagsOf(tagsFile),
|
|
119
126
|
thumbnails: thumbnails.map(file),
|
|
120
127
|
audience: studioAudience,
|
|
121
128
|
alteredContent: disclosure("video"),
|
|
129
|
+
playlists,
|
|
122
130
|
playlist,
|
|
123
131
|
...(edited?.chapterNotice === undefined ? {} : { chapterNotice: edited.chapterNotice }),
|
|
124
132
|
},
|
|
@@ -140,6 +148,7 @@ export function uploadPack(deps, projectId) {
|
|
|
140
148
|
short: clip.number,
|
|
141
149
|
video: render === undefined ? null : file(render),
|
|
142
150
|
title: clip.title.slice(0, studioTitleMax),
|
|
151
|
+
titles: [],
|
|
143
152
|
description: [
|
|
144
153
|
clip.description,
|
|
145
154
|
fullVideoLine(config.shorts?.fullVideoLink),
|
|
@@ -152,6 +161,7 @@ export function uploadPack(deps, projectId) {
|
|
|
152
161
|
thumbnails: [],
|
|
153
162
|
audience: studioAudience,
|
|
154
163
|
alteredContent: disclosure("short"),
|
|
164
|
+
playlists,
|
|
155
165
|
playlist,
|
|
156
166
|
});
|
|
157
167
|
}
|
|
@@ -162,6 +172,7 @@ export function uploadPack(deps, projectId) {
|
|
|
162
172
|
projectTitle: project.title,
|
|
163
173
|
items,
|
|
164
174
|
missing,
|
|
175
|
+
playlistChoices,
|
|
165
176
|
...(uploadedClips > 0 ? { footage: { clips: uploadedClips, real: realFootage } } : {}),
|
|
166
177
|
},
|
|
167
178
|
};
|
|
@@ -2,7 +2,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
4
4
|
import { studioPlaylistMax } from "./model.js";
|
|
5
|
-
//
|
|
5
|
+
// Rows of the key/value `settings` table. The playlists travel with a backup; the
|
|
6
6
|
// pairing stays on this machine (`slices/storage/portable.ts`): its token is what lets the
|
|
7
7
|
// browser extension read a project's upload pack.
|
|
8
8
|
export const studioPlaylistKey = "studio.playlist";
|
|
@@ -12,23 +12,37 @@ const pairingSchema = z.object({
|
|
|
12
12
|
origin: z.string().nullable(),
|
|
13
13
|
pairedAt: z.string().nullable(),
|
|
14
14
|
});
|
|
15
|
-
// A channel's own
|
|
16
|
-
// without one uses the default above.
|
|
15
|
+
// A channel's own playlists are a row of their own, `studio.playlist.<channelId>`; a channel
|
|
16
|
+
// without one uses the default list above. Each playlist is ticked by default or not; a
|
|
17
|
+
// project's Prepare upload can change which it goes into. A row saved before lists existed
|
|
18
|
+
// holds one name, read as one playlist ticked by default.
|
|
17
19
|
export const studioChannelPlaylistPrefix = `${studioPlaylistKey}.`;
|
|
18
20
|
function playlistKey(channelId) {
|
|
19
21
|
return channelId === undefined ? studioPlaylistKey : `${studioChannelPlaylistPrefix}${channelId}`;
|
|
20
22
|
}
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
export
|
|
23
|
+
// Up to 20 playlists a list: Studio's own dialog shows every playlist of the channel, and a
|
|
24
|
+
// video in more than a handful of them is rare.
|
|
25
|
+
export const studioPlaylistsMax = 20;
|
|
26
|
+
export const studioPlaylistSchema = z.object({
|
|
27
|
+
name: z.string().trim().min(1).max(studioPlaylistMax),
|
|
28
|
+
byDefault: z.boolean(),
|
|
29
|
+
});
|
|
30
|
+
// What a row holds: the list, or the one name a row held before lists.
|
|
31
|
+
export const storedPlaylistsSchema = z.union([
|
|
32
|
+
z.string().max(studioPlaylistMax),
|
|
33
|
+
z.array(studioPlaylistSchema).max(studioPlaylistsMax),
|
|
34
|
+
]);
|
|
35
|
+
// The playlists a channel's upload packs offer: the channel's own list, else the default.
|
|
36
|
+
// Without a channel, the default alone.
|
|
37
|
+
export function readStudioPlaylists(db, channelId) {
|
|
24
38
|
if (channelId !== undefined) {
|
|
25
|
-
const own =
|
|
39
|
+
const own = storedPlaylists(db, playlistKey(channelId));
|
|
26
40
|
if (own !== null)
|
|
27
41
|
return own;
|
|
28
42
|
}
|
|
29
|
-
return
|
|
43
|
+
return storedPlaylists(db, studioPlaylistKey) ?? [];
|
|
30
44
|
}
|
|
31
|
-
// Every channel's own
|
|
45
|
+
// Every channel's own list, by channel id.
|
|
32
46
|
export function readChannelPlaylists(db) {
|
|
33
47
|
const prefix = studioChannelPlaylistPrefix;
|
|
34
48
|
const rows = db
|
|
@@ -36,41 +50,92 @@ export function readChannelPlaylists(db) {
|
|
|
36
50
|
.all(prefix.length, prefix);
|
|
37
51
|
const out = {};
|
|
38
52
|
for (const { key } of rows) {
|
|
39
|
-
const value =
|
|
53
|
+
const value = storedPlaylists(db, key);
|
|
40
54
|
if (value !== null)
|
|
41
55
|
out[key.slice(prefix.length)] = value;
|
|
42
56
|
}
|
|
43
57
|
return out;
|
|
44
58
|
}
|
|
45
|
-
function
|
|
59
|
+
function storedPlaylists(db, key) {
|
|
46
60
|
const stored = readSetting(db, key);
|
|
47
61
|
if (stored === undefined)
|
|
48
62
|
return null;
|
|
49
63
|
try {
|
|
50
|
-
const
|
|
51
|
-
|
|
64
|
+
const parsed = storedPlaylistsSchema.safeParse(JSON.parse(stored));
|
|
65
|
+
if (!parsed.success)
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof parsed.data === "string")
|
|
68
|
+
return parsed.data.trim() === "" ? null : [{ name: parsed.data.trim(), byDefault: true }];
|
|
69
|
+
return parsed.data.length === 0 ? null : parsed.data;
|
|
52
70
|
}
|
|
53
71
|
catch {
|
|
54
72
|
return null;
|
|
55
73
|
}
|
|
56
74
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
// Why a list can't be saved, in words for Settings → YouTube Studio; undefined when it can.
|
|
76
|
+
export function studioPlaylistsProblem(list) {
|
|
77
|
+
const long = list.find((one) => one.name.trim().length > studioPlaylistMax);
|
|
78
|
+
if (long !== undefined)
|
|
79
|
+
return `The playlist name "${long.name.slice(0, 40)}…" is longer than YouTube allows (${String(studioPlaylistMax)} characters). Shorten it in Settings → YouTube Studio.`;
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const one of list) {
|
|
82
|
+
const key = one.name.trim().toLowerCase();
|
|
83
|
+
if (seen.has(key))
|
|
84
|
+
return `"${one.name.trim()}" is listed twice. Remove one in Settings → YouTube Studio.`;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
// Saves the default list, or a channel's own; an empty list clears it (the channel then uses
|
|
90
|
+
// the default again). Blank names are dropped.
|
|
91
|
+
export function saveStudioPlaylists(db, list, channelId) {
|
|
92
|
+
const value = list
|
|
93
|
+
.map((one) => ({ name: one.name.trim(), byDefault: one.byDefault }))
|
|
94
|
+
.filter((one) => one.name !== "");
|
|
66
95
|
const key = playlistKey(channelId);
|
|
67
|
-
if (value ===
|
|
96
|
+
if (value.length === 0) {
|
|
68
97
|
db.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
69
|
-
return
|
|
98
|
+
return [];
|
|
70
99
|
}
|
|
71
100
|
writeSetting(db, key, JSON.stringify(value));
|
|
72
101
|
return value;
|
|
73
102
|
}
|
|
103
|
+
// The playlists a project's uploads go into, when its Prepare upload changed them from the
|
|
104
|
+
// channel's defaults: one row, project id to names.
|
|
105
|
+
export const studioProjectPlaylistsKey = "studio.projectPlaylists";
|
|
106
|
+
export const storedProjectPlaylistsSchema = z.record(z.string().max(64), z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax));
|
|
107
|
+
function projectChoices(db) {
|
|
108
|
+
const stored = readSetting(db, studioProjectPlaylistsKey);
|
|
109
|
+
if (stored === undefined)
|
|
110
|
+
return {};
|
|
111
|
+
try {
|
|
112
|
+
const parsed = storedProjectPlaylistsSchema.safeParse(JSON.parse(stored));
|
|
113
|
+
return parsed.success ? parsed.data : {};
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// The channel's playlists, each ticked as this project goes into it: the project's own choice
|
|
120
|
+
// when it made one (a playlist the channel no longer lists is dropped), else the defaults.
|
|
121
|
+
export function projectPlaylists(db, projectId, channelId) {
|
|
122
|
+
const list = readStudioPlaylists(db, channelId);
|
|
123
|
+
const own = projectChoices(db)[projectId];
|
|
124
|
+
return list.map((one) => ({
|
|
125
|
+
name: one.name,
|
|
126
|
+
chosen: own === undefined
|
|
127
|
+
? one.byDefault
|
|
128
|
+
: own.some((name) => name.toLowerCase() === one.name.toLowerCase()),
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
export function saveProjectPlaylists(db, projectId, names) {
|
|
132
|
+
const { [projectId]: _previous, ...others } = projectChoices(db);
|
|
133
|
+
const next = names === null ? others : { ...others, [projectId]: [...names] };
|
|
134
|
+
if (Object.keys(next).length === 0)
|
|
135
|
+
db.prepare("DELETE FROM settings WHERE key = ?").run(studioProjectPlaylistsKey);
|
|
136
|
+
else
|
|
137
|
+
writeSetting(db, studioProjectPlaylistsKey, JSON.stringify(next));
|
|
138
|
+
}
|
|
74
139
|
// The projects whose uploaded clips are real footage (filmed, not generated): ticked in the
|
|
75
140
|
// project's Prepare upload, under AI use. One row holding their ids; YouTube's second AI use
|
|
76
141
|
// case is footage of a real event or place altered by AI (`disclosure.ts`).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { descriptionMaxCharacters, hashtagsMax, minChapterSeconds, minChapters, tagMaxCharacters, tagsMaxCharacters, } from "./model.js";
|
|
2
|
+
import { alternativeTitles, descriptionMaxCharacters, hashtagsMax, minChapterSeconds, minChapters, pinnedCommentMaxCharacters, tagMaxCharacters, tagsMaxCharacters, titleMaxCharacters, } from "./model.js";
|
|
3
3
|
import { parseTimestamp, youtubeTimestamp } from "./timestamps.js";
|
|
4
4
|
export function descriptionMessages(brief) {
|
|
5
5
|
const end = youtubeTimestamp(brief.durationSeconds);
|
|
@@ -8,7 +8,7 @@ export function descriptionMessages(brief) {
|
|
|
8
8
|
role: "system",
|
|
9
9
|
content: [
|
|
10
10
|
"You write YouTube descriptions. Answer with one JSON object and nothing else, in this shape:",
|
|
11
|
-
'{"summary": "...", "chapters": [{"start": "0:00", "title": "..."}], "hashtags": ["#Example"], "tags": ["example tag"]}',
|
|
11
|
+
'{"summary": "...", "chapters": [{"start": "0:00", "title": "..."}], "hashtags": ["#Example"], "tags": ["example tag"], "pinnedComment": "...", "titles": ["...", "..."]}',
|
|
12
12
|
"",
|
|
13
13
|
"Rules YouTube enforces, which the answer must follow:",
|
|
14
14
|
'- The first chapter starts at exactly "0:00".',
|
|
@@ -20,6 +20,11 @@ export function descriptionMessages(brief) {
|
|
|
20
20
|
`- Tags are plain search terms without # or commas, each at most ${String(tagMaxCharacters)} characters, no tag repeated, and all tags together at most ${String(tagsMaxCharacters)} characters.`,
|
|
21
21
|
"- No < or > anywhere.",
|
|
22
22
|
"- The summary is plain text without chapters, hashtags or links.",
|
|
23
|
+
`- The pinned comment is plain text without links, at most ${String(pinnedCommentMaxCharacters)} characters.`,
|
|
24
|
+
`- Titles are exactly ${String(alternativeTitles)} other titles for this video, for YouTube's title A/B test beside the video's own title: each at most ${String(titleMaxCharacters)} characters, one line, different from the video title and from each other.`,
|
|
25
|
+
"",
|
|
26
|
+
"The pinned comment is what the channel pins under the video, unless the instructions below say otherwise: one or two short sentences, at most 200 characters, in the same voice as the summary. Ask one question a viewer can answer from their own experience of the subject, and invite a suggestion for the next video.",
|
|
27
|
+
"The other titles try different angles on the same video (a question, a stake, a surprising fact) while staying true to it and in the video title's language and tone.",
|
|
23
28
|
].join("\n"),
|
|
24
29
|
},
|
|
25
30
|
{
|
|
@@ -42,16 +47,20 @@ const answerSchema = z.object({
|
|
|
42
47
|
chapters: z.array(z.object({ start: z.union([z.string(), z.number()]), title: z.string() })),
|
|
43
48
|
hashtags: z.array(z.string()),
|
|
44
49
|
tags: z.array(z.string()),
|
|
50
|
+
pinnedComment: z.string(),
|
|
51
|
+
titles: z.array(z.string()),
|
|
45
52
|
});
|
|
46
53
|
const fix = "Try again, or choose another model in Edit project → Providers.";
|
|
47
54
|
// Reads and checks the model's answer. The reason is the sentence the stage shows; the
|
|
48
55
|
// provider wrapper's `check` asks the model again while attempts remain.
|
|
49
|
-
export function checkDescriptionAnswer(text, durationSeconds
|
|
56
|
+
export function checkDescriptionAnswer(text, durationSeconds,
|
|
57
|
+
// The video's own title, which the other titles must differ from.
|
|
58
|
+
title = "") {
|
|
50
59
|
const parsed = answerSchema.safeParse(jsonOf(text));
|
|
51
60
|
if (!parsed.success)
|
|
52
61
|
return {
|
|
53
62
|
ok: false,
|
|
54
|
-
reason: `The AI model's YouTube description didn't come back in the expected format (a JSON object with a summary, chapters, hashtags and
|
|
63
|
+
reason: `The AI model's YouTube description didn't come back in the expected format (a JSON object with a summary, chapters, hashtags, tags, a pinned comment and titles). ${fix}`,
|
|
55
64
|
};
|
|
56
65
|
const summary = parsed.data.summary.trim();
|
|
57
66
|
if (summary === "")
|
|
@@ -65,7 +74,25 @@ export function checkDescriptionAnswer(text, durationSeconds) {
|
|
|
65
74
|
const tags = checkTags(parsed.data.tags);
|
|
66
75
|
if (!tags.ok)
|
|
67
76
|
return tags;
|
|
68
|
-
const
|
|
77
|
+
const pinnedComment = parsed.data.pinnedComment.trim();
|
|
78
|
+
if (pinnedComment === "")
|
|
79
|
+
return { ok: false, reason: `The AI model wrote no pinned comment. ${fix}` };
|
|
80
|
+
if (pinnedComment.length > pinnedCommentMaxCharacters)
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
reason: `The AI model's pinned comment is ${String(pinnedComment.length)} characters, over the ${String(pinnedCommentMaxCharacters)} Slopify allows. ${fix}`,
|
|
84
|
+
};
|
|
85
|
+
const titles = checkTitles(parsed.data.titles, title);
|
|
86
|
+
if (!titles.ok)
|
|
87
|
+
return titles;
|
|
88
|
+
const value = {
|
|
89
|
+
titles: titles.value,
|
|
90
|
+
summary,
|
|
91
|
+
chapters: chapters.value,
|
|
92
|
+
hashtags: hashtags.value,
|
|
93
|
+
tags: tags.value,
|
|
94
|
+
pinnedComment,
|
|
95
|
+
};
|
|
69
96
|
const description = assembleDescription(value);
|
|
70
97
|
if (/[<>]/.test(description))
|
|
71
98
|
return {
|
|
@@ -139,6 +166,28 @@ function checkChapters(raw, durationSeconds) {
|
|
|
139
166
|
}
|
|
140
167
|
return { ok: true, value: chapters };
|
|
141
168
|
}
|
|
169
|
+
function checkTitles(raw, title) {
|
|
170
|
+
const broke = (rule) => ({
|
|
171
|
+
ok: false,
|
|
172
|
+
reason: `The AI model's other titles broke YouTube's rules (${rule}). ${fix}`,
|
|
173
|
+
});
|
|
174
|
+
const titles = raw.map((one) => one.trim().replace(/\s+/g, " ")).filter((one) => one !== "");
|
|
175
|
+
if (titles.length !== alternativeTitles)
|
|
176
|
+
return broke(`there must be ${String(alternativeTitles)} other titles, and it wrote ${String(titles.length)}`);
|
|
177
|
+
const long = titles.find((one) => one.length > titleMaxCharacters);
|
|
178
|
+
if (long !== undefined)
|
|
179
|
+
return broke(`"${long.slice(0, 40)}…" is over ${String(titleMaxCharacters)} characters`);
|
|
180
|
+
const odd = titles.find((one) => /[<>]/.test(one));
|
|
181
|
+
if (odd !== undefined)
|
|
182
|
+
return broke(`"${odd}" contains < or >`);
|
|
183
|
+
const seen = new Set([title.trim().toLowerCase()]);
|
|
184
|
+
for (const one of titles) {
|
|
185
|
+
if (seen.has(one.toLowerCase()))
|
|
186
|
+
return broke(`"${one}" repeats a title`);
|
|
187
|
+
seen.add(one.toLowerCase());
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, value: titles };
|
|
190
|
+
}
|
|
142
191
|
function checkHashtags(raw) {
|
|
143
192
|
const broke = (rule) => ({
|
|
144
193
|
ok: false,
|
|
@@ -63,7 +63,8 @@ export function readChannelLinksFor(db, channelId) {
|
|
|
63
63
|
return channel.brand.links;
|
|
64
64
|
return channel.id === defaultChannelId ? readChannelLinks(db) : [];
|
|
65
65
|
}
|
|
66
|
-
// The description and
|
|
66
|
+
// The description, tags, pinned comment and other titles as the project page shows and copies
|
|
67
|
+
// them: the user's edits over
|
|
67
68
|
// the generated text, the chapters fitted to YouTube's rules (`chapters.ts`; the last one's
|
|
68
69
|
// length only checked when the video's length is given), placeholders filled from the
|
|
69
70
|
// project's and its channel's links (one with no link stays as typed). For anything that hands
|
|
@@ -71,7 +72,10 @@ export function readChannelLinksFor(db, channelId) {
|
|
|
71
72
|
// fitting changed.
|
|
72
73
|
export function effectiveDescription(db, projectId, generated) {
|
|
73
74
|
const edits = readDescriptionEdits(db, projectId);
|
|
74
|
-
const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags
|
|
75
|
+
const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags, {
|
|
76
|
+
pinnedComment: generated.pinnedComment,
|
|
77
|
+
titles: generated.titles,
|
|
78
|
+
}), edits.fields));
|
|
75
79
|
const fitted = fitChapters(shown.chapters, generated.durationSeconds);
|
|
76
80
|
const notice = chapterNotice(fitted.adjustments);
|
|
77
81
|
const links = mergeLinks(readChannelLinksFor(db, projectChannelId(db, projectId)), edits.links);
|
|
@@ -79,6 +83,8 @@ export function effectiveDescription(db, projectId, generated) {
|
|
|
79
83
|
description: fillPlaceholders(composeDescription({ ...shown, chapters: fitted.text }), links)
|
|
80
84
|
.text,
|
|
81
85
|
tags: fillPlaceholders(shown.tags, links).text,
|
|
86
|
+
pinnedComment: fillPlaceholders(shown.pinnedComment, links).text,
|
|
87
|
+
titles: shown.titles,
|
|
82
88
|
...(notice === undefined ? {} : { chapterNotice: notice }),
|
|
83
89
|
};
|
|
84
90
|
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
// Hand edits to the YouTube description. The project page lets the user edit the summary,
|
|
2
|
-
// the chapters, the hashtags and the
|
|
2
|
+
// the chapters, the hashtags, the tags and the pinned comment in place; each edit is saved with the generated
|
|
3
3
|
// text it was made from (`base`), so when the description is written again the page can tell
|
|
4
4
|
// a field the user changed from one they left alone. Browser-safe: the project page applies
|
|
5
5
|
// these rules to what it shows, and the server only stores the edits.
|
|
6
6
|
import { parseTimestamp } from "./timestamps.js";
|
|
7
|
-
export const descriptionFields = [
|
|
8
|
-
|
|
7
|
+
export const descriptionFields = [
|
|
8
|
+
"summary",
|
|
9
|
+
"chapters",
|
|
10
|
+
"hashtags",
|
|
11
|
+
"tags",
|
|
12
|
+
"pinnedComment",
|
|
13
|
+
"titles",
|
|
14
|
+
];
|
|
15
|
+
// Splits a written description back into its three parts, beside the tags, the pinned comment
|
|
16
|
+
// and the other titles (each empty for a description written before it existed). `assembleDescription` (answer.ts) lays it out as
|
|
9
17
|
// the summary, a blank line, one chapter per line, a blank line and the hashtags; reading it
|
|
10
18
|
// from the end keeps a summary with blank lines of its own whole.
|
|
11
|
-
export function splitDescription(description, tags) {
|
|
19
|
+
export function splitDescription(description, tags, extras = {}) {
|
|
12
20
|
const lines = description.replace(/\r\n?/gu, "\n").split("\n");
|
|
13
21
|
while (lines.length > 0 && lines.at(-1)?.trim() === "")
|
|
14
22
|
lines.pop();
|
|
@@ -29,6 +37,8 @@ export function splitDescription(description, tags) {
|
|
|
29
37
|
chapters: chapters.join("\n"),
|
|
30
38
|
hashtags,
|
|
31
39
|
tags: tags.trim(),
|
|
40
|
+
pinnedComment: (extras.pinnedComment ?? "").trim(),
|
|
41
|
+
titles: (extras.titles ?? "").trim(),
|
|
32
42
|
};
|
|
33
43
|
}
|
|
34
44
|
function isChapterLine(line) {
|
|
@@ -65,6 +75,8 @@ export function resolveFields(generated, edits) {
|
|
|
65
75
|
chapters: one("chapters"),
|
|
66
76
|
hashtags: one("hashtags"),
|
|
67
77
|
tags: one("tags"),
|
|
78
|
+
pinnedComment: one("pinnedComment"),
|
|
79
|
+
titles: one("titles"),
|
|
68
80
|
};
|
|
69
81
|
}
|
|
70
82
|
export function shownFields(resolved) {
|
|
@@ -73,6 +85,8 @@ export function shownFields(resolved) {
|
|
|
73
85
|
chapters: resolved.chapters.text,
|
|
74
86
|
hashtags: resolved.hashtags.text,
|
|
75
87
|
tags: resolved.tags.text,
|
|
88
|
+
pinnedComment: resolved.pinnedComment.text,
|
|
89
|
+
titles: resolved.titles.text,
|
|
76
90
|
};
|
|
77
91
|
}
|
|
78
92
|
// Saving an edit: text equal to the generated text is no edit at all, so the field follows the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The optional YouTube description step of the Video stage: a short summary, a chapter list
|
|
2
|
-
// YouTube turns into chapters, hashtags,
|
|
2
|
+
// YouTube turns into chapters, hashtags, a separate list for YouTube's Tags field, a comment
|
|
3
|
+
// to pin under the video, and two more titles for YouTube's title A/B test.
|
|
3
4
|
// Browser-safe: Play, Edit project and the project page read these names and limits too.
|
|
4
5
|
// YouTube's own rules for chapters in a description: the first starts at 0:00, there are at
|
|
5
6
|
// least three, and each lasts at least ten seconds.
|
|
@@ -12,6 +13,13 @@ export const tagMaxCharacters = 100;
|
|
|
12
13
|
export const hashtagsMax = 15;
|
|
13
14
|
// YouTube's limit on a description.
|
|
14
15
|
export const descriptionMaxCharacters = 5000;
|
|
16
|
+
// YouTube's title A/B test ("Test & compare") takes up to three titles: the video's own and
|
|
17
|
+
// these alternatives. YouTube's limit on a title.
|
|
18
|
+
export const alternativeTitles = 2;
|
|
19
|
+
export const titleMaxCharacters = 100;
|
|
20
|
+
// YouTube's limit on a comment is 10,000; a pinned comment people read in full is a line or
|
|
21
|
+
// two. The model is asked for 200; this is the most a hand-picked prompt may make it.
|
|
22
|
+
export const pinnedCommentMaxCharacters = 500;
|
|
15
23
|
// What a project uses when no Description prompt from the library is picked. No keywords,
|
|
16
24
|
// so it never asks Play for a field.
|
|
17
25
|
export const defaultDescriptionPrompt = [
|
|
@@ -57,7 +57,7 @@ Sizes are in millimetres (mm) unless marked pt (points; 1 pt is about 0.35 mm).
|
|
|
57
57
|
|
|
58
58
|
### Fonts
|
|
59
59
|
|
|
60
|
-
Each font has three controls: the family, its weight or italic, and **Letter spacing (mm)** from −0.5 to 2. The families are **Cinzel** (bundled; four weights, no italics) and **Times**, **Helvetica** and **Courier** (built into every PDF reader).
|
|
60
|
+
Each font has three controls: the family, its weight or italic, and **Letter spacing (mm)** from −0.5 to 2. The families are **Cinzel** (bundled; four weights, no italics), **Literata** (bundled; a text face made for long reading, in regular, bold, italic and bold italic) and **Times**, **Helvetica** and **Courier** (built into every PDF reader).
|
|
61
61
|
|
|
62
62
|
| Setting | Where it is used | Default |
|
|
63
63
|
|---|---|---|
|
|
@@ -19,7 +19,7 @@ From top to bottom:
|
|
|
19
19
|
| Destinations | **Home**, **Projects**, **Calendar** (also lit on Schedules), **Channels**, **Library** (Prompts, Intros & Outros, Templates, Document themes, Narration aliases), **Settings** (also lit on Usage). |
|
|
20
20
|
| **N running** | Shown while runs are going. It links to Running now on Home. |
|
|
21
21
|
| **Channel** | The channel picker (see below). |
|
|
22
|
-
| **New
|
|
22
|
+
| **New project** | Opens Play (`C` from anywhere). |
|
|
23
23
|
| Links | **GitHub**, **Patreon** and **Buy Me a Coffee**, each opening in a new tab. |
|
|
24
24
|
| Foot | "Free. Your keys, your machine.", the updates button (circular arrows, see [Updating and Patch Notes](Updating-and-Patch-Notes)) and the tutorial button (question mark, **Start interactive tutorial**). |
|
|
25
25
|
|
|
@@ -33,7 +33,7 @@ In every list, a press anywhere on a row or tile opens or picks it, not only on
|
|
|
33
33
|
|
|
34
34
|
### The command palette
|
|
35
35
|
|
|
36
|
-
Press `Ctrl+K`, or click **Search or run a command**, to jump anywhere or run an action by typing. The **Go to** group has **Open home**, **Open projects**, **Open calendar**, **Open schedules**, **Open channels**, **Open library**, **Open settings**, **Open usage and costs** and **Show patch notes**. **New
|
|
36
|
+
Press `Ctrl+K`, or click **Search or run a command**, to jump anywhere or run an action by typing. The **Go to** group has **Open home**, **Open projects**, **Open calendar**, **Open schedules**, **Open channels**, **Open library**, **Open settings**, **Open usage and costs** and **Show patch notes**. **New project** opens Play. The **Channel** group has **Show all channels** and **Switch to <channel>**. See [Keyboard Shortcuts and Command Palette](Keyboard-Shortcuts-and-Command-Palette).
|
|
37
37
|
|
|
38
38
|
### The Channel picker
|
|
39
39
|
|
|
@@ -43,7 +43,7 @@ The **Channel** picker near the foot of the sidebar (at the top of Home on a pho
|
|
|
43
43
|
|
|
44
44
|
**Where to find it:** the sidebar → **Home**.
|
|
45
45
|
|
|
46
|
-
The page title is the channel you picked (or **Home**), under today's date. At the top right are **Open calendar** and **New
|
|
46
|
+
The page title is the channel you picked (or **Home**), under today's date. At the top right are **Open calendar** and **New project**.
|
|
47
47
|
|
|
48
48
|
On a fresh install, Home sends you to the first-run screen once. See [First Launch and Welcome](First-Launch-and-Welcome).
|
|
49
49
|
|
|
@@ -98,7 +98,7 @@ See [Costs and Run Cost](Costs-and-Run-Cost).
|
|
|
98
98
|
|
|
99
99
|
**Where to find it:** the sidebar → **Projects**.
|
|
100
100
|
|
|
101
|
-
Every run ever started, newest first, for the channel picked in the sidebar. The heading says how many projects there are and for which channel. **New
|
|
101
|
+
Every run ever started, newest first, for the channel picked in the sidebar. The heading says how many projects there are and for which channel. **New project** opens Play.
|
|
102
102
|
|
|
103
103
|
### Search and filter
|
|
104
104
|
|
|
@@ -7,7 +7,7 @@ The command palette lets you reach every screen, every project and the frequent
|
|
|
7
7
|
## Use the command palette
|
|
8
8
|
|
|
9
9
|
1. Press `Ctrl+K`.
|
|
10
|
-
2. Type a few letters of what you want, such as `set` for **Open settings** or `back` for **Back up now**. Letters only need to appear in order, so `nwv` finds **New
|
|
10
|
+
2. Type a few letters of what you want, such as `set` for **Open settings** or `back` for **Back up now**. Letters only need to appear in order, so `nwv` finds **New project**. Commands also match other words, for example `keys` finds **Open settings**. Several words match in any order across the command's name, the project it acts on and its other words: `cleopatra regenerate image 3` finds **Regenerate image 3 in Cleopatra**.
|
|
11
11
|
3. Use `Up` and `Down` to move through the list, `Home` and `End` to jump to the first or last, and `Enter` to run the highlighted command. You can also click a command.
|
|
12
12
|
4. Press `Esc` (or `Ctrl+K` again) to close the palette without running anything.
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ Press `?` anywhere (outside a text field) to see **Keyboard shortcuts**: every k
|
|
|
23
23
|
|---|---|---|
|
|
24
24
|
| `Ctrl+K` / `Cmd+K` | Anywhere | Opens or closes the command palette. |
|
|
25
25
|
| `?` | Anywhere | Shows the keyboard shortcuts. |
|
|
26
|
-
| `C` | Anywhere | **New
|
|
26
|
+
| `C` | Anywhere | **New project** (opens Play). |
|
|
27
27
|
| `G` then `H` | Anywhere | Go to Home. |
|
|
28
28
|
| `G` then `P` | Anywhere | Go to Projects. |
|
|
29
29
|
| `G` then `C` | Anywhere | Go to the Calendar. |
|
|
@@ -70,7 +70,7 @@ Commands are grouped under headings in the palette. Group names below are the he
|
|
|
70
70
|
|
|
71
71
|
| Command | What it does |
|
|
72
72
|
|---|---|
|
|
73
|
-
| **New
|
|
73
|
+
| **New project** (`C`) | Opens Play. |
|
|
74
74
|
| **Show all channels** | Shows every channel's projects, schedules and costs. |
|
|
75
75
|
| **Switch to <channel>** | Shows one channel only. There is one command per channel. |
|
|
76
76
|
|