kaizen-agent 1.19.4 → 1.20.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/install.ts +6 -3
- 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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: story-video
|
|
3
|
+
description: Turn whatever a user brings for any product — a prompt, a story, product photos, app screenshots, a deck (.pptx/.pdf), footage — into a publish-ready video (MP4 + SRT + thumbnail + credits) in a chosen style (cinematic-drama, promo-booth, fun-energetic, viral-social 9:16) with the product's own brand, narration in the user's language with emotional direction, word-synced captions, sound design, music, logo reveal and loudness mastering; then revise it on request. Use for promo / launch / opener / social / viral / explainer videos or "turn this deck into a video". Not for hand-editing an existing video timeline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Story video
|
|
7
|
+
|
|
8
|
+
Product-agnostic. A film is data: `~/.kaizen/videos/<slug>/film.json` (storyboard + brand) + `lines.json`
|
|
9
|
+
(narration with emotion tags) + `words.json` (word timings, generated) + a preset.
|
|
10
|
+
Worked example: [`examples/sample-film/`](examples/sample-film/) (fictional product, no images needed).
|
|
11
|
+
Run `scripts/check.sh` first on a new machine.
|
|
12
|
+
|
|
13
|
+
Read before producing:
|
|
14
|
+
- [`references/production.md`](references/production.md) — what makes a video publish-grade; QA list.
|
|
15
|
+
- [`references/presets.md`](references/presets.md) — styles, when to use each, adding one.
|
|
16
|
+
- [`references/script.md`](references/script.md) — **how to write the narration** (a told story, not slide text).
|
|
17
|
+
- [`references/revisions.md`](references/revisions.md) — how user revisions are applied and versioned.
|
|
18
|
+
- [`references/licensing.md`](references/licensing.md) — **before anything is published**.
|
|
19
|
+
|
|
20
|
+
## 0. Voice — ask first
|
|
21
|
+
Ask: "Do you have an ElevenLabs account? Which plan?" Then:
|
|
22
|
+
- **Paid ElevenLabs** (Starter+): best and commercially licensed. Use a **native speaker of the
|
|
23
|
+
narration language** from the voice library (e.g. Indonesian: search `shared-voices?language=id`,
|
|
24
|
+
add it, let the user pick from 3 samples). Non-native voices sound synthetic — this is the
|
|
25
|
+
biggest single factor in how natural the video sounds.
|
|
26
|
+
- **Free ElevenLabs**: premade voices only (non-native for most languages) and **no commercial use**;
|
|
27
|
+
fine for drafts and internal review. Offer samples (v3 with `stability: 0` sounds most natural).
|
|
28
|
+
- **No ElevenLabs**: free Microsoft neural voices via edge-tts (`pip install edge-tts`), set
|
|
29
|
+
`"voiceProvider": "edge"` (automatic when no key). No emotion control; usable for drafts.
|
|
30
|
+
- Or the user records the narration: write the script with timings, cut the film to their audio.
|
|
31
|
+
Set `voiceProvider` / `voice` / `edgeVoice` in film.json accordingly.
|
|
32
|
+
|
|
33
|
+
## Where things live
|
|
34
|
+
- **Film workspace:** `~/.kaizen/videos/<slug>/` (one home for every video, whatever the product; override `STORY_VIDEO_HOME`) — `inputs/` (the user's material), generated
|
|
35
|
+
assets (photos, voice `vNN.mp3`, `music.mp3`), `lines.json`, `film.json`, `words.json`,
|
|
36
|
+
`REVISIONS.md`, `out/` (deliverables) and `out/previous/` (earlier versions). Nothing is written to the user's project folders.
|
|
37
|
+
- **Engine:** `~/.kaizen/story-video/engine/` (Remotion + node_modules + sfx), installed once per machine
|
|
38
|
+
and shared by every project. `scripts/setup.sh <slug>` (run from the project root) installs or updates
|
|
39
|
+
it and creates the film workspace (from any directory). Override with `STORY_VIDEO_ENGINE`.
|
|
40
|
+
- Scripts run from the skill: `S=<this skill>/scripts`, every script takes the film dir.
|
|
41
|
+
|
|
42
|
+
## 1. Intake — whatever the user brings
|
|
43
|
+
`$S/setup.sh <slug>`, put the user's material in `~/.kaizen/videos/<slug>/inputs/` sorted as
|
|
44
|
+
`photos/`, `screens/`, `footage/`, `logo*.png`, a deck (`*.pptx` / `*.pdf`) and/or `brief.md` (their
|
|
45
|
+
prompt or story, verbatim), then `python3 $S/intake.py ~/.kaizen/videos/<slug>`. It crops photos for
|
|
46
|
+
16:9 and 9:16, keeps screenshots untouched (records ratios), transcodes footage, renders deck slides +
|
|
47
|
+
text to `intake/`, and suggests a brand palette from the logo. Read the deck text, the brief and the
|
|
48
|
+
slide images before writing anything.
|
|
49
|
+
|
|
50
|
+
Missing material is fine: no photos → source candid ones (story-deck rules); no screenshots →
|
|
51
|
+
ask for them (product beats need the real product); no story → propose one.
|
|
52
|
+
|
|
53
|
+
## 2. Brief — ask with options (one round)
|
|
54
|
+
Purpose and where it plays · style preset · length · formats · language · voice (from step 0;
|
|
55
|
+
ask gender/tone) · music mood · brand (logo, colours, fonts — propose from the
|
|
56
|
+
logo palette) · CTA (URL / QR target / contact / event details — never invent them) · claims the
|
|
57
|
+
user can back up.
|
|
58
|
+
|
|
59
|
+
## 3. Treatment — approve before generating
|
|
60
|
+
A beat table: time · picture · narration line + emotion tag · on-screen text · SFX/ambience.
|
|
61
|
+
Write the narration as one told story (references/script.md) — the user's text is source, not script.
|
|
62
|
+
Show the full script as continuous prose too, so the user can judge how it flows. Only claims the material supports. The user approves or edits it; that is revision 0.
|
|
63
|
+
|
|
64
|
+
## 4. Produce (F = ~/.kaizen/videos/<slug>)
|
|
65
|
+
1. `F/film.json`: `preset`, `language` (`id`, `en`, …), `voiceProvider`, `voice`, `stability`,
|
|
66
|
+
`brand` (`palette`, `fonts`), `labels` (on-screen words in that language: `source`, `next`),
|
|
67
|
+
`logo` (light, for dark backgrounds), `logoOnLight`, `qr`, `cta`, `beats` — table below.
|
|
68
|
+
File names in beats are relative to `F`.
|
|
69
|
+
2. `F/lines.json` (approved script) → `python3 $S/vo.py F` (or only some lines: `vo.py F "" v04,v07`).
|
|
70
|
+
3. Music: `$S/music.sh F "Track"`; offer 3–5 previews when the mood is not settled.
|
|
71
|
+
4. QR: `python3 -c "import qrcode; qrcode.make('<url>').save('F/qr.png')"`.
|
|
72
|
+
5. Check frames: `$S/still.sh F 16x9 60 400 900`, contact-sheet, look at it (or send it to the user), fix. Delete the check images after.
|
|
73
|
+
6. Master each format: `$S/master.sh F <16x9|9x16> <name> <-14|-16> <thumb-second>` → `F/out/`.
|
|
74
|
+
|
|
75
|
+
## 5. Deliver, then revise
|
|
76
|
+
Report files, lengths, loudness, licence status, placeholders, claims to verify, and ask the user
|
|
77
|
+
to listen once (the agent cannot hear). Revisions follow `references/revisions.md`: change only
|
|
78
|
+
the touched beats/lines, re-render, keep earlier versions in `out/previous/`, log in `REVISIONS.md`.
|
|
79
|
+
|
|
80
|
+
## Beat kinds
|
|
81
|
+
|
|
82
|
+
| kind | use | key fields |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `photo` | story beat on a photo | `img, side, kick, title, sub, ambience, sfx` |
|
|
85
|
+
| `list` | items arrive one by one with the line | `img, kick, title, items[]` |
|
|
86
|
+
| `stat` | numbers count up, source on screen | `stat[{value,suffix,label,source}], title` |
|
|
87
|
+
| `black` | silence / pause | `title, sub, hold` |
|
|
88
|
+
| `triptych` | three viewpoints | `panels[{img,title,sub}]` |
|
|
89
|
+
| `question` | the turn ("what if") | `title` |
|
|
90
|
+
| `reveal` | logo wipe-in + tagline | `sub, line` |
|
|
91
|
+
| `shot` | real product screenshot, framed | `img, shotRatio, kick, title, sub` |
|
|
92
|
+
| `footage` | video clip | `video, kick, title` |
|
|
93
|
+
| `cta` | the ask: QR, contacts, URL | `img, hold` + `film.cta` |
|
|
94
|
+
|
|
95
|
+
Any beat: `vo`, `sfx[]` (whoosh, hit, riser, tick, pop, chime, ping, pings, bell, keyboard) and
|
|
96
|
+
`ambience` (amb-morning, amb-classroom, amb-office, amb-night). Add sounds with `scripts/sfx.py` (writes to the shared engine).
|
|
97
|
+
Fonts available: Playfair Display, Inter, Fraunces, Montserrat, Poppins, DM Sans, Lora,
|
|
98
|
+
Plus Jakarta Sans, Space Grotesk (add more in `engine/Film.tsx` FONTS).
|
|
99
|
+
|
|
100
|
+
## Distribution
|
|
101
|
+
Shipped with kaizen (`skills/story-video` in the kaizen repo).
|
|
102
|
+
|
|
103
|
+
## Agent notes (Claude Code, Codex, Gemini CLI, OpenCode, Cursor…)
|
|
104
|
+
Steps name capabilities, not tools. Where your agent lacks one:
|
|
105
|
+
- **web fetch** (finding stock photos, sources): ask the user for photos/links instead.
|
|
106
|
+
- **viewing images** (checking contact sheets / frames): send the image paths to the user and ask them to look.
|
|
107
|
+
- **sub-agents** (reading several repos at once): read them one after another.
|
|
108
|
+
- **structured questions**: ask in plain text with numbered options.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "story-video-films",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": { "studio": "remotion studio src/index.ts" },
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@remotion/cli": "4.0.529",
|
|
7
|
+
"@remotion/google-fonts": "4.0.529",
|
|
8
|
+
"react": "^19.0.0",
|
|
9
|
+
"react-dom": "^19.0.0",
|
|
10
|
+
"remotion": "4.0.529"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Composition, getInputProps } from 'remotion';
|
|
3
|
+
import { FilmView, FPS, timeline, type Film, type Words } from './engine/Film';
|
|
4
|
+
|
|
5
|
+
// One film per render: scripts/master.sh passes { film, words } as props and the film folder as public dir.
|
|
6
|
+
const SIZE = { '16x9': [1920, 1080], '9x16': [1080, 1920] } as const;
|
|
7
|
+
type Props = { film: Film; words: Words };
|
|
8
|
+
const props = getInputProps() as Partial<Props>;
|
|
9
|
+
|
|
10
|
+
export const Root: React.FC = () => (
|
|
11
|
+
<>
|
|
12
|
+
{(Object.keys(SIZE) as (keyof typeof SIZE)[]).map((fmt) => (
|
|
13
|
+
<Composition
|
|
14
|
+
key={fmt} id={`Film-${fmt}`} component={FilmView as unknown as React.FC<Record<string, unknown>>}
|
|
15
|
+
fps={FPS} width={SIZE[fmt][0]} height={SIZE[fmt][1]} durationInFrames={1}
|
|
16
|
+
defaultProps={props as Record<string, unknown>}
|
|
17
|
+
calculateMetadata={({ props: pr }) => {
|
|
18
|
+
const { film, words } = pr as unknown as Props;
|
|
19
|
+
return { durationInFrames: film && words ? timeline(film, words).total : 1 };
|
|
20
|
+
}}
|
|
21
|
+
/>
|
|
22
|
+
))}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
@@ -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.
|