@gentbajko/slopify 3.2.3 → 3.2.5
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/home.js +15 -3
- package/dist/edge/http/projects.js +7 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0044-project-set-aside.sql +9 -0
- package/dist/patch-notes/3.2.4.md +18 -0
- package/dist/patch-notes/3.2.5.md +11 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/start.js +4 -1
- package/dist/slices/rebuild/runtime-youtube.js +6 -1
- package/dist/slices/schedules/prepare.js +17 -8
- package/dist/slices/schedules/scheduler.js +2 -2
- package/dist/slices/storage/backup-format.js +2 -0
- package/dist/slices/uploads/repo.js +21 -0
- package/dist/slices/youtube/answer.js +10 -3
- package/dist/slices/youtube/titles.js +30 -0
- package/dist/web/assets/{index-CB4df3Jm.js → index-CLJylidD.js} +23 -23
- package/dist/web/assets/{pdf-BKeG-67K.js → pdf-D9H9C1JS.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/dist/edge/http/home.js
CHANGED
|
@@ -2,7 +2,7 @@ import { zValidator } from "@hono/zod-validator";
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { weekSummary } from "../../slices/run-cost/week.js";
|
|
5
|
-
import { markUploaded } from "../../slices/uploads/repo.js";
|
|
5
|
+
import { markUploaded, setAside } from "../../slices/uploads/repo.js";
|
|
6
6
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
7
7
|
const weekQuery = z.object({
|
|
8
8
|
since: z.iso.datetime({ offset: true }),
|
|
@@ -26,7 +26,19 @@ const idParam = z.object({
|
|
|
26
26
|
// Home's "Mark uploaded" and its undo. Slopify never uploads, so this only records what the
|
|
27
27
|
// person says they did.
|
|
28
28
|
export function uploadedRoutes(deps) {
|
|
29
|
-
return new Hono()
|
|
29
|
+
return (new Hono()
|
|
30
|
+
// Needs you's "Keep as is" and its undo.
|
|
31
|
+
.put("/:id/set-aside", zValidator("param", idParam, onInvalid), zValidator("json", z.object({ setAside: z.boolean() }).strict(), onInvalid), (c) => {
|
|
32
|
+
const result = setAside(deps.db, c.req.valid("param").id, c.req.valid("json").setAside, deps.clock.now().toISOString());
|
|
33
|
+
if (!result.ok)
|
|
34
|
+
return problem(c, {
|
|
35
|
+
status: 404,
|
|
36
|
+
title: titleOf(404),
|
|
37
|
+
detail: "This project no longer exists. Go back to Projects to pick another.",
|
|
38
|
+
});
|
|
39
|
+
return c.json({ setAside: c.req.valid("json").setAside });
|
|
40
|
+
})
|
|
41
|
+
.put("/:id/uploaded", zValidator("param", idParam, onInvalid), zValidator("json", z.object({ uploaded: z.boolean() }).strict(), onInvalid), (c) => {
|
|
30
42
|
const result = markUploaded(deps.db, c.req.valid("param").id, c.req.valid("json").uploaded, deps.clock.now().toISOString());
|
|
31
43
|
if (!result.ok)
|
|
32
44
|
return problem(c, {
|
|
@@ -35,5 +47,5 @@ export function uploadedRoutes(deps) {
|
|
|
35
47
|
detail: "This project no longer exists. Go back to Projects to pick another.",
|
|
36
48
|
});
|
|
37
49
|
return c.json({ uploadedAt: result.uploadedAt });
|
|
38
|
-
});
|
|
50
|
+
}));
|
|
39
51
|
}
|
|
@@ -14,7 +14,7 @@ import { currentRevisionId } from "../../slices/revisions/repo.js";
|
|
|
14
14
|
import { limitWaitsByProject, listingWait } from "../../slices/run-cost/panel.js";
|
|
15
15
|
import { outputsOf } from "../../slices/storage/repo.js";
|
|
16
16
|
import { trashProject } from "../../slices/trash/service.js";
|
|
17
|
-
import { uploadedProjects } from "../../slices/uploads/repo.js";
|
|
17
|
+
import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
|
|
18
18
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
19
19
|
import { createProject } from "./project-create.js";
|
|
20
20
|
const idParam = z.object({
|
|
@@ -59,6 +59,7 @@ export function projectRoutes(deps) {
|
|
|
59
59
|
const standings = stageStandingsByProject(deps.db);
|
|
60
60
|
const channels = projectChannels(deps.db);
|
|
61
61
|
const uploads = uploadedProjects(deps.db);
|
|
62
|
+
const aside = setAsideProjects(deps.db);
|
|
62
63
|
const waits = limitWaitsByProject(deps.db);
|
|
63
64
|
const projects = listProjects(deps.db).map((project) => {
|
|
64
65
|
const stages = standings.get(project.id) ?? [];
|
|
@@ -69,6 +70,7 @@ export function projectRoutes(deps) {
|
|
|
69
70
|
progress: progressOf(stages),
|
|
70
71
|
channelId: channels.get(project.id) ?? defaultChannelId,
|
|
71
72
|
uploadedAt: uploads.get(project.id) ?? null,
|
|
73
|
+
...(aside.has(project.id) ? { setAside: true } : {}),
|
|
72
74
|
...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
|
|
73
75
|
};
|
|
74
76
|
});
|
|
@@ -90,7 +92,10 @@ export function projectRoutes(deps) {
|
|
|
90
92
|
return c.json({
|
|
91
93
|
revisionId: currentRevisionId(deps.db, project.id) ?? null,
|
|
92
94
|
resumable: resumable(deps.db, project.id),
|
|
93
|
-
project:
|
|
95
|
+
project: {
|
|
96
|
+
...summarise(project),
|
|
97
|
+
...(setAsideProjects(deps.db).has(project.id) ? { setAside: true } : {}),
|
|
98
|
+
},
|
|
94
99
|
stages: stagesWithEta(deps.db, stagesOf(deps.db, project.id), project.config, deps.clock.now()).map((stage) => {
|
|
95
100
|
if (stage.kind !== "video" || stage.state !== "running")
|
|
96
101
|
return stage;
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- "Keep as is" on Home's Needs you: a run waiting only on held work the person does not want
|
|
2
|
+
-- (a step added after the project was made, say, whose run would redraw finished thumbnails)
|
|
3
|
+
-- leaves Needs you without running anything. It holds for the revision it was set on: an edit
|
|
4
|
+
-- makes a new head, and the project waits for the person again.
|
|
5
|
+
CREATE TABLE project_set_aside (
|
|
6
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
7
|
+
revision_id TEXT NOT NULL,
|
|
8
|
+
set_at TEXT NOT NULL
|
|
9
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Slopify 3.2.4
|
|
2
|
+
|
|
3
|
+
Released 1 October 2026.
|
|
4
|
+
|
|
5
|
+
3.2.4 keeps a title's fixed wording in the A/B titles, and lets a finished run with a leftover step leave Needs you.
|
|
6
|
+
|
|
7
|
+
## YouTube
|
|
8
|
+
|
|
9
|
+
- When a project's title comes from a pattern with keywords ("{{Topic}} | My Channel"), the two other titles for YouTube's title A/B test change only what the keywords hold ("Tiamat, the Five-Headed Dragon Queen | My Channel"). An answer that changes the rest is asked for again. Titles without keywords keep free alternatives.
|
|
10
|
+
- Projects made from a schedule keep their title pattern too.
|
|
11
|
+
|
|
12
|
+
## Home
|
|
13
|
+
|
|
14
|
+
- **Keep as is** on a Needs you card whose run waits on held work: the project leaves Needs you without running anything, for example a step added after the project was made whose run would redraw finished thumbnails. Undo is in the message; editing the project brings the card back. Projects lists it as "Kept as is", and it can show under Ready to upload.
|
|
15
|
+
|
|
16
|
+
## Upgrading from 3.2.3
|
|
17
|
+
|
|
18
|
+
- **Nothing becomes outdated.** The A/B titles change applies to projects made from now on.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Slopify 3.2.5
|
|
2
|
+
|
|
3
|
+
Released 1 October 2026.
|
|
4
|
+
|
|
5
|
+
## Project page
|
|
6
|
+
|
|
7
|
+
- A project kept as is on Home says so on its own page too: "Kept as is" beside the title, and the panel no longer asks you to continue the run. Editing the project brings the rest back, as before.
|
|
8
|
+
|
|
9
|
+
## Upgrading from 3.2.4
|
|
10
|
+
|
|
11
|
+
- **Nothing becomes outdated.**
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.2.5",
|
|
4
|
+
"title": "Slopify 3.2.5",
|
|
5
|
+
"version": "3.2.5",
|
|
6
|
+
"date": "2026-10-01"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.2.4",
|
|
10
|
+
"title": "Slopify 3.2.4",
|
|
11
|
+
"version": "3.2.4",
|
|
12
|
+
"date": "2026-10-01"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.2.3",
|
|
4
16
|
"title": "Slopify 3.2.3",
|
|
@@ -17,7 +17,7 @@ import { usesAmbientBed } from "../video/ambient-bed.js";
|
|
|
17
17
|
import { sourceOf, } from "./model.js";
|
|
18
18
|
import { insertProject, insertStage } from "./repo.js";
|
|
19
19
|
import { usesPronunciationGlossary, usesReference } from "./rules.js";
|
|
20
|
-
import { render } from "./substitute.js";
|
|
20
|
+
import { detectSlots, render } from "./substitute.js";
|
|
21
21
|
// Provide → `provided` with its output attached; Off → `skipped`;
|
|
22
22
|
// everything else → `pending`.
|
|
23
23
|
export function initialState(source) {
|
|
@@ -48,6 +48,9 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
|
|
|
48
48
|
const config = {
|
|
49
49
|
...fresh,
|
|
50
50
|
title,
|
|
51
|
+
...(title !== draft.title && detectSlots(draft.title).names.length > 0
|
|
52
|
+
? { titlePattern: draft.title }
|
|
53
|
+
: {}),
|
|
51
54
|
// A new project always names its document theme: a config without one reads as the
|
|
52
55
|
// DiceMaster of older projects (see documentThemeOf), which no new project should get.
|
|
53
56
|
...(sourceOf(draft.sources, "document") === "generate" && draft.document === undefined
|
|
@@ -3,6 +3,7 @@ import { withLanguage } from "../../kernel/ports/languages.js";
|
|
|
3
3
|
import { outputPath } from "../storage/layout.js";
|
|
4
4
|
import { assembleDescription, checkDescriptionAnswer, descriptionMessages, tagsText, } from "../youtube/answer.js";
|
|
5
5
|
import { defaultDescriptionPrompt } from "../youtube/model.js";
|
|
6
|
+
import { keepsShape, titleShape } from "../youtube/titles.js";
|
|
6
7
|
import { transcriptPassages, transcriptText } from "../youtube/transcript.js";
|
|
7
8
|
import { exportSnapshot, revisionAudio } from "./runtime-export-inputs.js";
|
|
8
9
|
import { savedCatalogue } from "./runtime-plan.js";
|
|
@@ -45,9 +46,13 @@ export async function executeYoutubeRecipe(deps, context, providers, piece) {
|
|
|
45
46
|
const durationSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
46
47
|
const saved = Array.isArray(input.values) ? input.values[4] : undefined;
|
|
47
48
|
const instruction = typeof saved === "string" && saved.trim() !== "" ? saved : defaultDescriptionPrompt;
|
|
49
|
+
// A title renamed away from its pattern in Edit project has nothing fixed any more.
|
|
50
|
+
const pattern = titleShape(config.titlePattern, config.values);
|
|
51
|
+
const shape = pattern !== undefined && keepsShape(pattern, config.title) ? pattern : undefined;
|
|
48
52
|
const messages = withLanguage(descriptionMessages({
|
|
49
53
|
instruction,
|
|
50
54
|
title: config.title,
|
|
55
|
+
shape,
|
|
51
56
|
durationSeconds,
|
|
52
57
|
transcript: transcriptText(passages),
|
|
53
58
|
}), config.language);
|
|
@@ -66,7 +71,7 @@ export async function executeYoutubeRecipe(deps, context, providers, piece) {
|
|
|
66
71
|
webSearch: false,
|
|
67
72
|
previewLabel: piece.key,
|
|
68
73
|
check: (value) => {
|
|
69
|
-
const checked = checkDescriptionAnswer(value.text, durationSeconds, config.title);
|
|
74
|
+
const checked = checkDescriptionAnswer(value.text, durationSeconds, config.title, shape);
|
|
70
75
|
return checked.ok ? undefined : checked.reason;
|
|
71
76
|
},
|
|
72
77
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { derive } from "../../kernel/runner/graph.js";
|
|
2
2
|
import { projectPaused, stagesOf } from "../admission/repo.js";
|
|
3
|
+
import { render } from "../admission/substitute.js";
|
|
3
4
|
import { changeCheckpoints } from "../checkpoints/change.js";
|
|
4
5
|
import { listCheckpoints } from "../checkpoints/repo.js";
|
|
5
6
|
import { reviewDraft } from "../play-drafts/review.js";
|
|
@@ -10,7 +11,10 @@ import { currentRevisionId } from "../revisions/repo.js";
|
|
|
10
11
|
import { scheduleById } from "./repo.js";
|
|
11
12
|
import { renderedTitle, scheduledValues } from "./topic-list.js";
|
|
12
13
|
// The draft a schedule's run starts for one topic: the template as it is now, the topic in its
|
|
13
|
-
// keyword, the schedule's values over the template's
|
|
14
|
+
// keyword, and the schedule's values over the template's. The title keeps its keywords, which
|
|
15
|
+
// the project fills from those values when it starts and keeps as its title pattern (YouTube's
|
|
16
|
+
// other titles change only what the keywords hold); `scheduledTitle` is the filled title. A
|
|
17
|
+
// topic with no keyword to fill is the title itself.
|
|
14
18
|
export function scheduledDocument(deps, schedule, topic) {
|
|
15
19
|
const template = deps.template(schedule.templateId);
|
|
16
20
|
if (template === undefined)
|
|
@@ -19,16 +23,22 @@ export function scheduledDocument(deps, schedule, topic) {
|
|
|
19
23
|
id: schedule.templateId,
|
|
20
24
|
version: template.version,
|
|
21
25
|
});
|
|
26
|
+
const values = scheduledValues(document.form, schedule, topic);
|
|
27
|
+
const filled = renderedTitle(document.form, schedule, topic);
|
|
22
28
|
return {
|
|
23
29
|
...document,
|
|
24
30
|
form: {
|
|
25
31
|
...document.form,
|
|
26
|
-
title:
|
|
27
|
-
values
|
|
32
|
+
title: render(document.form.title, values).trim() === filled.trim() ? document.form.title : filled,
|
|
33
|
+
values,
|
|
28
34
|
},
|
|
29
35
|
variants: [],
|
|
30
36
|
};
|
|
31
37
|
}
|
|
38
|
+
// The title a scheduled draft's project gets, as `admission/start.ts` fills it.
|
|
39
|
+
export function scheduledTitle(document) {
|
|
40
|
+
return render(document.form.title, document.form.values).trim() || document.form.title;
|
|
41
|
+
}
|
|
32
42
|
export async function prepareTopic(deps, input) {
|
|
33
43
|
const schedule = scheduleById(deps.db, input.scheduleId);
|
|
34
44
|
if (schedule === undefined)
|
|
@@ -39,7 +49,7 @@ export async function prepareTopic(deps, input) {
|
|
|
39
49
|
const document = scheduledDocument(deps, schedule, topic);
|
|
40
50
|
if (document === undefined)
|
|
41
51
|
return { ok: false, reason: "missing-template" };
|
|
42
|
-
const title = document
|
|
52
|
+
const title = scheduledTitle(document);
|
|
43
53
|
if (preparedProject(deps.db, schedule.id, title) !== undefined)
|
|
44
54
|
return { ok: false, reason: "already-prepared" };
|
|
45
55
|
const { document: held, added } = heldBeforeVideo(document);
|
|
@@ -139,9 +149,8 @@ export function preparedTopics(deps, scheduleId) {
|
|
|
139
149
|
const document = scheduledDocument(deps, schedule, topic);
|
|
140
150
|
if (document === undefined)
|
|
141
151
|
return [];
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: [{ title: document.form.title, topic: topic.title, projectId: found.projectId }];
|
|
152
|
+
const title = scheduledTitle(document);
|
|
153
|
+
const found = preparedProject(deps.db, scheduleId, title);
|
|
154
|
+
return found === undefined ? [] : [{ title, topic: topic.title, projectId: found.projectId }];
|
|
146
155
|
});
|
|
147
156
|
}
|
|
@@ -3,7 +3,7 @@ import { reviewDraft } from "../play-drafts/review.js";
|
|
|
3
3
|
import { createDraft } from "../play-drafts/service.js";
|
|
4
4
|
import { startPlayDraft } from "../play-drafts/start.js";
|
|
5
5
|
import { nextOccurrence } from "./calendar.js";
|
|
6
|
-
import { continuePrepared, preparedProject, scheduledDocument } from "./prepare.js";
|
|
6
|
+
import { continuePrepared, preparedProject, scheduledDocument, scheduledTitle } from "./prepare.js";
|
|
7
7
|
import { activeRun, dueSchedules, insertRun, recordRunDispatch, recordRunStartIdentity, recoverRunningRuns, scheduleById, scheduleRows, updateRun, } from "./repo.js";
|
|
8
8
|
import { generateTopics, generationDue, releaseTopicLeases } from "./topics.js";
|
|
9
9
|
const missedGraceMs = 60_000;
|
|
@@ -139,7 +139,7 @@ async function execute(deps, claimed) {
|
|
|
139
139
|
throw new ScheduleDispatchError("missing-template");
|
|
140
140
|
// Prepared ahead (`prepare.ts`), or made by hand under the same title: continue it rather
|
|
141
141
|
// than make the same video twice.
|
|
142
|
-
const prepared = preparedProject(deps.db, claimed.schedule.id, fresh
|
|
142
|
+
const prepared = preparedProject(deps.db, claimed.schedule.id, scheduledTitle(fresh), topic !== undefined);
|
|
143
143
|
if (prepared !== undefined) {
|
|
144
144
|
continuePrepared(deps, prepared);
|
|
145
145
|
run = { ...run, projectIds: [prepared.projectId] };
|
|
@@ -59,6 +59,8 @@ export const projectTables = [
|
|
|
59
59
|
// current wait, a lease of this install) and prompt_softening (a pending one-off request).
|
|
60
60
|
// Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
|
|
61
61
|
// Since 3.1.0, prepared_videos too: a schedule's hold on this install's next run.
|
|
62
|
+
// Since 3.2.4, project_set_aside too: a Home card hidden on this install, which a restored
|
|
63
|
+
// project simply shows again.
|
|
62
64
|
"review_verdicts",
|
|
63
65
|
"provider_usage",
|
|
64
66
|
"plan_limit_readings",
|
|
@@ -18,3 +18,24 @@ export function uploadedProjects(db) {
|
|
|
18
18
|
.all()
|
|
19
19
|
.map((row) => [String(row.project_id), String(row.uploaded_at)]));
|
|
20
20
|
}
|
|
21
|
+
// "Keep as is": a waiting run the person leaves as it is, until the project's next edit. Set on
|
|
22
|
+
// the current head revision; the listing counts it only while that revision is still the head.
|
|
23
|
+
export function setAside(db, projectId, aside, at) {
|
|
24
|
+
const head = db
|
|
25
|
+
.prepare("SELECT revision_id FROM project_heads WHERE project_id=?")
|
|
26
|
+
.get(projectId);
|
|
27
|
+
if (head === undefined)
|
|
28
|
+
return { ok: false, reason: "not-found" };
|
|
29
|
+
if (!aside)
|
|
30
|
+
db.prepare("DELETE FROM project_set_aside WHERE project_id=?").run(projectId);
|
|
31
|
+
else
|
|
32
|
+
db.prepare("INSERT INTO project_set_aside(project_id,revision_id,set_at) VALUES (?,?,?) ON CONFLICT(project_id) DO UPDATE SET revision_id=excluded.revision_id,set_at=excluded.set_at").run(projectId, String(head.revision_id), at);
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
export function setAsideProjects(db) {
|
|
36
|
+
return new Set(db
|
|
37
|
+
.prepare(`SELECT s.project_id FROM project_set_aside s
|
|
38
|
+
JOIN project_heads h ON h.project_id=s.project_id AND h.revision_id=s.revision_id`)
|
|
39
|
+
.all()
|
|
40
|
+
.map((row) => String(row.project_id)));
|
|
41
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { alternativeTitles, descriptionMaxCharacters, hashtagsMax, minChapterSeconds, minChapters, pinnedCommentMaxCharacters, tagMaxCharacters, tagsMaxCharacters, titleMaxCharacters, } from "./model.js";
|
|
3
3
|
import { parseTimestamp, youtubeTimestamp } from "./timestamps.js";
|
|
4
|
+
import { keepsShape, titleShapeRule } from "./titles.js";
|
|
4
5
|
export function descriptionMessages(brief) {
|
|
5
6
|
const end = youtubeTimestamp(brief.durationSeconds);
|
|
6
7
|
return [
|
|
@@ -22,6 +23,7 @@ export function descriptionMessages(brief) {
|
|
|
22
23
|
"- The summary is plain text without chapters, hashtags or links.",
|
|
23
24
|
`- The pinned comment is plain text without links, at most ${String(pinnedCommentMaxCharacters)} characters.`,
|
|
24
25
|
`- 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.`,
|
|
26
|
+
...(brief.shape === undefined ? [] : [titleShapeRule(brief.shape)]),
|
|
25
27
|
"",
|
|
26
28
|
"The pinned comment is what the channel pins under the video, unless the instructions below say otherwise: one or two short sentences, at most 200 characters, in the same voice as the summary. Ask one question a viewer can answer from their own experience of the subject, and invite a suggestion for the next video.",
|
|
27
29
|
"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.",
|
|
@@ -55,7 +57,9 @@ const fix = "Try again, or choose another model in Edit project → Providers.";
|
|
|
55
57
|
// provider wrapper's `check` asks the model again while attempts remain.
|
|
56
58
|
export function checkDescriptionAnswer(text, durationSeconds,
|
|
57
59
|
// The video's own title, which the other titles must differ from.
|
|
58
|
-
title = ""
|
|
60
|
+
title = "",
|
|
61
|
+
// Its pattern, whose fixed wording the other titles keep.
|
|
62
|
+
shape) {
|
|
59
63
|
const parsed = answerSchema.safeParse(jsonOf(text));
|
|
60
64
|
if (!parsed.success)
|
|
61
65
|
return {
|
|
@@ -82,7 +86,7 @@ title = "") {
|
|
|
82
86
|
ok: false,
|
|
83
87
|
reason: `The AI model's pinned comment is ${String(pinnedComment.length)} characters, over the ${String(pinnedCommentMaxCharacters)} Slopify allows. ${fix}`,
|
|
84
88
|
};
|
|
85
|
-
const titles = checkTitles(parsed.data.titles, title);
|
|
89
|
+
const titles = checkTitles(parsed.data.titles, title, shape);
|
|
86
90
|
if (!titles.ok)
|
|
87
91
|
return titles;
|
|
88
92
|
const value = {
|
|
@@ -166,7 +170,7 @@ function checkChapters(raw, durationSeconds) {
|
|
|
166
170
|
}
|
|
167
171
|
return { ok: true, value: chapters };
|
|
168
172
|
}
|
|
169
|
-
function checkTitles(raw, title) {
|
|
173
|
+
function checkTitles(raw, title, shape) {
|
|
170
174
|
const broke = (rule) => ({
|
|
171
175
|
ok: false,
|
|
172
176
|
reason: `The AI model's other titles broke YouTube's rules (${rule}). ${fix}`,
|
|
@@ -180,6 +184,9 @@ function checkTitles(raw, title) {
|
|
|
180
184
|
const odd = titles.find((one) => /[<>]/.test(one));
|
|
181
185
|
if (odd !== undefined)
|
|
182
186
|
return broke(`"${odd}" contains < or >`);
|
|
187
|
+
const reshaped = shape === undefined ? undefined : titles.find((one) => !keepsShape(shape, one));
|
|
188
|
+
if (shape !== undefined && reshaped !== undefined)
|
|
189
|
+
return broke(`"${reshaped}" changes the title pattern "${shape.pattern}" outside its keywords`);
|
|
183
190
|
const seen = new Set([title.trim().toLowerCase()]);
|
|
184
191
|
for (const one of titles) {
|
|
185
192
|
if (seen.has(one.toLowerCase()))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A title made from a pattern with keywords ("{{Topic}} | D&D Lore To Sleep To") keeps its
|
|
2
|
+
// fixed wording in YouTube's title A/B test: the other titles change only what the keywords
|
|
3
|
+
// stand for ("Tiamat, the Five-Headed Dragon Queen | D&D Lore To Sleep To"). A title without
|
|
4
|
+
// keywords has nothing fixed, and its other titles are free.
|
|
5
|
+
const slot = /\{\{([^{}\n]*)\}\}/g;
|
|
6
|
+
// Undefined when the project kept no pattern, or the pattern has no keyword.
|
|
7
|
+
export function titleShape(pattern, values) {
|
|
8
|
+
if (pattern === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const names = [...pattern.matchAll(slot)].map((match) => (match[1] ?? "").trim());
|
|
11
|
+
if (names.length === 0)
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
pattern,
|
|
15
|
+
keywords: [...new Set(names)].map((name) => ({ name, value: values?.[name] ?? "" })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// The rule the model is given: which part may change, and what it holds now.
|
|
19
|
+
export function titleShapeRule(shape) {
|
|
20
|
+
const held = shape.keywords.map((one) => `{{${one.name}}} is "${one.value}"`).join(", and ");
|
|
21
|
+
const parts = shape.keywords.map((one) => `{{${one.name}}}`).join(" and ");
|
|
22
|
+
return `- The video title is made from the channel's title pattern "${shape.pattern}", where ${held}. Each other title keeps every word of the pattern outside ${parts} exactly as it is, and changes only what ${parts} holds.`;
|
|
23
|
+
}
|
|
24
|
+
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
// Whether a title keeps the pattern's fixed wording, each keyword filled with something.
|
|
26
|
+
export function keepsShape(shape, title) {
|
|
27
|
+
const literal = shape.pattern.split(slot).filter((_, index) => index % 2 === 0);
|
|
28
|
+
const source = literal.map((part) => escaped(part.trim())).join("\\s*(.+?)\\s*");
|
|
29
|
+
return new RegExp(`^${source}$`, "u").test(title.trim());
|
|
30
|
+
}
|