@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.
@@ -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);
@@ -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
+ }
@@ -190,4 +190,5 @@ export const runDraftSchema = z.object({
190
190
  export const runConfigSchema = runDraftSchema.extend({
191
191
  rendered: z.record(z.string(), z.string()),
192
192
  titlePattern: z.string().optional(),
193
+ subjectTitle: z.string().optional(),
193
194
  });
@@ -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.title, article: text.articleText ?? "" }), config.language);
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.title, prompt)),
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.title,
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.title, article: text.articleText ?? "", pictures }), config.language);
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.title,
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.title,
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.title,
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.title, prompt)),
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: project.title.slice(0, studioTitleMax),
120
- titles: (edited?.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.map(file),
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
+ }