@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
@@ -1,5 +1,9 @@
1
+ import { chapterCuts, narrationShotFrames } from "./cuts.js";
1
2
  import { editListVersion, } from "./edit-list.js";
3
+ import { chapterCardSeconds } from "./edit-settings.js";
4
+ import { hasLook } from "./look.js";
2
5
  import { motionFor } from "./motion.js";
6
+ import { withTransitions } from "./transitions.js";
3
7
  // 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
4
8
  export const fps = 30;
5
9
  const frames = {
@@ -39,6 +43,22 @@ export function planRender(input) {
39
43
  ? input.images.length * input.imageSeconds
40
44
  : audio.reduce((sum, segment) => sum + segment.seconds, 0);
41
45
  const totalFrames = Math.max(1, Math.round(totalSeconds * fps));
46
+ const edit = input.edit;
47
+ const narration = input.body === undefined ? undefined : edit?.narration;
48
+ const lengths = narration === undefined
49
+ ? everyLengths(Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames)
50
+ : narrationShotFrames({
51
+ totalFrames,
52
+ fps,
53
+ imageSeconds: input.imageSeconds,
54
+ cutPoints: narration.cutPoints,
55
+ chapterStarts: narration.chapterStarts,
56
+ });
57
+ const planned = shots(input, lengths);
58
+ const transition = edit?.transition;
59
+ const cards = edit?.cards === undefined
60
+ ? []
61
+ : chapterCards(edit.cards.chapters, narration?.cutPoints ?? [], totalFrames);
42
62
  return {
43
63
  gapSeconds: input.gapSeconds,
44
64
  edgeSeconds: input.edgeSeconds,
@@ -51,13 +71,39 @@ export function planRender(input) {
51
71
  height: frame.height,
52
72
  fps,
53
73
  audio,
54
- shots: shots(input, Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames),
74
+ shots: transition === undefined
75
+ ? planned
76
+ : withTransitions(planned, transition.kind, Math.round(transition.seconds * fps)),
77
+ ...(edit?.look !== undefined && hasLook(edit.look) ? { look: edit.look } : {}),
78
+ ...(cards.length > 0 && edit?.cards !== undefined
79
+ ? { cards, cardFont: edit.cards.font }
80
+ : {}),
55
81
  },
56
82
  totalFrames,
57
83
  totalSeconds,
58
84
  output: input.output,
59
85
  };
60
86
  }
87
+ // A card at each chapter start, on the cut the chapter's shot starts with (a chapter at the
88
+ // very start stays there), for 2.5 s or what is left of the video.
89
+ function chapterCards(chapters, cutPoints, totalFrames) {
90
+ const length = Math.round(chapterCardSeconds * fps);
91
+ const cards = [];
92
+ for (const chapter of chapters) {
93
+ const title = chapter.title.trim();
94
+ const moved = chapter.start < 1 / fps ? [] : chapterCuts([chapter.start], cutPoints, Infinity);
95
+ const startFrame = Math.max(0, Math.round((moved[0] ?? chapter.start) * fps));
96
+ const frames = Math.min(length, totalFrames - startFrame);
97
+ if (title === "" || frames < 1)
98
+ continue;
99
+ // Two chapters inside 2.5 s would stack their cards; the second is left out.
100
+ const previous = cards.at(-1);
101
+ if (previous !== undefined && startFrame < previous.startFrame + previous.frames)
102
+ continue;
103
+ cards.push({ title, startFrame, frames });
104
+ }
105
+ return cards;
106
+ }
61
107
  export function spoken(kind) {
62
108
  return kind !== "gap" && kind !== "edge";
63
109
  }
@@ -92,15 +138,26 @@ export function audioTimeline(input, minimumGap = 1 / fps) {
92
138
  }
93
139
  return segments;
94
140
  }
95
- // The images take turns in slideshow order, each for `each` frames, starting over after the
96
- // last until the timeline is full; the last shot is cut to what is left. A timeline shorter
97
- // than one shot is a single shot. The motion goes by the shot's place, not the image
98
- // (`motion.ts`), so an image that comes round again may move another way.
99
- function shots(input, each, totalFrames) {
141
+ // Every N seconds: each shot `each` frames until the timeline is full; the last shot is cut
142
+ // to what is left. A timeline shorter than one shot is a single shot.
143
+ function everyLengths(each, totalFrames) {
100
144
  const count = Math.ceil(totalFrames / each);
101
- return Array.from({ length: count }, (_value, at) => ({
102
- source: { kind: "image", path: input.images[at % input.images.length] ?? "" },
103
- frames: Math.min(each, totalFrames - each * at),
104
- motion: motionFor(input.motionStyle, at, input.zoomPercent),
105
- }));
145
+ return Array.from({ length: count }, (_value, at) => Math.min(each, totalFrames - each * at));
146
+ }
147
+ // The images take turns in slideshow order, one per shot, starting over after the last until
148
+ // the timeline is full. The motion goes by the shot's place, not the image (`motion.ts`), so
149
+ // an image that comes round again may move another way. A moving clip in an image's place
150
+ // plays as it is, with no motion of its own.
151
+ function shots(input, lengths) {
152
+ return lengths.map((frames, at) => {
153
+ const index = at % input.images.length;
154
+ const clip = input.edit?.clips?.[index];
155
+ return clip === undefined
156
+ ? {
157
+ source: { kind: "image", path: input.images[index] ?? "" },
158
+ frames,
159
+ motion: motionFor(input.motionStyle, at, input.zoomPercent),
160
+ }
161
+ : { source: clip, frames, motion: { kind: "still" } };
162
+ });
106
163
  }
@@ -1,6 +1,8 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { clipArgs, concatList, joinArgs, runFfmpeg, slideshowClips } from "./ffmpeg.js";
1
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ import { cardsAss } from "./cards.js";
4
+ import { concatList, joinArgs, runFfmpeg, segmentArgs, slideshowClips } from "./ffmpeg.js";
5
+ import { cardsIn } from "./look.js";
4
6
  // estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
5
7
  // copying join is nearly free, so progress is weighted by that rather than by clock time.
6
8
  const clipWeight = 4;
@@ -12,24 +14,38 @@ export async function renderSlideshow(run) {
12
14
  const joinMs = msOf(edit.shots.reduce((sum, shot) => sum + shot.frames, 0));
13
15
  // Progress is reported against the video's length, the longer of picture and sound.
14
16
  const totalMs = Math.max(joinMs, edit.audio.reduce((sum, segment) => sum + segment.seconds * 1000, 0));
15
- const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.shot.frames), 0);
17
+ const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.frames), 0);
16
18
  const work = clipMs * clipWeight + joinMs * (run.burnSubtitles ? 1 : copyWeight);
17
19
  const report = (done) => {
18
20
  run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
19
21
  };
20
22
  const workspace = mkdtempSync(join(run.scratch, "render-"));
21
23
  try {
24
+ // Chapter cards are drawn by the clips from a script and a font beside them.
25
+ const cards = edit.cards ?? [];
26
+ const font = edit.cardFont;
27
+ if (cards.length > 0) {
28
+ if (font === undefined)
29
+ throw new Error("Slopify hit an internal error (the chapter cards have no font). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
30
+ mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
31
+ copyFileSync(font.path, join(workspace, "fonts", `card${extname(font.path)}`));
32
+ }
22
33
  let rendered = 0;
23
34
  for (const clip of clips) {
24
35
  const before = rendered;
36
+ const script = clip.name.replace(/\.mp4$/, ".ass");
37
+ const shown = cardsIn(cards, clip.segment.start, clip.segment.count);
38
+ if (shown.length > 0 && font !== undefined)
39
+ writeFileSync(join(workspace, script), cardsAss(edit, shown, font.name, clip.segment.start), { mode: 0o600 });
25
40
  await runFfmpeg({
26
41
  bin: run.bin,
27
- args: clipArgs(edit, clip.shot, join(workspace, clip.name), run.burnSubtitles),
42
+ ...(cards.length > 0 ? { cwd: workspace } : {}),
43
+ args: segmentArgs(edit, clip.segment, join(workspace, clip.name), run.burnSubtitles, script),
28
44
  signal: run.signal,
29
45
  log: run.log,
30
46
  onProgress: (elapsedMs) => report(before + elapsedMs * clipWeight),
31
47
  });
32
- rendered += msOf(clip.shot.frames) * clipWeight;
48
+ rendered += msOf(clip.frames) * clipWeight;
33
49
  }
34
50
  const list = join(workspace, "slides.ffconcat");
35
51
  writeFileSync(list, concatList(order), { mode: 0o600 });
@@ -0,0 +1,51 @@
1
+ // Transitions without a longer render. The slideshow is still encoded clip by clip and
2
+ // joined by the concat demuxer (`ffmpeg.ts`); a transition is one more short clip between
3
+ // two shots. The shot before gives up its last floor(T / 2) frames and the shot after its
4
+ // first ceil(T / 2), and the T-frame transition clip plays the first shot's picture from
5
+ // where it was cut, running on past its end, blended into the second's from half a
6
+ // transition before its start. Every frame of the timeline is still played exactly once, so
7
+ // the video stays as long as its sound.
8
+ // The frames a transition of `frames` takes from the shot before and the shot after.
9
+ export function transitionHalves(frames) {
10
+ const tail = Math.floor(frames / 2);
11
+ return { tail, head: frames - tail };
12
+ }
13
+ // Gives every shot after the first a transition of `frames` frames, shortened where a shot is
14
+ // too short to give up its share and still show at least one frame of its own, and left a
15
+ // hard cut where under two frames would remain.
16
+ export function withTransitions(shots, kind, frames) {
17
+ return shots.map((shot, index) => {
18
+ const before = shots[index - 1];
19
+ if (before === undefined)
20
+ return shot;
21
+ const room = Math.floor((Math.min(before.frames, shot.frames) - 1) / 2);
22
+ const length = Math.min(Math.round(frames), room);
23
+ return length < 2 ? shot : { ...shot, transition: { kind, frames: length } };
24
+ });
25
+ }
26
+ // The clips in timeline order, adding up to the shots' frames exactly.
27
+ export function segments(shots) {
28
+ const out = [];
29
+ let start = 0;
30
+ for (const [index, shot] of shots.entries()) {
31
+ const entering = shot.transition;
32
+ const leaving = shots[index + 1]?.transition;
33
+ const head = entering === undefined ? 0 : transitionHalves(entering.frames).head;
34
+ const tail = leaving === undefined ? 0 : transitionHalves(leaving.frames).tail;
35
+ if (entering !== undefined) {
36
+ const before = transitionHalves(entering.frames).tail;
37
+ out.push({
38
+ kind: "transition",
39
+ from: index - 1,
40
+ to: index,
41
+ count: entering.frames,
42
+ start: start - before,
43
+ });
44
+ start += head;
45
+ }
46
+ const count = shot.frames - head - tail;
47
+ out.push({ kind: "shot", shot: index, from: head, count, start });
48
+ start += count + tail;
49
+ }
50
+ return out;
51
+ }
@@ -45,3 +45,57 @@ export function transcriptText(passages) {
45
45
  .map((passage) => `[${youtubeTimestamp(passage.start)}] ${passage.text}`)
46
46
  .join("\n");
47
47
  }
48
+ // ceiling: a sentence with no full stop in 30 s (a list read out, a transcript without
49
+ // punctuation) is closed there, so no single number spans more than a short can hold.
50
+ const sentenceLongestSeconds = 30;
51
+ // The narration as numbered sentences, for the Shorts step: the model picks clips by
52
+ // sentence number and Slopify cuts at the sentences' own word times, so a clip never starts
53
+ // or ends mid-word. A sentence closes at its full stop, question or exclamation mark, at a
54
+ // pause of 1.5 s (the gap between intro, body and outro), or after 30 s.
55
+ export function transcriptSentences(words) {
56
+ const sentences = [];
57
+ let first = -1;
58
+ const flush = (last) => {
59
+ if (first < 0 || last < first)
60
+ return;
61
+ const span = words.slice(first, last + 1);
62
+ const text = span
63
+ .map((word) => word.text.replace(/\s+/g, " ").trim())
64
+ .filter((one) => one !== "")
65
+ .join(" ");
66
+ const opening = span[0];
67
+ const closing = span.at(-1);
68
+ if (text !== "" && opening !== undefined && closing !== undefined)
69
+ sentences.push({
70
+ number: sentences.length + 1,
71
+ start: opening.start,
72
+ end: closing.end,
73
+ text,
74
+ firstWord: first,
75
+ lastWord: last,
76
+ });
77
+ first = -1;
78
+ };
79
+ for (const [index, word] of words.entries()) {
80
+ const previous = words[index - 1];
81
+ const opening = first < 0 ? undefined : words[first];
82
+ if (opening !== undefined &&
83
+ previous !== undefined &&
84
+ (word.start - previous.end >= pauseSeconds ||
85
+ word.end - opening.start > sentenceLongestSeconds))
86
+ flush(index - 1);
87
+ if (first < 0)
88
+ first = index;
89
+ if (/[.!?]["'’”)]*$/.test(word.text.trim()))
90
+ flush(index);
91
+ }
92
+ flush(words.length - 1);
93
+ return sentences;
94
+ }
95
+ // One sentence per line, led by its number and its span in the video, which is what the
96
+ // model picks from.
97
+ export function sentencesText(sentences) {
98
+ return sentences
99
+ .map((sentence) => `[${String(sentence.number)}] (${youtubeTimestamp(sentence.start)}-${youtubeTimestamp(sentence.end)}) ${sentence.text}`)
100
+ .join("\n");
101
+ }