@gentbajko/slopify 3.11.6 → 3.12.0
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/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.12.0.md +9 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/rebuild/recipe-save.js +48 -3
- package/dist/slices/rebuild/recipe-shorts.js +20 -0
- package/dist/slices/rebuild/runtime-shorts.js +36 -7
- package/dist/slices/rebuild/shorts-keep-save.js +100 -0
- package/dist/slices/revisions/mutations.js +42 -1
- package/dist/slices/revisions/schema.js +15 -0
- package/dist/slices/shorts/keep.js +91 -0
- package/dist/slices/shorts/pick.js +10 -0
- package/dist/web/assets/{index-CmQ2so2R.js → index-CzzL9qVN.js} +125 -125
- package/dist/web/assets/index-VbpUYLAp.css +1 -0
- package/dist/web/assets/{pdf-B0AMuc3F.js → pdf-DBgNgPzd.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DmPZKDb1.css +0 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Slopify 3.12.0
|
|
2
|
+
|
|
3
|
+
Released 7 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Keep or redo your shorts.** Changing a project's shorts no longer throws them all away. When a change would pick the moments again (another count, length or Shorts prompt, or a new intro or narration edit that moves the timing), Slopify keeps the finished shorts: same moment, title, image prompts and images, only rendered again. In Edit project → Shorts, Keep finished shorts lists them with a tick each, so you choose which stay; Pick different moments still picks every short anew.
|
|
6
|
+
- **Raising the count** keeps every short and picks only the extra ones, at moments the kept ones don't use.
|
|
7
|
+
- **Lowering the count** keeps the ones you tick, numbered 1, 2, 3 in their order, so release times, uploads and the extension's "Short N" line up. A short already on YouTube moves its record with it.
|
|
8
|
+
- **A kept short is remembered by its sentences**, so a new intro just moves it. When its narration was rewritten, the pick stops and names it, so you can pick a new moment for it or drop it.
|
|
9
|
+
- **The remake dialog names shorts work** ("Short 2: render", "Shorts: pick the moments") instead of "Output".
|
|
@@ -6,7 +6,9 @@ import { render } from "../admission/substitute.js";
|
|
|
6
6
|
import { effectiveClips, pickedShortsOf, rangeProblem } from "../shorts/clips.js";
|
|
7
7
|
import { buildRecipes } from "./recipe-build.js";
|
|
8
8
|
import { selectedReference } from "./recipe-model.js";
|
|
9
|
+
import { shortsPickKey } from "./recipe-shorts.js";
|
|
9
10
|
import { validateRecipeInputs } from "./recipe-validation.js";
|
|
11
|
+
import { finishedShorts, keepChosen, movedShorts, withKeep } from "./shorts-keep-save.js";
|
|
10
12
|
// A range set by hand for a short is checked against the pick it was set on, while that is
|
|
11
13
|
// still the project's pick; one left from an earlier pick no longer applies and is ignored.
|
|
12
14
|
function shortRangeFields(base, config, content) {
|
|
@@ -70,8 +72,49 @@ function picturesArticleOf(base, edit, changed) {
|
|
|
70
72
|
base.outputs.some((row) => row.selected && keys.includes(row.workKey) && row.state === "ready");
|
|
71
73
|
return drawn ? base.articleMarkdown : undefined;
|
|
72
74
|
}
|
|
75
|
+
// A save's plan. Shorts already finished are kept when the edit would pick them again (a new
|
|
76
|
+
// count, intro or narration moving the timing), unless the edit asks for new moments (Pick
|
|
77
|
+
// new moments) or chose its own keep list (Edit project → Shorts): `shorts/keep.ts`.
|
|
73
78
|
export function planRevision(base, edit, prepared = { outputs: [], pieces: [] }, inspected = []) {
|
|
74
|
-
|
|
79
|
+
if (edit.regenerate?.includes(shortsPickKey) === true) {
|
|
80
|
+
const { shortsKeep: _dropped, ...content } = edit.content;
|
|
81
|
+
return planEdit(base, { ...edit, content }, prepared, inspected, false);
|
|
82
|
+
}
|
|
83
|
+
if (keepChosen(base, edit) && edit.content.shortsKeep !== undefined) {
|
|
84
|
+
const count = edit.config.shorts?.count ?? 0;
|
|
85
|
+
if (edit.content.shortsKeep.length > count)
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
fields: [
|
|
89
|
+
{
|
|
90
|
+
field: "content.shortsKeep",
|
|
91
|
+
message: `Tick at most ${String(count)} ${count === 1 ? "short" : "shorts"} to keep, the number of shorts set above.`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
return planEdit(base, edit, prepared, inspected, true);
|
|
96
|
+
}
|
|
97
|
+
const first = planEdit(base, edit, prepared, inspected, false);
|
|
98
|
+
if (!first.ok)
|
|
99
|
+
return first;
|
|
100
|
+
const before = base.pieces.find((row) => row.key === shortsPickKey && row.selected && row.piece.state === "done")?.fingerprint;
|
|
101
|
+
const after = first.fingerprints[shortsPickKey];
|
|
102
|
+
if (before === undefined || after === undefined || after === before)
|
|
103
|
+
return first;
|
|
104
|
+
const finished = finishedShorts(base);
|
|
105
|
+
if (finished.length === 0)
|
|
106
|
+
return first;
|
|
107
|
+
return planEdit(base, { ...edit, content: { ...edit.content, shortsKeep: finished } }, prepared, inspected, true);
|
|
108
|
+
}
|
|
109
|
+
function planEdit(base, edit, prepared, inspected, keeping) {
|
|
110
|
+
const normal = normalizeImages(normalizeArticleIntent(base, edit));
|
|
111
|
+
// A keep list chosen in this save: kept shorts numbered by their place, their work moved
|
|
112
|
+
// with them.
|
|
113
|
+
const kept = keeping && normal.shortsKeep !== undefined
|
|
114
|
+
? withKeep(normal, normal.shortsKeep, edit.config.shorts?.count ?? normal.shortsKeep.length)
|
|
115
|
+
: undefined;
|
|
116
|
+
const content = kept?.content ?? normal;
|
|
117
|
+
const moves = kept?.moves ?? new Map();
|
|
75
118
|
const fields = [
|
|
76
119
|
...validateRevisionEdit(edit.config, content),
|
|
77
120
|
...shortModeFields(edit.config),
|
|
@@ -108,6 +151,7 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
|
|
|
108
151
|
outputs: base.outputs.filter(selectedReference),
|
|
109
152
|
pieces: base.pieces.filter(selectedReference),
|
|
110
153
|
};
|
|
154
|
+
const carried = movedShorts(manifest, moves);
|
|
111
155
|
const oldContext = {
|
|
112
156
|
config: base.revision.config,
|
|
113
157
|
content: base.revision.content,
|
|
@@ -122,13 +166,13 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
|
|
|
122
166
|
const replacementKeys = new Set(prepared.outputs.map((row) => row.workKey));
|
|
123
167
|
const proposedManifest = {
|
|
124
168
|
outputs: [
|
|
125
|
-
...
|
|
169
|
+
...carried.outputs
|
|
126
170
|
.filter((row) => !replacementKeys.has(row.workKey))
|
|
127
171
|
.map((row) => inspected.find((one) => one.slot === row.slot && one.assetId === row.assetId) ?? row),
|
|
128
172
|
...prepared.outputs,
|
|
129
173
|
],
|
|
130
174
|
pieces: [
|
|
131
|
-
...
|
|
175
|
+
...carried.pieces.filter((row) => !prepared.pieces.some((one) => one.key === row.key)),
|
|
132
176
|
...prepared.pieces,
|
|
133
177
|
],
|
|
134
178
|
};
|
|
@@ -196,6 +240,7 @@ export function planRevision(base, edit, prepared = { outputs: [], pieces: [] },
|
|
|
196
240
|
manifest: { outputs, pieces: proposedManifest.pieces },
|
|
197
241
|
baseFingerprints: Object.fromEntries(old.map((row) => [row.key, row.fingerprint])),
|
|
198
242
|
recipes: desired,
|
|
243
|
+
shortMoves: [...moves.entries()],
|
|
199
244
|
};
|
|
200
245
|
}
|
|
201
246
|
function normalizeImages(content) {
|
|
@@ -57,6 +57,26 @@ appearance) {
|
|
|
57
57
|
subjectOf(config),
|
|
58
58
|
// The project language, only when it is not English, so English keeps its fingerprint.
|
|
59
59
|
...(config.language === undefined || config.language === "en" ? [] : [config.language]),
|
|
60
|
+
// The shorts kept through a change of their moments, only when there are any, so
|
|
61
|
+
// every other project keeps its fingerprint (`shorts/keep.ts`).
|
|
62
|
+
...(context.content.shortsKeep === undefined
|
|
63
|
+
? []
|
|
64
|
+
: [
|
|
65
|
+
[
|
|
66
|
+
"keep-v1",
|
|
67
|
+
context.content.shortsKeep.map((short) => [
|
|
68
|
+
short.from,
|
|
69
|
+
short.opening,
|
|
70
|
+
short.closing,
|
|
71
|
+
short.sentences,
|
|
72
|
+
short.title,
|
|
73
|
+
short.description,
|
|
74
|
+
[...short.hashtags],
|
|
75
|
+
short.why,
|
|
76
|
+
short.seed ?? null,
|
|
77
|
+
]),
|
|
78
|
+
],
|
|
79
|
+
]),
|
|
60
80
|
],
|
|
61
81
|
}, [timing.key],
|
|
62
82
|
// A paid LLM call: readiness, the charge warning and the estimate treat it like one.
|
|
@@ -7,6 +7,7 @@ import { resolveBoldFont } from "../fonts/index.js";
|
|
|
7
7
|
import { masterGoal } from "../loudness/model.js";
|
|
8
8
|
import { clipWords } from "../shorts/captions.js";
|
|
9
9
|
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
10
|
+
import { anchorKept, lostMessage, withNewPicks } from "../shorts/keep.js";
|
|
10
11
|
import { defaultShortsPrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
|
|
11
12
|
import { checkPicks, keepNumbers, noPicksMessage, pickMessages, pickRetryMessages, } from "../shorts/pick.js";
|
|
12
13
|
import { checkImagePrompts } from "../shorts/prompts.js";
|
|
@@ -71,11 +72,29 @@ async function pick(deps, context, providers, piece, snapshot) {
|
|
|
71
72
|
throw new Error("The narration's word timing holds no words, so there is nothing to cut shorts from. Use More → Render the video again in the Video section, then Try again.");
|
|
72
73
|
const audio = await revisionAudio(deps, context, view);
|
|
73
74
|
const durationSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
75
|
+
// Kept shorts are found again in this timing; the model is asked only for the rest.
|
|
76
|
+
const keep = view.revision.content.shortsKeep;
|
|
77
|
+
const anchored = keep === undefined
|
|
78
|
+
? undefined
|
|
79
|
+
: anchorKept(keep.slice(0, shorts.count), sentences, durationSeconds);
|
|
80
|
+
if (anchored !== undefined && anchored.lost.length > 0)
|
|
81
|
+
throw new Error(lostMessage(anchored.lost, keep ?? []));
|
|
82
|
+
const keptPicks = anchored?.picks ?? [];
|
|
83
|
+
const wanted = shorts.count - keptPicks.length;
|
|
84
|
+
const taken = keptPicks.map(({ first, last }) => ({ first, last }));
|
|
85
|
+
const seed = view.revision.content.regenerationTokens[shortsPickKey] ?? null;
|
|
86
|
+
if (anchored !== undefined && wanted <= 0) {
|
|
87
|
+
await publishPicks(deps, context, piece, keptPicks, durationSeconds, sentences);
|
|
88
|
+
return "done";
|
|
89
|
+
}
|
|
74
90
|
const limits = {
|
|
75
|
-
count: shorts.count,
|
|
91
|
+
count: anchored === undefined ? shorts.count : shorts.count - anchored.picks.length,
|
|
76
92
|
minSeconds: shorts.minSeconds,
|
|
77
93
|
maxSeconds: shorts.maxSeconds,
|
|
78
94
|
durationSeconds,
|
|
95
|
+
...(anchored === undefined
|
|
96
|
+
? {}
|
|
97
|
+
: { taken: anchored.picks.map(({ first, last }) => ({ first, last })) }),
|
|
79
98
|
};
|
|
80
99
|
// Nothing to ask the model for, and nothing to pay for.
|
|
81
100
|
if (durationSeconds < shorts.minSeconds)
|
|
@@ -85,9 +104,10 @@ async function pick(deps, context, providers, piece, snapshot) {
|
|
|
85
104
|
instruction: typeof saved === "string" && saved.trim() !== "" ? saved : defaultShortsPrompt,
|
|
86
105
|
title: config.title,
|
|
87
106
|
durationSeconds,
|
|
88
|
-
count:
|
|
107
|
+
count: limits.count,
|
|
89
108
|
minSeconds: shorts.minSeconds,
|
|
90
109
|
maxSeconds: shorts.maxSeconds,
|
|
110
|
+
...(taken.length === 0 ? {} : { taken }),
|
|
91
111
|
sentences: sentencesText(sentences, usesVoices(config) && config.voices !== undefined
|
|
92
112
|
? Object.fromEntries(config.voices.speakers.map((speaker) => [speaker.id, speaker.name.trim()]))
|
|
93
113
|
: undefined),
|
|
@@ -106,7 +126,7 @@ async function pick(deps, context, providers, piece, snapshot) {
|
|
|
106
126
|
throw new Error(checked.reason);
|
|
107
127
|
// One more ask when clips were missing or broke the rules, with what was wrong; then
|
|
108
128
|
// whichever answer held more usable clips.
|
|
109
|
-
if (checked.picks.length <
|
|
129
|
+
if (checked.picks.length < limits.count && checked.problems.length > 0) {
|
|
110
130
|
if (!context.maySubmit(piece.id))
|
|
111
131
|
return "held";
|
|
112
132
|
const second = await providers
|
|
@@ -120,10 +140,20 @@ async function pick(deps, context, providers, piece, snapshot) {
|
|
|
120
140
|
}
|
|
121
141
|
if (checked.picks.length === 0)
|
|
122
142
|
throw new Error(noPicksMessage(checked.problems, limits));
|
|
123
|
-
//
|
|
124
|
-
//
|
|
143
|
+
// With kept shorts, they stay 1…k and the new ones follow. Otherwise a clip picked again
|
|
144
|
+
// with the same sentences keeps its number and seed, so its prompts, images and render are
|
|
145
|
+
// reused; the new ones carry this pick's token.
|
|
125
146
|
const previous = pickedShortsOf(view.pieces.find((row) => row.key === shortsPickKey && row.selected)?.piece.payload);
|
|
126
|
-
const picks =
|
|
147
|
+
const picks = anchored === undefined
|
|
148
|
+
? keepNumbers(checked.picks, previous?.shorts ?? [], seed)
|
|
149
|
+
: withNewPicks(keptPicks, checked.picks, seed);
|
|
150
|
+
await publishPicks(deps, context, piece, picks, durationSeconds, sentences);
|
|
151
|
+
return "done";
|
|
152
|
+
}
|
|
153
|
+
// The pick's answer saved: the clips (shorts.json) and, for planning, the numbered
|
|
154
|
+
// transcript's times a range set by hand is measured against. Work for shorts past the new
|
|
155
|
+
// count is dropped.
|
|
156
|
+
async function publishPicks(deps, context, piece, picks, durationSeconds, sentences) {
|
|
127
157
|
await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
|
|
128
158
|
[
|
|
129
159
|
"shorts",
|
|
@@ -140,7 +170,6 @@ async function pick(deps, context, providers, piece, snapshot) {
|
|
|
140
170
|
}, null,
|
|
141
171
|
// The shorts past the new count, from a pick that made more.
|
|
142
172
|
(key) => Number(/^shorts:(\d+):/.exec(key)?.[1] ?? 0) > picks.length);
|
|
143
|
-
return "done";
|
|
144
173
|
}
|
|
145
174
|
async function prompts(deps, context, providers, piece, snapshot, clip) {
|
|
146
175
|
const input = piece.input;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
2
|
+
import { keptOf } from "../shorts/keep.js";
|
|
3
|
+
// What a save does for the shorts it keeps (`shorts/keep.ts`): which shorts are finished, the
|
|
4
|
+
// keep list a save falls back to, and kept shorts moved to their new numbers, with the work
|
|
5
|
+
// that made their prompts and images, so only their render runs again.
|
|
6
|
+
const pickKey = "shorts:pick";
|
|
7
|
+
// The shorts of a version that are finished (their render ready and in use), in number order.
|
|
8
|
+
export function finishedShorts(base) {
|
|
9
|
+
const piece = base.pieces.find((row) => row.key === pickKey && row.selected && row.piece.state === "done");
|
|
10
|
+
const picked = pickedShortsOf(piece?.piece.payload);
|
|
11
|
+
const limits = base.revision.config.shorts;
|
|
12
|
+
if (piece === undefined || picked?.sentences === undefined || limits === undefined)
|
|
13
|
+
return [];
|
|
14
|
+
const sentences = picked.sentences;
|
|
15
|
+
const clips = effectiveClips(picked, base.revision.content.shortsRanges, piece.fingerprint, limits);
|
|
16
|
+
return clips
|
|
17
|
+
.filter((clip) => base.outputs.some((row) => rendersClip(row, clip)))
|
|
18
|
+
.toSorted((left, right) => left.number - right.number)
|
|
19
|
+
.flatMap((clip) => {
|
|
20
|
+
const kept = keptOf(clip, sentences);
|
|
21
|
+
return kept === undefined ? [] : [kept];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// Whether the edit set its own keep list (rather than carrying the version's along).
|
|
25
|
+
export function keepChosen(base, edit) {
|
|
26
|
+
return (JSON.stringify(edit.content.shortsKeep ?? null) !==
|
|
27
|
+
JSON.stringify(base.revision.content.shortsKeep ?? null));
|
|
28
|
+
}
|
|
29
|
+
// The content with a keep list in place: at most the count, each numbered by its place, the
|
|
30
|
+
// hand-set ranges (folded into the kept sentences) dropped, and every "Make this short again"
|
|
31
|
+
// token moved with its short. `moves` says which shorts change number.
|
|
32
|
+
export function withKeep(content, keep, count) {
|
|
33
|
+
const kept = keep.slice(0, count);
|
|
34
|
+
const moves = new Map();
|
|
35
|
+
for (const [at, short] of kept.entries())
|
|
36
|
+
if (short.from !== at + 1)
|
|
37
|
+
moves.set(short.from, at + 1);
|
|
38
|
+
const tokens = {};
|
|
39
|
+
for (const [key, value] of Object.entries(content.regenerationTokens)) {
|
|
40
|
+
const number = /^shorts:(\d+)$/.exec(key)?.[1];
|
|
41
|
+
if (number === undefined)
|
|
42
|
+
tokens[key] = value;
|
|
43
|
+
}
|
|
44
|
+
for (const [at, short] of kept.entries()) {
|
|
45
|
+
const own = content.regenerationTokens[`shorts:${String(short.from)}`];
|
|
46
|
+
if (own !== undefined)
|
|
47
|
+
tokens[`shorts:${String(at + 1)}`] = own;
|
|
48
|
+
}
|
|
49
|
+
const { shortsRanges: _folded, ...rest } = content;
|
|
50
|
+
return {
|
|
51
|
+
content: {
|
|
52
|
+
...rest,
|
|
53
|
+
regenerationTokens: tokens,
|
|
54
|
+
shortsKeep: kept.map((short, at) => ({ ...short, from: at + 1 })),
|
|
55
|
+
},
|
|
56
|
+
moves,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// The manifest with each moved short's rows under its new number, and the rows of the
|
|
60
|
+
// numbers they take over dropped, so the kept short's prompts and images are found again.
|
|
61
|
+
export function movedShorts(manifest, moves) {
|
|
62
|
+
if (moves.size === 0)
|
|
63
|
+
return manifest;
|
|
64
|
+
const targets = new Set(moves.values());
|
|
65
|
+
const numberOf = (key) => Number(/^shorts:(\d+):/.exec(key)?.[1] ?? Number.NaN);
|
|
66
|
+
const moved = (key) => {
|
|
67
|
+
const number = numberOf(key);
|
|
68
|
+
if (Number.isNaN(number))
|
|
69
|
+
return key;
|
|
70
|
+
const to = moves.get(number);
|
|
71
|
+
if (to !== undefined)
|
|
72
|
+
return key.replace(/^shorts:\d+:/, `shorts:${String(to)}:`);
|
|
73
|
+
// A number another kept short moves into: its own work is no longer the short there.
|
|
74
|
+
return targets.has(number) ? null : key;
|
|
75
|
+
};
|
|
76
|
+
const outputs = manifest.outputs.flatMap((row) => {
|
|
77
|
+
const workKey = moved(row.workKey);
|
|
78
|
+
if (workKey === null)
|
|
79
|
+
return [];
|
|
80
|
+
const slot = moved(row.slot) ?? row.slot;
|
|
81
|
+
return [workKey === row.workKey ? row : { ...row, workKey, slot }];
|
|
82
|
+
});
|
|
83
|
+
const pieces = manifest.pieces.flatMap((row) => {
|
|
84
|
+
const key = moved(row.key);
|
|
85
|
+
if (key === null)
|
|
86
|
+
return [];
|
|
87
|
+
return [key === row.key ? row : { ...row, key }];
|
|
88
|
+
});
|
|
89
|
+
return { outputs, pieces };
|
|
90
|
+
}
|
|
91
|
+
// A render in use, cut from exactly this clip's sentences: a render left from an earlier pick
|
|
92
|
+
// under the same number is another short.
|
|
93
|
+
export function rendersClip(row, clip) {
|
|
94
|
+
const sentences = row.output.meta.sentences;
|
|
95
|
+
return (row.workKey === `shorts:${String(clip.number)}:render` &&
|
|
96
|
+
row.selected &&
|
|
97
|
+
row.state === "ready" &&
|
|
98
|
+
sentences?.[0] === clip.first &&
|
|
99
|
+
sentences?.[1] === clip.last);
|
|
100
|
+
}
|
|
@@ -122,6 +122,7 @@ export async function saveRevision(deps, input) {
|
|
|
122
122
|
const fields = [
|
|
123
123
|
...validateAssetReferences(deps, input.projectId, plan.content, prepared.map((row) => row.asset)),
|
|
124
124
|
...validateProposedCues(fresh, finalEdit, plan, prepared, durations),
|
|
125
|
+
...replacedUploads(deps, input.projectId, plan.shortMoves),
|
|
125
126
|
];
|
|
126
127
|
if (fields.length > 0)
|
|
127
128
|
return { ok: false, reason: "invalid-edit", currentRevisionId: fresh.revision.id, fields };
|
|
@@ -130,8 +131,9 @@ export async function saveRevision(deps, input) {
|
|
|
130
131
|
if (timing !== undefined) {
|
|
131
132
|
plan = planRevision(fresh, {
|
|
132
133
|
...finalEdit,
|
|
134
|
+
// From the edit as sent, so the plan decides the kept shorts the same way again.
|
|
133
135
|
content: {
|
|
134
|
-
...
|
|
136
|
+
...finalEdit.content,
|
|
135
137
|
subtitleCues: {
|
|
136
138
|
...plan.content.subtitleCues,
|
|
137
139
|
audioFingerprint: timing.logicalFingerprint,
|
|
@@ -170,6 +172,7 @@ export async function saveRevision(deps, input) {
|
|
|
170
172
|
recipes: plan.recipes,
|
|
171
173
|
});
|
|
172
174
|
carryCheckpointGates(deps, input.baseRevisionId, revision);
|
|
175
|
+
moveShortRecords(deps, input.projectId, plan.shortMoves);
|
|
173
176
|
projectSelected(deps.db, revision);
|
|
174
177
|
projectStandings(deps, revision.projectId);
|
|
175
178
|
insertReceipt(deps, identity, revision.id);
|
|
@@ -293,3 +296,41 @@ export function logicalKeys(deps, projectId, revisionId, fingerprints) {
|
|
|
293
296
|
}
|
|
294
297
|
return values;
|
|
295
298
|
}
|
|
299
|
+
// A short already on YouTube can't make way for a kept short moving into its number: its
|
|
300
|
+
// upload record would be lost. The person keeps it, or lowers the count past it.
|
|
301
|
+
function replacedUploads(deps, projectId, moves) {
|
|
302
|
+
const from = new Set(moves.map(([number]) => number));
|
|
303
|
+
return moves.flatMap(([, to]) => {
|
|
304
|
+
if (from.has(to))
|
|
305
|
+
return [];
|
|
306
|
+
const uploaded = deps.db
|
|
307
|
+
.prepare("SELECT 1 FROM youtube_videos WHERE project_id=? AND short=?")
|
|
308
|
+
.get(projectId, to);
|
|
309
|
+
return uploaded === undefined
|
|
310
|
+
? []
|
|
311
|
+
: [
|
|
312
|
+
{
|
|
313
|
+
field: "content.shortsKeep",
|
|
314
|
+
message: `Short ${String(to)} is on YouTube, and another kept short would take its number. Keep short ${String(to)} too, or lower the count so it isn't needed.`,
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// Kept shorts that changed number take their upload record and release time with them.
|
|
320
|
+
// Moved in two steps, through numbers out of the way, so swaps don't collide.
|
|
321
|
+
function moveShortRecords(deps, projectId, moves) {
|
|
322
|
+
if (moves.length === 0)
|
|
323
|
+
return;
|
|
324
|
+
const aside = 1000;
|
|
325
|
+
for (const table of ["youtube_videos", "releases"]) {
|
|
326
|
+
for (const [from, to] of moves)
|
|
327
|
+
deps.db
|
|
328
|
+
.prepare(`UPDATE ${table} SET short=? WHERE project_id=? AND short=?`)
|
|
329
|
+
.run(to + aside, projectId, from);
|
|
330
|
+
for (const [, to] of moves)
|
|
331
|
+
deps.db.prepare(`DELETE FROM ${table} WHERE project_id=? AND short=?`).run(projectId, to);
|
|
332
|
+
deps.db
|
|
333
|
+
.prepare(`UPDATE ${table} SET short=short-? WHERE project_id=? AND short>=?`)
|
|
334
|
+
.run(aside, projectId, aside);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -30,6 +30,19 @@ const cue = z
|
|
|
30
30
|
path: ["end"],
|
|
31
31
|
message: "The end time must be after the start time.",
|
|
32
32
|
});
|
|
33
|
+
const keptShortSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
from: z.number().int().positive().max(99),
|
|
36
|
+
opening: z.string().min(1).max(20000),
|
|
37
|
+
closing: z.string().min(1).max(20000),
|
|
38
|
+
sentences: z.number().int().positive().max(10000),
|
|
39
|
+
title: z.string().max(200),
|
|
40
|
+
description: z.string().max(5000),
|
|
41
|
+
hashtags: z.array(z.string().max(100)).max(30),
|
|
42
|
+
why: z.string().max(2000),
|
|
43
|
+
seed: z.string().max(128).nullable().optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
33
46
|
export const revisionContentSchema = z
|
|
34
47
|
.object({
|
|
35
48
|
articleMarkdown: z.string().max(500000).optional(),
|
|
@@ -97,6 +110,8 @@ export const revisionContentSchema = z
|
|
|
97
110
|
})
|
|
98
111
|
.strict())
|
|
99
112
|
.optional(),
|
|
113
|
+
// The shorts kept through a change of their moments, in order (`shorts/keep.ts`).
|
|
114
|
+
shortsKeep: z.array(keptShortSchema).max(20).optional(),
|
|
100
115
|
ambientBed: id.optional(),
|
|
101
116
|
thumbnailOverrides: z.partialRecord(z.enum(["1", "2", "3"]), id).optional(),
|
|
102
117
|
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { clipBounds } from "./pick.js";
|
|
2
|
+
// Shorts kept through a change of their moments: a count raised or lowered, a new intro or a
|
|
3
|
+
// narration edit that moves the timing. A kept short is remembered by its sentences' text, so
|
|
4
|
+
// it is found again however the timing moved, and keeps its title, hashtags and seed (and with
|
|
5
|
+
// the seed its prompts, images and render's token). Kept shorts come first, numbered 1…k in
|
|
6
|
+
// the order listed; new ones follow. Pure: planning, the save and the pick read the same.
|
|
7
|
+
// The short a clip of the current pick is, as a kept short.
|
|
8
|
+
export function keptOf(clip, sentences) {
|
|
9
|
+
const opening = sentences[clip.first - 1]?.text;
|
|
10
|
+
const closing = sentences[clip.last - 1]?.text;
|
|
11
|
+
if (opening === undefined || closing === undefined || clip.last < clip.first)
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
from: clip.number,
|
|
15
|
+
opening,
|
|
16
|
+
closing,
|
|
17
|
+
sentences: clip.last - clip.first + 1,
|
|
18
|
+
title: clip.title,
|
|
19
|
+
description: clip.description,
|
|
20
|
+
hashtags: clip.hashtags,
|
|
21
|
+
why: clip.why,
|
|
22
|
+
...(clip.seed === undefined ? {} : { seed: clip.seed }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const same = (left, right) => left.replace(/\s+/g, " ").trim() === right.replace(/\s+/g, " ").trim();
|
|
26
|
+
// Each kept short found in the transcript, numbered 1…k in order, and the ones whose
|
|
27
|
+
// sentences are no longer there (by their place in the list, 1-based).
|
|
28
|
+
export function anchorKept(kept, sentences, durationSeconds) {
|
|
29
|
+
const picks = [];
|
|
30
|
+
const lost = [];
|
|
31
|
+
const taken = (first, last) => picks.some((one) => first <= one.last && last >= one.first);
|
|
32
|
+
for (const [index, short] of kept.entries()) {
|
|
33
|
+
let found;
|
|
34
|
+
for (let at = 0; at + short.sentences <= sentences.length && found === undefined; at++) {
|
|
35
|
+
const first = at + 1;
|
|
36
|
+
const last = at + short.sentences;
|
|
37
|
+
const opening = sentences[at];
|
|
38
|
+
const closing = sentences[last - 1];
|
|
39
|
+
if (opening === undefined || closing === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
if (!same(opening.text, short.opening) || !same(closing.text, short.closing))
|
|
42
|
+
continue;
|
|
43
|
+
if (taken(first, last))
|
|
44
|
+
continue;
|
|
45
|
+
const bounds = clipBounds(sentences, first, last, durationSeconds);
|
|
46
|
+
if (bounds === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
found = {
|
|
49
|
+
number: index + 1,
|
|
50
|
+
first,
|
|
51
|
+
last,
|
|
52
|
+
start: bounds.start,
|
|
53
|
+
end: bounds.end,
|
|
54
|
+
title: short.title,
|
|
55
|
+
description: short.description,
|
|
56
|
+
hashtags: short.hashtags,
|
|
57
|
+
why: short.why,
|
|
58
|
+
text: sentences
|
|
59
|
+
.slice(first - 1, last)
|
|
60
|
+
.map((sentence) => sentence.text)
|
|
61
|
+
.join(" "),
|
|
62
|
+
...(short.seed === undefined ? {} : { seed: short.seed }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (found === undefined)
|
|
66
|
+
lost.push(index + 1);
|
|
67
|
+
else
|
|
68
|
+
picks.push(found);
|
|
69
|
+
}
|
|
70
|
+
return { picks, lost };
|
|
71
|
+
}
|
|
72
|
+
// The kept shorts and the new ones together: kept 1…k, then the new ones as k+1… in the order
|
|
73
|
+
// they play, carrying `seed` (the pick's token).
|
|
74
|
+
export function withNewPicks(kept, fresh, seed) {
|
|
75
|
+
return [
|
|
76
|
+
...kept,
|
|
77
|
+
...fresh
|
|
78
|
+
.toSorted((left, right) => left.start - right.start)
|
|
79
|
+
.map((pick, index) => ({ ...pick, number: kept.length + index + 1, seed })),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
// Why the pick can't go on: kept shorts whose sentences changed in the narration.
|
|
83
|
+
export function lostMessage(lost, kept) {
|
|
84
|
+
const names = lost
|
|
85
|
+
.map((at) => {
|
|
86
|
+
const short = kept[at - 1];
|
|
87
|
+
return short === undefined ? `short ${String(at)}` : `short ${String(at)} ("${short.title}")`;
|
|
88
|
+
})
|
|
89
|
+
.join(", ");
|
|
90
|
+
return `The narration of ${names} changed, so its moment can't be kept. Open Edit project → Shorts and choose Pick a new moment for it or drop it, then Remake.`;
|
|
91
|
+
}
|
|
@@ -68,6 +68,11 @@ export function pickMessages(brief) {
|
|
|
68
68
|
'- "first" and "last" are sentence numbers from the transcript. A clip runs from the start of sentence "first" to the end of sentence "last", both included.',
|
|
69
69
|
`- Each clip lasts between ${String(brief.minSeconds)} and ${String(brief.maxSeconds)} seconds, measured from the start time of its first sentence to the end time of its last one.`,
|
|
70
70
|
"- No two clips share a sentence.",
|
|
71
|
+
...(brief.taken === undefined || brief.taken.length === 0
|
|
72
|
+
? []
|
|
73
|
+
: [
|
|
74
|
+
`- These sentences are already shorts and no clip may use any of them: ${brief.taken.map((range) => (range.first === range.last ? String(range.first) : `${String(range.first)}-${String(range.last)}`)).join(", ")}.`,
|
|
75
|
+
]),
|
|
71
76
|
`- "title" is at most ${String(shortTitleMax)} characters, one line, without hashtags.`,
|
|
72
77
|
'- "description" is one line.',
|
|
73
78
|
`- "hashtags" holds ${String(shortHashtagsMin)} to ${String(shortHashtagsMax)} single words, each starting with #.`,
|
|
@@ -154,6 +159,11 @@ export function checkPicks(text, sentences, limits) {
|
|
|
154
159
|
problems.push(`${label} (sentences ${String(first)}-${String(last)}) lasts ${String(Math.round(seconds))} seconds, and each clip must last ${String(limits.minSeconds)}-${String(limits.maxSeconds)} seconds.`);
|
|
155
160
|
continue;
|
|
156
161
|
}
|
|
162
|
+
const used = (limits.taken ?? []).find((one) => first <= one.last && last >= one.first);
|
|
163
|
+
if (used !== undefined) {
|
|
164
|
+
problems.push(`${label} (sentences ${String(first)}-${String(last)}) uses sentences ${String(used.first)}-${String(used.last)}, which are already a short.`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
157
167
|
const clash = kept.find((one) => first <= one.last && last >= one.first);
|
|
158
168
|
if (clash !== undefined) {
|
|
159
169
|
problems.push(`${label} (sentences ${String(first)}-${String(last)}) shares sentences with the clip of sentences ${String(clash.first)}-${String(clash.last)}.`);
|