@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.
Files changed (146) hide show
  1. package/dist/adapter-registry.js +9 -2
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +99 -8
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +48 -2
  11. package/dist/adapters/image/video.js +28 -0
  12. package/dist/adapters/llm/codex-models.js +2 -1
  13. package/dist/adapters/llm/codex-server-models.js +2 -1
  14. package/dist/adapters/llm/run-cli.js +14 -2
  15. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  16. package/dist/assets/fonts/SOURCE.txt +6 -0
  17. package/dist/assets/models.yaml +89 -1
  18. package/dist/catalog/registry.js +24 -3
  19. package/dist/catalog/runtime-models.js +1 -1
  20. package/dist/catalog/schema.js +20 -1
  21. package/dist/catalog/store.js +2 -2
  22. package/dist/catalog/validate.js +47 -4
  23. package/dist/edge/docker-launch.js +4 -0
  24. package/dist/edge/docker-projects/engine.js +57 -1
  25. package/dist/edge/docker-projects/install.js +10 -2
  26. package/dist/edge/docker-projects/prune.js +66 -0
  27. package/dist/edge/events/hub.js +11 -0
  28. package/dist/edge/http/app.js +2 -0
  29. package/dist/edge/http/backups.js +40 -0
  30. package/dist/edge/http/host-cli.js +17 -2
  31. package/dist/edge/http/problem.js +1 -0
  32. package/dist/edge/http/providers.js +8 -0
  33. package/dist/edge/http/schedules.js +1 -1
  34. package/dist/edge/http/settings.js +44 -0
  35. package/dist/edge/http/timeouts.js +38 -0
  36. package/dist/host-cli/runtime.js +3 -2
  37. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  38. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  39. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  40. package/dist/kernel/ports/host-cli.js +17 -2
  41. package/dist/kernel/ports/image.js +3 -1
  42. package/dist/kernel/ports/llm.js +3 -1
  43. package/dist/kernel/runner/attempt.js +5 -2
  44. package/dist/kernel/runner/providers.js +42 -2
  45. package/dist/main.js +55 -7
  46. package/dist/slices/admission/model.js +8 -0
  47. package/dist/slices/admission/rules.js +91 -2
  48. package/dist/slices/admission/schema.js +42 -1
  49. package/dist/slices/admission/start.js +53 -15
  50. package/dist/slices/backups/files.js +135 -0
  51. package/dist/slices/backups/folder.js +35 -0
  52. package/dist/slices/backups/model.js +65 -0
  53. package/dist/slices/backups/repo.js +28 -0
  54. package/dist/slices/backups/schedule.js +60 -0
  55. package/dist/slices/backups/service.js +311 -0
  56. package/dist/slices/batch/index.js +2 -0
  57. package/dist/slices/estimate/index.js +65 -2
  58. package/dist/slices/fonts/catalog.js +23 -0
  59. package/dist/slices/fonts/index.js +1 -1
  60. package/dist/slices/library/model.js +8 -1
  61. package/dist/slices/library/slots.js +13 -2
  62. package/dist/slices/library/snapshot.js +8 -2
  63. package/dist/slices/notifications/notifier.js +76 -0
  64. package/dist/slices/notifications/rules.js +81 -0
  65. package/dist/slices/notifications/send.js +40 -0
  66. package/dist/slices/notifications/settings.js +34 -0
  67. package/dist/slices/play-drafts/convert.js +72 -4
  68. package/dist/slices/play-drafts/readiness.js +7 -3
  69. package/dist/slices/play-drafts/repo.js +7 -1
  70. package/dist/slices/play-drafts/review-inputs.js +2 -0
  71. package/dist/slices/play-drafts/schema.js +44 -3
  72. package/dist/slices/play-drafts/service.js +6 -0
  73. package/dist/slices/play-drafts/uploads.js +2 -1
  74. package/dist/slices/project-templates/from-project.js +42 -0
  75. package/dist/slices/project-templates/setup.js +13 -0
  76. package/dist/slices/rebuild/preview-details.js +53 -3
  77. package/dist/slices/rebuild/preview-plan.js +2 -2
  78. package/dist/slices/rebuild/recipe-build.js +13 -3
  79. package/dist/slices/rebuild/recipe-edit.js +178 -0
  80. package/dist/slices/rebuild/recipe-exports.js +9 -4
  81. package/dist/slices/rebuild/recipe-input-schema.js +9 -1
  82. package/dist/slices/rebuild/recipe-legacy.js +12 -0
  83. package/dist/slices/rebuild/recipe-model.js +7 -1
  84. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  85. package/dist/slices/rebuild/recipe-reference.js +44 -0
  86. package/dist/slices/rebuild/recipe-save.js +25 -1
  87. package/dist/slices/rebuild/recipe-shorts.js +186 -0
  88. package/dist/slices/rebuild/recipe-validation.js +33 -2
  89. package/dist/slices/rebuild/recipe-visual.js +29 -8
  90. package/dist/slices/rebuild/recipe-work.js +83 -1
  91. package/dist/slices/rebuild/runtime-actions.js +9 -2
  92. package/dist/slices/rebuild/runtime-animate.js +110 -0
  93. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  94. package/dist/slices/rebuild/runtime-export.js +13 -1
  95. package/dist/slices/rebuild/runtime-image.js +50 -0
  96. package/dist/slices/rebuild/runtime-local.js +6 -3
  97. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  98. package/dist/slices/rebuild/runtime-piece-label.js +17 -0
  99. package/dist/slices/rebuild/runtime-provider.js +17 -10
  100. package/dist/slices/rebuild/runtime-publication.js +25 -4
  101. package/dist/slices/rebuild/runtime-run.js +11 -5
  102. package/dist/slices/rebuild/runtime-shorts.js +328 -0
  103. package/dist/slices/rebuild/runtime-store.js +7 -3
  104. package/dist/slices/rebuild/service-readiness.js +15 -1
  105. package/dist/slices/rebuild/transition-repo.js +40 -1
  106. package/dist/slices/revisions/adopt-content.js +7 -0
  107. package/dist/slices/revisions/adopt.js +7 -2
  108. package/dist/slices/revisions/mutation-assets.js +51 -9
  109. package/dist/slices/revisions/mutation-prepare.js +14 -14
  110. package/dist/slices/revisions/mutations.js +2 -1
  111. package/dist/slices/revisions/publish.js +20 -2
  112. package/dist/slices/revisions/schema.js +16 -1
  113. package/dist/slices/schedules/repo.js +1 -1
  114. package/dist/slices/schedules/service.js +6 -1
  115. package/dist/slices/settings/model.js +5 -0
  116. package/dist/slices/shorts/captions.js +272 -0
  117. package/dist/slices/shorts/clips.js +89 -0
  118. package/dist/slices/shorts/model.js +186 -0
  119. package/dist/slices/shorts/pick.js +232 -0
  120. package/dist/slices/shorts/prompts.js +68 -0
  121. package/dist/slices/shorts/render.js +163 -0
  122. package/dist/slices/storage/asset-name.js +9 -2
  123. package/dist/slices/storage/layout.js +18 -0
  124. package/dist/slices/storage/model.js +9 -0
  125. package/dist/slices/storage/portable.js +6 -3
  126. package/dist/slices/storage/reconcile.js +5 -1
  127. package/dist/slices/storage/schema.js +3 -0
  128. package/dist/slices/telemetry/model.js +3 -0
  129. package/dist/slices/video/cards.js +72 -0
  130. package/dist/slices/video/chapters.js +89 -0
  131. package/dist/slices/video/cuts.js +141 -0
  132. package/dist/slices/video/edit-list.js +36 -6
  133. package/dist/slices/video/edit-settings.js +191 -0
  134. package/dist/slices/video/ffmpeg.js +155 -29
  135. package/dist/slices/video/look.js +164 -0
  136. package/dist/slices/video/plan.js +68 -11
  137. package/dist/slices/video/slideshow.js +22 -6
  138. package/dist/slices/video/transitions.js +51 -0
  139. package/dist/slices/youtube/transcript.js +54 -0
  140. package/dist/web/assets/index-CktpxlJd.js +171 -0
  141. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  142. package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
  143. package/dist/web/index.html +2 -2
  144. package/package.json +1 -1
  145. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  146. 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 === "thumbnail"
20
- ? "thumbnail"
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.stage === "audio"
35
- ? "audio:provided"
36
- : "thumbnail:image";
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
- : to.kind === "narration"
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.sources[kind] !== "provide" ||
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 === "audio" ? "audio_body" : "thumbnail";
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({ kind: z.literal("provided"), stage: z.enum(["audio", "thumbnail"]) })
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 or a thumbnail you supplied, which scheduled runs cannot use. Pick a template that generates these instead.",
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
- if (mediaSources.some((kind) => sources[kind] === "provide"))
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
+ }