@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
package/dist/edge/http/studio.js
CHANGED
|
@@ -10,7 +10,7 @@ import { findDownload } from "../../slices/storage/downloads.js";
|
|
|
10
10
|
import { studioPlaylistMax } from "../../slices/studio/model.js";
|
|
11
11
|
import { packItem, uploadPack } from "../../slices/studio/pack.js";
|
|
12
12
|
import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
|
|
13
|
-
import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists,
|
|
13
|
+
import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
|
|
14
14
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
15
15
|
const id = z
|
|
16
16
|
.string()
|
|
@@ -30,11 +30,20 @@ const queueItemBody = z.object({
|
|
|
30
30
|
projectId: id,
|
|
31
31
|
short: z.number().int().min(1).max(99).nullable().optional(),
|
|
32
32
|
});
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const playlistsBody = z.object({
|
|
34
|
+
playlists: z
|
|
35
|
+
.array(z.object({
|
|
36
|
+
name: z.string().max(studioPlaylistMax * 2),
|
|
37
|
+
byDefault: z.boolean(),
|
|
38
|
+
}))
|
|
39
|
+
.max(studioPlaylistsMax),
|
|
40
|
+
// A channel's own list; absent is the default every other channel uses.
|
|
36
41
|
channelId: id.optional(),
|
|
37
42
|
});
|
|
43
|
+
// The names this project's uploads go into; null goes back to the channel's defaults.
|
|
44
|
+
const projectPlaylistsBody = z.object({
|
|
45
|
+
playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
|
|
46
|
+
});
|
|
38
47
|
const realFootageBody = z.object({ realFootage: z.boolean() });
|
|
39
48
|
// The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
|
|
40
49
|
const extensionFiles = {
|
|
@@ -89,32 +98,32 @@ export function studioRoutes(deps) {
|
|
|
89
98
|
if (denied !== undefined)
|
|
90
99
|
return denied;
|
|
91
100
|
return c.json({
|
|
92
|
-
|
|
101
|
+
playlists: readStudioPlaylists(deps.db),
|
|
93
102
|
channelPlaylists: readChannelPlaylists(deps.db),
|
|
94
103
|
pairing: studioPairing(deps.db),
|
|
95
104
|
});
|
|
96
105
|
})
|
|
97
|
-
.put("/settings/
|
|
106
|
+
.put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
|
|
98
107
|
const denied = samePage(c);
|
|
99
108
|
if (denied !== undefined)
|
|
100
109
|
return denied;
|
|
101
|
-
const {
|
|
110
|
+
const { playlists: raw, channelId } = c.req.valid("json");
|
|
102
111
|
if (channelId !== undefined && channelById(deps.db, channelId) === undefined)
|
|
103
112
|
return problem(c, {
|
|
104
113
|
status: 404,
|
|
105
114
|
title: titleOf(404),
|
|
106
|
-
detail: "The
|
|
115
|
+
detail: "The playlists weren't saved: that channel no longer exists. Reload Settings → YouTube Studio and pick the channel again.",
|
|
107
116
|
});
|
|
108
|
-
const invalid =
|
|
117
|
+
const invalid = studioPlaylistsProblem(raw);
|
|
109
118
|
if (invalid !== undefined)
|
|
110
119
|
return problem(c, {
|
|
111
120
|
status: 400,
|
|
112
121
|
title: titleOf(400),
|
|
113
122
|
detail: invalid,
|
|
114
|
-
extensions: { fields: [{ field: "
|
|
123
|
+
extensions: { fields: [{ field: "playlists", message: invalid }] },
|
|
115
124
|
});
|
|
116
125
|
return c.json({
|
|
117
|
-
|
|
126
|
+
playlists: saveStudioPlaylists(deps.db, raw, channelId),
|
|
118
127
|
channelId: channelId ?? null,
|
|
119
128
|
});
|
|
120
129
|
})
|
|
@@ -141,6 +150,18 @@ export function studioRoutes(deps) {
|
|
|
141
150
|
saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
|
|
142
151
|
const result = uploadPack(deps, projectId);
|
|
143
152
|
return result.ok ? c.json(result.pack) : unknownProject(c);
|
|
153
|
+
})
|
|
154
|
+
// Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
|
|
155
|
+
.put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
|
|
156
|
+
const denied = samePage(c);
|
|
157
|
+
if (denied !== undefined)
|
|
158
|
+
return denied;
|
|
159
|
+
const { projectId } = c.req.valid("param");
|
|
160
|
+
if (!uploadPack(deps, projectId).ok)
|
|
161
|
+
return unknownProject(c);
|
|
162
|
+
saveProjectPlaylists(deps.db, projectId, c.req.valid("json").playlists);
|
|
163
|
+
const result = uploadPack(deps, projectId);
|
|
164
|
+
return result.ok ? c.json(result.pack) : unknownProject(c);
|
|
144
165
|
})
|
|
145
166
|
.post("/packs/:projectId/choose", zValidator("param", projectParam, onInvalid), zValidator("json", chooseBody, onInvalid), (c) => {
|
|
146
167
|
const denied = samePage(c);
|
|
Binary file
|
|
Binary file
|
package/dist/main.js
CHANGED
|
@@ -419,7 +419,7 @@ export async function boot(config, options = {}) {
|
|
|
419
419
|
};
|
|
420
420
|
const scheduleRunnerDeps = {
|
|
421
421
|
...draftDeps,
|
|
422
|
-
template: (id
|
|
422
|
+
template: (id) => templateById(runtimeDb, id),
|
|
423
423
|
topicLlm: (call) => standaloneLlm(standalone, call),
|
|
424
424
|
topicsWaiting: (event) => {
|
|
425
425
|
hub.emitGlobal(event);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Slopify 3.0.6
|
|
2
|
+
|
|
3
|
+
Released 28 September 2026.
|
|
4
|
+
|
|
5
|
+
3.0.6 writes more of what YouTube asks for: a comment to pin and two more titles to test, alongside the description and tags. A channel can send its videos to several playlists, and schedules follow the latest version of their template.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- **A pinned comment** written with every description.
|
|
10
|
+
- **Two more titles** for YouTube's title A/B test.
|
|
11
|
+
- **Several playlists** per channel, ticked per project.
|
|
12
|
+
- **Schedules use the template as it is now.**
|
|
13
|
+
|
|
14
|
+
## YouTube description
|
|
15
|
+
|
|
16
|
+
- The description step also writes a comment to pin under the video: a thank-you, a question viewers can answer from their own experience, a pointer to the chapters and an ask for the next topic. It shows under Tags in the project's YouTube section with **Copy**, and **Edit** keeps your changes through a rewrite. Your Description prompt can say how you want it.
|
|
17
|
+
- The same step writes two **Other titles** for YouTube's A/B Testing, next to the title in Studio. Each takes a different angle on the same video, within YouTube's 100 characters. They show in the YouTube section and in Prepare upload's Title step, with **Copy others**.
|
|
18
|
+
- **Write again**, next to Description, writes the description, tags, pinned comment and titles anew after asking. It no longer takes a trip through Edit settings and a remake.
|
|
19
|
+
|
|
20
|
+
## Publishing
|
|
21
|
+
|
|
22
|
+
- Settings → YouTube Studio → **Playlists** is a list for each channel (or the default list for all channels). Each playlist is either **On by default** or not.
|
|
23
|
+
- Prepare upload's Playlist step has a tick for each playlist, saved for that project, and the Studio extension ticks every chosen playlist in Studio.
|
|
24
|
+
- **Select all** above Prepare upload's steps ticks them all, or clears them.
|
|
25
|
+
|
|
26
|
+
## Schedules
|
|
27
|
+
|
|
28
|
+
- A schedule runs, previews and checks its topics against its template as it is now. It used to keep the template version it was made with, so edits to the template never reached its runs, and the Calendar showed the template's old name.
|
|
29
|
+
|
|
30
|
+
## Upgrading from 3.0.5
|
|
31
|
+
|
|
32
|
+
- **Nothing becomes outdated.** A description written before shows no pinned comment or other titles until you press **Write again**.
|
|
33
|
+
- A playlist you set before becomes the first entry of the channel's list, on by default.
|
|
34
|
+
- Update the Studio extension to have it tick more than one playlist. An older extension still ticks the first.
|
|
@@ -68,6 +68,8 @@ export function legacyOutputWorkKey(output, config) {
|
|
|
68
68
|
return "document:pdf";
|
|
69
69
|
case "youtube_description":
|
|
70
70
|
case "youtube_tags":
|
|
71
|
+
case "youtube_pinned_comment":
|
|
72
|
+
case "youtube_titles":
|
|
71
73
|
return "youtube:description";
|
|
72
74
|
case "shorts":
|
|
73
75
|
return "shorts:pick";
|
|
@@ -8,7 +8,7 @@ import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
|
|
|
8
8
|
import { savedCatalogue } from "./runtime-plan.js";
|
|
9
9
|
import { preparedTexts, publishResult } from "./runtime-publication.js";
|
|
10
10
|
import { wordsSchema } from "./runtime-subtitles.js";
|
|
11
|
-
// Writes the YouTube description and
|
|
11
|
+
// Writes the YouTube description, tags, pinned comment and other titles from the saved word timing: the transcript's
|
|
12
12
|
// passages carry their start in the final video, so the chapters the model picks are real
|
|
13
13
|
// times. An answer that breaks YouTube's chapter or tag rules is a failed attempt, which the
|
|
14
14
|
// provider wrapper asks again while attempts remain.
|
|
@@ -66,21 +66,32 @@ export async function executeYoutubeRecipe(deps, context, providers, piece) {
|
|
|
66
66
|
webSearch: false,
|
|
67
67
|
previewLabel: piece.key,
|
|
68
68
|
check: (value) => {
|
|
69
|
-
const checked = checkDescriptionAnswer(value.text, durationSeconds);
|
|
69
|
+
const checked = checkDescriptionAnswer(value.text, durationSeconds, config.title);
|
|
70
70
|
return checked.ok ? undefined : checked.reason;
|
|
71
71
|
},
|
|
72
72
|
});
|
|
73
73
|
if (!answer.ok)
|
|
74
74
|
return "held";
|
|
75
|
-
const checked = checkDescriptionAnswer(answer.value.text, durationSeconds);
|
|
75
|
+
const checked = checkDescriptionAnswer(answer.value.text, durationSeconds, config.title);
|
|
76
76
|
if (!checked.ok)
|
|
77
77
|
throw new Error(checked.reason);
|
|
78
78
|
const description = assembleDescription(checked.value);
|
|
79
79
|
const tags = tagsText(checked.value.tags);
|
|
80
|
+
const pinnedComment = checked.value.pinnedComment;
|
|
81
|
+
const titles = checked.value.titles.join("\n");
|
|
80
82
|
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
81
83
|
["youtube_description", "description.txt", description],
|
|
82
84
|
["youtube_tags", "tags.txt", tags],
|
|
83
|
-
|
|
85
|
+
["youtube_pinned_comment", "pinned-comment.txt", pinnedComment],
|
|
86
|
+
["youtube_titles", "titles.txt", titles],
|
|
87
|
+
]), {
|
|
88
|
+
description,
|
|
89
|
+
tags: checked.value.tags,
|
|
90
|
+
chapters: checked.value.chapters,
|
|
91
|
+
pinnedComment,
|
|
92
|
+
titles: checked.value.titles,
|
|
93
|
+
durationSeconds,
|
|
94
|
+
});
|
|
84
95
|
deps.count?.("stage.completed", {
|
|
85
96
|
provider: llm.provider,
|
|
86
97
|
model: llm.model,
|
|
@@ -52,7 +52,7 @@ function upcomingRuns(deps, schedule, now, from, to) {
|
|
|
52
52
|
: paused
|
|
53
53
|
? nextOccurrence(schedule.cadence, schedule.timezone, now)
|
|
54
54
|
: new Date(schedule.nextRunAt);
|
|
55
|
-
const template = deps.template(schedule.templateId
|
|
55
|
+
const template = deps.template(schedule.templateId);
|
|
56
56
|
const templateName = template?.name ?? null;
|
|
57
57
|
const form = template?.document.form;
|
|
58
58
|
const { items } = schedule;
|
|
@@ -133,12 +133,13 @@ function noTopicReason(schedule) {
|
|
|
133
133
|
async function execute(deps, claimed) {
|
|
134
134
|
let run = claimed.run;
|
|
135
135
|
try {
|
|
136
|
-
|
|
136
|
+
// The template as it is now, so an edit to it reaches the next run.
|
|
137
|
+
const template = deps.template(claimed.schedule.templateId);
|
|
137
138
|
if (template === undefined)
|
|
138
139
|
throw new ScheduleDispatchError("missing-template");
|
|
139
140
|
const document = freshTemplateDraft(deps, template.document, {
|
|
140
141
|
id: claimed.schedule.templateId,
|
|
141
|
-
version:
|
|
142
|
+
version: template.version,
|
|
142
143
|
});
|
|
143
144
|
const topic = claimed.schedule.items[0];
|
|
144
145
|
const fresh = {
|
|
@@ -35,7 +35,7 @@ export function createSchedule(deps, input) {
|
|
|
35
35
|
? { ok: true, value: scheduleById(deps.db, parsed.data.id) }
|
|
36
36
|
: { ok: false, reason: "conflict" };
|
|
37
37
|
}
|
|
38
|
-
const checked = checkTemplate(deps, parsed.data.templateId
|
|
38
|
+
const checked = checkTemplate(deps, parsed.data.templateId);
|
|
39
39
|
if (!checked.ok)
|
|
40
40
|
return checked;
|
|
41
41
|
const topics = checkTopics(deps, parsed.data, []);
|
|
@@ -45,7 +45,8 @@ export function createSchedule(deps, input) {
|
|
|
45
45
|
const next = nextOccurrence(parsed.data.cadence, parsed.data.timezone, now);
|
|
46
46
|
if (next === null)
|
|
47
47
|
return { ok: false, reason: "not-due" };
|
|
48
|
-
|
|
48
|
+
// The version it was saved against, for the record; runs use the newest.
|
|
49
|
+
const value = summary({ ...parsed.data, templateVersion: checked.value }, next.toISOString(), now.toISOString());
|
|
49
50
|
insertSchedule(deps.db, value, hash);
|
|
50
51
|
return { ok: true, value };
|
|
51
52
|
});
|
|
@@ -77,7 +78,7 @@ export function updateSchedule(deps, input) {
|
|
|
77
78
|
return { ok: false, reason: "conflict" };
|
|
78
79
|
if (previous.status === "canceled" || previous.status === "completed")
|
|
79
80
|
return { ok: false, reason: "conflict" };
|
|
80
|
-
const checked = checkTemplate(deps, parsed.data.templateId
|
|
81
|
+
const checked = checkTemplate(deps, parsed.data.templateId);
|
|
81
82
|
if (!checked.ok)
|
|
82
83
|
return checked;
|
|
83
84
|
const topics = checkTopics(deps, parsed.data, previous.items);
|
|
@@ -155,8 +156,9 @@ function control(deps, input, status) {
|
|
|
155
156
|
return { ok: false, reason: "conflict" };
|
|
156
157
|
return { ok: true, value: scheduleById(deps.db, previous.id) };
|
|
157
158
|
}
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
// Answers the template's newest version when a schedule may use it.
|
|
160
|
+
function checkTemplate(deps, id) {
|
|
161
|
+
const template = deps.template(id);
|
|
160
162
|
if (!template)
|
|
161
163
|
return { ok: false, reason: "missing-template" };
|
|
162
164
|
const sources = template.document.form.sources;
|
|
@@ -169,13 +171,13 @@ function checkTemplate(deps, id, version) {
|
|
|
169
171
|
form.ambientBed?.source === "upload" ||
|
|
170
172
|
(sources.images === "generate" && form.reference?.source === "provide"))
|
|
171
173
|
return { ok: false, reason: "unsupported-media" };
|
|
172
|
-
return { ok: true, value:
|
|
174
|
+
return { ok: true, value: template.version };
|
|
173
175
|
}
|
|
174
176
|
// The queue's keywords must be the template's: a topic (or an every-run value) naming a keyword
|
|
175
177
|
// the template doesn't use would silently do nothing. Topics saved before, unchanged, are left
|
|
176
178
|
// alone, so a queue from before this check still saves.
|
|
177
179
|
function checkTopics(deps, input, kept) {
|
|
178
|
-
const template = deps.template(input.templateId
|
|
180
|
+
const template = deps.template(input.templateId);
|
|
179
181
|
if (!template)
|
|
180
182
|
return { ok: false, reason: "missing-template" };
|
|
181
183
|
const keywords = templateKeywords(template.document.form);
|
|
@@ -101,7 +101,7 @@ async function ask(deps, schedule, options) {
|
|
|
101
101
|
.run(failedAt.toISOString(), text, schedule.id);
|
|
102
102
|
return { ok: false, reason: "failed", message: text };
|
|
103
103
|
};
|
|
104
|
-
const template = deps.template(schedule.templateId
|
|
104
|
+
const template = deps.template(schedule.templateId);
|
|
105
105
|
const llm = schedule.topicGeneration.llm ?? template?.document.form.llm;
|
|
106
106
|
if (llm === undefined)
|
|
107
107
|
return fail("the template this schedule uses was deleted, so there is no LLM to ask. Edit the schedule and choose a template, or pick an LLM under Topic generation.");
|
|
@@ -394,7 +394,7 @@ export function editHeldTopic(deps, scheduleId, topicId, input) {
|
|
|
394
394
|
const schedule = scheduleById(deps.db, scheduleId);
|
|
395
395
|
if (schedule === undefined)
|
|
396
396
|
return { ok: false, reason: "not-found" };
|
|
397
|
-
const template = deps.template(schedule.templateId
|
|
397
|
+
const template = deps.template(schedule.templateId);
|
|
398
398
|
if (template === undefined)
|
|
399
399
|
return { ok: false, reason: "missing-template" };
|
|
400
400
|
const problems = topicRowProblems([{ title: parsed.data, values }], {
|
|
@@ -463,7 +463,7 @@ export function replaceTopics(deps, scheduleId, input) {
|
|
|
463
463
|
const schedule = open.value;
|
|
464
464
|
if (schedule.version !== parsed.data.baseVersion)
|
|
465
465
|
return { ok: false, reason: "conflict" };
|
|
466
|
-
const template = deps.template(schedule.templateId
|
|
466
|
+
const template = deps.template(schedule.templateId);
|
|
467
467
|
if (!template)
|
|
468
468
|
return { ok: false, reason: "missing-template" };
|
|
469
469
|
const problems = topicRowProblems(parsed.data.items, {
|
|
@@ -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
|
};
|