@gentbajko/slopify 3.0.4 → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edge/http/projects.js +7 -1
- 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.5.md +30 -0
- package/dist/patch-notes/3.0.6.md +34 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/rebuild/activity.js +58 -0
- package/dist/slices/rebuild/recipe-legacy.js +2 -0
- package/dist/slices/rebuild/runtime-youtube.js +15 -4
- package/dist/slices/run-cost/panel.js +6 -23
- package/dist/slices/run-cost/timing.js +76 -0
- package/dist/slices/schedules/agenda.js +1 -1
- package/dist/slices/schedules/scheduler.js +3 -2
- package/dist/slices/schedules/service.js +9 -7
- 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 +8 -1
- package/dist/tutorials/Costs-and-Run-Cost.md +3 -3
- package/dist/tutorials/Project-Page.md +1 -1
- package/dist/tutorials/Publishing-to-YouTube.md +4 -4
- 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-CskH1GRY.js +310 -0
- package/dist/web/assets/{pdf-BEwm41kk.js → pdf-C5xctS6p.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-haZKPEuq.js +0 -307
|
@@ -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: thank the viewer in one line, ask one question a viewer can answer from their own experience of the subject, point to the chapters in the description, and end by inviting a suggestion for the next video. Two to four short paragraphs, in the same voice as the summary.",
|
|
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,12 @@ 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 far shorter.
|
|
21
|
+
export const pinnedCommentMaxCharacters = 1500;
|
|
15
22
|
// What a project uses when no Description prompt from the library is picked. No keywords,
|
|
16
23
|
// so it never asks Play for a field.
|
|
17
24
|
export const defaultDescriptionPrompt = [
|
|
@@ -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
|
|
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 cancelled 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
|
|
|
@@ -65,7 +65,7 @@ If nothing has been spent yet, it says "Nothing has been spent yet. The cost of
|
|
|
65
65
|
| **paid to providers** | The total cost of this project's calls. Reads "known cost, paid to providers" when some calls have no price, with a line "Plus N calls the model catalogue has no price for." |
|
|
66
66
|
| **same work via API** | The API equivalent of the calls that ran on a CLI plan. |
|
|
67
67
|
| Plan meters | One per plan window a CLI reported, for example "3% of weekly Codex limit". Hover or read the line below for the level it is at now. |
|
|
68
|
-
| **
|
|
68
|
+
| **this run's working time** | How long some step of the run was working. Waiting on a review, a limit or a pause never counts, and steps that ran side by side count once. Each save that remakes something starts a new run. |
|
|
69
69
|
|
|
70
70
|
Lines under the summary:
|
|
71
71
|
|
|
@@ -89,7 +89,7 @@ Lines under the summary:
|
|
|
89
89
|
|
|
90
90
|
**Cost by model**: the same spending split by provider and model, with **Cost**, **Via API**, **Usage** and **Calls**. A CLI model shows the API model it was priced as, in brackets after "as". The CLI's own default model shows as "default model".
|
|
91
91
|
|
|
92
|
-
The last lines give the total usage and
|
|
92
|
+
The last lines give the total usage and the working time over every run, and "Priced from the model catalogue of <date> when each call finished."
|
|
93
93
|
|
|
94
94
|
## Across all projects: Home → This week
|
|
95
95
|
|
|
@@ -20,7 +20,7 @@ The page opens where the next action points: the failed step, the held review or
|
|
|
20
20
|
|
|
21
21
|
### The run cost line
|
|
22
22
|
|
|
23
|
-
When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min
|
|
23
|
+
When a run has ended (done, done with problems, failed or cancelled), a line under the title sums it up, for example `This run cost $0.42 · ~$3.10 via API · 12 min of work`: only the time Slopify was working, never the time waiting on you, a review or a limit. A run that ended early says **Spent so far** instead, and calls without a known price add "plus unpriced calls". **See cost by stage** opens the **Cost** section. The line is hidden while the run is going and when it spent nothing. While the run is going, a clock sits there instead: `Working for 9 min 30 s`, ticking while a step runs; while it is paused or waits on you it holds still and reads `9 min 30 s of work so far`. See [Costs and Run Cost](Costs-and-Run-Cost).
|
|
24
24
|
|
|
25
25
|
### Time left
|
|
26
26
|
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Slopify never uploads or publishes for you: there is no YouTube sign-in and no upload API. Instead, **Prepare upload** lists everything YouTube Studio asks for, in the order it asks, with a Copy button for each value, for the long video and for every short. You upload in Studio in your own browser and press Publish yourself. The optional [Studio-Extension](Studio-Extension) can fill Studio's upload dialog from the same list.
|
|
4
4
|
|
|
5
|
-
**Where to find it:** a finished project → **Prepare upload** (the next action, and in the **YouTube** section), Home → **Ready to upload** → **Prepare upload**, or the Calendar's **Needs you** → **Prepare upload**. Settings → **YouTube Studio** holds the
|
|
5
|
+
**Where to find it:** a finished project → **Prepare upload** (the next action, and in the **YouTube** section), Home → **Ready to upload** → **Prepare upload**, or the Calendar's **Needs you** → **Prepare upload**. Settings → **YouTube Studio** holds the playlists, and each channel's **Brand** tab holds the links it uses.
|
|
6
6
|
|
|
7
7
|
## Before your first upload
|
|
8
8
|
|
|
9
|
-
1. Open **Settings → YouTube Studio** and type
|
|
9
|
+
1. Open **Settings → YouTube Studio**, press **Add playlist** and type each playlist exactly as it is called in Studio (up to 150 characters). Tick **On by default** for the ones every video goes into, then press **Save playlists**. The list is the default for every channel; pick a channel above it to give that channel its own list.
|
|
10
10
|
2. Open **Channels** → your channel → **Brand** and, under **Channel links**, add any links your descriptions use, such as `Patreon` or `Discord`. See [Channels](Channels#channel-links).
|
|
11
11
|
3. In **Settings → Voices**, turn on **Real person** for any voice cloned from, or made to sound like, a real person.
|
|
12
12
|
4. In **Library → Prompts**, turn on **Draws photorealistic pictures** for any Image prompt whose style looks like real photos or film.
|
|
@@ -27,7 +27,7 @@ Steps 3 and 4 decide the AI use answer; see [AI use disclosure](#ai-use-disclosu
|
|
|
27
27
|
| **Title** | The project's title (up to 100 characters), or the short's title. **Copy**. |
|
|
28
28
|
| **Description** | The description with chapters and hashtags, links filled in. For a short: its description, the "Watch the full video:" line and its hashtags. **Copy**. |
|
|
29
29
|
| **Thumbnail** | The thumbnail file, with **Open folder**. With three thumbnails: "The first under Thumbnail; all 3 in A/B Testing (beside the title)." Shorts have no thumbnail step, since Studio shows a frame of a short. |
|
|
30
|
-
| **Playlist** | The project's channel's
|
|
30
|
+
| **Playlist** | The project's channel's playlists from Settings → YouTube Studio (or the default list), or "None set". With more than one, each has a tick: tick the ones this project goes into (saved for this project; the others keep the defaults). **Copy**. |
|
|
31
31
|
| **Audience** | "No, it's not made for kids". |
|
|
32
32
|
| **AI use (under Show more)** | **Yes** or **No**, with the reason. |
|
|
33
33
|
| **Tags (under Show more)** | The tags, comma-separated. For a short, its hashtags without the `#`. **Copy**. |
|
|
@@ -119,7 +119,7 @@ Slopify does not check YouTube, so it can't know when you've published. After yo
|
|
|
119
119
|
|
|
120
120
|
## Tips
|
|
121
121
|
|
|
122
|
-
- Set the
|
|
122
|
+
- Set the playlists once in Settings (per channel if your channels upload to different playlists), and tick the few a single video joins in its Prepare upload.
|
|
123
123
|
- Upload the long video first, then paste its link as the shorts' **Full video link** so each short's description points to it.
|
|
124
124
|
- If the AI use answer surprises you, read its reason: it names the voice, footage or prompt that made it Yes.
|
|
125
125
|
|
|
@@ -15,7 +15,7 @@ Every section of Settings, what each control does and its default. Sections with
|
|
|
15
15
|
| [Playback & appearance](#playback--appearance) | Silence gap, light or dark, volume levelling | This page |
|
|
16
16
|
| [Notifications](#notifications) | Browser and phone notifications | [Notifications](Notifications) |
|
|
17
17
|
| [Channel links](#channel-links) | A pointer to each channel's own links | [Channels](Channels#channel-links) |
|
|
18
|
-
| [YouTube Studio](#youtube-studio) |
|
|
18
|
+
| [YouTube Studio](#youtube-studio) | Playlists per channel, the Studio extension's download and pairing | [Studio-Extension](Studio-Extension) |
|
|
19
19
|
| [Backup & storage](#backup--storage) | Your files folder, export and import, disk space, samples | [Where-Your-Files-Live](Where-Your-Files-Live) |
|
|
20
20
|
| [Backups](#backups) | The daily automatic backup | [Backups](Backups) |
|
|
21
21
|
| [Trash](#trash) | Deleted items, kept 30 days | [Trash](Trash) |
|
|
@@ -140,12 +140,12 @@ Each channel now keeps its own links, on its **Brand** tab under **Channel links
|
|
|
140
140
|
|
|
141
141
|
## YouTube Studio
|
|
142
142
|
|
|
143
|
-
"The
|
|
143
|
+
"The playlists upload packs offer, and the Studio extension's pairing."
|
|
144
144
|
|
|
145
145
|
| Control | What it does | Default / limit |
|
|
146
146
|
|---|---|---|
|
|
147
147
|
| **Upload pack and extension** | Explains that Slopify never uploads or publishes: **Prepare upload** on a finished project lists everything Studio asks for, and the optional extension fills Studio's upload dialog from it. | |
|
|
148
|
-
| **
|
|
148
|
+
| **Playlists** | The YouTube playlists upload packs offer, each typed exactly as it is called in Studio. Pick **Every channel (default)** or one channel first: the default list is used by every channel without its own. **Add playlist** adds a row; **On by default** puts every project into it; **Remove** drops it; **Save playlists** stores the list. A project's **Prepare upload** changes which ones it goes into. | None; up to 20, each up to 150 characters |
|
|
149
149
|
| **Extension pairing token** | A secret the Slopify Studio extension needs before it may read your upload packs. **Copy** it into the extension's options and press Pair there. | |
|
|
150
150
|
| **New pairing token** | Makes a fresh token, unpairs the extension until you paste the new one, and clears the uploads waiting for Studio. | |
|
|
151
151
|
| **Install the Studio extension** | **Chrome, Edge, Brave** or **Firefox**, then **Download for Chrome** or **Download for Firefox**, with the three install steps. | |
|
|
@@ -58,7 +58,7 @@ The section is headed **Upload pack and extension**.
|
|
|
58
58
|
|
|
59
59
|
| Option | What it does | Default / range |
|
|
60
60
|
|---|---|---|
|
|
61
|
-
| **
|
|
61
|
+
| **Playlists** | The YouTube playlists upload packs offer, so the extension ticks them in Studio's dialog. Pick **Every channel (default)** or one channel, **Add playlist** for each, type it exactly as it is called in Studio, tick **On by default** for the ones every video joins, and press **Save playlists**. A channel's own list wins for its videos; Prepare upload changes the ticks for one project. | None; up to 20, each up to 150 characters |
|
|
62
62
|
| **Extension pairing token** | The secret the extension needs before it may read your upload packs. **Copy** copies it; **New pairing token** makes a new one and unpairs the extension. | Made for you |
|
|
63
63
|
| **Install the Studio extension** | The download and install steps (see [Get the extension](#get-the-extension)). | |
|
|
64
64
|
|
|
@@ -89,7 +89,7 @@ Each press of **Fill in YouTube Studio** adds the item to **Waiting for Studio**
|
|
|
89
89
|
| **Title** | Types the title (up to 100 characters for the video, the short's own title for a short). |
|
|
90
90
|
| **Description** | Types the description line by line, so line breaks are kept. |
|
|
91
91
|
| **Thumbnail** | Sets the first thumbnail. With three thumbnails, it looks for Studio's **A/B Testing** button beside the title and puts all three in. If the button isn't there or its dialog can't be filled, it sets the first and asks you to add the others in A/B Testing by hand. Shorts get no thumbnail. |
|
|
92
|
-
| **Playlists** | Opens the dropdown, ticks
|
|
92
|
+
| **Playlists** | Opens the dropdown, ticks every playlist the project goes into (Settings → YouTube Studio, and the project's own ticks in Prepare upload), and closes it. |
|
|
93
93
|
| **Audience** | Picks "No, it's not made for kids". |
|
|
94
94
|
| **Show more** | Presses it once, to reach the advanced settings. |
|
|
95
95
|
| **AI use** | Picks Yes or No, as Prepare upload worked it out. See [AI use disclosure](Publishing-to-YouTube#ai-use-disclosure). |
|