kaizen-agent 1.19.4 → 1.21.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/README.md +11 -0
- package/cli/board.test.ts +6 -2
- package/cli/install.ts +6 -3
- package/cli/state.test.ts +18 -1
- package/cli/state.ts +24 -1
- package/cli/web.ts +5 -1
- package/package.json +1 -1
- package/skills/story-deck/SKILL.md +63 -0
- package/skills/story-deck/examples/build-example.js +29 -0
- package/skills/story-deck/references/rules.md +62 -0
- package/skills/story-deck/references/story-arc.md +35 -0
- package/skills/story-deck/scripts/check.sh +12 -0
- package/skills/story-deck/scripts/install_fonts.py +42 -0
- package/skills/story-deck/scripts/kit.js +174 -0
- package/skills/story-deck/scripts/prep_images.py +70 -0
- package/skills/story-deck/scripts/qa.sh +48 -0
- package/skills/story-video/SKILL.md +108 -0
- package/skills/story-video/engine/package.json +12 -0
- package/skills/story-video/engine/src/Root.tsx +24 -0
- package/skills/story-video/engine/src/engine/Film.tsx +405 -0
- package/skills/story-video/engine/src/engine/presets.json +58 -0
- package/skills/story-video/engine/src/index.ts +3 -0
- package/skills/story-video/engine/tsconfig.json +1 -0
- package/skills/story-video/examples/sample-film/README.md +12 -0
- package/skills/story-video/examples/sample-film/film.json +16 -0
- package/skills/story-video/examples/sample-film/lines.json +7 -0
- package/skills/story-video/references/licensing.md +14 -0
- package/skills/story-video/references/presets.md +21 -0
- package/skills/story-video/references/production.md +29 -0
- package/skills/story-video/references/revisions.md +25 -0
- package/skills/story-video/references/script.md +30 -0
- package/skills/story-video/scripts/check.sh +19 -0
- package/skills/story-video/scripts/intake.py +73 -0
- package/skills/story-video/scripts/master.sh +26 -0
- package/skills/story-video/scripts/music.sh +10 -0
- package/skills/story-video/scripts/setup.sh +25 -0
- package/skills/story-video/scripts/sfx.py +64 -0
- package/skills/story-video/scripts/srt.py +30 -0
- package/skills/story-video/scripts/still.sh +10 -0
- package/skills/story-video/scripts/vo.py +99 -0
- package/web/board.html +2 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// Story-video engine. One film = public/<folder>/film.json (beats) + words.json (VO word timings)
|
|
2
|
+
// + a preset from presets.json. Renders 16:9 or 9:16 from the same film (layout follows the frame).
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
AbsoluteFill, Audio, Img, OffthreadVideo, Sequence, interpolate, random, spring, staticFile,
|
|
6
|
+
useCurrentFrame, useVideoConfig,
|
|
7
|
+
} from 'remotion';
|
|
8
|
+
import { loadFont as loadPlayfair } from '@remotion/google-fonts/PlayfairDisplay';
|
|
9
|
+
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
|
|
10
|
+
import { loadFont as loadFraunces } from '@remotion/google-fonts/Fraunces';
|
|
11
|
+
import { loadFont as loadMontserrat } from '@remotion/google-fonts/Montserrat';
|
|
12
|
+
import { loadFont as loadPoppins } from '@remotion/google-fonts/Poppins';
|
|
13
|
+
import { loadFont as loadDMSans } from '@remotion/google-fonts/DMSans';
|
|
14
|
+
import { loadFont as loadLora } from '@remotion/google-fonts/Lora';
|
|
15
|
+
import { loadFont as loadJakarta } from '@remotion/google-fonts/PlusJakartaSans';
|
|
16
|
+
import { loadFont as loadSpace } from '@remotion/google-fonts/SpaceGrotesk';
|
|
17
|
+
import PRESETS from './presets.json';
|
|
18
|
+
|
|
19
|
+
const FONTS: Record<string, string> = {
|
|
20
|
+
'Playfair Display': loadPlayfair('normal', { weights: ['400'], subsets: ['latin'] }).fontFamily,
|
|
21
|
+
Inter: loadInter('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
|
|
22
|
+
Fraunces: loadFraunces('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
|
|
23
|
+
Montserrat: loadMontserrat('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
|
|
24
|
+
Poppins: loadPoppins('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
|
|
25
|
+
'DM Sans': loadDMSans('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
|
|
26
|
+
Lora: loadLora('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
|
|
27
|
+
'Plus Jakarta Sans': loadJakarta('normal', { weights: ['400', '700', '800'], subsets: ['latin'] }).fontFamily,
|
|
28
|
+
'Space Grotesk': loadSpace('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
|
|
29
|
+
};
|
|
30
|
+
loadPlayfair('italic', { weights: ['400'], subsets: ['latin'] });
|
|
31
|
+
|
|
32
|
+
// Files live in the public dir (the film folder when rendered by master.sh); `folder` is an optional prefix.
|
|
33
|
+
const asset = (film: Film, n: string) => staticFile(film.folder ? `${film.folder}/${n}` : n);
|
|
34
|
+
|
|
35
|
+
export type PresetName = keyof typeof PRESETS;
|
|
36
|
+
type Preset = (typeof PRESETS)[PresetName];
|
|
37
|
+
// A film's brand overrides the preset's palette/fonts; any font in FONTS may be named.
|
|
38
|
+
const resolve = (film: Film): Preset => {
|
|
39
|
+
const base = PRESETS[film.preset];
|
|
40
|
+
return { ...base, palette: { ...base.palette, ...(film.brand?.palette ?? {}) }, fonts: { ...base.fonts, ...(film.brand?.fonts ?? {}) } } as Preset;
|
|
41
|
+
};
|
|
42
|
+
export const FPS = 30;
|
|
43
|
+
|
|
44
|
+
export type Beat = {
|
|
45
|
+
vo?: string; // key in words.json
|
|
46
|
+
kind: 'photo' | 'black' | 'question' | 'reveal' | 'stat' | 'list' | 'triptych' | 'shot' | 'footage' | 'cta';
|
|
47
|
+
img?: string; // file in the folder (photo/shot/cta/stat/list background)
|
|
48
|
+
video?: string; // footage: mp4 in the folder
|
|
49
|
+
shotRatio?: number;
|
|
50
|
+
side?: 'bottom' | 'left';
|
|
51
|
+
kick?: string; title?: string; sub?: string; line?: string;
|
|
52
|
+
items?: string[]; // list: staged one by one across the line
|
|
53
|
+
stat?: { value: number; suffix?: string; label: string; source?: string }[];
|
|
54
|
+
panels?: { img: string; title: string; sub: string }[];
|
|
55
|
+
sfx?: string[]; // extra sound effects at the beat start (files in /sfx)
|
|
56
|
+
ambience?: string; // looped room tone under the beat (file in /sfx)
|
|
57
|
+
hold?: number; // seconds added after the VO
|
|
58
|
+
};
|
|
59
|
+
export type Film = {
|
|
60
|
+
preset: PresetName; folder?: string; logo?: string; logoOnLight?: string; qr?: string;
|
|
61
|
+
language?: string; voice?: string; // used by scripts/vo.py (default id / Matilda)
|
|
62
|
+
brand?: { palette?: Partial<Preset['palette']>; fonts?: Partial<Preset['fonts']> };
|
|
63
|
+
labels?: { source?: string; next?: string }; // on-screen words, in the film's language
|
|
64
|
+
cta?: { kick?: string; title: string; sub?: string; lines?: string[]; url?: string };
|
|
65
|
+
beats: Beat[];
|
|
66
|
+
};
|
|
67
|
+
export type Words = Record<string, { dur: number; offset: number; text: string; words: { w: string; s: number; e: number }[] }>;
|
|
68
|
+
|
|
69
|
+
export const timeline = (film: Film, words: Words) => {
|
|
70
|
+
const p = resolve(film);
|
|
71
|
+
const len = (b: Beat) => Math.round(((b.vo ? p.pace.lead + words[b.vo].dur + p.pace.tail : 0) + (b.hold ?? 0)) * FPS);
|
|
72
|
+
const starts: number[] = [];
|
|
73
|
+
film.beats.forEach((b, i) => starts.push(i === 0 ? 0 : starts[i - 1] + len(film.beats[i - 1])));
|
|
74
|
+
const total = starts[starts.length - 1] + len(film.beats[film.beats.length - 1]);
|
|
75
|
+
const reveal = film.beats.findIndex((b) => b.kind === 'reveal');
|
|
76
|
+
return { len, starts, total, revealAt: reveal >= 0 ? starts[reveal] : Math.round(total / 2) };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ---------- helpers ----------
|
|
80
|
+
const useUnit = () => {
|
|
81
|
+
const { width, height } = useVideoConfig();
|
|
82
|
+
return { u: Math.min(width, height) / 1080, portrait: height > width, width, height };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const Anim: React.FC<{ p: Preset; delay: number; children: React.ReactNode; style?: React.CSSProperties }> = ({ p, delay, children, style }) => {
|
|
86
|
+
const f = useCurrentFrame();
|
|
87
|
+
const { fps } = useVideoConfig();
|
|
88
|
+
const bouncy = p.text === 'pop';
|
|
89
|
+
const k = spring({ frame: f - delay, fps, config: bouncy ? { damping: 9, stiffness: 160 } : { damping: 200 }, durationInFrames: bouncy ? 20 : 28 });
|
|
90
|
+
const t = p.text === 'rise' ? `translateY(${(1 - k) * 26}px)` : p.text === 'slide' ? `translateX(${(1 - k) * -50}px)` : `scale(${0.6 + 0.4 * k})`;
|
|
91
|
+
return <div style={{ opacity: Math.min(1, k * 1.4), transform: t, transformOrigin: 'left center', ...style }}>{children}</div>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const Kick: React.FC<{ p: Preset; text: string; u: number }> = ({ p, text, u }) => (
|
|
95
|
+
<div style={{ fontFamily: FONTS[p.fonts.body], fontWeight: 700, fontSize: 22 * u, letterSpacing: 9 * u, color: p.palette.accent, textTransform: 'uppercase' }}>{text}</div>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const Title: React.FC<{ p: Preset; text: string; u: number; size?: number; italic?: boolean; color?: string }> = ({ p, text, u, size = 88, italic, color }) => (
|
|
99
|
+
<div style={{
|
|
100
|
+
fontFamily: FONTS[p.fonts.display], fontWeight: p.fonts.display === 'Inter' ? 900 : 400, fontStyle: italic ? 'italic' : 'normal',
|
|
101
|
+
fontSize: size * u, lineHeight: 1.08, color: color ?? p.palette.fg, letterSpacing: p.fonts.display === 'Inter' ? -1 * u : 0,
|
|
102
|
+
}}>{text}</div>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const Photo: React.FC<{ src: string; d: number; amount: number; style?: React.CSSProperties }> = ({ src, d, amount, style }) => {
|
|
106
|
+
const f = useCurrentFrame();
|
|
107
|
+
const z = interpolate(f, [0, d], [1, 1 + amount]);
|
|
108
|
+
const x = interpolate(f, [0, d], [0, -24]);
|
|
109
|
+
return <Img src={src} style={{ width: '100%', height: '100%', objectFit: 'cover', transform: `scale(${z}) translateX(${x}px)`, ...style }} />;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ---------- beat ----------
|
|
113
|
+
const BeatView: React.FC<{ film: Film; b: Beat; d: number; words: Words }> = ({ film, b, d, words }) => {
|
|
114
|
+
const p = resolve(film);
|
|
115
|
+
const f = useCurrentFrame();
|
|
116
|
+
const { u, portrait } = useUnit();
|
|
117
|
+
const src = (n: string) => asset(film, n);
|
|
118
|
+
const logo = (p.palette.bg.toUpperCase() > '#C' && film.logoOnLight) || film.logo;
|
|
119
|
+
const lead = Math.round(p.pace.lead * FPS);
|
|
120
|
+
const line = b.vo ? words[b.vo].dur * FPS : d;
|
|
121
|
+
|
|
122
|
+
// transition in/out
|
|
123
|
+
const fadeIn = interpolate(f, [0, 10], [0, 1], { extrapolateRight: 'clamp' });
|
|
124
|
+
const fadeOut = interpolate(f, [d - 10, d], [1, 0], { extrapolateLeft: 'clamp' });
|
|
125
|
+
let wrap: React.CSSProperties = { opacity: Math.min(fadeIn, fadeOut) };
|
|
126
|
+
if (p.transition === 'wipe') wrap = { clipPath: `inset(0 ${interpolate(f, [0, 14], [100, 0], { extrapolateRight: 'clamp' })}% 0 0)`, opacity: fadeOut };
|
|
127
|
+
if (p.transition === 'punch') wrap = { transform: `scale(${interpolate(f, [0, 8], [1.12, 1], { extrapolateRight: 'clamp' })})`, opacity: Math.min(1, fadeOut + 0.0) };
|
|
128
|
+
|
|
129
|
+
const pad = portrait ? 80 * u : 140 * u;
|
|
130
|
+
const bgFill = (b.kind === 'question') ? p.palette.accentBg : p.palette.bg;
|
|
131
|
+
|
|
132
|
+
const textBlock = (maxW: number, size = 88) => (
|
|
133
|
+
<div style={{ maxWidth: maxW * u }}>
|
|
134
|
+
{b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
|
|
135
|
+
{b.title && <Anim p={p} delay={lead + 6} style={{ marginTop: 22 * u }}><Title p={p} text={b.title} u={u} size={size} /></Anim>}
|
|
136
|
+
{b.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 28 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 30 * u, color: p.palette.muted, lineHeight: 1.4 }}>{b.sub}</div></Anim>}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
let content: React.ReactNode = null;
|
|
141
|
+
|
|
142
|
+
if (b.kind === 'black') {
|
|
143
|
+
content = (
|
|
144
|
+
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad }}>
|
|
145
|
+
<Anim p={p} delay={lead}><Title p={p} text={b.title!} u={u} size={portrait ? 96 : 84} italic={p.fonts.display !== 'Inter'} /></Anim>
|
|
146
|
+
{b.sub && <Anim p={p} delay={lead + Math.round(line * 0.55)} style={{ marginTop: 30 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 34 * u, color: p.palette.muted }}>{b.sub}</div></Anim>}
|
|
147
|
+
</AbsoluteFill>
|
|
148
|
+
);
|
|
149
|
+
} else if (b.kind === 'question') {
|
|
150
|
+
content = (
|
|
151
|
+
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad * 1.5 }}>
|
|
152
|
+
<Anim p={p} delay={lead}><Title p={p} text={b.title!} u={u} size={portrait ? 92 : 88} italic={p.fonts.display !== 'Inter'} color={p.palette.bg} /></Anim>
|
|
153
|
+
</AbsoluteFill>
|
|
154
|
+
);
|
|
155
|
+
} else if (b.kind === 'reveal') {
|
|
156
|
+
const wipe = interpolate(f, [lead, lead + 24], [100, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
|
157
|
+
const s = interpolate(f, [lead, d], [1.04, 1]);
|
|
158
|
+
content = (
|
|
159
|
+
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad }}>
|
|
160
|
+
{logo
|
|
161
|
+
? <Img src={src(logo)} style={{ width: (portrait ? 760 : 820) * u, clipPath: `inset(0 ${wipe}% 0 0)`, transform: `scale(${s})` }} />
|
|
162
|
+
: <Title p={p} text={b.title!} u={u} size={170} />}
|
|
163
|
+
{b.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 40 * u }}><Title p={p} text={b.sub} u={u} size={46} italic={p.fonts.display !== 'Inter'} /></Anim>}
|
|
164
|
+
{b.line && <Anim p={p} delay={lead + 50} style={{ marginTop: 34 * u }}><Kick p={p} text={b.line} u={u} /></Anim>}
|
|
165
|
+
</AbsoluteFill>
|
|
166
|
+
);
|
|
167
|
+
} else if (b.kind === 'stat') {
|
|
168
|
+
const n = b.stat!.length;
|
|
169
|
+
content = (
|
|
170
|
+
<AbsoluteFill style={{ padding: pad, justifyContent: 'center' }}>
|
|
171
|
+
{b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
|
|
172
|
+
<div style={{ display: 'flex', flexDirection: portrait ? 'column' : 'row', gap: 70 * u, marginTop: 40 * u }}>
|
|
173
|
+
{b.stat!.map((st, i) => {
|
|
174
|
+
const at = lead + Math.round((line * i) / n);
|
|
175
|
+
const v = interpolate(f, [at, at + 40], [0, st.value], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
|
176
|
+
return (
|
|
177
|
+
<Anim key={i} p={p} delay={at}>
|
|
178
|
+
<div style={{ fontFamily: FONTS[p.fonts.display], fontSize: 150 * u, color: p.palette.accent, lineHeight: 1 }}>
|
|
179
|
+
{Math.round(v).toLocaleString('id-ID')}{st.suffix ?? ''}
|
|
180
|
+
</div>
|
|
181
|
+
<div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 30 * u, color: p.palette.muted, marginTop: 12 * u, maxWidth: 560 * u }}>{st.label}</div>
|
|
182
|
+
</Anim>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</div>
|
|
186
|
+
{b.title && <Anim p={p} delay={lead + Math.round(line * 0.7)} style={{ marginTop: 50 * u }}><Title p={p} text={b.title} u={u} size={52} italic={p.fonts.display !== 'Inter'} /></Anim>}
|
|
187
|
+
{b.stat!.some((s) => s.source) && (
|
|
188
|
+
<div style={{ position: 'absolute', left: pad, bottom: (portrait ? 330 : 70) * u, fontFamily: FONTS[p.fonts.body], fontSize: 18 * u, color: p.palette.muted, opacity: 0.8 }}>
|
|
189
|
+
{film.labels?.source ?? 'Sumber'}: {b.stat!.map((s) => s.source).filter(Boolean).join(' · ')}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</AbsoluteFill>
|
|
193
|
+
);
|
|
194
|
+
} else if (b.kind === 'triptych') {
|
|
195
|
+
const cw = portrait ? 300 : 420, ch = portrait ? 420 : 520;
|
|
196
|
+
content = (
|
|
197
|
+
<AbsoluteFill style={{ padding: pad, justifyContent: 'center' }}>
|
|
198
|
+
{b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
|
|
199
|
+
{b.title && <Anim p={p} delay={lead + 4} style={{ marginTop: 18 * u }}><Title p={p} text={b.title} u={u} size={60} /></Anim>}
|
|
200
|
+
<div style={{ display: 'flex', gap: 36 * u, marginTop: 50 * u }}>
|
|
201
|
+
{b.panels!.map((pn, i) => (
|
|
202
|
+
<Anim key={i} p={p} delay={lead + Math.round((line * (i + 0.3)) / 3)}>
|
|
203
|
+
<div style={{ width: cw * u, height: ch * u, overflow: 'hidden', border: `2px solid ${p.palette.accent}` }}><Img src={src(pn.img)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
|
204
|
+
<div style={{ fontFamily: FONTS[p.fonts.display], fontSize: 34 * u, color: p.palette.fg, marginTop: 16 * u }}>{pn.title}</div>
|
|
205
|
+
<div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 22 * u, color: p.palette.muted, marginTop: 6 * u, maxWidth: cw * u }}>{pn.sub}</div>
|
|
206
|
+
</Anim>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</AbsoluteFill>
|
|
210
|
+
);
|
|
211
|
+
} else if (b.kind === 'shot') {
|
|
212
|
+
const sw = (portrait ? 920 : 1040) * u, sh = sw / (b.shotRatio ?? 1.6);
|
|
213
|
+
content = (
|
|
214
|
+
<AbsoluteFill style={{ padding: pad, flexDirection: portrait ? 'column' : 'row', alignItems: 'center', justifyContent: 'space-between', gap: 50 * u }}>
|
|
215
|
+
{textBlock(portrait ? 900 : 560, 72)}
|
|
216
|
+
<Anim p={p} delay={lead + 10}>
|
|
217
|
+
<div style={{ padding: 10 * u, background: '#F3EDE3', border: `2px solid ${p.palette.accent}`, transform: `scale(${interpolate(f, [0, d], [1, 1.04])})` }}>
|
|
218
|
+
<Img src={src(b.img!)} style={{ width: sw, height: sh, display: 'block' }} />
|
|
219
|
+
</div>
|
|
220
|
+
</Anim>
|
|
221
|
+
</AbsoluteFill>
|
|
222
|
+
);
|
|
223
|
+
} else if (b.kind === 'cta') {
|
|
224
|
+
const c = film.cta!;
|
|
225
|
+
content = (
|
|
226
|
+
<AbsoluteFill>
|
|
227
|
+
{b.img && <AbsoluteFill style={{ opacity: 0.35 }}><Photo src={src(b.img)} d={d} amount={p.kenburns} /></AbsoluteFill>}
|
|
228
|
+
<AbsoluteFill style={{ padding: pad, justifyContent: 'center', alignItems: portrait ? 'center' : 'flex-start', textAlign: portrait ? 'center' : 'left' }}>
|
|
229
|
+
<Anim p={p} delay={lead}><Kick p={p} text={c.kick ?? film.labels?.next ?? 'Langkah berikutnya'} u={u} /></Anim>
|
|
230
|
+
<Anim p={p} delay={lead + 6} style={{ marginTop: 24 * u }}><Title p={p} text={c.title} u={u} size={portrait ? 96 : 86} /></Anim>
|
|
231
|
+
{c.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 40 * u }}><Kick p={p} text={c.sub} u={u} /></Anim>}
|
|
232
|
+
{(c.lines ?? []).map((l, i) => (
|
|
233
|
+
<Anim key={i} p={p} delay={lead + 36 + i * 6} style={{ marginTop: 12 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 38 * u, color: p.palette.fg }}>{l}</div></Anim>
|
|
234
|
+
))}
|
|
235
|
+
{c.url && <Anim p={p} delay={lead + 48} style={{ marginTop: 12 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 34 * u, color: p.palette.muted }}>{c.url}</div></Anim>}
|
|
236
|
+
{portrait && film.qr && <Anim p={p} delay={lead + 54} style={{ marginTop: 50 * u }}><Img src={src(film.qr)} style={{ width: 360 * u, height: 360 * u, borderRadius: 8 }} /></Anim>}
|
|
237
|
+
{logo && <Anim p={p} delay={lead + 64} style={{ marginTop: 60 * u }}><Img src={src(logo)} style={{ width: 360 * u }} /></Anim>}
|
|
238
|
+
</AbsoluteFill>
|
|
239
|
+
{!portrait && film.qr && (
|
|
240
|
+
<Anim p={p} delay={lead + 54} style={{ position: 'absolute', right: pad, bottom: 150 * u }}>
|
|
241
|
+
<Img src={src(film.qr)} style={{ width: 330 * u, height: 330 * u, borderRadius: 6 }} />
|
|
242
|
+
</Anim>
|
|
243
|
+
)}
|
|
244
|
+
</AbsoluteFill>
|
|
245
|
+
);
|
|
246
|
+
} else {
|
|
247
|
+
// photo | footage | list — the layout depends on the preset
|
|
248
|
+
const media = b.kind === 'footage' && b.video
|
|
249
|
+
? <OffthreadVideo src={src(b.video)} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
250
|
+
: b.img ? <Photo src={src(b.img)} d={d} amount={p.kenburns} /> : null;
|
|
251
|
+
const items = b.items && (
|
|
252
|
+
<div style={{ marginTop: 34 * u }}>
|
|
253
|
+
{b.items.map((it, i) => (
|
|
254
|
+
<Anim key={i} p={p} delay={lead + 20 + Math.round(((line - 20) * i) / b.items!.length)} style={{ marginTop: 10 * u }}>
|
|
255
|
+
<div style={{ fontFamily: FONTS[p.fonts.display], fontStyle: p.fonts.display === 'Inter' ? 'normal' : 'italic', fontSize: 40 * u, color: p.palette.accent }}>{it}</div>
|
|
256
|
+
</Anim>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
if (p.layout === 'window' && !portrait) {
|
|
261
|
+
const open = interpolate(f, [lead, lead + 18], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
|
262
|
+
content = (
|
|
263
|
+
<AbsoluteFill style={{ flexDirection: 'row' }}>
|
|
264
|
+
<div style={{ width: '52%', padding: `0 ${pad}px`, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>{textBlock(820, 76)}{items}</div>
|
|
265
|
+
<div style={{ width: '40%', margin: `${110 * u}px 0`, overflow: 'hidden', border: `2px solid ${p.palette.accent}`, clipPath: `inset(${(1 - open) * 50}% 0 ${(1 - open) * 50}% 0)` }}>{media}</div>
|
|
266
|
+
</AbsoluteFill>
|
|
267
|
+
);
|
|
268
|
+
} else if (p.layout === 'card' && !portrait) {
|
|
269
|
+
const k = spring({ frame: f - lead, fps: FPS, config: { damping: 10 } });
|
|
270
|
+
content = (
|
|
271
|
+
<AbsoluteFill style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
272
|
+
<div style={{ width: '50%', padding: `0 ${pad}px` }}>{textBlock(780, 80)}{items}</div>
|
|
273
|
+
<div style={{ width: 760 * u, height: 760 * u, borderRadius: 40 * u, overflow: 'hidden', transform: `rotate(${(1 - k) * 8 + 2}deg) scale(${0.8 + 0.2 * k})`, boxShadow: '0 30px 80px rgba(0,0,0,0.25)' }}>{media}</div>
|
|
274
|
+
</AbsoluteFill>
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
const top = portrait && p.captions === 'kinetic';
|
|
278
|
+
const bottom = !top && (portrait || b.side !== 'left');
|
|
279
|
+
content = (
|
|
280
|
+
<AbsoluteFill>
|
|
281
|
+
<AbsoluteFill style={{ overflow: 'hidden' }}>{media}</AbsoluteFill>
|
|
282
|
+
<AbsoluteFill style={{ background: top
|
|
283
|
+
? `linear-gradient(to bottom, ${p.palette.bg}E6 0%, transparent 40%, transparent 55%, ${p.palette.bg}CC 100%)`
|
|
284
|
+
: bottom
|
|
285
|
+
? `linear-gradient(to top, ${p.palette.bg}F0 0%, ${p.palette.bg}99 35%, transparent 65%)`
|
|
286
|
+
: `linear-gradient(to right, ${p.palette.bg}F2 0%, ${p.palette.bg}AA 40%, transparent 70%)` }} />
|
|
287
|
+
<AbsoluteFill style={{ padding: top ? `${200 * u}px ${pad}px 0` : bottom ? `0 ${pad}px ${(portrait ? 420 : 150) * u}px` : `0 ${pad}px`, justifyContent: top ? 'flex-start' : bottom ? 'flex-end' : 'center' }}>
|
|
288
|
+
{textBlock(bottom ? 1400 : 900, portrait ? 92 : bottom ? 92 : 80)}{items}
|
|
289
|
+
</AbsoluteFill>
|
|
290
|
+
</AbsoluteFill>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return <AbsoluteFill style={{ background: bgFill, ...wrap }}>{content}</AbsoluteFill>;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// ---------- captions ----------
|
|
299
|
+
const Captions: React.FC<{ film: Film; words: Words; starts: number[] }> = ({ film, words, starts }) => {
|
|
300
|
+
const p = resolve(film);
|
|
301
|
+
const f = useCurrentFrame();
|
|
302
|
+
const { u, portrait } = useUnit();
|
|
303
|
+
if (p.captions === 'none') return null;
|
|
304
|
+
const i = starts.findLastIndex((s) => s <= f);
|
|
305
|
+
const b = film.beats[i];
|
|
306
|
+
if (!b?.vo || b.kind === 'cta') return null;
|
|
307
|
+
const t = (f - starts[i]) / FPS - p.pace.lead;
|
|
308
|
+
const ws = words[b.vo].words;
|
|
309
|
+
if (t < -0.1 || t > words[b.vo].dur + 0.4) return null;
|
|
310
|
+
const cur = Math.max(0, ws.findLastIndex((w) => w.s <= t));
|
|
311
|
+
|
|
312
|
+
if (p.captions === 'kinetic') {
|
|
313
|
+
const per = ws.slice(Math.floor(cur / 3) * 3, Math.floor(cur / 3) * 3 + 3).join('').length > 16 ? 2 : 3;
|
|
314
|
+
const g0 = Math.floor(cur / per) * per;
|
|
315
|
+
const group = ws.slice(g0, g0 + per);
|
|
316
|
+
const k = spring({ frame: Math.round((t - ws[g0].s) * FPS), fps: FPS, config: { damping: 10, stiffness: 180 }, durationInFrames: 10 });
|
|
317
|
+
return (
|
|
318
|
+
<AbsoluteFill style={{ justifyContent: 'flex-end', alignItems: 'center', paddingBottom: (portrait ? 300 : 120) * u }}>
|
|
319
|
+
<div style={{ transform: `scale(${0.85 + 0.15 * k})`, display: 'flex', flexWrap: 'wrap', justifyContent: 'center', maxWidth: (portrait ? 940 : 1500) * u }}>
|
|
320
|
+
{group.map((w, j) => (
|
|
321
|
+
<span key={j} style={{
|
|
322
|
+
fontFamily: FONTS.Inter, fontWeight: 900, fontSize: (portrait ? 84 : 72) * u, lineHeight: 1.15, textTransform: 'uppercase',
|
|
323
|
+
color: g0 + j === cur ? p.palette.accent : '#FFFFFF', margin: `0 ${10 * u}px`,
|
|
324
|
+
WebkitTextStroke: `${3 * u}px #000`, paintOrder: 'stroke', textShadow: `0 ${6 * u}px ${18 * u}px rgba(0,0,0,0.6)`,
|
|
325
|
+
}}>{w.w}</span>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
</AbsoluteFill>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
// subtitle: chunks of up to 7 words, broken at sentence punctuation
|
|
332
|
+
const chunks: number[][] = [];
|
|
333
|
+
let c: number[] = [];
|
|
334
|
+
ws.forEach((w, j) => { c.push(j); if (c.length >= 7 || /[.,?!]$/.test(w.w)) { chunks.push(c); c = []; } });
|
|
335
|
+
if (c.length) chunks.push(c);
|
|
336
|
+
const chunk = chunks.find((ch) => ch.includes(cur)) ?? chunks[0];
|
|
337
|
+
const light = p.palette.bg.toUpperCase() > '#C';
|
|
338
|
+
return (
|
|
339
|
+
<AbsoluteFill style={{ justifyContent: 'flex-end', alignItems: 'center', paddingBottom: (portrait ? 220 : 56) * u }}>
|
|
340
|
+
<div style={{
|
|
341
|
+
fontFamily: FONTS[p.fonts.body], fontSize: (portrait ? 40 : 32) * u, color: light ? p.palette.fg : '#FFFFFF', textAlign: 'center',
|
|
342
|
+
background: light ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.45)', padding: `${8 * u}px ${18 * u}px`, borderRadius: 6 * u, maxWidth: 1500 * u,
|
|
343
|
+
}}>{chunk.map((j) => ws[j].w).join(' ')}</div>
|
|
344
|
+
</AbsoluteFill>
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// ---------- texture ----------
|
|
349
|
+
const Texture: React.FC<{ p: Preset; starts: number[] }> = ({ p, starts }) => {
|
|
350
|
+
const f = useCurrentFrame();
|
|
351
|
+
const { width, height } = useVideoConfig();
|
|
352
|
+
const sinceCut = f - (starts.findLast((s) => s <= f) ?? 0);
|
|
353
|
+
const leak = p.grade.leak ? interpolate(sinceCut, [0, 8, 26], [0, 0.55, 0], { extrapolateRight: 'clamp' }) : 0;
|
|
354
|
+
return (
|
|
355
|
+
<>
|
|
356
|
+
{p.grade.vignette > 0 && <AbsoluteFill style={{ background: `radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,${p.grade.vignette}) 100%)` }} />}
|
|
357
|
+
{leak > 0 && <AbsoluteFill style={{ background: `radial-gradient(circle at ${20 + random(Math.floor(f / 40)) * 60}% 30%, rgba(255,190,120,${leak}) 0%, transparent 55%)`, mixBlendMode: 'screen' }} />}
|
|
358
|
+
{p.grade.grain > 0 && (
|
|
359
|
+
<AbsoluteFill style={{ opacity: p.grade.grain, mixBlendMode: 'overlay' }}>
|
|
360
|
+
<svg width={width} height={height}>
|
|
361
|
+
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed={f % 50} /></filter>
|
|
362
|
+
<rect width="100%" height="100%" filter="url(#g)" />
|
|
363
|
+
</svg>
|
|
364
|
+
</AbsoluteFill>
|
|
365
|
+
)}
|
|
366
|
+
</>
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// ---------- film ----------
|
|
371
|
+
// Props-driven: the render passes { film, words } (scripts/master.sh), the public dir is the film folder.
|
|
372
|
+
export const FilmView: React.FC<{ film: Film; words: Words }> = ({ film, words }) => {
|
|
373
|
+
const p = resolve(film);
|
|
374
|
+
const { len, starts, total, revealAt } = timeline(film, words);
|
|
375
|
+
const music = (fr: number) => interpolate(
|
|
376
|
+
fr, [0, 30, revealAt - 15, revealAt + 10, revealAt + 90, total - 60, total],
|
|
377
|
+
[0, p.music.base, p.music.base, p.music.lift, p.music.base + 0.04, p.music.base + 0.04, 0],
|
|
378
|
+
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
|
|
379
|
+
const sfx = (name: string, vol = 0.6) => name ? <Audio src={staticFile(`sfx/${name}.mp3`)} volume={vol} /> : null;
|
|
380
|
+
return (
|
|
381
|
+
<AbsoluteFill style={{ background: p.palette.bg }}>
|
|
382
|
+
<Audio src={asset(film, p.music.track)} volume={music} />
|
|
383
|
+
{film.beats.map((b, i) => {
|
|
384
|
+
const lead = Math.round(p.pace.lead * FPS);
|
|
385
|
+
const fx = b.kind === 'reveal' ? p.sfx.reveal : b.kind === 'question' ? p.sfx.question : i > 0 ? p.sfx.cut : '';
|
|
386
|
+
return (
|
|
387
|
+
<Sequence key={i} from={starts[i]} durationInFrames={len(b)}>
|
|
388
|
+
<BeatView film={film} b={b} d={len(b)} words={words} />
|
|
389
|
+
{fx && sfx(fx, b.kind === 'reveal' ? 0.7 : 0.35)}
|
|
390
|
+
{(b.sfx ?? []).map((s, j) => <Sequence key={j} from={lead}>{sfx(s, 0.45)}</Sequence>)}
|
|
391
|
+
{b.ambience && <Audio src={staticFile(`sfx/${b.ambience}.mp3`)} volume={0.18} loop />}
|
|
392
|
+
{p.sfx.text && b.title && i > 0 && <Sequence from={lead + 6}>{sfx(p.sfx.text, 0.25)}</Sequence>}
|
|
393
|
+
{b.vo && (
|
|
394
|
+
<Sequence from={lead}>
|
|
395
|
+
<Audio src={asset(film, `${b.vo}.mp3`)} trimBefore={Math.round(words[b.vo].offset * FPS)} />
|
|
396
|
+
</Sequence>
|
|
397
|
+
)}
|
|
398
|
+
</Sequence>
|
|
399
|
+
);
|
|
400
|
+
})}
|
|
401
|
+
<Texture p={p} starts={starts} />
|
|
402
|
+
<Captions film={film} words={words} starts={starts} />
|
|
403
|
+
</AbsoluteFill>
|
|
404
|
+
);
|
|
405
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cinematic-drama": {
|
|
3
|
+
"palette": { "bg": "#14110F", "fg": "#F3EDE3", "muted": "#B8AFA3", "accent": "#C9A24E", "accentBg": "#C9A96E" },
|
|
4
|
+
"fonts": { "display": "Playfair Display", "body": "Inter" },
|
|
5
|
+
"pace": { "lead": 0.8, "tail": 1.1 },
|
|
6
|
+
"text": "rise",
|
|
7
|
+
"transition": "fade",
|
|
8
|
+
"layout": "fullbleed",
|
|
9
|
+
"grade": { "grain": 0.07, "vignette": 0.55, "leak": false },
|
|
10
|
+
"captions": "subtitle",
|
|
11
|
+
"music": { "base": 0.2, "lift": 0.32, "track": "music.mp3" },
|
|
12
|
+
"sfx": { "cut": "", "text": "", "reveal": "hit", "question": "riser" },
|
|
13
|
+
"kenburns": 0.08,
|
|
14
|
+
"loudness": -16
|
|
15
|
+
},
|
|
16
|
+
"promo-booth": {
|
|
17
|
+
"palette": { "bg": "#14110F", "fg": "#F3EDE3", "muted": "#B8AFA3", "accent": "#C9A24E", "accentBg": "#C9A96E" },
|
|
18
|
+
"fonts": { "display": "Playfair Display", "body": "Inter" },
|
|
19
|
+
"pace": { "lead": 0.45, "tail": 0.7 },
|
|
20
|
+
"text": "slide",
|
|
21
|
+
"transition": "wipe",
|
|
22
|
+
"layout": "window",
|
|
23
|
+
"grade": { "grain": 0.04, "vignette": 0.35, "leak": false },
|
|
24
|
+
"captions": "subtitle",
|
|
25
|
+
"music": { "base": 0.28, "lift": 0.4, "track": "music.mp3" },
|
|
26
|
+
"sfx": { "cut": "whoosh", "text": "tick", "reveal": "hit", "question": "riser" },
|
|
27
|
+
"kenburns": 0.05,
|
|
28
|
+
"loudness": -16
|
|
29
|
+
},
|
|
30
|
+
"fun-energetic": {
|
|
31
|
+
"palette": { "bg": "#FFF6E8", "fg": "#1E1A17", "muted": "#5A524A", "accent": "#E8833A", "accentBg": "#F4C95D" },
|
|
32
|
+
"fonts": { "display": "Fraunces", "body": "Inter" },
|
|
33
|
+
"pace": { "lead": 0.2, "tail": 0.35 },
|
|
34
|
+
"text": "pop",
|
|
35
|
+
"transition": "punch",
|
|
36
|
+
"layout": "card",
|
|
37
|
+
"grade": { "grain": 0.0, "vignette": 0.0, "leak": true },
|
|
38
|
+
"captions": "subtitle",
|
|
39
|
+
"music": { "base": 0.38, "lift": 0.5, "track": "music.mp3" },
|
|
40
|
+
"sfx": { "cut": "pop", "text": "pop", "reveal": "chime", "question": "pop" },
|
|
41
|
+
"kenburns": 0.1,
|
|
42
|
+
"loudness": -14
|
|
43
|
+
},
|
|
44
|
+
"viral-social": {
|
|
45
|
+
"palette": { "bg": "#0E0C0B", "fg": "#FFFFFF", "muted": "#D6CEC4", "accent": "#F2C14E", "accentBg": "#F2C14E" },
|
|
46
|
+
"fonts": { "display": "Inter", "body": "Inter" },
|
|
47
|
+
"pace": { "lead": 0.05, "tail": 0.2 },
|
|
48
|
+
"text": "pop",
|
|
49
|
+
"transition": "punch",
|
|
50
|
+
"layout": "fullbleed",
|
|
51
|
+
"grade": { "grain": 0.03, "vignette": 0.45, "leak": false },
|
|
52
|
+
"captions": "kinetic",
|
|
53
|
+
"music": { "base": 0.3, "lift": 0.42, "track": "music.mp3" },
|
|
54
|
+
"sfx": { "cut": "whoosh", "text": "", "reveal": "hit", "question": "riser" },
|
|
55
|
+
"kenburns": 0.14,
|
|
56
|
+
"loudness": -14
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "compilerOptions": { "target": "ES2023", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "resolveJsonModule": true, "esModuleInterop": true, "skipLibCheck": true, "lib": ["ES2023", "DOM"] } }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Sample film (fictional product, no media needed)
|
|
2
|
+
Runs with the free voice and synthesized sound effects — no accounts required:
|
|
3
|
+
```bash
|
|
4
|
+
S=<skill>/scripts
|
|
5
|
+
$S/setup.sh sample-film
|
|
6
|
+
cp <skill>/examples/sample-film/{film.json,lines.json} ~/.kaizen/videos/sample-film/
|
|
7
|
+
$S/music.sh ~/.kaizen/videos/sample-film "Wholesome"
|
|
8
|
+
python3 $S/vo.py ~/.kaizen/videos/sample-film # needs: pip install edge-tts
|
|
9
|
+
$S/still.sh ~/.kaizen/videos/sample-film 16x9 30 200
|
|
10
|
+
$S/master.sh ~/.kaizen/videos/sample-film 16x9 sample-film-16x9 -16 4
|
|
11
|
+
```
|
|
12
|
+
The statistic is a placeholder: real films only show sourced numbers.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"preset": "cinematic-drama",
|
|
3
|
+
"language": "en",
|
|
4
|
+
"voiceProvider": "edge",
|
|
5
|
+
"edgeVoice": "en-US-AriaNeural",
|
|
6
|
+
"brand": { "palette": { "accent": "#D9A441", "accentBg": "#D9A441" } },
|
|
7
|
+
"labels": { "source": "Source", "next": "Next step" },
|
|
8
|
+
"cta": { "title": "See your own numbers in 20 minutes.", "sub": "Book a demo", "url": "[website]" },
|
|
9
|
+
"beats": [
|
|
10
|
+
{ "vo": "v01", "kind": "black", "title": "Every café closes at ten.", "sub": "The real work starts after.", "ambience": "amb-night" },
|
|
11
|
+
{ "vo": "v02", "kind": "stat", "kick": "The hidden shift", "stat": [{ "value": 4, "suffix": "+ hours", "label": "a week reconciling receipts (example figure — replace with a sourced one)", "source": "[source]" }] },
|
|
12
|
+
{ "vo": "v03", "kind": "question", "title": "What if the books closed themselves?" },
|
|
13
|
+
{ "vo": "v04", "kind": "reveal", "title": "Ledgerly", "sub": "Bookkeeping that closes with the café.", "line": "Invoices · Cash · Reports" },
|
|
14
|
+
{ "vo": "v05", "kind": "cta", "hold": 2.5 }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v01": "[calm, warm storyteller] Every café in the city closes at ten. But for the owner, that's when the real work begins.",
|
|
3
|
+
"v02": "[thoughtful] Most small cafés spend more than four hours a week just reconciling receipts, invoices and cash.",
|
|
4
|
+
"v03": "[gently curious, rising hope] So what if the books could close themselves, right when the doors do?",
|
|
5
|
+
"v04": "[warm, proud] Meet Ledgerly. Bookkeeping that closes with the café.",
|
|
6
|
+
"v05": "[warm, inviting] See your own numbers in twenty minutes. Book a demo today."
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Licensing — read before publishing
|
|
2
|
+
|
|
3
|
+
- **Voice provider is the user's choice** (SKILL.md step 0). Edge-tts voices: Microsoft service terms apply; treat as draft quality.
|
|
4
|
+
- **ElevenLabs voice and sound effects**: the free plan does NOT allow commercial use and requires
|
|
5
|
+
attribution. Anything published for a business (ads, booth, client) needs a paid plan
|
|
6
|
+
(Starter or above) active when the audio is generated — regenerate after upgrading.
|
|
7
|
+
Library (community) voices also need a paid plan for API use.
|
|
8
|
+
- **Music**: Kevin MacLeod / incompetech tracks are CC BY 4.0 — credit "Music: <Track> — Kevin MacLeod
|
|
9
|
+
(incompetech.com), CC BY 4.0" in the description or end credits. `music.sh` writes the credit file.
|
|
10
|
+
- **Photos**: Unsplash licence (free commercial use, no attribution required; credit anyway in notes).
|
|
11
|
+
Don't use photos with identifiable people in a way that implies endorsement.
|
|
12
|
+
- **Data on screen**: every statistic carries its source on screen (stat `source`).
|
|
13
|
+
- **Contacts on the CTA**: confirm phone numbers, booth numbers and URLs with the user — never reuse
|
|
14
|
+
contacts seen in someone else's material without confirmation.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Presets (src/engine/presets.json)
|
|
2
|
+
|
|
3
|
+
| preset | use for | look & motion | audio | captions | loudness |
|
|
4
|
+
|---|---|---|---|---|---|
|
|
5
|
+
| cinematic-drama | pitch opener, brand film | dark, full-bleed photos, slow push-in, rise text, grain + vignette, fades | quiet music, hit on reveal, riser into the question, room tone | subtitle bar | −16 |
|
|
6
|
+
| promo-booth | exhibition loop, deck-faithful promo (the friend's style) | photo in a framed window beside the text, wipes, slide-in text, staged builds | whoosh on cuts, tick on titles, slightly louder music | subtitle bar | −16 |
|
|
7
|
+
| fun-energetic | social posts, parents, events | cream background, rounded photo cards that bounce in, pop text, light leaks | bright track (Carefree), pops and a chime | subtitle bar | −14 |
|
|
8
|
+
| viral-social | TikTok / Reels / Shorts (9:16) | full-bleed, fast punch-in cuts, bold Inter, strong Ken Burns | Easy Lemon-type track, whooshes | kinetic 2–3 words, accent on the spoken word | −14 |
|
|
9
|
+
|
|
10
|
+
Pace: `lead` / `tail` = silence before and after each line. Drama 0.8/1.1, booth 0.45/0.7, fun 0.2/0.35, viral 0.05/0.2.
|
|
11
|
+
|
|
12
|
+
## Writing for each preset
|
|
13
|
+
- **drama**: full story arc, 70–110 s, long pauses allowed.
|
|
14
|
+
- **booth**: the deck's beats in order, 80–100 s, must make sense when joined mid-loop; real contacts on the CTA.
|
|
15
|
+
- **fun**: 35–50 s, playful lines ("Kenal rasanya?"), one problem montage, three quick benefits.
|
|
16
|
+
- **viral**: ≤ 30 s, line 1 is the hook (a sharp, relatable pain), product by second ~12, one CTA.
|
|
17
|
+
|
|
18
|
+
## Adding a preset
|
|
19
|
+
Copy an entry in presets.json; fields: palette, fonts (Playfair Display / Inter / Fraunces are loaded —
|
|
20
|
+
add others in Film.tsx FONTS), pace, text (rise|slide|pop), transition (fade|wipe|punch),
|
|
21
|
+
layout (fullbleed|window|card), grade, captions (subtitle|kinetic|none), music levels, sfx names, kenburns, loudness.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# What makes a video publish-grade
|
|
2
|
+
|
|
3
|
+
Learned by comparing deck-to-video renders against richer productions . The examples are from an Indonesian school product; apply the principles to any product.
|
|
4
|
+
|
|
5
|
+
## Layers, in order of impact
|
|
6
|
+
1. **Sound design**: room tone under every beat (`ambience`), one-shots on story moments (`pings` for
|
|
7
|
+
"140 pesan", `keyboard`, `bell`), soft whoosh/tick on cuts, a hit on the reveal, a riser into the
|
|
8
|
+
question. Music ducks under speech and only lifts at the reveal — never a dramatic swell unless
|
|
9
|
+
the preset is drama.
|
|
10
|
+
2. **Captions**: always. Projectors and phones are often muted. Subtitle bar for 16:9, kinetic
|
|
11
|
+
2–3-word captions for 9:16. Captions come from `words.json`, so they follow the voice exactly.
|
|
12
|
+
3. **Staged builds inside a beat**: numbers count up, list items arrive one by one with the voice,
|
|
13
|
+
triptych panels arrive in turn. A whole slide appearing at once reads as a slideshow.
|
|
14
|
+
4. **Directed voice**: emotion tag per line, low and slow through the problem, lift at the question,
|
|
15
|
+
warm and bright after the reveal. Fix mispronounced words by rewording, not by hoping.
|
|
16
|
+
5. **Rhythm**: vary beat length; hold on silence (`black` with `hold`); pace by preset.
|
|
17
|
+
6. **Real product**: actual screenshots / screen recordings for product beats. Stock photos of code
|
|
18
|
+
or generic laptops are a last resort.
|
|
19
|
+
7. **Brand**: real logo reveal, consistent palette and type, the same end card every time.
|
|
20
|
+
8. **Texture**: grain + vignette for drama, light leaks for fun; consistent grade on every photo.
|
|
21
|
+
9. **Format-native**: 9:16 has its own script (hook in the first 2 s, ≤ 30 s), not a crop of 16:9.
|
|
22
|
+
10. **Delivery**: master loudness (−14 LUFS social, −16 presentation), SRT, thumbnail, credits.
|
|
23
|
+
|
|
24
|
+
## QA before delivering
|
|
25
|
+
- Stills from every film at 3–4 points: no text overlap, captions inside safe area, CTA not covered.
|
|
26
|
+
- Length matches the brief (tell the user when the story needs more).
|
|
27
|
+
- `master.sh` printed the final duration and LUFS.
|
|
28
|
+
- Credits file present; licence status stated (see licensing.md).
|
|
29
|
+
- Ask the user to listen once for pronunciation and the mix — the agent cannot hear.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Revisions
|
|
2
|
+
|
|
3
|
+
Clients revise. Make every revision cheap, traceable and reversible.
|
|
4
|
+
|
|
5
|
+
## Classify the request, then touch only that layer
|
|
6
|
+
| request | what changes | redo |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| "change this sentence / say it warmer / mispronounced X" | `lines.json` entry | `vo.py <film-dir> "" v04` (only that key; empty voice = use film.json), then master |
|
|
9
|
+
| "different text on screen" | beat `kick/title/sub/items` in `film.json` | master |
|
|
10
|
+
| "swap this picture" | beat `img` (+ intake for new photos) | master |
|
|
11
|
+
| "slower / faster / shorter" | preset `pace`, beat `hold`, drop or merge beats | master |
|
|
12
|
+
| "music too loud / different mood" | preset `music.base/lift` or `music.sh` new track | master |
|
|
13
|
+
| "other colours / logo / font" | `film.brand`, `logo` | master |
|
|
14
|
+
| "make a vertical / 15 s version" | new film entry with its own (shorter) script | vo + master |
|
|
15
|
+
| "different style" | `preset` (and usually a rewritten script) | vo + master |
|
|
16
|
+
|
|
17
|
+
Never regenerate all narration for a one-line change: voices vary between takes and credits cost money.
|
|
18
|
+
|
|
19
|
+
## Versioning
|
|
20
|
+
- `master.sh` moves the previous delivery to `out/previous/<name>-<timestamp>.*` before writing the new one.
|
|
21
|
+
- Append to `~/.kaizen/videos/<slug>/REVISIONS.md`: date, the user's words verbatim, what changed, output file.
|
|
22
|
+
- When a revision is rejected, restore from `out/previous/` instead of re-rendering.
|
|
23
|
+
|
|
24
|
+
## Before re-delivering
|
|
25
|
+
Re-check stills of the beats that changed, master, and say exactly what changed and what did not.
|