@gentbajko/slopify 3.0.5 → 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/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/index.json +6 -0
- 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 +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/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-CkHBkfTB.js → pdf-C5xctS6p.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-QDLAjCSW.js +0 -307
|
@@ -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 = [
|
|
@@ -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). |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Slopify can write your video's YouTube description for you: a short summary, chapters at the narration's real times, hashtags, and a separate list of search tags. Each part has a Copy button and can be edited in place, and your edits survive when the description is written again.
|
|
4
4
|
|
|
5
|
-
**Where to find it:** Play → **Outputs** row → **YouTube description**. On a finished project: the **YouTube** section (Description and
|
|
5
|
+
**Where to find it:** Play → **Outputs** row → **YouTube description**. On a finished project: the **YouTube** section (Description, Tags, Pinned comment and Other titles), and Edit project → **Prompts**.
|
|
6
6
|
|
|
7
7
|
## What you get
|
|
8
8
|
|
|
@@ -12,8 +12,10 @@ Slopify can write your video's YouTube description for you: a short summary, cha
|
|
|
12
12
|
| **Chapters** | Timestamped lines such as `0:00 Introduction`, at the narration's real times. YouTube turns them into chapters. |
|
|
13
13
|
| **Hashtags** | The hashtags at the end of the description. YouTube shows the first three above the title. |
|
|
14
14
|
| **Tags** | Search tags for YouTube Studio's **Tags** field, kept separate from the description. Viewers don't see them. |
|
|
15
|
+
| **Pinned comment** | A comment to post under the video and pin: a thank-you, a question viewers can answer from their own experience, a pointer to the chapters and an ask for the next topic. Your Description prompt can say how you want it. |
|
|
16
|
+
| **Other titles** | Two more titles for YouTube's **A/B Testing** (Test & compare), beside the project's own title: each a different angle on the same video, one per line. |
|
|
15
17
|
|
|
16
|
-
Slopify lays out the description itself (summary, blank line, one chapter per line, blank line, hashtags), so the layout YouTube needs never depends on the model's spacing. The files are saved in the project as `description.txt` and `
|
|
18
|
+
Slopify lays out the description itself (summary, blank line, one chapter per line, blank line, hashtags), so the layout YouTube needs never depends on the model's spacing. The files are saved in the project as `description.txt`, `tags.txt`, `pinned-comment.txt` and `titles.txt`.
|
|
17
19
|
|
|
18
20
|
## Turn it on
|
|
19
21
|
|
|
@@ -42,20 +44,22 @@ To change the tone or add your own links, write a prompt of the kind **YouTube D
|
|
|
42
44
|
|
|
43
45
|
## Read and copy it
|
|
44
46
|
|
|
45
|
-
When the step has run, the project page's **YouTube** section shows
|
|
47
|
+
When the step has run, the project page's **YouTube** section shows four parts. A finished project with a written description opens on this section, so copying it is one press:
|
|
46
48
|
|
|
47
49
|
- **Description**, with the Summary, Chapters and Hashtags, a character count such as `1,240 / 5000 characters`, and **Copy**.
|
|
48
50
|
- **Tags**, shown as chips, with a count against YouTube's 500-character limit and **Copy**.
|
|
51
|
+
- **Pinned comment**, below the tags, with **Copy**. YouTube pins comments only on a published video: once it is live, post the comment from your channel and choose **Pin** in its menu.
|
|
52
|
+
- **Other titles**, below the pinned comment, with **Copy**. In Studio, press **A/B Testing** beside the title and add them next to the video's own title. Prepare upload lists them in its Title step with **Copy others**.
|
|
49
53
|
|
|
50
54
|
**Copy** on Description copies the whole description, placeholders filled in. **Copy** on Tags copies them comma-separated, ready to paste. A count over YouTube's limit turns red and says "over YouTube's limit".
|
|
51
55
|
|
|
52
|
-
You can also run **Copy description** (`Shift+D`) and **Copy
|
|
56
|
+
You can also run **Copy description** (`Shift+D`), **Copy tags** and **Copy pinned comment** from the command palette (`Ctrl+K`) while the project is open. The **YouTube description (.txt)** and **YouTube tags (.txt)** downloads are the text exactly as the page shows it: your edits, the fitted chapters and the filled links. See [Keyboard-Shortcuts-and-Command-Palette](Keyboard-Shortcuts-and-Command-Palette).
|
|
53
57
|
|
|
54
58
|
While the stage is running the parts say "Written after the subtitle timing."; before it starts they say "Not written yet. It is made with the video."
|
|
55
59
|
|
|
56
60
|
## Edit a part
|
|
57
61
|
|
|
58
|
-
1. In the project's YouTube section, press **Edit** beside Summary, Chapters, Hashtags or
|
|
62
|
+
1. In the project's YouTube section, press **Edit** beside Summary, Chapters, Hashtags, Tags, Pinned comment or Other titles.
|
|
59
63
|
2. Change the text and save.
|
|
60
64
|
|
|
61
65
|
Your text is now marked **Your edit**. It is kept as yours:
|
|
@@ -103,6 +107,10 @@ Because placeholders are filled when shown or copied, changing a channel's link
|
|
|
103
107
|
|
|
104
108
|
## Write it again, or turn it on later
|
|
105
109
|
|
|
110
|
+
To write a description again, press **Write again** beside **Description** in the project's YouTube section, then confirm. Only this step runs. A description written before pinned comments and other titles existed shows neither until it is written again this way.
|
|
111
|
+
|
|
112
|
+
To turn it on, or to change its prompt first:
|
|
113
|
+
|
|
106
114
|
1. Open the project and press **Edit project**.
|
|
107
115
|
2. Open **Prompts**. Tick **YouTube description** if it was off, and pick a **Description prompt**.
|
|
108
116
|
3. To replace a description that is already written, press **Write the description again after review**. **Keep the current description** cancels it.
|