@gentbajko/slopify 2.3.1 → 2.5.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 +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -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/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -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 +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- 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 +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -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-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- 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-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- 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 +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -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 +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- 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-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -5,7 +5,7 @@ import { normalizeArticleIntent } from "../rebuild/recipe-save.js";
|
|
|
5
5
|
import { discardPreparedAssets } from "../storage/assets.js";
|
|
6
6
|
import { prepareStagedFile, prepareText } from "../storage/prepare.js";
|
|
7
7
|
import { outputSchema } from "../storage/schema.js";
|
|
8
|
-
import { bindUpload, measureAudio, providedAssetSelected, } from "./mutation-assets.js";
|
|
8
|
+
import { bindUpload, measureAudio, providedAssetSelected, providedOutput, providesOwnFile, } from "./mutation-assets.js";
|
|
9
9
|
export async function prepareEditAssets(deps, base, edit) {
|
|
10
10
|
const prepared = [];
|
|
11
11
|
const allocated = [];
|
|
@@ -16,8 +16,8 @@ export async function prepareEditAssets(deps, base, edit) {
|
|
|
16
16
|
const to = upload.destination;
|
|
17
17
|
const role = to.kind === "image"
|
|
18
18
|
? "image"
|
|
19
|
-
: to.kind === "provided" && to.stage
|
|
20
|
-
?
|
|
19
|
+
: to.kind === "provided" && to.stage !== "audio"
|
|
20
|
+
? providedOutput[to.stage].role
|
|
21
21
|
: "audio_body";
|
|
22
22
|
const result = prepareStagedFile(deps, {
|
|
23
23
|
projectId,
|
|
@@ -27,22 +27,22 @@ export async function prepareEditAssets(deps, base, edit) {
|
|
|
27
27
|
});
|
|
28
28
|
if (!result.ok)
|
|
29
29
|
throw new Error("A validated upload became unavailable.");
|
|
30
|
+
// The shorts' music is an asset the revision names, like a narration replacement,
|
|
31
|
+
// never an output of its own.
|
|
30
32
|
const workKey = to.kind === "image"
|
|
31
33
|
? `image:${to.imageKey}`
|
|
32
34
|
: to.kind === "narration"
|
|
33
35
|
? to.key
|
|
34
|
-
: to.
|
|
35
|
-
? "
|
|
36
|
-
:
|
|
36
|
+
: to.kind === "shortsMusic"
|
|
37
|
+
? "shorts:music"
|
|
38
|
+
: providedOutput[to.stage].workKey;
|
|
37
39
|
const item = {
|
|
38
40
|
...result,
|
|
39
41
|
upload,
|
|
40
42
|
workKey,
|
|
41
|
-
slot: to.kind === "image"
|
|
43
|
+
slot: to.kind === "image" || to.kind === "narration" || to.kind === "shortsMusic"
|
|
42
44
|
? workKey
|
|
43
|
-
:
|
|
44
|
-
? workKey
|
|
45
|
-
: `${result.output.stageKind}:${role}`,
|
|
45
|
+
: `${result.output.stageKind}:${role}`,
|
|
46
46
|
};
|
|
47
47
|
allocated.push(result.asset);
|
|
48
48
|
const durationMs = result.output.stageKind === "audio"
|
|
@@ -91,21 +91,21 @@ export async function prepareEditAssets(deps, base, edit) {
|
|
|
91
91
|
slot: workKey,
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
-
for (const kind of ["audio", "thumbnail"]) {
|
|
94
|
+
for (const kind of ["audio", "thumbnail", "reference"]) {
|
|
95
95
|
const assetId = content.provided[kind];
|
|
96
|
-
if (edit.config
|
|
96
|
+
if (!providesOwnFile(edit.config, kind) ||
|
|
97
97
|
assetId === undefined ||
|
|
98
98
|
providedAssetSelected(base, kind, assetId) ||
|
|
99
99
|
prepared.some((row) => row.asset.id === assetId))
|
|
100
100
|
continue;
|
|
101
101
|
const existing = deps.db
|
|
102
102
|
.prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? LIMIT 1")
|
|
103
|
-
.get(projectId, assetId, kind);
|
|
103
|
+
.get(projectId, assetId, kind === "reference" ? "images" : kind);
|
|
104
104
|
if (existing === undefined)
|
|
105
105
|
throw new Error("Validated replacement asset has no descriptor.");
|
|
106
106
|
const descriptor = outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
|
|
107
107
|
const asset = retainedAsset(deps, projectId, assetId);
|
|
108
|
-
const role = kind
|
|
108
|
+
const role = providedOutput[kind].role;
|
|
109
109
|
const item = {
|
|
110
110
|
asset,
|
|
111
111
|
output: {
|
|
@@ -78,7 +78,8 @@ export async function saveRevision(deps, input) {
|
|
|
78
78
|
...supplied,
|
|
79
79
|
content: { ...supplied.content, regenerationTokens: tokens },
|
|
80
80
|
});
|
|
81
|
-
const outputAssets = prepared.filter((row) => row.upload?.destination.kind !== "narration"
|
|
81
|
+
const outputAssets = prepared.filter((row) => row.upload?.destination.kind !== "narration" &&
|
|
82
|
+
row.upload?.destination.kind !== "shortsMusic");
|
|
82
83
|
const inspected = measuredOutputs(deps, fresh, durations);
|
|
83
84
|
const preparedManifest = {
|
|
84
85
|
outputs: [
|
|
@@ -3,8 +3,12 @@ import { publicationTargets } from "../rebuild/repo.js";
|
|
|
3
3
|
import { discardPreparedAssets } from "../storage/assets.js";
|
|
4
4
|
import { positionedOutput, positionedPiece, projectSelected } from "./projection.js";
|
|
5
5
|
import { isPublicationReplay, publicationAuthority, replacementSlots, validateBundle, validatePublication, } from "./publication-rules.js";
|
|
6
|
-
import { insertAsset, insertManifestOutput, insertManifestPiece, outputsForRevision, revisionById, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
7
|
-
export function commitRevisionOutputs(deps, publication, outputs, pieces
|
|
6
|
+
import { insertAsset, insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, revisionById, selectOutputRecord, selectPieceRecord, } from "./repo.js";
|
|
7
|
+
export function commitRevisionOutputs(deps, publication, outputs, pieces,
|
|
8
|
+
// Output slots and piece keys this result makes obsolete, deselected in every revision
|
|
9
|
+
// it lands in: the shorts past a pick's new count, a short's images past its new number.
|
|
10
|
+
// Their files stay in history and leave the project's current outputs.
|
|
11
|
+
retired) {
|
|
8
12
|
const assets = [
|
|
9
13
|
...outputs.map((row) => row.asset),
|
|
10
14
|
...pieces.flatMap((row) => (row.asset === null ? [] : [row.asset])),
|
|
@@ -70,6 +74,20 @@ export function commitRevisionOutputs(deps, publication, outputs, pieces) {
|
|
|
70
74
|
.prepare("UPDATE revision_outputs SET selected=0 WHERE revision_id=? AND slot=?")
|
|
71
75
|
.run(revision.id, slot);
|
|
72
76
|
}
|
|
77
|
+
if (selected && retired !== undefined) {
|
|
78
|
+
for (const slot of new Set(before.filter((row) => row.selected).map((row) => row.slot)))
|
|
79
|
+
if (retired(slot))
|
|
80
|
+
deps.db
|
|
81
|
+
.prepare("UPDATE revision_outputs SET selected=0 WHERE revision_id=? AND slot=?")
|
|
82
|
+
.run(revision.id, slot);
|
|
83
|
+
for (const key of new Set(piecesForRevision(deps.db, revision.projectId, revision.id)
|
|
84
|
+
.filter((row) => row.selected)
|
|
85
|
+
.map((row) => row.key)))
|
|
86
|
+
if (retired(key))
|
|
87
|
+
deps.db
|
|
88
|
+
.prepare("UPDATE revision_pieces SET selected=0 WHERE revision_id=? AND piece_key=?")
|
|
89
|
+
.run(revision.id, key);
|
|
90
|
+
}
|
|
73
91
|
for (const output of outputs) {
|
|
74
92
|
const row = {
|
|
75
93
|
slot: output.slot,
|
|
@@ -32,6 +32,7 @@ export const revisionContentSchema = z
|
|
|
32
32
|
article: id.optional(),
|
|
33
33
|
audio: id.optional(),
|
|
34
34
|
thumbnail: id.optional(),
|
|
35
|
+
reference: id.optional(),
|
|
35
36
|
})
|
|
36
37
|
.strict(),
|
|
37
38
|
imageOrder: z.array(id).max(60),
|
|
@@ -68,6 +69,16 @@ export const revisionContentSchema = z
|
|
|
68
69
|
.optional(),
|
|
69
70
|
regenerationTokens: tokens,
|
|
70
71
|
promptTemplates: z.record(z.string().min(1), z.string().max(500000).nullable()),
|
|
72
|
+
shortsMusic: id.optional(),
|
|
73
|
+
shortsRanges: z
|
|
74
|
+
.record(z.string().regex(/^[1-9]\d?$/u), z
|
|
75
|
+
.object({
|
|
76
|
+
first: z.number().int().positive(),
|
|
77
|
+
last: z.number().int().positive(),
|
|
78
|
+
pick: z.string().min(1).max(128),
|
|
79
|
+
})
|
|
80
|
+
.strict())
|
|
81
|
+
.optional(),
|
|
71
82
|
})
|
|
72
83
|
.strict();
|
|
73
84
|
export const revisionEditSchema = z
|
|
@@ -81,10 +92,14 @@ export const revisionEditSchema = z
|
|
|
81
92
|
stagedFileId: id,
|
|
82
93
|
destination: z.discriminatedUnion("kind", [
|
|
83
94
|
z
|
|
84
|
-
.object({
|
|
95
|
+
.object({
|
|
96
|
+
kind: z.literal("provided"),
|
|
97
|
+
stage: z.enum(["audio", "thumbnail", "reference"]),
|
|
98
|
+
})
|
|
85
99
|
.strict(),
|
|
86
100
|
z.object({ kind: z.literal("image"), imageKey: id }).strict(),
|
|
87
101
|
z.object({ kind: z.literal("narration"), key: workKey }).strict(),
|
|
102
|
+
z.object({ kind: z.literal("shortsMusic") }).strict(),
|
|
88
103
|
]),
|
|
89
104
|
})
|
|
90
105
|
.strict())
|
|
@@ -308,7 +308,7 @@ function parseRun(row) {
|
|
|
308
308
|
const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
|
|
309
309
|
const runErrors = {
|
|
310
310
|
"missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
|
|
311
|
-
"unsupported-media": "This template uses audio, images
|
|
311
|
+
"unsupported-media": "This template uses audio, images, a thumbnail or shorts background music you supplied, which scheduled runs cannot use. Pick a template that generates these instead, or remove the music under Outputs → Export → More shorts options on Play and save the template again.",
|
|
312
312
|
"spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
|
|
313
313
|
readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
|
|
314
314
|
"not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
|
|
@@ -152,7 +152,12 @@ function checkTemplate(deps, id, version) {
|
|
|
152
152
|
return { ok: false, reason: "missing-template" };
|
|
153
153
|
const sources = template.document.form.sources;
|
|
154
154
|
const mediaSources = ["audio", "images", "thumbnail"];
|
|
155
|
-
|
|
155
|
+
const { form } = template.document;
|
|
156
|
+
// A supplied file is attached again on Play, which a scheduled run cannot do; the shorts'
|
|
157
|
+
// background music is one too.
|
|
158
|
+
if (mediaSources.some((kind) => sources[kind] === "provide") ||
|
|
159
|
+
(form.shorts?.enabled === true && form.provided.shortsMusic) ||
|
|
160
|
+
(sources.images === "generate" && form.reference?.source === "provide"))
|
|
156
161
|
return { ok: false, reason: "unsupported-media" };
|
|
157
162
|
return { ok: true, value: true };
|
|
158
163
|
}
|
|
@@ -22,6 +22,11 @@ export const providerIds = [
|
|
|
22
22
|
export function isLocalCliProvider(id) {
|
|
23
23
|
return id === "claude-code" || id === "codex" || id === "gemini" || id === "codex-image";
|
|
24
24
|
}
|
|
25
|
+
// How many calls to a command-line tool run at once. Each Codex image is its own job - its
|
|
26
|
+
// own folder and thread - so four are drawn side by side; the text CLIs keep three.
|
|
27
|
+
export function localCliConcurrency(id) {
|
|
28
|
+
return id === "codex-image" ? 4 : 3;
|
|
29
|
+
}
|
|
25
30
|
export const providers = [
|
|
26
31
|
{ id: "openrouter", family: "llm", displayName: "OpenRouter", auth: "key" },
|
|
27
32
|
{
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// The shorts' captions: big, centred a little below the middle of the 9:16 frame, two to
|
|
2
|
+
// four words on screen at a time, and the word being spoken drawn in a strong colour. Built
|
|
3
|
+
// as ASS so the render burns them in with the same `ass` filter and font folder the video's
|
|
4
|
+
// captions use. Pure.
|
|
5
|
+
// Groups break at a sentence end, after a comma once they hold two words, at a pause, and
|
|
6
|
+
// at four words.
|
|
7
|
+
const groupMost = 4;
|
|
8
|
+
const groupPause = 0.6;
|
|
9
|
+
// A word left alone joins a neighbour across a pause up to this long; across a longer one
|
|
10
|
+
// the neighbour would hang on screen through the silence.
|
|
11
|
+
const joinPause = 1.5;
|
|
12
|
+
// A group stays on screen this long after its last word, unless the next one starts first,
|
|
13
|
+
// so a short pause doesn't flash an empty frame.
|
|
14
|
+
const holdSeconds = 0.5;
|
|
15
|
+
// ceiling: 7.5% of the height is about 144 px on 1080×1920, which fits twelve characters of
|
|
16
|
+
// a typical sans-serif per line inside the side margins.
|
|
17
|
+
const fontShare = 0.075;
|
|
18
|
+
const lineCharacters = 12;
|
|
19
|
+
const sideMargin = 80;
|
|
20
|
+
// A little below the middle: clear of the face-height centre of most images, and clear of
|
|
21
|
+
// the title and buttons the apps draw along the bottom.
|
|
22
|
+
const heightShare = 0.62;
|
|
23
|
+
// ASS colours are &HAABBGGRR. White for the words around it, gold for the one being spoken.
|
|
24
|
+
const white = "&H00FFFFFF";
|
|
25
|
+
const spoken = "&H0000D4FF";
|
|
26
|
+
// ceiling: the title headline starts at 6% of the height (115 px) and shrinks to 3.5% until
|
|
27
|
+
// it fits two lines. Barlow Bold averages about a third of its size per letter; 0.45 leaves
|
|
28
|
+
// room for wider fonts a project may caption in.
|
|
29
|
+
const titleShares = [0.06, 0.055, 0.05, 0.045, 0.04, 0.035];
|
|
30
|
+
const letterWidth = 0.45;
|
|
31
|
+
// Below the status bar, search and camera buttons the apps draw along the top: 11% of the
|
|
32
|
+
// height is 211 px on 1920.
|
|
33
|
+
const titleTop = 0.11;
|
|
34
|
+
// The word times of a clip played `speed` times faster: every time divided, so the captions
|
|
35
|
+
// stay on the words as they are heard.
|
|
36
|
+
export function fasterWords(words, speed) {
|
|
37
|
+
if (speed === 1)
|
|
38
|
+
return words;
|
|
39
|
+
return words.map((word) => ({
|
|
40
|
+
...word,
|
|
41
|
+
start: millis(word.start / speed),
|
|
42
|
+
end: millis(word.end / speed),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
// The words said inside the clip, with their times moved to the clip's own timeline and
|
|
46
|
+
// clamped to it. A word is kept when most of it falls inside the clip.
|
|
47
|
+
export function clipWords(words, start, end) {
|
|
48
|
+
const length = end - start;
|
|
49
|
+
return words
|
|
50
|
+
.filter((word) => (word.start + word.end) / 2 >= start && (word.start + word.end) / 2 <= end)
|
|
51
|
+
.map((word) => ({
|
|
52
|
+
text: word.text.replace(/\s+/g, " ").trim(),
|
|
53
|
+
start: millis(clamp(word.start - start, 0, length)),
|
|
54
|
+
end: millis(clamp(word.end - start, 0, length)),
|
|
55
|
+
}))
|
|
56
|
+
.filter((word) => word.text !== "" && word.end > word.start);
|
|
57
|
+
}
|
|
58
|
+
export function captionGroups(words) {
|
|
59
|
+
const groups = [];
|
|
60
|
+
let current = [];
|
|
61
|
+
const close = () => {
|
|
62
|
+
if (current.length > 0)
|
|
63
|
+
groups.push(current);
|
|
64
|
+
current = [];
|
|
65
|
+
};
|
|
66
|
+
for (const word of words) {
|
|
67
|
+
const previous = current.at(-1);
|
|
68
|
+
if (previous !== undefined && word.start - previous.end > groupPause)
|
|
69
|
+
close();
|
|
70
|
+
current.push(word);
|
|
71
|
+
if (current.length >= groupMost ||
|
|
72
|
+
sentenceEnd(word.text) ||
|
|
73
|
+
(current.length >= 2 && /[,;:]["'’”)]*$/.test(word.text)))
|
|
74
|
+
close();
|
|
75
|
+
}
|
|
76
|
+
close();
|
|
77
|
+
// Every change leaves one word fewer on its own, so this ends.
|
|
78
|
+
let changed = true;
|
|
79
|
+
while (changed) {
|
|
80
|
+
changed = groups.some((group, at) => group.length === 1 && keepCompany(groups, at));
|
|
81
|
+
for (let at = groups.length - 1; at >= 0; at--)
|
|
82
|
+
if (groups[at]?.length === 0)
|
|
83
|
+
groups.splice(at, 1);
|
|
84
|
+
}
|
|
85
|
+
return groups;
|
|
86
|
+
}
|
|
87
|
+
// A word left on its own would flash by alone, so it joins a neighbouring group: the one
|
|
88
|
+
// before it, or, when it opens a sentence, the one after it first. When the neighbour is
|
|
89
|
+
// too wide to take it, the neighbour's nearest word comes over to make a pair instead,
|
|
90
|
+
// as long as that leaves the neighbour two words. It stays alone only after a long
|
|
91
|
+
// silence, or between neighbours of one or two words that cannot fit it. Returns whether
|
|
92
|
+
// the groups changed.
|
|
93
|
+
function keepCompany(groups, at) {
|
|
94
|
+
const lone = groups[at];
|
|
95
|
+
const word = lone?.[0];
|
|
96
|
+
if (lone === undefined || word === undefined)
|
|
97
|
+
return false;
|
|
98
|
+
const before = groups[at - 1];
|
|
99
|
+
const after = groups[at + 1];
|
|
100
|
+
const opensSentence = before?.at(-1) !== undefined && sentenceEnd(before.at(-1)?.text ?? "");
|
|
101
|
+
// Words either side of a long silence stay apart: joined, one would sit on screen through it.
|
|
102
|
+
const near = (left, right) => left !== undefined && right !== undefined && right.start - left.end <= joinPause;
|
|
103
|
+
const tries = [
|
|
104
|
+
() => {
|
|
105
|
+
if (before === undefined || !near(before.at(-1), word))
|
|
106
|
+
return false;
|
|
107
|
+
if (!fits([...before, word], before))
|
|
108
|
+
return false;
|
|
109
|
+
before.push(word);
|
|
110
|
+
lone.length = 0;
|
|
111
|
+
return true;
|
|
112
|
+
},
|
|
113
|
+
() => {
|
|
114
|
+
if (after === undefined || !near(word, after[0]))
|
|
115
|
+
return false;
|
|
116
|
+
if (!fits([word, ...after], after))
|
|
117
|
+
return false;
|
|
118
|
+
after.unshift(word);
|
|
119
|
+
lone.length = 0;
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
() => {
|
|
123
|
+
const moved = before?.at(-1);
|
|
124
|
+
if (before === undefined || before.length < 3 || !near(moved, word))
|
|
125
|
+
return false;
|
|
126
|
+
before.pop();
|
|
127
|
+
if (moved !== undefined)
|
|
128
|
+
lone.unshift(moved);
|
|
129
|
+
return true;
|
|
130
|
+
},
|
|
131
|
+
() => {
|
|
132
|
+
const moved = after?.[0];
|
|
133
|
+
if (after === undefined || after.length < 3 || !near(word, moved))
|
|
134
|
+
return false;
|
|
135
|
+
after.shift();
|
|
136
|
+
if (moved !== undefined)
|
|
137
|
+
lone.push(moved);
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const order = opensSentence ? [1, 3, 0, 2] : [0, 2, 1, 3];
|
|
142
|
+
return order.some((index) => tries[index]?.() === true);
|
|
143
|
+
}
|
|
144
|
+
// A group with one more word fits when it holds at most one word over the usual most and
|
|
145
|
+
// its longest line is no wider than a line holds, or than the group's own longest already
|
|
146
|
+
// was.
|
|
147
|
+
function fits(merged, group) {
|
|
148
|
+
const widest = (words) => longestLine(words.map((one) => one.text));
|
|
149
|
+
return (merged.length <= groupMost + 1 && widest(merged) <= Math.max(lineCharacters, widest(group)));
|
|
150
|
+
}
|
|
151
|
+
function sentenceEnd(text) {
|
|
152
|
+
return /[.!?]["'’”)]*$/.test(text);
|
|
153
|
+
}
|
|
154
|
+
export function shortCaptionsAss(words, style) {
|
|
155
|
+
const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
|
|
156
|
+
const size = Math.round(style.height * fontShare);
|
|
157
|
+
const position = `{\\an5\\pos(${String(style.width / 2)},${String(Math.round(style.height * heightShare))})}`;
|
|
158
|
+
const title = style.title === undefined || style.title.text.trim() === ""
|
|
159
|
+
? undefined
|
|
160
|
+
: { ...titleLayout(style.title.text, style), seconds: style.title.seconds };
|
|
161
|
+
const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${String(style.width)}\nPlayResY: ${String(style.height)}\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Short,${name},${String(size)},${white},${white},&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,7,3,5,${String(sideMargin)},${String(sideMargin)},0,1\n${title === undefined ? "" : `Style: Title,${name},${String(title.size)},${white},${white},&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,6,3,8,${String(sideMargin)},${String(sideMargin)},0,1\n`}\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
|
|
162
|
+
const groups = captionGroups(words);
|
|
163
|
+
// The headline sits on a layer of its own, so a caption never hides it.
|
|
164
|
+
const events = title === undefined
|
|
165
|
+
? []
|
|
166
|
+
: [
|
|
167
|
+
`Dialogue: 1,${assStamp(0)},${assStamp(title.seconds)},Title,,0,0,0,,{\\an8\\pos(${String(style.width / 2)},${String(Math.round(style.height * titleTop))})}${title.lines.map(assText).join("\\N")}\n`,
|
|
168
|
+
];
|
|
169
|
+
for (const [at, group] of groups.entries()) {
|
|
170
|
+
const next = groups[at + 1]?.[0];
|
|
171
|
+
const last = group.at(-1);
|
|
172
|
+
if (last === undefined)
|
|
173
|
+
continue;
|
|
174
|
+
const shown = next === undefined ? last.end + holdSeconds : Math.min(last.end + holdSeconds, next.start);
|
|
175
|
+
const lines = lineBreaks(group.map((word) => word.text));
|
|
176
|
+
for (const [index, word] of group.entries()) {
|
|
177
|
+
const from = word.start;
|
|
178
|
+
const to = group[index + 1]?.start ?? Math.max(shown, word.end);
|
|
179
|
+
if (to <= from)
|
|
180
|
+
continue;
|
|
181
|
+
const text = group
|
|
182
|
+
.map((one, place) => {
|
|
183
|
+
const escaped = assText(one.text);
|
|
184
|
+
const shownWord = place === index
|
|
185
|
+
? `{\\c${spoken}&\\fscx108\\fscy108}${escaped}{\\c${white}&\\fscx100\\fscy100}`
|
|
186
|
+
: escaped;
|
|
187
|
+
return `${shownWord}${lines.has(place) ? "\\N" : place < group.length - 1 ? " " : ""}`;
|
|
188
|
+
})
|
|
189
|
+
.join("");
|
|
190
|
+
events.push(`Dialogue: 0,${assStamp(from)},${assStamp(to)},Short,,0,0,0,,${position}${text}\n`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return header + events.join("");
|
|
194
|
+
}
|
|
195
|
+
// The places after which a group breaks onto a second line: none when it fits on one,
|
|
196
|
+
// otherwise the one break that makes the longer line shortest.
|
|
197
|
+
function lineBreaks(texts, fit = lineCharacters) {
|
|
198
|
+
const total = texts.join(" ").length;
|
|
199
|
+
if (total <= fit || texts.length < 2)
|
|
200
|
+
return new Set();
|
|
201
|
+
let best = 0;
|
|
202
|
+
let bestLongest = Number.POSITIVE_INFINITY;
|
|
203
|
+
for (let after = 0; after < texts.length - 1; after++) {
|
|
204
|
+
const top = texts.slice(0, after + 1).join(" ").length;
|
|
205
|
+
const bottom = texts.slice(after + 1).join(" ").length;
|
|
206
|
+
const longest = Math.max(top, bottom);
|
|
207
|
+
if (longest < bestLongest) {
|
|
208
|
+
bestLongest = longest;
|
|
209
|
+
best = after;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return new Set([best]);
|
|
213
|
+
}
|
|
214
|
+
// The characters of the longest line the words are drawn on.
|
|
215
|
+
function longestLine(texts) {
|
|
216
|
+
return Math.max(...splitAt(texts, lineBreaks(texts)).map((line) => line.length));
|
|
217
|
+
}
|
|
218
|
+
// The title headline: the largest size at which it fits two lines inside the side margins,
|
|
219
|
+
// broken where the longer line is shortest; at the smallest size a title still too long is
|
|
220
|
+
// cut at a word and ends in an ellipsis.
|
|
221
|
+
export function titleLayout(title, style) {
|
|
222
|
+
const words = title
|
|
223
|
+
.replace(/\s+/g, " ")
|
|
224
|
+
.trim()
|
|
225
|
+
.split(" ")
|
|
226
|
+
.filter((word) => word !== "");
|
|
227
|
+
const room = (share) => Math.floor((style.width - 2 * sideMargin) / (style.height * share * letterWidth));
|
|
228
|
+
const laid = (share, texts) => {
|
|
229
|
+
const fit = room(share);
|
|
230
|
+
const breaks = lineBreaks(texts, fit);
|
|
231
|
+
const lines = breaks.size === 0 ? [texts.join(" ")] : splitAt(texts, breaks);
|
|
232
|
+
return { size: Math.round(style.height * share), lines, fit };
|
|
233
|
+
};
|
|
234
|
+
for (const share of titleShares) {
|
|
235
|
+
const layout = laid(share, words);
|
|
236
|
+
if (layout.lines.every((line) => line.length <= layout.fit))
|
|
237
|
+
return { size: layout.size, lines: layout.lines };
|
|
238
|
+
}
|
|
239
|
+
const smallest = titleShares.at(-1) ?? 0.03;
|
|
240
|
+
const fit = room(smallest);
|
|
241
|
+
const kept = [];
|
|
242
|
+
for (const word of words) {
|
|
243
|
+
if ([...kept, word].join(" ").length + 1 > fit * 2 - 1)
|
|
244
|
+
break;
|
|
245
|
+
kept.push(word);
|
|
246
|
+
}
|
|
247
|
+
const layout = laid(smallest, [...kept.slice(0, -1), `${kept.at(-1) ?? ""}…`]);
|
|
248
|
+
return { size: layout.size, lines: layout.lines };
|
|
249
|
+
}
|
|
250
|
+
function splitAt(texts, breaks) {
|
|
251
|
+
const lines = [[]];
|
|
252
|
+
for (const [at, text] of texts.entries()) {
|
|
253
|
+
lines.at(-1)?.push(text);
|
|
254
|
+
if (breaks.has(at))
|
|
255
|
+
lines.push([]);
|
|
256
|
+
}
|
|
257
|
+
return lines.map((line) => line.join(" "));
|
|
258
|
+
}
|
|
259
|
+
function assText(text) {
|
|
260
|
+
// Full-width alternatives stay visible and cannot open an ASS override or escape.
|
|
261
|
+
return text.replaceAll("\\", "\").replaceAll("{", "{").replaceAll("}", "}");
|
|
262
|
+
}
|
|
263
|
+
function assStamp(seconds) {
|
|
264
|
+
const cs = Math.max(0, Math.round(seconds * 100));
|
|
265
|
+
return `${String(Math.floor(cs / 360_000))}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
|
|
266
|
+
}
|
|
267
|
+
function millis(seconds) {
|
|
268
|
+
return Math.round(seconds * 1000) / 1000;
|
|
269
|
+
}
|
|
270
|
+
function clamp(value, low, high) {
|
|
271
|
+
return Math.min(high, Math.max(low, value));
|
|
272
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { clipBounds, shortPickSchema } from "./pick.js";
|
|
3
|
+
// What the pick saves in its payload. `sentences` (the numbered transcript's times and
|
|
4
|
+
// words) came with hand-set ranges; a pick saved before them has none, and its clips can
|
|
5
|
+
// only be adjusted after the moments are picked again.
|
|
6
|
+
export const pickedShortsSchema = z.object({
|
|
7
|
+
shorts: z.array(shortPickSchema),
|
|
8
|
+
durationSeconds: z.number().finite().nonnegative().optional(),
|
|
9
|
+
sentences: z
|
|
10
|
+
.array(z.object({
|
|
11
|
+
start: z.number().finite().nonnegative(),
|
|
12
|
+
end: z.number().finite().nonnegative(),
|
|
13
|
+
text: z.string(),
|
|
14
|
+
}))
|
|
15
|
+
.optional(),
|
|
16
|
+
});
|
|
17
|
+
export function pickedShortsOf(payload) {
|
|
18
|
+
if (payload === null || payload === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = pickedShortsSchema.safeParse(JSON.parse(payload));
|
|
22
|
+
return parsed.success ? parsed.data : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Why a hand-set range can't be used, in the words Edit project shows under the clip; or
|
|
29
|
+
// undefined when it can.
|
|
30
|
+
export function rangeProblem(picked, number, first, last, limits, others = []) {
|
|
31
|
+
const sentences = picked.sentences;
|
|
32
|
+
if (sentences === undefined || sentences.length === 0)
|
|
33
|
+
return "These shorts were picked before clips could be adjusted by hand. Use Pick different moments, then adjust the new clips.";
|
|
34
|
+
if (!picked.shorts.some((clip) => clip.number === number))
|
|
35
|
+
return `There is no short ${String(number)} any more. Reload the page and choose again.`;
|
|
36
|
+
if (!Number.isInteger(first) ||
|
|
37
|
+
!Number.isInteger(last) ||
|
|
38
|
+
first < 1 ||
|
|
39
|
+
last > sentences.length ||
|
|
40
|
+
first > last)
|
|
41
|
+
return `Choose a start sentence that comes before the end sentence, between 1 and ${String(sentences.length)}.`;
|
|
42
|
+
const bounds = clipBounds(sentences, first, last, durationOf(picked));
|
|
43
|
+
if (bounds === undefined)
|
|
44
|
+
return "Choose a start and an end sentence from the transcript.";
|
|
45
|
+
const seconds = bounds.end - bounds.start;
|
|
46
|
+
// The same hundredth-of-a-second allowance the pick is checked with.
|
|
47
|
+
if (seconds < limits.minSeconds - 0.01)
|
|
48
|
+
return `Short ${String(number)} would last ${String(Math.round(seconds))} seconds, shorter than the ${String(limits.minSeconds)}-second minimum. Start it earlier or end it later.`;
|
|
49
|
+
if (seconds > limits.maxSeconds + 0.01)
|
|
50
|
+
return `Short ${String(number)} would last ${String(Math.round(seconds))} seconds, longer than the ${String(limits.maxSeconds)}-second maximum. Start it later or end it earlier.`;
|
|
51
|
+
const clash = others.find((clip) => clip.number !== number && first <= clip.last && last >= clip.first);
|
|
52
|
+
if (clash !== undefined)
|
|
53
|
+
return `Short ${String(number)} would share sentences with short ${String(clash.number)} (sentences ${String(clash.first)}-${String(clash.last)}). Choose a range that doesn't overlap it.`;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
// The picked clips with every usable hand-set range in place: the model's title,
|
|
57
|
+
// description and hashtags stay, the sentences, times and words are the range's. A range
|
|
58
|
+
// set against another pick, or one that no longer fits the limits, is left out.
|
|
59
|
+
export function effectiveClips(picked, ranges, pick, limits) {
|
|
60
|
+
const sentences = picked.sentences;
|
|
61
|
+
if (ranges === undefined || sentences === undefined)
|
|
62
|
+
return picked.shorts;
|
|
63
|
+
const clips = [...picked.shorts];
|
|
64
|
+
for (const [at, clip] of clips.entries()) {
|
|
65
|
+
const range = ranges[String(clip.number)];
|
|
66
|
+
if (range === undefined || range.pick !== pick)
|
|
67
|
+
continue;
|
|
68
|
+
if (rangeProblem(picked, clip.number, range.first, range.last, limits, clips) !== undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const bounds = clipBounds(sentences, range.first, range.last, durationOf(picked));
|
|
71
|
+
if (bounds === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
clips[at] = {
|
|
74
|
+
...clip,
|
|
75
|
+
first: range.first,
|
|
76
|
+
last: range.last,
|
|
77
|
+
start: bounds.start,
|
|
78
|
+
end: bounds.end,
|
|
79
|
+
text: sentences
|
|
80
|
+
.slice(range.first - 1, range.last)
|
|
81
|
+
.map((sentence) => sentence.text)
|
|
82
|
+
.join(" "),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return clips;
|
|
86
|
+
}
|
|
87
|
+
function durationOf(picked) {
|
|
88
|
+
return picked.durationSeconds ?? picked.sentences?.at(-1)?.end ?? 0;
|
|
89
|
+
}
|