@gentbajko/slopify 3.2.7 → 3.2.9
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 +123 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0045-youtube-videos.sql +15 -0
- package/dist/patch-notes/3.2.8.md +20 -0
- package/dist/patch-notes/3.2.9.md +15 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/model.js +4 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/rebuild/recipe-appearance.js +2 -1
- package/dist/slices/rebuild/recipe-reference.js +2 -2
- package/dist/slices/rebuild/recipe-scenes.js +3 -3
- package/dist/slices/rebuild/recipe-shorts.js +3 -2
- package/dist/slices/rebuild/recipe-text.js +2 -1
- package/dist/slices/rebuild/recipe-visual.js +2 -2
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/subject.js +10 -0
- package/dist/slices/storage/backup-format.js +2 -0
- package/dist/slices/studio/pack.js +23 -6
- package/dist/slices/studio/pick.js +32 -0
- package/dist/slices/studio/videos.js +60 -0
- package/dist/web/assets/{index-CcJ6VLvA.css → index-DaTBqaK8.css} +1 -1
- package/dist/web/assets/index-hUEQRe6s.js +309 -0
- package/dist/web/assets/{pdf-1QQn6_Al.js → pdf-C1Iq6van.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B2AyG09c.js +0 -309
package/dist/edge/http/studio.js
CHANGED
|
@@ -9,8 +9,10 @@ import { channelById } from "../../slices/channels/repo.js";
|
|
|
9
9
|
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
|
+
import { writeUploadPick } from "../../slices/studio/pick.js";
|
|
12
13
|
import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
|
|
13
14
|
import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
|
|
15
|
+
import { forgetVideo, projectVideos, recordVideo, setAbState, videoIdOf, videoIdPattern, waitingAbTests, } from "../../slices/studio/videos.js";
|
|
14
16
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
15
17
|
const id = z
|
|
16
18
|
.string()
|
|
@@ -45,6 +47,24 @@ const projectPlaylistsBody = z.object({
|
|
|
45
47
|
playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
|
|
46
48
|
});
|
|
47
49
|
const realFootageBody = z.object({ realFootage: z.boolean() });
|
|
50
|
+
const pickBody = z.object({
|
|
51
|
+
title: z.number().int().min(0).max(9),
|
|
52
|
+
thumbnail: z.number().int().min(0).max(9),
|
|
53
|
+
});
|
|
54
|
+
const shortField = z.number().int().min(1).max(99).nullable().optional();
|
|
55
|
+
const extVideoBody = z.object({
|
|
56
|
+
projectId: id,
|
|
57
|
+
short: shortField,
|
|
58
|
+
videoId: z.string().regex(videoIdPattern),
|
|
59
|
+
});
|
|
60
|
+
const abResultBody = z.object({
|
|
61
|
+
projectId: id,
|
|
62
|
+
short: shortField,
|
|
63
|
+
ok: z.boolean(),
|
|
64
|
+
message: z.string().max(2000),
|
|
65
|
+
});
|
|
66
|
+
const videoLinkBody = z.object({ short: shortField, link: z.string().max(500) });
|
|
67
|
+
const abTestBody = z.object({ short: shortField, start: z.boolean() });
|
|
48
68
|
// The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
|
|
49
69
|
const extensionFiles = {
|
|
50
70
|
"chrome.zip": "slopify-studio-chrome.zip",
|
|
@@ -91,6 +111,13 @@ export function studioRoutes(deps) {
|
|
|
91
111
|
title: "Unauthorized",
|
|
92
112
|
detail: "The Slopify Studio extension isn't paired with this Slopify. Copy the pairing token from Slopify's Settings → YouTube Studio into the extension's options and press Pair.",
|
|
93
113
|
});
|
|
114
|
+
const extAllowed = (c) => studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin"));
|
|
115
|
+
// An upload has an A/B test to run when it has other titles or more than one thumbnail.
|
|
116
|
+
const abTestable = (projectId, short) => {
|
|
117
|
+
const result = uploadPack(deps, projectId);
|
|
118
|
+
const item = result.ok ? packItem(result.pack, short ?? undefined) : undefined;
|
|
119
|
+
return item !== undefined && (item.titles.length > 0 || item.thumbnails.length > 1);
|
|
120
|
+
};
|
|
94
121
|
return (new Hono()
|
|
95
122
|
.get("/settings", (c) => {
|
|
96
123
|
// The token is read only by the Slopify page, never by another origin's.
|
|
@@ -150,6 +177,19 @@ export function studioRoutes(deps) {
|
|
|
150
177
|
saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
|
|
151
178
|
const result = uploadPack(deps, projectId);
|
|
152
179
|
return result.ok ? c.json(result.pack) : unknownProject(c);
|
|
180
|
+
})
|
|
181
|
+
// Prepare upload's pick: which title and thumbnail the upload carries; the A/B test tries
|
|
182
|
+
// the others once the video is public.
|
|
183
|
+
.put("/packs/:projectId/pick", zValidator("param", projectParam, onInvalid), zValidator("json", pickBody, onInvalid), (c) => {
|
|
184
|
+
const denied = samePage(c);
|
|
185
|
+
if (denied !== undefined)
|
|
186
|
+
return denied;
|
|
187
|
+
const { projectId } = c.req.valid("param");
|
|
188
|
+
if (!uploadPack(deps, projectId).ok)
|
|
189
|
+
return unknownProject(c);
|
|
190
|
+
writeUploadPick(deps.db, projectId, c.req.valid("json"));
|
|
191
|
+
const result = uploadPack(deps, projectId);
|
|
192
|
+
return result.ok ? c.json(result.pack) : unknownProject(c);
|
|
153
193
|
})
|
|
154
194
|
// Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
|
|
155
195
|
.put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
|
|
@@ -182,6 +222,52 @@ export function studioRoutes(deps) {
|
|
|
182
222
|
return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
|
|
183
223
|
})
|
|
184
224
|
// What waits for the extension, oldest first: each new upload dialog takes the first.
|
|
225
|
+
// The YouTube videos a project's uploads became, and their A/B tests.
|
|
226
|
+
.get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
|
|
227
|
+
// A video's link pasted in Slopify, for an upload made without the extension; an empty
|
|
228
|
+
// link forgets it.
|
|
229
|
+
.put("/videos/:projectId", zValidator("param", projectParam, onInvalid), zValidator("json", videoLinkBody, onInvalid), (c) => {
|
|
230
|
+
const wrong = samePage(c);
|
|
231
|
+
if (wrong !== undefined)
|
|
232
|
+
return wrong;
|
|
233
|
+
const { projectId } = c.req.valid("param");
|
|
234
|
+
const { short, link } = c.req.valid("json");
|
|
235
|
+
if (link.trim() === "") {
|
|
236
|
+
forgetVideo(deps.db, projectId, short ?? null);
|
|
237
|
+
return c.json({ videos: projectVideos(deps.db, projectId) });
|
|
238
|
+
}
|
|
239
|
+
const videoId = videoIdOf(link);
|
|
240
|
+
if (videoId === undefined)
|
|
241
|
+
return problem(c, {
|
|
242
|
+
status: 400,
|
|
243
|
+
title: titleOf(400),
|
|
244
|
+
detail: "That isn't a YouTube video link. Copy the video's link from YouTube Studio (Details → Video link, like https://youtu.be/…) and paste it again.",
|
|
245
|
+
});
|
|
246
|
+
recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
|
|
247
|
+
return c.json({ videos: projectVideos(deps.db, projectId) });
|
|
248
|
+
})
|
|
249
|
+
// Start A/B test (or Cancel): the extension starts it once the video is public.
|
|
250
|
+
.post("/videos/:projectId/ab-test", zValidator("param", projectParam, onInvalid), zValidator("json", abTestBody, onInvalid), (c) => {
|
|
251
|
+
const wrong = samePage(c);
|
|
252
|
+
if (wrong !== undefined)
|
|
253
|
+
return wrong;
|
|
254
|
+
const { projectId } = c.req.valid("param");
|
|
255
|
+
const { short, start } = c.req.valid("json");
|
|
256
|
+
if (start && !abTestable(projectId, short ?? null))
|
|
257
|
+
return problem(c, {
|
|
258
|
+
status: 409,
|
|
259
|
+
title: titleOf(409),
|
|
260
|
+
detail: "There is nothing to A/B test: this upload has one thumbnail and no other titles. Make more thumbnails (Edit project → Thumbnail) or let the YouTube step write other titles, then try again.",
|
|
261
|
+
});
|
|
262
|
+
const set = setAbState(deps.db, projectId, short ?? null, start ? "waiting" : "none", null, deps.clock.now().toISOString());
|
|
263
|
+
if (!set)
|
|
264
|
+
return problem(c, {
|
|
265
|
+
status: 409,
|
|
266
|
+
title: titleOf(409),
|
|
267
|
+
detail: "Slopify doesn't know this upload's YouTube video yet. Paste its link (from YouTube Studio → Details → Video link) first.",
|
|
268
|
+
});
|
|
269
|
+
return c.json({ videos: projectVideos(deps.db, projectId) });
|
|
270
|
+
})
|
|
185
271
|
.get("/queue", (c) => {
|
|
186
272
|
const denied = samePage(c);
|
|
187
273
|
if (denied !== undefined)
|
|
@@ -275,6 +361,43 @@ export function studioRoutes(deps) {
|
|
|
275
361
|
const { projectId, short } = c.req.valid("json");
|
|
276
362
|
const left = removeFill(deps.db, projectId, short ?? null, deps.clock.now());
|
|
277
363
|
return c.json({ waiting: left.length });
|
|
364
|
+
})
|
|
365
|
+
// The extension read the new video's id from Studio's upload dialog. An upload with an
|
|
366
|
+
// A/B test to run (other titles, or several thumbnails) waits for its video to be public:
|
|
367
|
+
// scheduled videos are private until then, and Studio tests only public ones.
|
|
368
|
+
.post("/ext/video", zValidator("json", extVideoBody, onInvalid), (c) => {
|
|
369
|
+
allowOrigin(c, false);
|
|
370
|
+
if (!extAllowed(c))
|
|
371
|
+
return refused(c);
|
|
372
|
+
const { projectId, short, videoId } = c.req.valid("json");
|
|
373
|
+
recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
|
|
374
|
+
const testable = abTestable(projectId, short ?? null);
|
|
375
|
+
if (testable)
|
|
376
|
+
setAbState(deps.db, projectId, short ?? null, "waiting", null, deps.clock.now().toISOString());
|
|
377
|
+
return c.json({ abTest: testable });
|
|
378
|
+
})
|
|
379
|
+
// The A/B tests waiting for their videos to be public, each with its upload item (titles
|
|
380
|
+
// and thumbnails). One whose project or item is gone is dropped.
|
|
381
|
+
.get("/ext/ab-tests", (c) => {
|
|
382
|
+
allowOrigin(c, false);
|
|
383
|
+
if (!extAllowed(c))
|
|
384
|
+
return refused(c);
|
|
385
|
+
const tests = waitingAbTests(deps.db).flatMap((video) => {
|
|
386
|
+
const result = uploadPack(deps, video.projectId);
|
|
387
|
+
const item = result.ok ? packItem(result.pack, video.short ?? undefined) : undefined;
|
|
388
|
+
return item === undefined
|
|
389
|
+
? []
|
|
390
|
+
: [{ projectId: video.projectId, short: video.short, videoId: video.videoId, item }];
|
|
391
|
+
});
|
|
392
|
+
return c.json({ tests });
|
|
393
|
+
})
|
|
394
|
+
.post("/ext/ab-tests/result", zValidator("json", abResultBody, onInvalid), (c) => {
|
|
395
|
+
allowOrigin(c, false);
|
|
396
|
+
if (!extAllowed(c))
|
|
397
|
+
return refused(c);
|
|
398
|
+
const { projectId, short, ok, message } = c.req.valid("json");
|
|
399
|
+
setAbState(deps.db, projectId, short ?? null, ok ? "started" : "failed", message, deps.clock.now().toISOString());
|
|
400
|
+
return c.json({ ok: true });
|
|
278
401
|
})
|
|
279
402
|
.get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
|
|
280
403
|
allowOrigin(c, false);
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- The YouTube video each upload became (the long video, short 0, or short N), as the Slopify
|
|
2
|
+
-- Studio extension read it from Studio's upload dialog or the person pasted its link. With it,
|
|
3
|
+
-- the video's A/B test (its other titles and thumbnails) can wait until the video is public:
|
|
4
|
+
-- a scheduled video is private until then, and Studio tests only public ones. The extension
|
|
5
|
+
-- checks the waiting tests, starts each once its video is public, and reports back.
|
|
6
|
+
CREATE TABLE youtube_videos (
|
|
7
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
short INTEGER NOT NULL CHECK (short >= 0),
|
|
9
|
+
video_id TEXT NOT NULL,
|
|
10
|
+
recorded_at TEXT NOT NULL,
|
|
11
|
+
ab_state TEXT NOT NULL DEFAULT 'none' CHECK (ab_state IN ('none', 'waiting', 'started', 'failed')),
|
|
12
|
+
ab_message TEXT,
|
|
13
|
+
ab_at TEXT,
|
|
14
|
+
PRIMARY KEY (project_id, short)
|
|
15
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Slopify 3.2.8
|
|
2
|
+
|
|
3
|
+
Released 2 October 2026.
|
|
4
|
+
|
|
5
|
+
3.2.8 starts your A/B tests once a scheduled video goes public, tests the other titles too, and lets the pinned comment and other titles be written again.
|
|
6
|
+
|
|
7
|
+
## A/B tests after publishing
|
|
8
|
+
|
|
9
|
+
- YouTube only A/B tests public videos, and a scheduled video is private until its time. The Slopify Studio extension now keeps each upload's YouTube link and starts its A/B test by itself once the video is public: it opens the video in a background tab, fills A/B Testing and presses **Set test**. Chrome has to be open around that time; otherwise the test starts the next time Chrome opens.
|
|
10
|
+
- A/B Testing now tests the **other titles** too: **Title and thumbnail** with two or three thumbnails, **Title only** with one.
|
|
11
|
+
- The project's YouTube section has **On YouTube**: each upload's link (paste one for an upload you made by hand), its A/B test's state, and **Start A/B test** to test any video after the fact.
|
|
12
|
+
|
|
13
|
+
## YouTube section
|
|
14
|
+
|
|
15
|
+
- **Write again** is on the pinned comment and the other titles too, not only the description. It still writes every part anew in one AI call; a part you edited keeps your text and offers the new one beside it.
|
|
16
|
+
|
|
17
|
+
## Upgrading from 3.2.7
|
|
18
|
+
|
|
19
|
+
- **Download the extension again** (Settings → YouTube Studio → Download), unzip it over the old one and press reload in `chrome://extensions`. It is version 0.3.0, and Chrome asks once to allow its new permissions (a 15-minute timer, and YouTube's public video check).
|
|
20
|
+
- **Nothing becomes outdated.**
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Slopify 3.2.9
|
|
2
|
+
|
|
3
|
+
Released 2 October 2026.
|
|
4
|
+
|
|
5
|
+
## Projects
|
|
6
|
+
|
|
7
|
+
- **Renaming a project no longer makes everything again.** The pictures, scenes, looks and shorts stay as they are: they keep what the project was about. What shows the title follows the new name: the YouTube description and other titles, the PDF and the audio files' tags.
|
|
8
|
+
|
|
9
|
+
## Prepare upload
|
|
10
|
+
|
|
11
|
+
- **Pick the upload's title and thumbnail.** The A/B test now starts after the video is public, so the upload itself carries one title and one thumbnail: choose the title among the project's and its other titles, and press **Use for upload** on a thumbnail. Fill in YouTube Studio uses your pick, and the A/B test later tries the others beside it.
|
|
12
|
+
|
|
13
|
+
## Upgrading from 3.2.8
|
|
14
|
+
|
|
15
|
+
- **Nothing becomes outdated.** A project renamed before this version keeps whatever its rename already queued.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.2.9",
|
|
4
|
+
"title": "Slopify 3.2.9",
|
|
5
|
+
"version": "3.2.9",
|
|
6
|
+
"date": "2026-10-02"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.2.8",
|
|
10
|
+
"title": "Slopify 3.2.8",
|
|
11
|
+
"version": "3.2.8",
|
|
12
|
+
"date": "2026-10-02"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.2.7",
|
|
4
16
|
"title": "Slopify 3.2.7",
|
|
@@ -45,3 +45,7 @@ export const referenceKey = "reference:image";
|
|
|
45
45
|
// mode existed was) or one vertical short of about a minute, made by the same stages with
|
|
46
46
|
// the Shorts renderer's word-by-word captions (`admission/short-mode.ts`).
|
|
47
47
|
export const runModes = ["video", "short"];
|
|
48
|
+
// What the project is about, for the steps that only use the title as context.
|
|
49
|
+
export function subjectOf(config) {
|
|
50
|
+
return config.subjectTitle ?? config.title;
|
|
51
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
2
|
+
import { subjectOf } from "../admission/model.js";
|
|
2
3
|
import { appearanceFor, appearanceMessages, appearanceSchema, usesAppearance, withAppearance, } from "../images/appearance.js";
|
|
3
4
|
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
4
5
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
@@ -36,7 +37,7 @@ export function imageAppearance(context, text) {
|
|
|
36
37
|
const { config } = context;
|
|
37
38
|
if (!pictureBodies(context).some(usesAppearance))
|
|
38
39
|
return undefined;
|
|
39
|
-
const messages = withLanguage(appearanceMessages({ title: config
|
|
40
|
+
const messages = withLanguage(appearanceMessages({ title: subjectOf(config), article: text.articleText ?? "" }), config.language);
|
|
40
41
|
const value = recipe(context, imageAppearanceKey, config.sources.images === "generate" ? "images" : "thumbnail", text.articleText === null
|
|
41
42
|
? {
|
|
42
43
|
kind: "deferred",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { referenceKey } from "../admission/model.js";
|
|
1
|
+
import { referenceKey, subjectOf } from "../admission/model.js";
|
|
2
2
|
import { usesReference } from "../admission/rules.js";
|
|
3
3
|
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
4
4
|
import { castFor } from "./recipe-cast.js";
|
|
@@ -56,7 +56,7 @@ appearance) {
|
|
|
56
56
|
prompt,
|
|
57
57
|
// The establishing image sets the look for the whole video, so it is drawn with every
|
|
58
58
|
// member the title mentions as well as the ones its own brief does.
|
|
59
|
-
...castField(castFor(config, config
|
|
59
|
+
...castField(castFor(config, subjectOf(config), prompt)),
|
|
60
60
|
}, [], { unresolved: prompt.trim() === "" });
|
|
61
61
|
}
|
|
62
62
|
// Which image the others are drawn from: the step's fingerprint, so making it again or
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
|
-
import { thumbnailCountOf } from "../admission/model.js";
|
|
3
|
+
import { subjectOf, thumbnailCountOf } from "../admission/model.js";
|
|
4
4
|
import { pictureKind, sceneMessages, usesScene } from "../images/scenes.js";
|
|
5
5
|
import { recipe, selectedReference, } from "./recipe-model.js";
|
|
6
6
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
@@ -19,7 +19,7 @@ export function thumbnailScenes(context, text) {
|
|
|
19
19
|
return undefined;
|
|
20
20
|
const count = thumbnailCountOf(config);
|
|
21
21
|
const messages = withLanguage(sceneMessages({
|
|
22
|
-
title: config
|
|
22
|
+
title: subjectOf(config),
|
|
23
23
|
article: text.articleText ?? "",
|
|
24
24
|
pictures: [],
|
|
25
25
|
thumbnails: count,
|
|
@@ -60,7 +60,7 @@ export function imageScenes(context, text) {
|
|
|
60
60
|
}
|
|
61
61
|
if (keys.length === 0)
|
|
62
62
|
return undefined;
|
|
63
|
-
const messages = withLanguage(sceneMessages({ title: config
|
|
63
|
+
const messages = withLanguage(sceneMessages({ title: subjectOf(config), article: text.articleText ?? "", pictures }), config.language);
|
|
64
64
|
const value = recipe(context, imageScenesKey, "images", text.articleText === null
|
|
65
65
|
? {
|
|
66
66
|
kind: "deferred",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
|
+
import { subjectOf } from "../admission/model.js";
|
|
3
4
|
import { usesShorts } from "../admission/rules.js";
|
|
4
5
|
import { withoutScene } from "../images/scenes.js";
|
|
5
6
|
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
@@ -53,7 +54,7 @@ appearance) {
|
|
|
53
54
|
shorts.count,
|
|
54
55
|
shorts.minSeconds,
|
|
55
56
|
shorts.maxSeconds,
|
|
56
|
-
config
|
|
57
|
+
subjectOf(config),
|
|
57
58
|
// The project language, only when it is not English, so English keeps its fingerprint.
|
|
58
59
|
...(config.language === undefined || config.language === "en" ? [] : [config.language]),
|
|
59
60
|
],
|
|
@@ -113,7 +114,7 @@ appearance) {
|
|
|
113
114
|
const prompts = {
|
|
114
115
|
...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
|
|
115
116
|
style: looked,
|
|
116
|
-
videoTitle: config
|
|
117
|
+
videoTitle: subjectOf(config),
|
|
117
118
|
shortTitle: clip.title,
|
|
118
119
|
text: clip.text,
|
|
119
120
|
count,
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { withLanguage } from "../../kernel/ports/languages.js";
|
|
3
3
|
import { documentIndex } from "../../kernel/ports/llm-documents.js";
|
|
4
4
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
5
|
+
import { subjectOf } from "../admission/model.js";
|
|
5
6
|
import { usesPronunciationGlossary } from "../admission/rules.js";
|
|
6
7
|
import { render } from "../admission/substitute.js";
|
|
7
8
|
import { articleMessages, continuationMessages } from "../article/continuation.js";
|
|
@@ -225,7 +226,7 @@ export function textRecipes(context) {
|
|
|
225
226
|
const prompt = renderedPrompt(context, "thumbnailPrompt");
|
|
226
227
|
const messages = thumbnailMessages({
|
|
227
228
|
instruction: prompt,
|
|
228
|
-
title: config
|
|
229
|
+
title: subjectOf(config),
|
|
229
230
|
values: config.values,
|
|
230
231
|
format: config.format,
|
|
231
232
|
article: articleText ?? "",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { thumbnailCountOf, thumbnailKey } from "../admission/model.js";
|
|
1
|
+
import { subjectOf, thumbnailCountOf, thumbnailKey } from "../admission/model.js";
|
|
2
2
|
import { usesShortMode } from "../admission/short-mode.js";
|
|
3
3
|
import { render } from "../admission/substitute.js";
|
|
4
4
|
import { withoutScene, withScene } from "../images/scenes.js";
|
|
@@ -266,7 +266,7 @@ appearance) {
|
|
|
266
266
|
prompt: thumbnailVariantPrompt(prompt, variant),
|
|
267
267
|
...(reference === undefined ? {} : { reference: reference.input }),
|
|
268
268
|
// The thumbnail stands for the whole video, like the establishing image.
|
|
269
|
-
...castField(castFor(config, config
|
|
269
|
+
...castField(castFor(config, subjectOf(config), prompt)),
|
|
270
270
|
}, [
|
|
271
271
|
...(promptRecipe === undefined ? [] : [promptRecipe.key]),
|
|
272
272
|
...(reference === undefined ? [] : [reference.key]),
|
|
@@ -24,6 +24,7 @@ import { cloneManifest, ensureRevisionStages, projectSelected } from "./projecti
|
|
|
24
24
|
import { insertAsset, insertRevision } from "./repo.js";
|
|
25
25
|
import { bindNarrationSources, validateNarrationIntent, validateTemplateIntent } from "./rules.js";
|
|
26
26
|
import { revisionEditSchema } from "./schema.js";
|
|
27
|
+
import { keptSubject } from "./subject.js";
|
|
27
28
|
export { bindUpload } from "./mutation-assets.js";
|
|
28
29
|
export { restoreRevision } from "./restore.js";
|
|
29
30
|
export async function saveRevision(deps, input) {
|
|
@@ -57,7 +58,7 @@ export async function saveRevision(deps, input) {
|
|
|
57
58
|
currentRevisionId: base.revision.id,
|
|
58
59
|
fields: replanned.fields,
|
|
59
60
|
};
|
|
60
|
-
const edit = bindNarrationSources(base, rebrandedEdit(deps.db, base.revision.config, replanned.edit));
|
|
61
|
+
const edit = bindNarrationSources(base, keptSubject(base.revision.config, rebrandedEdit(deps.db, base.revision.config, replanned.edit)));
|
|
61
62
|
const fields = [
|
|
62
63
|
...settingProblems(edit),
|
|
63
64
|
...validateUploads(deps, edit),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A renamed project keeps what it is about (`subjectOf`): on the first rename the title it was
|
|
2
|
+
// made with is kept as `subjectTitle`, so the scenes, looks, cast and shorts made from it keep
|
|
3
|
+
// their fingerprints and nothing is drawn again. What shows the title (YouTube's text, the
|
|
4
|
+
// PDF, the files' tags) follows the new name. An edit can't drop a kept subject either.
|
|
5
|
+
export function keptSubject(base, edit) {
|
|
6
|
+
const subject = base.subjectTitle ?? (edit.config.title !== base.title ? base.title : undefined);
|
|
7
|
+
if (subject === undefined || edit.config.subjectTitle === subject)
|
|
8
|
+
return edit;
|
|
9
|
+
return { ...edit, config: { ...edit.config, subjectTitle: subject } };
|
|
10
|
+
}
|
|
@@ -55,6 +55,8 @@ export const projectTables = [
|
|
|
55
55
|
"project_recovery_requests",
|
|
56
56
|
"youtube_description_edits",
|
|
57
57
|
"project_uploads",
|
|
58
|
+
// Since 3.2.8: the YouTube video each upload became, and its A/B test.
|
|
59
|
+
"youtube_videos",
|
|
58
60
|
// Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
|
|
59
61
|
// current wait, a lease of this install) and prompt_softening (a pending one-off request).
|
|
60
62
|
// Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
|
|
@@ -17,6 +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 { picked, readUploadPick } from "./pick.js";
|
|
20
21
|
import { projectPlaylists, readRealFootage } from "./settings.js";
|
|
21
22
|
// What `shorts.json` holds for each picked clip; only what the pack uses.
|
|
22
23
|
const clipsSchema = z.object({
|
|
@@ -112,18 +113,34 @@ export function uploadPack(deps, projectId) {
|
|
|
112
113
|
missing.push(`Only ${String(thumbnails.length)} of the ${String(count)} thumbnails are made. Regenerate the missing ones in the project's Images section.`);
|
|
113
114
|
if (playlistChoices.length === 0)
|
|
114
115
|
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.");
|
|
116
|
+
// The video's titles and thumbnails in the project's order, and the ones the person picked
|
|
117
|
+
// for the upload first (`pick.ts`); the A/B test later tries the rest beside them.
|
|
118
|
+
const ownTitle = project.title.slice(0, studioTitleMax);
|
|
119
|
+
const allTitles = [
|
|
120
|
+
ownTitle,
|
|
121
|
+
...(edited?.titles ?? "")
|
|
122
|
+
.split("\n")
|
|
123
|
+
.map((one) => one.trim().slice(0, studioTitleMax))
|
|
124
|
+
.filter((one) => one !== ""),
|
|
125
|
+
];
|
|
126
|
+
const allThumbnails = thumbnails.map(file);
|
|
127
|
+
const pick = readUploadPick(deps.db, projectId);
|
|
128
|
+
const arranged = picked(allTitles, allThumbnails, pick);
|
|
115
129
|
const items = [
|
|
116
130
|
{
|
|
117
131
|
kind: "video",
|
|
118
132
|
video: video === undefined ? null : file(video),
|
|
119
|
-
title:
|
|
120
|
-
titles: (
|
|
121
|
-
.split("\n")
|
|
122
|
-
.map((one) => one.trim().slice(0, studioTitleMax))
|
|
123
|
-
.filter((one) => one !== ""),
|
|
133
|
+
title: arranged.titles[0] ?? ownTitle,
|
|
134
|
+
titles: arranged.titles.slice(1),
|
|
124
135
|
description: description ?? "",
|
|
125
136
|
tags: tagsOf(tagsFile),
|
|
126
|
-
thumbnails: thumbnails
|
|
137
|
+
thumbnails: arranged.thumbnails,
|
|
138
|
+
pickable: {
|
|
139
|
+
titles: allTitles,
|
|
140
|
+
thumbnails: allThumbnails,
|
|
141
|
+
title: pick.title < allTitles.length ? pick.title : 0,
|
|
142
|
+
thumbnail: pick.thumbnail < allThumbnails.length ? pick.thumbnail : 0,
|
|
143
|
+
},
|
|
127
144
|
audience: studioAudience,
|
|
128
145
|
alteredContent: disclosure("video"),
|
|
129
146
|
playlists,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
3
|
+
const pickSchema = z.object({
|
|
4
|
+
title: z.number().int().min(0).max(9),
|
|
5
|
+
thumbnail: z.number().int().min(0).max(9),
|
|
6
|
+
});
|
|
7
|
+
const keyOf = (projectId) => `studio.uploadPick.${projectId}`;
|
|
8
|
+
export function readUploadPick(db, projectId) {
|
|
9
|
+
const stored = readSetting(db, keyOf(projectId));
|
|
10
|
+
if (stored === undefined)
|
|
11
|
+
return { title: 0, thumbnail: 0 };
|
|
12
|
+
try {
|
|
13
|
+
const parsed = pickSchema.safeParse(JSON.parse(stored));
|
|
14
|
+
return parsed.success ? parsed.data : { title: 0, thumbnail: 0 };
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { title: 0, thumbnail: 0 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function writeUploadPick(db, projectId, pick) {
|
|
21
|
+
writeSetting(db, keyOf(projectId), JSON.stringify(pickSchema.parse(pick)));
|
|
22
|
+
}
|
|
23
|
+
// The chosen title first, the others after it in their order; the same for the thumbnails. A
|
|
24
|
+
// pick past the end (a title list rewritten shorter) falls back to the first.
|
|
25
|
+
export function picked(titles, thumbnails, pick) {
|
|
26
|
+
const first = (list, at) => {
|
|
27
|
+
const index = at < list.length ? at : 0;
|
|
28
|
+
const chosen = list[index];
|
|
29
|
+
return chosen === undefined ? list : [chosen, ...list.filter((_, one) => one !== index)];
|
|
30
|
+
};
|
|
31
|
+
return { titles: first(titles, pick.title), thumbnails: first(thumbnails, pick.thumbnail) };
|
|
32
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A YouTube video id: 11 letters, digits, `-` and `_`.
|
|
2
|
+
export const videoIdPattern = /^[A-Za-z0-9_-]{11}$/;
|
|
3
|
+
// The id inside a link as Studio and YouTube show it (youtu.be/ID, watch?v=ID, /shorts/ID,
|
|
4
|
+
// studio's /video/ID/edit), or the bare id.
|
|
5
|
+
export function videoIdOf(text) {
|
|
6
|
+
const trimmed = text.trim();
|
|
7
|
+
if (videoIdPattern.test(trimmed))
|
|
8
|
+
return trimmed;
|
|
9
|
+
const match = /(?:youtu\.be\/|[?&]v=|\/shorts\/|\/video\/|\/live\/|\/embed\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/.exec(trimmed);
|
|
10
|
+
return match?.[1];
|
|
11
|
+
}
|
|
12
|
+
const slot = (short) => short ?? 0;
|
|
13
|
+
function rowOf(row) {
|
|
14
|
+
const short = Number(row.short);
|
|
15
|
+
return {
|
|
16
|
+
projectId: String(row.project_id),
|
|
17
|
+
short: short === 0 ? null : short,
|
|
18
|
+
videoId: String(row.video_id),
|
|
19
|
+
recordedAt: String(row.recorded_at),
|
|
20
|
+
abState: String(row.ab_state),
|
|
21
|
+
abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
|
|
22
|
+
abAt: typeof row.ab_at === "string" ? row.ab_at : null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Records (or replaces) the video an upload became. A new video id starts its A/B test over.
|
|
26
|
+
export function recordVideo(db, projectId, short, videoId, at) {
|
|
27
|
+
db.prepare(`INSERT INTO youtube_videos(project_id,short,video_id,recorded_at) VALUES (?,?,?,?)
|
|
28
|
+
ON CONFLICT(project_id,short) DO UPDATE SET
|
|
29
|
+
ab_state=CASE WHEN video_id=excluded.video_id THEN ab_state ELSE 'none' END,
|
|
30
|
+
ab_message=CASE WHEN video_id=excluded.video_id THEN ab_message ELSE NULL END,
|
|
31
|
+
ab_at=CASE WHEN video_id=excluded.video_id THEN ab_at ELSE NULL END,
|
|
32
|
+
video_id=excluded.video_id, recorded_at=excluded.recorded_at`).run(projectId, slot(short), videoId, at);
|
|
33
|
+
}
|
|
34
|
+
export function forgetVideo(db, projectId, short) {
|
|
35
|
+
db.prepare("DELETE FROM youtube_videos WHERE project_id=? AND short=?").run(projectId, slot(short));
|
|
36
|
+
}
|
|
37
|
+
export function projectVideos(db, projectId) {
|
|
38
|
+
return db
|
|
39
|
+
.prepare("SELECT * FROM youtube_videos WHERE project_id=? ORDER BY short")
|
|
40
|
+
.all(projectId)
|
|
41
|
+
.map(rowOf);
|
|
42
|
+
}
|
|
43
|
+
export function videoOf(db, projectId, short) {
|
|
44
|
+
const row = db
|
|
45
|
+
.prepare("SELECT * FROM youtube_videos WHERE project_id=? AND short=?")
|
|
46
|
+
.get(projectId, slot(short));
|
|
47
|
+
return row === undefined ? undefined : rowOf(row);
|
|
48
|
+
}
|
|
49
|
+
// Sets an A/B test's state; false when the project has no video recorded for that upload.
|
|
50
|
+
export function setAbState(db, projectId, short, state, message, at) {
|
|
51
|
+
return (db
|
|
52
|
+
.prepare("UPDATE youtube_videos SET ab_state=?, ab_message=?, ab_at=? WHERE project_id=? AND short=?")
|
|
53
|
+
.run(state, message, at, projectId, slot(short)).changes > 0);
|
|
54
|
+
}
|
|
55
|
+
export function waitingAbTests(db) {
|
|
56
|
+
return db
|
|
57
|
+
.prepare("SELECT * FROM youtube_videos WHERE ab_state='waiting' ORDER BY recorded_at")
|
|
58
|
+
.all()
|
|
59
|
+
.map(rowOf);
|
|
60
|
+
}
|