@gentbajko/slopify 2.3.0 → 2.4.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/adapter-registry.js +1 -1
- package/dist/adapters/image/fal.js +77 -6
- package/dist/adapters/image/replicate.js +41 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +82 -1
- package/dist/catalog/schema.js +10 -0
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +32 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/runner/attempt.js +4 -1
- package/dist/kernel/runner/providers.js +20 -0
- package/dist/main.js +3 -0
- package/dist/slices/admission/rules.js +44 -2
- package/dist/slices/admission/schema.js +31 -0
- package/dist/slices/estimate/index.js +57 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +6 -1
- package/dist/slices/play-drafts/convert.js +37 -1
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/schema.js +24 -1
- package/dist/slices/project-templates/from-project.js +22 -0
- package/dist/slices/project-templates/setup.js +2 -0
- package/dist/slices/rebuild/preview-details.js +29 -1
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +6 -2
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +1 -0
- package/dist/slices/rebuild/recipe-legacy.js +9 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +179 -0
- package/dist/slices/rebuild/recipe-validation.js +19 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +15 -0
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +330 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/transition-repo.js +39 -0
- package/dist/slices/revisions/mutation-assets.js +18 -0
- package/dist/slices/revisions/mutation-prepare.js +9 -7
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +11 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +8 -0
- package/dist/slices/storage/model.js +6 -0
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-BGgYMm2u.css +1 -0
- package/dist/web/assets/index-BVVgtntL.js +171 -0
- package/dist/web/assets/{pdf-BkdJFKQ-.js → pdf-CAJNT254.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cw1mnRrx.js +0 -168
- package/dist/web/assets/index-kAKdJa-U.css +0 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const requestLimits = { requestMs: 300_000, uploadIdleMs: 300_000 };
|
|
2
|
+
// Node's `requestTimeout` (5 minutes) is one limit for the whole server: every
|
|
3
|
+
// `connectionsCheckingInterval` it answers 408 and closes any connection whose current request
|
|
4
|
+
// started longer ago than that and has not fully arrived, however steadily its body is still
|
|
5
|
+
// coming in. A multi-GB backup over a slow link needs longer, so the server's limit is switched
|
|
6
|
+
// off and the same limit is kept here per request, with one exception: Import everything
|
|
7
|
+
// (PUT /api/storage/import) may take as long as it needs, and is only cut off when it sends
|
|
8
|
+
// nothing at all for `uploadIdleMs`. `headersTimeout` (1 minute) is Node's and still applies to
|
|
9
|
+
// every request.
|
|
10
|
+
export function limitRequestTimes(server, limits = requestLimits) {
|
|
11
|
+
if (!("requestTimeout" in server))
|
|
12
|
+
return;
|
|
13
|
+
server.requestTimeout = 0;
|
|
14
|
+
server.on("request", (request) => {
|
|
15
|
+
if (isBackupUpload(request)) {
|
|
16
|
+
// Idle only while the file is arriving; once it is in, the import may take its time.
|
|
17
|
+
request.setTimeout(limits.uploadIdleMs);
|
|
18
|
+
request.once("end", () => request.setTimeout(0));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
if (request.complete)
|
|
23
|
+
return;
|
|
24
|
+
// What Node itself does: a 408 if nothing has been answered on this connection yet.
|
|
25
|
+
const socket = request.socket;
|
|
26
|
+
if (socket.writable && socket.bytesWritten === 0)
|
|
27
|
+
socket.write("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n");
|
|
28
|
+
socket.destroy();
|
|
29
|
+
}, limits.requestMs);
|
|
30
|
+
timer.unref();
|
|
31
|
+
const clear = () => clearTimeout(timer);
|
|
32
|
+
request.once("end", clear);
|
|
33
|
+
request.once("close", clear);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function isBackupUpload(request) {
|
|
37
|
+
return request.method === "PUT" && (request.url ?? "").split("?")[0] === "/api/storage/import";
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Library prompts of the new "shorts" kind: what the Video stage's Shorts step asks the text
|
|
2
|
+
-- model for when it picks the clips. SQLite cannot change a CHECK in place, so the table is
|
|
3
|
+
-- copied into one whose CHECK lists the new kind, like 0015 did for "description".
|
|
4
|
+
CREATE TABLE prompts_shorts_upgrade (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration','description','shorts')),
|
|
7
|
+
name TEXT NOT NULL,
|
|
8
|
+
body TEXT NOT NULL,
|
|
9
|
+
slots TEXT NOT NULL,
|
|
10
|
+
updated_at TEXT NOT NULL
|
|
11
|
+
);
|
|
12
|
+
INSERT INTO prompts_shorts_upgrade (id,kind,name,body,slots,updated_at)
|
|
13
|
+
SELECT id,kind,name,body,slots,updated_at FROM prompts;
|
|
14
|
+
DROP TABLE prompts;
|
|
15
|
+
ALTER TABLE prompts_shorts_upgrade RENAME TO prompts;
|
|
16
|
+
CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
|
|
@@ -5,11 +5,13 @@ import { isProviderError, providerError } from "../ports/model.js";
|
|
|
5
5
|
// Four attempts - the first plus 3 retries - waiting 2 s, 8 s, 30 s.
|
|
6
6
|
export const attemptLimit = 4;
|
|
7
7
|
export const backoffMs = [2000, 8000, 30_000];
|
|
8
|
-
// Each attempt times out at 120 s, image calls at 300 s
|
|
8
|
+
// Each attempt times out at 120 s, image calls at 300 s, and an image animated into a clip at
|
|
9
|
+
// 900 s: image-to-video models queue and take one to five minutes when they run.
|
|
9
10
|
export const timeoutMs = {
|
|
10
11
|
llm: 120_000,
|
|
11
12
|
tts: 120_000,
|
|
12
13
|
image: 300_000,
|
|
14
|
+
video: 900_000,
|
|
13
15
|
};
|
|
14
16
|
// A refusal and an unsupported capability are the provider's final answer; retrying only
|
|
15
17
|
// spends the user's money again. The third is a key removed mid-run, which leaves nothing
|
|
@@ -131,6 +133,7 @@ const callee = {
|
|
|
131
133
|
llm: "AI model",
|
|
132
134
|
tts: "narration provider",
|
|
133
135
|
image: "image provider",
|
|
136
|
+
video: "image-to-video model",
|
|
134
137
|
};
|
|
135
138
|
// Node's `fetch` rejects a request that never reached the server with this TypeError.
|
|
136
139
|
function unreachable(error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { providerError } from "../ports/model.js";
|
|
2
3
|
import { attempt } from "./attempt.js";
|
|
3
4
|
export function stageProviders(deps, context, pieceId) {
|
|
4
5
|
const ctx = {
|
|
@@ -149,6 +150,25 @@ export function stageProviders(deps, context, pieceId) {
|
|
|
149
150
|
// One request, one answer: the 300 s runs over the whole call.
|
|
150
151
|
{ kind: "image" }));
|
|
151
152
|
},
|
|
153
|
+
animate: (call) => {
|
|
154
|
+
const port = deps.registry.image(call.provider);
|
|
155
|
+
const animate = port.animate;
|
|
156
|
+
if (animate === undefined)
|
|
157
|
+
return Promise.reject(providerError({
|
|
158
|
+
kind: "unsupported",
|
|
159
|
+
message: `${call.provider} can't turn images into video clips. Choose fal.ai or Replicate as the image provider in Edit project → Providers, or turn Animate images off in Edit project → Inputs → Look.`,
|
|
160
|
+
}));
|
|
161
|
+
return schedule(call.provider, () => attempt(ctx, (signal) => animate({
|
|
162
|
+
model: call.model,
|
|
163
|
+
prompt: call.prompt,
|
|
164
|
+
image: call.image,
|
|
165
|
+
aspect: call.aspect,
|
|
166
|
+
seconds: call.seconds,
|
|
167
|
+
signal,
|
|
168
|
+
}),
|
|
169
|
+
// Queued and slow: the 900 s runs over the whole call, polling included.
|
|
170
|
+
{ kind: "video" }));
|
|
171
|
+
},
|
|
152
172
|
forPiece: (piece) => stageProviders(deps, context, piece),
|
|
153
173
|
};
|
|
154
174
|
}
|
package/dist/main.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createHub } from "./edge/events/hub.js";
|
|
|
16
16
|
import { currentProjectEvent } from "./edge/events/visibility.js";
|
|
17
17
|
import { createApp } from "./edge/http/app.js";
|
|
18
18
|
import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
|
|
19
|
+
import { limitRequestTimes } from "./edge/http/timeouts.js";
|
|
19
20
|
import { openFolder } from "./edge/open-folder.js";
|
|
20
21
|
import { createAudioPreviewStore } from "./kernel/audio-preview.js";
|
|
21
22
|
import { systemClock } from "./kernel/clock.js";
|
|
@@ -517,6 +518,8 @@ function listen(app, config, log) {
|
|
|
517
518
|
});
|
|
518
519
|
resolve(server);
|
|
519
520
|
});
|
|
521
|
+
// Before the first connection: a backup import may outlast Node's 5-minute request limit.
|
|
522
|
+
limitRequestTimes(server);
|
|
520
523
|
server.once("error", reject);
|
|
521
524
|
});
|
|
522
525
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
+
import { shortsSettingsProblems } from "../shorts/model.js";
|
|
3
|
+
import { usesAnimation, videoEditProblems } from "../video/edit-settings.js";
|
|
2
4
|
import { sourceOf } from "./model.js";
|
|
3
5
|
export const titleMax = 200;
|
|
4
6
|
export const valueMax = 200;
|
|
@@ -81,7 +83,8 @@ export function admit(input) {
|
|
|
81
83
|
draft.intro?.mode === "llm" ||
|
|
82
84
|
draft.outro?.mode === "llm" ||
|
|
83
85
|
usesNarrationPreparation(draft) ||
|
|
84
|
-
usesYoutubeDescription(draft)
|
|
86
|
+
usesYoutubeDescription(draft) ||
|
|
87
|
+
usesShorts(draft);
|
|
85
88
|
if (needsLlm && !chosen(draft.llm)) {
|
|
86
89
|
fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
|
|
87
90
|
}
|
|
@@ -103,7 +106,9 @@ export function admit(input) {
|
|
|
103
106
|
}
|
|
104
107
|
if ((sources.images === "generate" ||
|
|
105
108
|
sources.thumbnail === "from_prompt" ||
|
|
106
|
-
sources.thumbnail === "prompt_by_llm"
|
|
109
|
+
sources.thumbnail === "prompt_by_llm" ||
|
|
110
|
+
usesShorts(draft) ||
|
|
111
|
+
usesAnimation(draft)) &&
|
|
107
112
|
!chosen(draft.images)) {
|
|
108
113
|
fields.push({ field: "images", message: "Choose an image provider and model." });
|
|
109
114
|
}
|
|
@@ -118,6 +123,8 @@ export function admit(input) {
|
|
|
118
123
|
});
|
|
119
124
|
}
|
|
120
125
|
fields.push(...youtubeDescriptionFields(draft));
|
|
126
|
+
fields.push(...shortsFields(draft));
|
|
127
|
+
fields.push(...videoEditFields(draft));
|
|
121
128
|
checkProvided(draft, input.staged, fields);
|
|
122
129
|
checkValues(draft, input.requiredSlots, fields);
|
|
123
130
|
if (!Number.isFinite(draft.silenceGapSeconds) ||
|
|
@@ -305,6 +312,41 @@ export function youtubeDescriptionFields(draft) {
|
|
|
305
312
|
]
|
|
306
313
|
: [];
|
|
307
314
|
}
|
|
315
|
+
// Shorts are cut from the narration and captioned from its word timings, so they need
|
|
316
|
+
// narration, like the YouTube description; they run whether the video renders or not.
|
|
317
|
+
export function usesShorts(draft) {
|
|
318
|
+
return draft.shorts?.enabled === true && draft.sources.audio !== "off";
|
|
319
|
+
}
|
|
320
|
+
export function shortsFields(draft) {
|
|
321
|
+
const shorts = draft.shorts;
|
|
322
|
+
if (shorts?.enabled !== true)
|
|
323
|
+
return [];
|
|
324
|
+
if (draft.sources.audio === "off")
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
field: "shorts.enabled",
|
|
328
|
+
message: "Shorts are cut from the narration. Turn narration on, or turn Shorts off.",
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
const fields = shortsSettingsProblems(shorts).map((problem) => ({
|
|
332
|
+
field: `shorts.${problem.field}`,
|
|
333
|
+
message: problem.message,
|
|
334
|
+
}));
|
|
335
|
+
// The shorts hold each image as long as the video does. With the video off nothing else
|
|
336
|
+
// checks the number.
|
|
337
|
+
const imageProblem = draft.sources.video === "generate" ? undefined : imageSecondsProblem(draft.imageSeconds);
|
|
338
|
+
if (imageProblem !== undefined)
|
|
339
|
+
fields.push({ field: "imageSeconds", message: imageProblem });
|
|
340
|
+
return fields;
|
|
341
|
+
}
|
|
342
|
+
// The Video stage's edit settings, checked only while the video renders: a hidden setting
|
|
343
|
+
// cannot hold up a run.
|
|
344
|
+
export function videoEditFields(draft) {
|
|
345
|
+
return videoEditProblems(draft).map((problem) => ({
|
|
346
|
+
field: `videoEdit.${problem.field}`,
|
|
347
|
+
message: problem.message,
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
308
350
|
function chosen(choice) {
|
|
309
351
|
return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
|
|
310
352
|
}
|
|
@@ -4,6 +4,7 @@ import { checkpointStageSchema } from "../checkpoints/schema.js";
|
|
|
4
4
|
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
5
5
|
import { chunkModes } from "../narration/chunk.js";
|
|
6
6
|
import { subtitleConfigSchema } from "../subtitles/model.js";
|
|
7
|
+
import { animateModes, atmospheres, colorGrades, cutModes, lookLevels, transitionKinds, } from "../video/edit-settings.js";
|
|
7
8
|
import { entryModes, formats, motionStyles, stageSources } from "./model.js";
|
|
8
9
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "./rules.js";
|
|
9
10
|
const providerChoice = z.object({
|
|
@@ -12,6 +13,19 @@ const providerChoice = z.object({
|
|
|
12
13
|
thinking: z.enum(thinkingModes).optional(),
|
|
13
14
|
});
|
|
14
15
|
const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
|
|
16
|
+
export const videoEditSchema = z.object({
|
|
17
|
+
cuts: z.enum(cutModes),
|
|
18
|
+
transition: z.enum(transitionKinds),
|
|
19
|
+
transitionSeconds: z.number(),
|
|
20
|
+
vignette: z.enum(lookLevels),
|
|
21
|
+
grain: z.enum(lookLevels),
|
|
22
|
+
grade: z.enum(colorGrades),
|
|
23
|
+
atmosphere: z.enum(atmospheres),
|
|
24
|
+
chapterCards: z.boolean(),
|
|
25
|
+
animate: z.enum(animateModes),
|
|
26
|
+
animateEvery: z.number(),
|
|
27
|
+
animateModel: z.string(),
|
|
28
|
+
});
|
|
15
29
|
// The shape Play posts and the shape `projects.config` holds, in one place: the second
|
|
16
30
|
// is the first plus the rendered prompt texts.
|
|
17
31
|
export const runDraftSchema = z.object({
|
|
@@ -86,6 +100,23 @@ export const runDraftSchema = z.object({
|
|
|
86
100
|
document: documentSettingsSchema.optional(),
|
|
87
101
|
youtubeDescription: z.boolean().optional(),
|
|
88
102
|
descriptionPrompt: z.string().optional(),
|
|
103
|
+
// The ranges are `slices/shorts/model.ts`'s, checked by admission, not the schema's.
|
|
104
|
+
shorts: z
|
|
105
|
+
.object({
|
|
106
|
+
enabled: z.boolean(),
|
|
107
|
+
count: z.number(),
|
|
108
|
+
minSeconds: z.number(),
|
|
109
|
+
maxSeconds: z.number(),
|
|
110
|
+
prompt: z.string().optional(),
|
|
111
|
+
imagePrompt: z.string().optional(),
|
|
112
|
+
titleOnScreen: z.boolean().optional(),
|
|
113
|
+
fullVideoLink: z.string().optional(),
|
|
114
|
+
musicVolume: z.number().optional(),
|
|
115
|
+
speed: z.number().optional(),
|
|
116
|
+
})
|
|
117
|
+
.optional(),
|
|
118
|
+
// The ranges are `slices/video/edit-settings.ts`'s, checked by admission, not the schema's.
|
|
119
|
+
videoEdit: videoEditSchema.optional(),
|
|
89
120
|
});
|
|
90
121
|
export const runConfigSchema = runDraftSchema.extend({
|
|
91
122
|
rendered: z.record(z.string(), z.string()),
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { sourceOf } from "../admission/model.js";
|
|
3
|
-
import { usesNarrationPreparation, usesYoutubeDescription } from "../admission/rules.js";
|
|
3
|
+
import { imageSecondsProblem, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
4
4
|
import { plainText } from "../article/plain.js";
|
|
5
5
|
import { splitEndMatter } from "../article/split.js";
|
|
6
6
|
import { chunkNarration, defaultChunking } from "../narration/chunk.js";
|
|
7
7
|
import { normalizeNarrationText } from "../narration/plan.js";
|
|
8
8
|
import { preparationMessages } from "../narration/preparation.js";
|
|
9
|
+
import { defaultShortsImagePrompt, defaultShortsPrompt, shortsImageUpperBound, shortsSettingsProblems, } from "../shorts/model.js";
|
|
10
|
+
import { animatedClipSeconds, animatedImageIndexes, usesAnimation, videoEditProblems, } from "../video/edit-settings.js";
|
|
9
11
|
import { defaultDescriptionPrompt } from "../youtube/model.js";
|
|
12
|
+
// ceiling: the most chapters "Chapter openers" is charged for before the article exists, the
|
|
13
|
+
// top of the 5-12 chapters the built-in YouTube description prompt asks for.
|
|
14
|
+
const chapterOpenersMax = 12;
|
|
10
15
|
import { estimateRequests, groupEstimateRows } from "./requests.js";
|
|
11
16
|
export { estimateRequests } from "./requests.js";
|
|
12
17
|
export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
@@ -34,7 +39,10 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
34
39
|
!model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
|
|
35
40
|
const generatedArticle = draft.sources.article === "generate";
|
|
36
41
|
const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
|
|
37
|
-
const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum +
|
|
42
|
+
const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum +
|
|
43
|
+
(key === "narration" || key === "description" || key === "shorts" || key === "shortsImage"
|
|
44
|
+
? 0
|
|
45
|
+
: value.length), 0);
|
|
38
46
|
const local = (stage, detail) => {
|
|
39
47
|
requests.push({ kind: "local", stage, detail });
|
|
40
48
|
};
|
|
@@ -132,6 +140,53 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
132
140
|
: defaultDescriptionPrompt.length) +
|
|
133
141
|
Math.round(articleChars * 1.05) +
|
|
134
142
|
1500, 2400, "One LLM call on the timed transcript.");
|
|
143
|
+
// Shorts: one call picks the clips from the numbered transcript, one per clip writes its
|
|
144
|
+
// image prompts, and the images are charged at the most the step can ask for, every clip
|
|
145
|
+
// at the longest length allowed.
|
|
146
|
+
const shorts = draft.shorts;
|
|
147
|
+
// Numbers the run would refuse are not priced: a typo of 3000 shorts would list them all.
|
|
148
|
+
if (usesShorts(draft) &&
|
|
149
|
+
shorts !== undefined &&
|
|
150
|
+
shortsSettingsProblems(shorts).length === 0 &&
|
|
151
|
+
imageSecondsProblem(draft.imageSeconds) === undefined) {
|
|
152
|
+
text("Shorts", (rendered.shorts?.trim() ? rendered.shorts.length : defaultShortsPrompt.length) +
|
|
153
|
+
Math.round(articleChars * 1.15) +
|
|
154
|
+
1500, 400 * shorts.count, "One LLM call on the numbered transcript picks the clips.");
|
|
155
|
+
const style = rendered.shortsImage?.trim()
|
|
156
|
+
? rendered.shortsImage.length
|
|
157
|
+
: defaultShortsImagePrompt.length;
|
|
158
|
+
for (let clip = 0; clip < shorts.count; clip++)
|
|
159
|
+
text("Shorts", style + 3000, 2400, "One LLM call per short writes its image prompts.");
|
|
160
|
+
const count = shortsImageUpperBound(shorts, draft.imageSeconds);
|
|
161
|
+
for (let index = 0; index < count; index++)
|
|
162
|
+
requests.push({
|
|
163
|
+
kind: "image",
|
|
164
|
+
stage: "Shorts",
|
|
165
|
+
provider: image.provider,
|
|
166
|
+
model: image.model,
|
|
167
|
+
detail: `Up to ${String(count)} vertical images. ${imageNote}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Animated images: one clip from the image-to-video model per animated image, each priced
|
|
171
|
+
// as the catalogue prices one clip. Only drawn images are animated; the chapter openers are
|
|
172
|
+
// charged at the most there can be, one per image up to twelve chapters.
|
|
173
|
+
const edit = draft.videoEdit;
|
|
174
|
+
if (usesAnimation(draft) && edit !== undefined && videoEditProblems(draft).length === 0) {
|
|
175
|
+
const clips = edit.animate === "every"
|
|
176
|
+
? animatedImageIndexes(edit, images).length
|
|
177
|
+
: Math.min(images, chapterOpenersMax);
|
|
178
|
+
const note = data.image.find((model) => model.provider === image.provider && model.id === edit.animateModel)?.pricing.note ?? "Model or account pricing is unknown.";
|
|
179
|
+
for (let index = 0; index < clips; index++)
|
|
180
|
+
requests.push({
|
|
181
|
+
kind: "image",
|
|
182
|
+
stage: "Animated images",
|
|
183
|
+
provider: image.provider,
|
|
184
|
+
model: edit.animateModel,
|
|
185
|
+
detail: edit.animate === "every"
|
|
186
|
+
? `${String(clips)} clips of ${String(animatedClipSeconds)} seconds. ${note}`
|
|
187
|
+
: `Up to ${String(clips)} clips of ${String(animatedClipSeconds)} seconds, one per chapter opening. ${note}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
135
190
|
if (sourceOf(draft.sources, "document") === "generate")
|
|
136
191
|
local("Document", "Laid out locally from the article; no API fee.");
|
|
137
192
|
return {
|
|
@@ -9,6 +9,9 @@ import { readFontMetadata } from "./sfnt.js";
|
|
|
9
9
|
export const fontIdPattern = /^(?:default|(?:system|uploaded)-[a-f0-9]{64})$/;
|
|
10
10
|
const uploadName = /^(uploaded-[a-f0-9]{64})\.(ttf|otf)$/;
|
|
11
11
|
const bundled = fileURLToPath(new URL("../../assets/fonts/Barlow-Regular.ttf", import.meta.url));
|
|
12
|
+
// Barlow's own Bold face, for text drawn bold in the bundled font (the shorts' captions);
|
|
13
|
+
// not listed as a font of its own.
|
|
14
|
+
const bundledBold = fileURLToPath(new URL("../../assets/fonts/Barlow-Bold.ttf", import.meta.url));
|
|
12
15
|
export async function listFonts(paths) {
|
|
13
16
|
const [defaultFont, uploaded, system] = await Promise.all([
|
|
14
17
|
bundledFont(),
|
|
@@ -30,6 +33,26 @@ export async function resolveFont(paths, id) {
|
|
|
30
33
|
throw missingFont();
|
|
31
34
|
return font;
|
|
32
35
|
}
|
|
36
|
+
// The font to draw bold text in: for the bundled Barlow its real Bold face, so the letters
|
|
37
|
+
// are drawn as designed; any other font is the font itself, which libass emboldens.
|
|
38
|
+
export async function resolveBoldFont(paths, id) {
|
|
39
|
+
if (id !== "default")
|
|
40
|
+
return resolveFont(paths, id);
|
|
41
|
+
const bytes = await readFontFile(bundledBold);
|
|
42
|
+
const face = bytes === undefined ? undefined : readFontMetadata(bytes)?.[0];
|
|
43
|
+
if (face === undefined)
|
|
44
|
+
throw new Error("Slopify's bundled Barlow Bold font is missing or invalid. Reinstall Slopify.");
|
|
45
|
+
return {
|
|
46
|
+
id: "default",
|
|
47
|
+
name: face.name,
|
|
48
|
+
family: face.family,
|
|
49
|
+
source: "bundled",
|
|
50
|
+
path: bundledBold,
|
|
51
|
+
extension: ".ttf",
|
|
52
|
+
assName: face.assName,
|
|
53
|
+
faceIndex: 0,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
33
56
|
export function missingFont() {
|
|
34
57
|
return new Error("The chosen caption font is no longer available; it may have been uninstalled or deleted. Choose another font in Edit project → Subtitles, then try again.", {
|
|
35
58
|
cause: "font-not-found",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { fontIdPattern, isMissingFont, listFonts, resolveFont } from "./catalog.js";
|
|
1
|
+
export { fontIdPattern, isMissingFont, listFonts, resolveBoldFont, resolveFont, } from "./catalog.js";
|
|
2
2
|
export { fontMaxBytes } from "./model.js";
|
|
3
3
|
export { previewFont } from "./preview.js";
|
|
4
4
|
export { standaloneFont } from "./sfnt.js";
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// set covers both, which is why the drafts, the lint and the save path below are shared
|
|
3
3
|
// rather than mirrored.
|
|
4
4
|
export { entryModes } from "../admission/model.js";
|
|
5
|
-
export const promptKinds = [
|
|
5
|
+
export const promptKinds = [
|
|
6
|
+
"article",
|
|
7
|
+
"image",
|
|
8
|
+
"thumbnail",
|
|
9
|
+
"narration",
|
|
10
|
+
"description",
|
|
11
|
+
"shorts",
|
|
12
|
+
];
|
|
6
13
|
export const entryCategories = ["intro", "outro"];
|
|
7
14
|
// The name is what the Play pickers show, so it shares admission's title bound.
|
|
8
15
|
export const nameMax = 200;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the picked templates, the slot names they ask for, and the rendered text stored on the
|
|
3
3
|
// project. The bodies are read at the click, not at selection, so an edit made in between
|
|
4
4
|
// is the one that runs.
|
|
5
|
-
import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
|
+
import { normaliseDraft, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
6
6
|
import { collectFields, render } from "../admission/substitute.js";
|
|
7
7
|
import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
|
|
8
8
|
export function pickTemplates(db, input, snapshot) {
|
|
@@ -25,6 +25,11 @@ export function pickTemplates(db, input, snapshot) {
|
|
|
25
25
|
// None picked is the built-in prompt, which asks for no keyword.
|
|
26
26
|
if (usesYoutubeDescription(draft))
|
|
27
27
|
body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
|
|
28
|
+
// The Shorts step's two prompts; none picked is the built-in one, which asks for no keyword.
|
|
29
|
+
if (usesShorts(draft)) {
|
|
30
|
+
body(db, "shorts", draft.shorts?.prompt, "shorts.prompt", missing, text, "shorts", snapshot);
|
|
31
|
+
body(db, "image", draft.shorts?.imagePrompt, "shorts.imagePrompt", missing, image, "shortsImage", snapshot);
|
|
32
|
+
}
|
|
28
33
|
if (sources.images === "generate") {
|
|
29
34
|
for (const [index, picked] of draft.imagePrompts.entries()) {
|
|
30
35
|
const field = `imagePrompts.${String(index)}.name`;
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
|
|
3
3
|
import { runDraftSchema } from "../admission/schema.js";
|
|
4
4
|
import { draftDocumentThemeOf } from "../document/model.js";
|
|
5
|
+
import { defaultShorts, shortsExtrasOf, shortsSettingsProblems, } from "../shorts/model.js";
|
|
5
6
|
import { defaultSubtitles } from "../subtitles/model.js";
|
|
6
7
|
export function toAdmissionDraft(input) {
|
|
7
8
|
const { form } = input.document;
|
|
@@ -66,6 +67,36 @@ export function toAdmissionDraft(input) {
|
|
|
66
67
|
? "files"
|
|
67
68
|
: form.subtitles.mode;
|
|
68
69
|
const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
|
|
70
|
+
const shortsOn = sources.audio !== "off" && form.shorts?.enabled === true;
|
|
71
|
+
// Refused in admission's words, so Play and the run say the same sentence.
|
|
72
|
+
const shortsOf = (raw) => {
|
|
73
|
+
const whole = (value) => (value.trim() === "" ? Number.NaN : Number(value));
|
|
74
|
+
const settings = {
|
|
75
|
+
enabled: true,
|
|
76
|
+
count: whole(raw.count),
|
|
77
|
+
minSeconds: whole(raw.minSeconds),
|
|
78
|
+
maxSeconds: whole(raw.maxSeconds),
|
|
79
|
+
...(raw.prompt.trim() ? { prompt: raw.prompt } : {}),
|
|
80
|
+
...(raw.imagePrompt.trim() ? { imagePrompt: raw.imagePrompt } : {}),
|
|
81
|
+
...shortsExtrasOf(raw),
|
|
82
|
+
};
|
|
83
|
+
for (const problem of shortsSettingsProblems(settings))
|
|
84
|
+
fields.push({ field: `shorts.${problem.field}`, message: problem.message });
|
|
85
|
+
const { musicVolume, speed, ...rest } = settings;
|
|
86
|
+
return {
|
|
87
|
+
...rest,
|
|
88
|
+
// A number already refused above is left out rather than saved as NaN.
|
|
89
|
+
...(musicVolume !== undefined && Number.isFinite(musicVolume) ? { musicVolume } : {}),
|
|
90
|
+
...(speed !== undefined && Number.isFinite(speed) ? { speed } : {}),
|
|
91
|
+
count: Number.isFinite(settings.count) ? settings.count : defaultShorts.count,
|
|
92
|
+
minSeconds: Number.isFinite(settings.minSeconds)
|
|
93
|
+
? settings.minSeconds
|
|
94
|
+
: defaultShorts.minSeconds,
|
|
95
|
+
maxSeconds: Number.isFinite(settings.maxSeconds)
|
|
96
|
+
? settings.maxSeconds
|
|
97
|
+
: defaultShorts.maxSeconds,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
69
100
|
const draft = {
|
|
70
101
|
...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
|
|
71
102
|
title: form.title,
|
|
@@ -99,6 +130,9 @@ export function toAdmissionDraft(input) {
|
|
|
99
130
|
...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
|
|
100
131
|
}
|
|
101
132
|
: {}),
|
|
133
|
+
// Cut from the narration too, so the same rule: a switch left on with narration Off asks
|
|
134
|
+
// for nothing.
|
|
135
|
+
...(shortsOn && form.shorts !== undefined ? { shorts: shortsOf(form.shorts) } : {}),
|
|
102
136
|
imagePrompts: sources.images === "generate"
|
|
103
137
|
? form.imagePrompts.map((prompt, index) => ({
|
|
104
138
|
name: prompt.name,
|
|
@@ -144,11 +178,13 @@ export function toAdmissionDraft(input) {
|
|
|
144
178
|
fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
|
|
145
179
|
},
|
|
146
180
|
silenceGapSeconds: input.silenceGapSeconds,
|
|
147
|
-
imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
|
|
181
|
+
imageSeconds: measure("imageSeconds", sources.video === "generate" || shortsOn, defaultImageSeconds, imageSecondsProblem),
|
|
148
182
|
zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
|
|
149
183
|
// A choice from a list, so there is nothing to refuse; a run without a video keeps it
|
|
150
184
|
// for when the video is turned back on.
|
|
151
185
|
motionStyle: form.motionStyle,
|
|
186
|
+
// Kept the same way; admission checks it only while the video renders.
|
|
187
|
+
...(form.videoEdit === undefined ? {} : { videoEdit: form.videoEdit }),
|
|
152
188
|
edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
|
|
153
189
|
};
|
|
154
190
|
const parsed = runDraftSchema.safeParse(draft);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
2
2
|
import { modelFields } from "../../catalog/validate.js";
|
|
3
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
4
|
-
import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
|
|
4
|
+
import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
5
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
6
6
|
import { hasKey, listVoices } from "../settings/repo.js";
|
|
7
|
+
import { usesAnimation } from "../video/edit-settings.js";
|
|
7
8
|
export function choices(runs) {
|
|
8
9
|
const selected = runs
|
|
9
10
|
.flatMap(({ draft: d }) => [
|
|
@@ -17,7 +18,8 @@ export function choices(runs) {
|
|
|
17
18
|
d.intro?.mode === "llm" ||
|
|
18
19
|
d.outro?.mode === "llm" ||
|
|
19
20
|
usesNarrationPreparation(d) ||
|
|
20
|
-
usesYoutubeDescription(d)
|
|
21
|
+
usesYoutubeDescription(d) ||
|
|
22
|
+
usesShorts(d),
|
|
21
23
|
},
|
|
22
24
|
{
|
|
23
25
|
field: "audio",
|
|
@@ -30,7 +32,9 @@ export function choices(runs) {
|
|
|
30
32
|
family: "image",
|
|
31
33
|
choice: d.images,
|
|
32
34
|
needed: d.sources.images === "generate" ||
|
|
33
|
-
["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail)
|
|
35
|
+
["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail) ||
|
|
36
|
+
usesShorts(d) ||
|
|
37
|
+
usesAnimation(d),
|
|
34
38
|
},
|
|
35
39
|
])
|
|
36
40
|
.flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
|
|
@@ -3,7 +3,7 @@ import { formats, stageKinds } from "../../kernel/pipeline.js";
|
|
|
3
3
|
import { thinkingModes } from "../../kernel/ports/llm.js";
|
|
4
4
|
import { motionStyles, stageSources } from "../admission/model.js";
|
|
5
5
|
import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
|
|
6
|
-
import { runDraftSchema } from "../admission/schema.js";
|
|
6
|
+
import { runDraftSchema, videoEditSchema } from "../admission/schema.js";
|
|
7
7
|
import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
|
|
8
8
|
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
9
9
|
import { librarySnapshotSchema } from "../library/snapshot.js";
|
|
@@ -50,6 +50,26 @@ export const playDraftFormSchema = z
|
|
|
50
50
|
// Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
|
|
51
51
|
youtubeDescription: z.boolean().optional(),
|
|
52
52
|
descriptionPrompt: text.optional(),
|
|
53
|
+
// Absent on drafts and templates saved before Shorts: off. The numbers are raw text, like
|
|
54
|
+
// every other number on Play; the prompts are names, "" being the built-in ones.
|
|
55
|
+
shorts: z
|
|
56
|
+
.object({
|
|
57
|
+
enabled: z.boolean(),
|
|
58
|
+
count: text,
|
|
59
|
+
minSeconds: text,
|
|
60
|
+
maxSeconds: text,
|
|
61
|
+
prompt: text,
|
|
62
|
+
imagePrompt: text,
|
|
63
|
+
// Absent on drafts saved before these: the title off, no link, the music at its
|
|
64
|
+
// default level, normal speed. The two numbers are raw text too.
|
|
65
|
+
titleOnScreen: z.boolean().optional(),
|
|
66
|
+
fullVideoLink: text.optional(),
|
|
67
|
+
musicVolume: text.optional(),
|
|
68
|
+
speed: text.optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.readonly()
|
|
72
|
+
.optional(),
|
|
53
73
|
imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
|
|
54
74
|
thumbnailPrompt: text,
|
|
55
75
|
intro: text,
|
|
@@ -74,6 +94,9 @@ export const playDraftFormSchema = z
|
|
|
74
94
|
edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
|
|
75
95
|
zoomPercent: text.default(String(defaultZoomPercent)),
|
|
76
96
|
motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
|
|
97
|
+
// Absent on drafts and templates saved before the edit settings: today's slideshow. Every
|
|
98
|
+
// field is a pick from a list, so it is kept as the settings themselves.
|
|
99
|
+
videoEdit: videoEditSchema.strict().readonly().optional(),
|
|
77
100
|
values,
|
|
78
101
|
provided: z
|
|
79
102
|
.object({
|
|
@@ -7,6 +7,7 @@ import { listCheckpoints } from "../checkpoints/repo.js";
|
|
|
7
7
|
import { documentThemeOf } from "../document/model.js";
|
|
8
8
|
import { requestHash } from "../play-drafts/repo.js";
|
|
9
9
|
import { currentRevisionId, revisionById } from "../revisions/repo.js";
|
|
10
|
+
import { shortsExtrasForm } from "../shorts/model.js";
|
|
10
11
|
import { createTemplate, readTemplate } from "./service.js";
|
|
11
12
|
export const projectTemplateCreateSchema = z
|
|
12
13
|
.object({
|
|
@@ -97,6 +98,11 @@ function documentFromProject(deps, revision) {
|
|
|
97
98
|
return { name: prompt.name, number: String(prompt.number) };
|
|
98
99
|
});
|
|
99
100
|
addPrompt("thumbnail", config.thumbnailPrompt, "thumbnailPrompt");
|
|
101
|
+
addPrompt("shorts", config.shorts?.prompt, "shorts");
|
|
102
|
+
// The shorts' image style may be the same Library prompt one of the slideshow images uses,
|
|
103
|
+
// and a snapshot holds one prompt per kind and name.
|
|
104
|
+
if (!prompts.some((prompt) => prompt.kind === "image" && prompt.name === config.shorts?.imagePrompt))
|
|
105
|
+
addPrompt("image", config.shorts?.imagePrompt, "shortsImage");
|
|
100
106
|
for (const category of ["intro", "outro"]) {
|
|
101
107
|
const choice = config[category];
|
|
102
108
|
const body = revision.content.promptTemplates[category] ?? config.rendered[category];
|
|
@@ -147,6 +153,22 @@ function documentFromProject(deps, revision) {
|
|
|
147
153
|
...(config.descriptionPrompt === undefined
|
|
148
154
|
? {}
|
|
149
155
|
: { descriptionPrompt: config.descriptionPrompt }),
|
|
156
|
+
...(config.shorts === undefined
|
|
157
|
+
? {}
|
|
158
|
+
: {
|
|
159
|
+
shorts: {
|
|
160
|
+
enabled: config.shorts.enabled,
|
|
161
|
+
count: String(config.shorts.count),
|
|
162
|
+
minSeconds: String(config.shorts.minSeconds),
|
|
163
|
+
maxSeconds: String(config.shorts.maxSeconds),
|
|
164
|
+
prompt: config.shorts.prompt ?? "",
|
|
165
|
+
imagePrompt: config.shorts.imagePrompt ?? "",
|
|
166
|
+
...shortsExtrasForm(config.shorts),
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
// A project without edit settings makes a template without them, so a video made from
|
|
170
|
+
// it cuts every N seconds like the project did.
|
|
171
|
+
...(config.videoEdit === undefined ? {} : { videoEdit: config.videoEdit }),
|
|
150
172
|
imagePrompts,
|
|
151
173
|
thumbnailPrompt: config.thumbnailPrompt ?? "",
|
|
152
174
|
intro: config.intro?.name ?? "",
|
|
@@ -8,6 +8,8 @@ export function templateSetup(deps, input) {
|
|
|
8
8
|
{ kind: "description", name: input.form.descriptionPrompt ?? "" },
|
|
9
9
|
...input.form.imagePrompts.map((row) => ({ kind: "image", name: row.name })),
|
|
10
10
|
{ kind: "thumbnail", name: input.form.thumbnailPrompt },
|
|
11
|
+
{ kind: "shorts", name: input.form.shorts?.prompt ?? "" },
|
|
12
|
+
{ kind: "image", name: input.form.shorts?.imagePrompt ?? "" },
|
|
11
13
|
];
|
|
12
14
|
for (const choice of choices) {
|
|
13
15
|
if (choice.name === "")
|