@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.
- package/README.md +28 -6
- package/dist/cli/main.js +5 -4
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/options.d.ts +11 -1
- package/dist/cli/options.d.ts.map +1 -1
- package/dist/cli/options.js +12 -3
- package/dist/cli/options.js.map +1 -1
- package/dist/cli/voice.d.ts.map +1 -1
- package/dist/cli/voice.js +2 -1
- package/dist/cli/voice.js.map +1 -1
- package/dist/compose/compose.d.ts +3 -3
- package/dist/compose/compose.d.ts.map +1 -1
- package/dist/compose/compose.js +7 -7
- package/dist/compose/compose.js.map +1 -1
- package/dist/compose/timeline.d.ts +19 -0
- package/dist/compose/timeline.d.ts.map +1 -1
- package/dist/compose/timeline.js +42 -0
- package/dist/compose/timeline.js.map +1 -1
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +1 -0
- package/dist/config/config.js.map +1 -1
- package/dist/config/day.d.ts +5 -0
- package/dist/config/day.d.ts.map +1 -0
- package/dist/config/day.js +10 -0
- package/dist/config/day.js.map +1 -0
- package/dist/config/schema.d.ts +6 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +12 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/film.d.ts +3 -1
- package/dist/film.d.ts.map +1 -1
- package/dist/film.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/record/record.d.ts +1 -1
- package/dist/record/record.d.ts.map +1 -1
- package/dist/record/record.js +2 -2
- package/dist/record/record.js.map +1 -1
- package/dist/render/render.js +10 -10
- package/dist/render/render.js.map +1 -1
- package/dist/voice/cache.d.ts +11 -2
- package/dist/voice/cache.d.ts.map +1 -1
- package/dist/voice/cache.js +30 -5
- package/dist/voice/cache.js.map +1 -1
- package/dist/voice/produce.d.ts +2 -0
- package/dist/voice/produce.d.ts.map +1 -1
- package/dist/voice/produce.js +14 -4
- package/dist/voice/produce.js.map +1 -1
- package/package.json +1 -1
- package/schema/marketing.schema.json +15 -0
- package/src/cli/main.ts +5 -4
- package/src/cli/options.ts +14 -3
- package/src/cli/voice.ts +2 -1
- package/src/compose/compose.ts +9 -9
- package/src/compose/timeline.ts +55 -0
- package/src/config/config.ts +3 -0
- package/src/config/day.ts +9 -0
- package/src/config/schema.ts +15 -1
- package/src/film.ts +3 -2
- package/src/index.ts +7 -1
- package/src/record/record.ts +3 -3
- package/src/render/render.ts +10 -10
- package/src/voice/cache.ts +33 -5
- 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,
|
package/src/record/record.ts
CHANGED
|
@@ -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
|
|
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
|
|
350
|
-
|
|
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;
|
package/src/render/render.ts
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
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
|
|
87
|
-
rmSync(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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",
|
package/src/voice/cache.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 };
|
package/src/voice/produce.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
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 ${
|
|
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
|
}
|