@softure-ai/marketing-kit 0.1.5 → 0.1.6

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 (67) hide show
  1. package/README.md +28 -6
  2. package/dist/cli/main.js +5 -4
  3. package/dist/cli/main.js.map +1 -1
  4. package/dist/cli/options.d.ts +11 -1
  5. package/dist/cli/options.d.ts.map +1 -1
  6. package/dist/cli/options.js +12 -3
  7. package/dist/cli/options.js.map +1 -1
  8. package/dist/cli/voice.d.ts.map +1 -1
  9. package/dist/cli/voice.js +2 -1
  10. package/dist/cli/voice.js.map +1 -1
  11. package/dist/compose/compose.d.ts +3 -3
  12. package/dist/compose/compose.d.ts.map +1 -1
  13. package/dist/compose/compose.js +7 -7
  14. package/dist/compose/compose.js.map +1 -1
  15. package/dist/compose/timeline.d.ts +19 -0
  16. package/dist/compose/timeline.d.ts.map +1 -1
  17. package/dist/compose/timeline.js +42 -0
  18. package/dist/compose/timeline.js.map +1 -1
  19. package/dist/config/config.d.ts +2 -0
  20. package/dist/config/config.d.ts.map +1 -1
  21. package/dist/config/config.js +1 -0
  22. package/dist/config/config.js.map +1 -1
  23. package/dist/config/day.d.ts +5 -0
  24. package/dist/config/day.d.ts.map +1 -0
  25. package/dist/config/day.js +10 -0
  26. package/dist/config/day.js.map +1 -0
  27. package/dist/config/schema.d.ts +6 -0
  28. package/dist/config/schema.d.ts.map +1 -1
  29. package/dist/config/schema.js +12 -1
  30. package/dist/config/schema.js.map +1 -1
  31. package/dist/film.d.ts +3 -1
  32. package/dist/film.d.ts.map +1 -1
  33. package/dist/film.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/record/record.d.ts +1 -1
  39. package/dist/record/record.d.ts.map +1 -1
  40. package/dist/record/record.js +2 -2
  41. package/dist/record/record.js.map +1 -1
  42. package/dist/render/render.js +10 -10
  43. package/dist/render/render.js.map +1 -1
  44. package/dist/voice/cache.d.ts +11 -2
  45. package/dist/voice/cache.d.ts.map +1 -1
  46. package/dist/voice/cache.js +30 -5
  47. package/dist/voice/cache.js.map +1 -1
  48. package/dist/voice/produce.d.ts +2 -0
  49. package/dist/voice/produce.d.ts.map +1 -1
  50. package/dist/voice/produce.js +14 -4
  51. package/dist/voice/produce.js.map +1 -1
  52. package/package.json +1 -1
  53. package/schema/marketing.schema.json +15 -0
  54. package/src/cli/main.ts +5 -4
  55. package/src/cli/options.ts +14 -3
  56. package/src/cli/voice.ts +2 -1
  57. package/src/compose/compose.ts +9 -9
  58. package/src/compose/timeline.ts +55 -0
  59. package/src/config/config.ts +3 -0
  60. package/src/config/day.ts +9 -0
  61. package/src/config/schema.ts +15 -1
  62. package/src/film.ts +3 -2
  63. package/src/index.ts +7 -1
  64. package/src/record/record.ts +3 -3
  65. package/src/render/render.ts +10 -10
  66. package/src/voice/cache.ts +33 -5
  67. package/src/voice/produce.ts +17 -4
package/src/index.ts CHANGED
@@ -83,7 +83,7 @@ export type { TtsEstimate, TtsInput, TtsProvider, TtsRecording, TtsResult } from
83
83
  export { ELEVENLABS_API_KEY_ENV, ELEVENLABS_PROVIDER_ID, createElevenLabsProvider, type ElevenLabsOptions } from "./voice/elevenlabs.js";
84
84
  export { createFakeTtsProvider, type FakeTtsOptions, type FakeTtsProvider } from "./voice/fake.js";
85
85
  export { createTtsProvider, type CreateTtsProviderOptions, type TtsProviderId } from "./voice/providers.js";
86
- export { getVoiceoverPaths, readCachedVoiceover, writeVoiceover, type Voiceover, type VoiceoverPaths } from "./voice/cache.js";
86
+ export { findStaleVoiceovers, getVoiceoverPaths, readCachedVoiceover, writeVoiceover, type Voiceover, type VoiceoverPaths } from "./voice/cache.js";
87
87
  export { describeEstimate, produceVoiceover, type ProduceVoiceoverOptions, type VoiceoverOutcome } from "./voice/produce.js";
88
88
  export {
89
89
  BROWSER_BAR_HEIGHT,
@@ -91,21 +91,26 @@ export {
91
91
  LAYOUTS,
92
92
  LAYOUT_NAMES,
93
93
  MIN_DESKTOP_WIDTH,
94
+ TRANSITIONS,
94
95
  VIDEO_FORMATS,
95
96
  cameraPose,
96
97
  captionChunks,
98
+ crossfadeArgs,
97
99
  fitScale,
98
100
  fitsFrame,
99
101
  getGeometry,
100
102
  getLayoutName,
103
+ getTransitionSeconds,
101
104
  isDesktopViewport,
102
105
  resolveLayout,
103
106
  rewindFrames,
107
+ transitionFrames,
104
108
  unionRect,
105
109
  widePose,
106
110
  type CameraPose,
107
111
  type CaptionChunk,
108
112
  type CaptionLayout,
113
+ type CrossfadeInput,
109
114
  type DeviceKind,
110
115
  type EndCardLayout,
111
116
  type Geometry,
@@ -114,6 +119,7 @@ export {
114
119
  type LayoutOverride,
115
120
  type Point,
116
121
  type Rect,
122
+ type Transition,
117
123
  type TextBox,
118
124
  type VideoFormat,
119
125
  type Viewport,
@@ -60,7 +60,7 @@ export interface RecordOptions {
60
60
  outDir: string;
61
61
  voices: BeatVoice[];
62
62
  voiceoverKey: string;
63
- /** The day the app counts from: today by default; another day only to reproduce an old film. */
63
+ /** The day the app counts from (`--today` or the video's `today`); the day of the run without one. */
64
64
  today?: string;
65
65
  /** The file that holds the scene (its module, or marketing.json for actions), named in errors that ask for a scene fix. */
66
66
  filmPath: string;
@@ -346,8 +346,8 @@ export async function recordFilm(options: RecordOptions): Promise<RecordingLog>
346
346
  if (missing.length > 0) {
347
347
  throw new ScreenGuardError(
348
348
  `The screen does not say what the voiceover says: missing ${missing.map((p) => `"${p}"`).join(", ")}. ` +
349
- `The app counts from the recording day, so fix the sentences and phrases in ${filmPath}. ` +
350
- `--today=YYYY-MM-DD only reproduces an old film; it is not a way around this refusal.`,
349
+ `The app counts from the recording day: if the voiceover was paid for on another day, pin that day in the video's ` +
350
+ `"today" (or pass --today=YYYY-MM-DD); otherwise fix the sentences and phrases in ${filmPath}.`,
351
351
  );
352
352
  }
353
353
  guardChecked = true;
@@ -4,7 +4,7 @@ import { createRequire } from "node:module";
4
4
  import { dirname, extname, join } from "node:path";
5
5
 
6
6
  import { composeFilm, filmTimes, type ComposeFont } from "../compose/compose.js";
7
- import { getGeometry, getLayoutName, rewindFrames } from "../compose/timeline.js";
7
+ import { crossfadeArgs, getGeometry, getLayoutName, getTransitionSeconds, transitionFrames } from "../compose/timeline.js";
8
8
  import type { BrandColors } from "../config/colors.js";
9
9
  import type { BrandFont } from "../config/config.js";
10
10
  import type { SfxEvent } from "../config/schema.js";
@@ -79,17 +79,17 @@ export function buildComposition(input: RenderInput): string {
79
79
 
80
80
  const ff = (args: string[]) => run("ffmpeg", ["-v", "error", "-y", ...args]);
81
81
  ff(["-framerate", String(log.fps), "-i", join(dir, "frames", "f%05d.jpg"), "-c:v", "libx264", "-preset", "slow", "-crf", "14", "-pix_fmt", "yuv420p", join(assetsDir, "screen.mp4")]);
82
- // Rewind: 24 frames from the opening frame back to the start of the scene (0.8 s), with motion blur.
82
+ // The transition from the opening frame into the scene: stills joined by dissolves (none for a cut).
83
83
  const still = log.stills[film.hook.still];
84
84
  const firstBeat = log.beats[0];
85
85
  if (still === undefined || firstBeat === undefined) throw new Error(`The recording of ${film.id} has no opening frame or no sentences: record it again.`);
86
- const rewindDir = join(dir, "rewind");
87
- rmSync(rewindDir, { recursive: true, force: true });
88
- mkdirSync(rewindDir, { recursive: true });
89
- rewindFrames(firstBeat.f0, still).forEach((index, k) => {
90
- cpSync(getFramePath(dir, index), join(rewindDir, `r${String(k).padStart(5, "0")}.jpg`));
91
- });
92
- ff(["-framerate", String(log.fps), "-i", join(rewindDir, "r%05d.jpg"), "-vf", "tmix=frames=3", "-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p", join(assetsDir, "rewind.mp4")]);
86
+ const transitionPath = join(assetsDir, "transition.mp4");
87
+ rmSync(transitionPath, { force: true });
88
+ const frames = transitionFrames(film.hook.transition, firstBeat.f0, still);
89
+ if (frames.length > 0) {
90
+ const inputs = frames.map((index) => getFramePath(dir, index));
91
+ ff(crossfadeArgs({ inputs, fps: log.fps, seconds: getTransitionSeconds(film.hook.transition), output: transitionPath }));
92
+ }
93
93
  cpSync(getFramePath(dir, still), join(assetsDir, "hook.jpg"));
94
94
  cpSync(getFramePath(dir, log.frames - 1), join(assetsDir, "last.jpg"));
95
95
  ff(["-i", input.voiceoverAudio, "-filter:a", `atempo=${film.voice.tempo}`, "-c:a", "libmp3lame", "-q:a", "2", join(assetsDir, "voiceover.mp3")]);
@@ -120,7 +120,7 @@ export function buildComposition(input: RenderInput): string {
120
120
  fonts,
121
121
  assets: {
122
122
  screen: "assets/screen.mp4",
123
- rewind: "assets/rewind.mp4",
123
+ transition: frames.length > 0 ? "assets/transition.mp4" : null,
124
124
  hookStill: "assets/hook.jpg",
125
125
  lastFrame: "assets/last.jpg",
126
126
  voiceover: "assets/voiceover.mp3",
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
 
4
4
  import { err, ok, type TtsInput, type TtsRecording, type TtsResult } from "./provider.js";
5
5
  import { voiceoverKey, type TimedWord } from "./voiceover.js";
@@ -8,6 +8,8 @@ export interface VoiceoverPaths {
8
8
  key: string;
9
9
  audio: string;
10
10
  words: string;
11
+ /** `video`: in the video's own folder; `flat`: `<key>.*` in the cache root, the layout before 0.1.6, still read. */
12
+ layout: "video" | "flat";
11
13
  }
12
14
 
13
15
  /** A voiceover on disk: the audio file's path and the timed words. */
@@ -16,10 +18,35 @@ export interface Voiceover {
16
18
  words: TimedWord[];
17
19
  }
18
20
 
19
- /** Where a recording of this input lives in the cache: `<key>.mp3` and `<key>.json`. */
20
- export function getVoiceoverPaths(cacheDir: string, input: TtsInput): VoiceoverPaths {
21
+ function getPathsIn(dir: string, key: string, layout: VoiceoverPaths["layout"]): VoiceoverPaths {
22
+ return { key, audio: join(dir, `${key}.mp3`), words: join(dir, `${key}.json`), layout };
23
+ }
24
+
25
+ function hasRecording(paths: VoiceoverPaths): boolean {
26
+ return existsSync(paths.audio) && existsSync(paths.words);
27
+ }
28
+
29
+ /**
30
+ * Where a recording of this input lives in the cache. With a video id: `<cacheDir>/<video-id>/<key>.mp3|json`, so
31
+ * a reader sees which film a paid file belongs to; a recording saved flat by an earlier version
32
+ * (`<cacheDir>/<key>.*`) is still found when the video's folder has none, so no paid file is recorded twice.
33
+ * Without a video id: the flat layout. The key (text, voice, model, language) is the same in both.
34
+ */
35
+ export function getVoiceoverPaths(cacheDir: string, input: TtsInput, videoId?: string): VoiceoverPaths {
21
36
  const key = voiceoverKey(input.text, input.voiceId, input.model, input.language);
22
- return { key, audio: join(cacheDir, `${key}.mp3`), words: join(cacheDir, `${key}.json`) };
37
+ const flat = getPathsIn(cacheDir, key, "flat");
38
+ if (videoId === undefined) return flat;
39
+ const own = getPathsIn(join(cacheDir, videoId), key, "video");
40
+ return hasRecording(own) || !hasRecording(flat) ? own : flat;
41
+ }
42
+
43
+ /** Recordings in the video's folder other than `key`: what an earlier script of the film paid for. Sorted file names. */
44
+ export function findStaleVoiceovers(cacheDir: string, videoId: string, key: string): string[] {
45
+ const dir = join(cacheDir, videoId);
46
+ if (!existsSync(dir)) return [];
47
+ return readdirSync(dir)
48
+ .filter((name) => name.endsWith(".mp3") && name !== `${key}.mp3`)
49
+ .sort();
23
50
  }
24
51
 
25
52
  function isTimedWords(value: unknown): value is TimedWord[] {
@@ -49,6 +76,7 @@ export function readCachedVoiceover(paths: VoiceoverPaths): TtsResult<Voiceover
49
76
  /** Writes a recording in FIRE_TRACKER's format, so files recorded there stay byte-identical. */
50
77
  export function writeVoiceover(cacheDir: string, paths: VoiceoverPaths, recording: TtsRecording): Voiceover {
51
78
  mkdirSync(cacheDir, { recursive: true });
79
+ mkdirSync(dirname(paths.audio), { recursive: true });
52
80
  writeFileSync(paths.audio, recording.audio);
53
81
  writeFileSync(paths.words, `${JSON.stringify(recording.words, null, 1)}\n`);
54
82
  return { audio: paths.audio, words: recording.words };
@@ -1,9 +1,13 @@
1
- import { getVoiceoverPaths, readCachedVoiceover, writeVoiceover, type Voiceover } from "./cache.js";
1
+ import { dirname, join } from "node:path";
2
+
3
+ import { findStaleVoiceovers, getVoiceoverPaths, readCachedVoiceover, writeVoiceover, type Voiceover } from "./cache.js";
2
4
  import { err, ok, type TtsEstimate, type TtsInput, type TtsProvider, type TtsResult } from "./provider.js";
3
5
 
4
6
  export interface ProduceVoiceoverOptions {
5
7
  cacheDir: string;
6
8
  input: TtsInput;
9
+ /** The film the voiceover belongs to: its recordings live in `<cacheDir>/<videoId>/`. Without it, the flat layout. */
10
+ videoId?: string;
7
11
  provider: TtsProvider;
8
12
  /** Only `true` lets the provider spend money. */
9
13
  isCommit: boolean;
@@ -24,12 +28,21 @@ export function describeEstimate(provider: TtsProvider, estimate: TtsEstimate):
24
28
  * run and before every paid call; without `isCommit` the provider is never called.
25
29
  */
26
30
  export async function produceVoiceover(options: ProduceVoiceoverOptions): Promise<TtsResult<VoiceoverOutcome>> {
27
- const { cacheDir, input, provider, isCommit, log } = options;
28
- const paths = getVoiceoverPaths(cacheDir, input);
31
+ const { cacheDir, input, videoId, provider, isCommit, log } = options;
32
+ const paths = getVoiceoverPaths(cacheDir, input, videoId);
29
33
  const cached = readCachedVoiceover(paths);
30
34
  if (!cached.ok) return cached;
35
+ if (videoId !== undefined) {
36
+ const stale = findStaleVoiceovers(cacheDir, videoId, paths.key);
37
+ if (stale.length > 0) {
38
+ log(`voiceover: ${join(cacheDir, videoId)} also holds ${stale.join(", ")}, which the current script does not use; delete them once no film needs them.`);
39
+ }
40
+ }
31
41
  if (cached.value !== null) {
32
42
  log(`voiceover: from the cache ${paths.key}, nothing spent.`);
43
+ if (videoId !== undefined && paths.layout === "flat") {
44
+ log(`voiceover: ${paths.key}.mp3 and .json sit in the cache root (the layout before 0.1.6); move them into ${join(cacheDir, videoId)}/ to keep them with their film.`);
45
+ }
33
46
  return ok({ kind: "cached", key: paths.key, voiceover: cached.value });
34
47
  }
35
48
  const estimate = provider.estimate(input);
@@ -41,6 +54,6 @@ export async function produceVoiceover(options: ProduceVoiceoverOptions): Promis
41
54
  const recording = await provider.synthesize(input);
42
55
  if (!recording.ok) return err(recording.error);
43
56
  const voiceover = writeVoiceover(cacheDir, paths, recording.value);
44
- log(`voiceover: recorded ${estimate.characters} characters, saved ${paths.key}.{mp3,json} in ${cacheDir} (commit them).`);
57
+ log(`voiceover: recorded ${estimate.characters} characters, saved ${paths.key}.{mp3,json} in ${dirname(paths.audio)} (commit them).`);
45
58
  return ok({ kind: "recorded", key: paths.key, voiceover, estimate });
46
59
  }