belt-trailer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +78 -0
  3. data/CHANGELOG.md +22 -0
  4. data/LICENSE +21 -0
  5. data/README.md +223 -0
  6. data/engine/bin/trailer.mjs +45 -0
  7. data/engine/lib/args.mjs +18 -0
  8. data/engine/lib/camera.mjs +140 -0
  9. data/engine/lib/check.mjs +134 -0
  10. data/engine/lib/clock.mjs +38 -0
  11. data/engine/lib/config.mjs +97 -0
  12. data/engine/lib/cut.mjs +169 -0
  13. data/engine/lib/ease.mjs +6 -0
  14. data/engine/lib/ffmpeg.mjs +46 -0
  15. data/engine/lib/film.mjs +161 -0
  16. data/engine/lib/index.mjs +5 -0
  17. data/engine/lib/showcase.mjs +83 -0
  18. data/engine/lib/sound.mjs +145 -0
  19. data/engine/lib/titles.mjs +43 -0
  20. data/engine/package.json +14 -0
  21. data/engine/titles.html +144 -0
  22. data/examples/posh-nosh/trailer-titles.css +26 -0
  23. data/examples/posh-nosh/trailer.config.mjs +168 -0
  24. data/lib/belt/commands/trailer_command.rb +123 -0
  25. data/lib/belt/generators/trailer_generator.rb +215 -0
  26. data/lib/belt/trailer/templates/showcase/build.mjs +24 -0
  27. data/lib/belt/trailer/templates/showcase/fixtures.js.erb +22 -0
  28. data/lib/belt/trailer/templates/showcase/showcase.html +14 -0
  29. data/lib/belt/trailer/templates/showcase/showcase.jsx +50 -0
  30. data/lib/belt/trailer/templates/trailer-storyboard.md.erb +37 -0
  31. data/lib/belt/trailer/templates/trailer-titles.css +22 -0
  32. data/lib/belt/trailer/templates/trailer.config.mjs.erb +69 -0
  33. data/lib/belt/trailer/version.rb +7 -0
  34. data/lib/belt/trailer.rb +69 -0
  35. data/lib/belt-trailer.rb +3 -0
  36. data/skills/belt-trailer/SKILL.md +128 -0
  37. data/skills/belt-trailer/references/beats.md +136 -0
  38. data/skills/belt-trailer/references/reviewing.md +90 -0
  39. data/skills/belt-trailer/references/showcase-and-fixtures.md +80 -0
  40. data/skills/belt-trailer/references/storyboard.md +87 -0
  41. metadata +102 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../trailer'
4
+
5
+ module Belt
6
+ module Commands
7
+ # `belt trailer film|cut|check` — runs the trailer engine on a frontend.
8
+ class TrailerCommand
9
+ SUBCOMMANDS = %w[film cut check].freeze
10
+
11
+ def self.description
12
+ 'Film, cut and check a launch trailer from the app itself (belt-trailer)'
13
+ end
14
+
15
+ def self.run(args)
16
+ new.run(args.dup)
17
+ end
18
+
19
+ def run(args)
20
+ command = args.shift
21
+ return help if command.nil? || %w[help --help -h].include?(command)
22
+ return guide if command == 'guide'
23
+
24
+ unless SUBCOMMANDS.include?(command)
25
+ warn "Unknown command: belt trailer #{command}. Run `belt trailer help`."
26
+ exit 1
27
+ end
28
+ return help if args.include?('--help') || args.include?('-h')
29
+
30
+ app_dir = frontend_dir(extract_flag!(args, '--frontend'))
31
+ config = option(args, '--config') || Belt::Trailer::CONFIG_FILE
32
+ missing = Belt::Trailer.missing_requirements(app_dir, config: config)
33
+ unless missing.empty?
34
+ warn "belt trailer needs:\n#{missing.map { |m| " - #{m}" }.join("\n")}"
35
+ exit 1
36
+ end
37
+
38
+ exit(system(*Belt::Trailer.engine_command(command, app_dir, args)) ? 0 : 1)
39
+ end
40
+
41
+ # The frontend's directory: --frontend NAME from config/frontends.yml,
42
+ # else the default one, else frontend/.
43
+ def frontend_dir(name)
44
+ registry = registry_class&.load
45
+ return 'frontend' if registry.nil? || (registry.empty? && name.nil?)
46
+
47
+ registry.resolve!(name).path
48
+ end
49
+
50
+ private
51
+
52
+ def registry_class
53
+ require 'belt/cli/frontend_registry'
54
+ Belt::CLI::FrontendRegistry
55
+ rescue LoadError
56
+ nil
57
+ end
58
+
59
+ def extract_flag!(args, flag)
60
+ index = args.index { |a| a == flag || a.start_with?("#{flag}=") }
61
+ return nil unless index
62
+
63
+ arg = args.delete_at(index)
64
+ arg.include?('=') ? arg.split('=', 2).last : args.delete_at(index)
65
+ end
66
+
67
+ def option(args, flag)
68
+ index = args.index { |a| a == flag || a.start_with?("#{flag}=") }
69
+ return nil unless index
70
+
71
+ args[index].include?('=') ? args[index].split('=', 2).last : args[index + 1]
72
+ end
73
+
74
+ # The skill for agents making a trailer, with its references' full paths.
75
+ def guide
76
+ skill = File.join(Belt::Trailer::SKILL_DIR, 'SKILL.md')
77
+ puts "# #{skill}\n\n"
78
+ puts File.read(skill).gsub('](references/', "](#{Belt::Trailer::SKILL_DIR}/references/")
79
+ .gsub('<gem>', Belt::Trailer::ROOT)
80
+ end
81
+
82
+ def help
83
+ puts <<~HELP
84
+ Film a launch trailer of the app from its own screens and sounds.
85
+
86
+ Usage: belt trailer <film|cut|check|guide> [options]
87
+
88
+ film Film each beat of the storyboard in trailer.config.mjs:
89
+ <beat>-<format>.mp4, <beat>-<format>-clean.mp4 (no titles)
90
+ and <beat>-<format>.json (the sounds it played, its marks)
91
+ cut Join the beats in order with their sounds and the music,
92
+ levelled to -14 LUFS: trailer-<format>.mp4
93
+ check Pass or fail the cut: audio from 0 for the whole video,
94
+ -14 LUFS ±1, true peak below -1 dBFS, at most 30 s, every
95
+ effect on its frame, no title in the last 0.5 s of a beat.
96
+ Writes trailer-<format>-sheet.png, a contact sheet to look at.
97
+ guide The skill for agents making a trailer: storyboard, fixtures,
98
+ beats, reviewing frames (start here)
99
+
100
+ Options:
101
+ --frontend NAME Which frontend (config/frontends.yml; default: frontend/)
102
+ --only a,b film: just these beats, by the names in the config
103
+ --format NAME wide, tall or both (default)
104
+ --clean film: delete those beats' old clips, leftover frames
105
+ and cached live sounds first
106
+ --keep-frames film: keep the frame images
107
+ --music PLAN cut, check: a named plan from music.plans
108
+ --sounds bundled film, cut: the app's bundled sounds, not the live site's
109
+ --out DIR Output directory (default: <frontend>/tmp/trailer)
110
+ --config FILE Config file in the frontend (default: trailer.config.mjs)
111
+
112
+ Needs Node, ffmpeg and Playwright's Chromium in the frontend.
113
+ Start with `belt generate trailer`.
114
+
115
+ Examples:
116
+ belt trailer film --format wide
117
+ belt trailer film --only dragon --format wide
118
+ belt trailer cut && belt trailer check
119
+ HELP
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require_relative '../trailer'
7
+ require_relative '../commands/trailer_command'
8
+
9
+ module Belt
10
+ module Generators
11
+ # `belt generate trailer` — a starter trailer for a React + Vite frontend:
12
+ # trailer.config.mjs with a two-beat storyboard, a titles theme, and a
13
+ # minimal showcase (the app rendered from fixtures, no backend) if the
14
+ # frontend has none.
15
+ class TrailerGenerator
16
+ TEMPLATE_DIR = File.expand_path('../trailer/templates', __dir__)
17
+ # Showcases the generator recognises, its own first.
18
+ SHOWCASES = %w[scripts/showcase/build.mjs scripts/screenshots/build.mjs].freeze
19
+ SHOWCASE_FILES = %w[build.mjs showcase.html showcase.jsx fixtures.js].freeze
20
+ STARTER_BEATS = %w[hook end].freeze
21
+ AGENTS_MARKER = '## Launch trailer (belt-trailer)'
22
+ AGENTS_SECTION = <<~MD.freeze
23
+ #{AGENTS_MARKER}
24
+
25
+ The trailer is filmed from the app by belt-trailer. Before working on it,
26
+ read the skill: `belt trailer guide`. Storyboard first
27
+ (`FRONTEND/trailer-storyboard.md`), then fixtures, then the beats in
28
+ `FRONTEND/trailer.config.mjs`. Review every beat from its frames before
29
+ calling it done, and leave publishing (and music rights) to a person.
30
+ MD
31
+
32
+ def self.description
33
+ 'Scaffold a launch trailer: trailer.config.mjs, a titles theme and a showcase'
34
+ end
35
+
36
+ def self.run(args)
37
+ return print_help if args.include?('--help') || args.include?('-h')
38
+
39
+ new(args).generate
40
+ end
41
+
42
+ def self.destroy(args)
43
+ new(args).destroy
44
+ end
45
+
46
+ def self.print_help
47
+ puts <<~HELP
48
+ Scaffold a launch trailer for a React + Vite frontend.
49
+
50
+ Usage: belt generate trailer [options]
51
+
52
+ Options:
53
+ --frontend NAME Which frontend (config/frontends.yml; default: frontend/)
54
+ --url ADDRESS The address on the end card (default: example.com)
55
+ --skip-showcase Don't write a showcase, even if the frontend has none
56
+ --force Overwrite existing files
57
+
58
+ What gets created (in the frontend):
59
+ trailer.config.mjs The storyboard: beats, titles, music, canned API
60
+ trailer-titles.css The titles' fonts and colours
61
+ trailer-storyboard.md The storyboard to write first
62
+ scripts/showcase/ If there's no showcase yet: build.mjs,
63
+ showcase.html, showcase.jsx, fixtures.js
64
+
65
+ Then:
66
+ cd frontend && npm install -D playwright && npx playwright install chromium
67
+ belt trailer film --format wide
68
+ belt trailer cut && belt trailer check
69
+
70
+ Examples:
71
+ belt g trailer
72
+ belt g trailer --url myapp.com
73
+ belt d trailer
74
+ HELP
75
+ end
76
+
77
+ def initialize(args, out: $stdout)
78
+ args = args.dup
79
+ @out = out
80
+ @force = args.delete('--force')
81
+ @skip_showcase = args.delete('--skip-showcase')
82
+ @url = option(args, '--url') || 'example.com'
83
+ @dir = Belt::Commands::TrailerCommand.new.frontend_dir(option(args, '--frontend'))
84
+ end
85
+
86
+ def generate
87
+ unless File.directory?(@dir)
88
+ abort "No #{@dir}/ here. Pass --frontend NAME, or run from a Belt app with a frontend."
89
+ end
90
+
91
+ @app_title = app_title
92
+ @beats = STARTER_BEATS
93
+ existing = SHOWCASES.find { |file| File.exist?(path(file)) }
94
+ @showcase_build = "./#{existing || SHOWCASES.first}"
95
+
96
+ write('trailer.config.mjs', render('trailer.config.mjs.erb'))
97
+ write('trailer-titles.css', File.read(File.join(TEMPLATE_DIR, 'trailer-titles.css')))
98
+ write('trailer-storyboard.md', render('trailer-storyboard.md.erb'))
99
+ if existing
100
+ say " exist #{path(existing)} (filming that showcase)"
101
+ elsif !@skip_showcase
102
+ write_showcase
103
+ end
104
+ ignore_output
105
+ point_agents
106
+ next_steps
107
+ end
108
+
109
+ def destroy
110
+ files = %w[trailer.config.mjs trailer-titles.css trailer-storyboard.md]
111
+ files += SHOWCASE_FILES.map { |f| "scripts/showcase/#{f}" } if generated_showcase?
112
+ files.each do |file|
113
+ next unless File.exist?(path(file))
114
+
115
+ FileUtils.rm_f(path(file))
116
+ say " remove #{path(file)}"
117
+ end
118
+ [path('scripts/showcase'), path('scripts')].each do |dir|
119
+ FileUtils.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def write_showcase
126
+ SHOWCASE_FILES.each do |file|
127
+ template = File.join('showcase', file)
128
+ body = if File.exist?(File.join(TEMPLATE_DIR, "#{template}.erb"))
129
+ render("#{template}.erb")
130
+ else
131
+ File.read(File.join(TEMPLATE_DIR, template))
132
+ end
133
+ write("scripts/showcase/#{file}", body)
134
+ end
135
+ end
136
+
137
+ # Ours if its build.mjs is the one this generator writes.
138
+ def generated_showcase?
139
+ build = path('scripts/showcase/build.mjs')
140
+ File.exist?(build) && File.read(build) == File.read(File.join(TEMPLATE_DIR, 'showcase/build.mjs'))
141
+ end
142
+
143
+ def write(file, body)
144
+ target = path(file)
145
+ if File.exist?(target) && !@force
146
+ say " skip #{target} (exists; --force to overwrite)"
147
+ return
148
+ end
149
+ FileUtils.mkdir_p(File.dirname(target))
150
+ File.write(target, body)
151
+ say " create #{target}"
152
+ end
153
+
154
+ # The showcase build and the clips go under tmp/, which shouldn't be committed.
155
+ def ignore_output
156
+ gitignore = path('.gitignore')
157
+ return unless File.exist?(gitignore)
158
+
159
+ lines = File.readlines(gitignore, chomp: true)
160
+ return if lines.any? { |l| %w[tmp tmp/ /tmp /tmp/].include?(l.strip) }
161
+
162
+ File.write(gitignore, "#{File.read(gitignore).chomp}\n/tmp/\n")
163
+ say " update #{gitignore} (ignore /tmp/)"
164
+ end
165
+
166
+ # Most trailers are made by AI agents: tell the ones working in this app
167
+ # where the skill is.
168
+ def point_agents
169
+ agents = 'AGENTS.md'
170
+ return unless File.exist?(agents)
171
+ return if File.read(agents).include?(AGENTS_MARKER)
172
+
173
+ File.write(agents, "#{File.read(agents).chomp}\n\n#{AGENTS_SECTION.gsub('FRONTEND', @dir)}")
174
+ say " update #{agents} (#{AGENTS_MARKER})"
175
+ end
176
+
177
+ def render(template)
178
+ ERB.new(File.read(File.join(TEMPLATE_DIR, template)), trim_mode: '-').result(binding)
179
+ end
180
+
181
+ def app_title
182
+ name = JSON.parse(File.read(path('package.json')))['name'] if File.exist?(path('package.json'))
183
+ name = File.basename(Dir.pwd) if name.to_s.empty? || name == 'frontend'
184
+ name.to_s.sub(%r{\A@[^/]+/}, '').split(/[-_\s]+/).map(&:capitalize).join(' ').gsub("'", '’')
185
+ end
186
+
187
+ def next_steps
188
+ say <<~STEPS
189
+
190
+ Next:
191
+ 1. Read the skill: belt trailer guide. Write the storyboard in
192
+ #{path('trailer-storyboard.md')}, then the fixtures it films, then
193
+ the beats in #{path('trailer.config.mjs')}.
194
+ 2. cd #{@dir} && npm install -D playwright && npx playwright install chromium
195
+ 3. belt trailer film --format wide, then belt trailer cut and belt trailer check
196
+ STEPS
197
+ end
198
+
199
+ def path(file)
200
+ File.join(@dir, file)
201
+ end
202
+
203
+ def option(args, flag)
204
+ index = args.index { |a| a == flag || a.start_with?("#{flag}=") }
205
+ return nil unless index
206
+
207
+ args[index].include?('=') ? args[index].split('=', 2).last : args[index + 1]
208
+ end
209
+
210
+ def say(text)
211
+ @out.puts(text)
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,24 @@
1
+ // Builds the showcase (showcase.html) with Vite into tmp/showcase-dist, for
2
+ // belt-trailer to film. API calls go to the showcase's own origin, where the
3
+ // fixture or trailer.config.mjs answers them; nothing reaches a backend.
4
+ import { build } from 'vite';
5
+ import react from '@vitejs/plugin-react';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const frontend = path.resolve(here, '../..');
11
+ export const distDir = path.join(frontend, 'tmp/showcase-dist');
12
+
13
+ export async function buildShowcase() {
14
+ process.env.VITE_API_URL = 'http://showcase.local/api';
15
+ await build({
16
+ root: here,
17
+ publicDir: path.join(frontend, 'public'),
18
+ plugins: [react()],
19
+ logLevel: 'warn',
20
+ configFile: false,
21
+ build: { outDir: distDir, emptyOutDir: true, rollupOptions: { input: path.join(here, 'showcase.html') } },
22
+ });
23
+ return { distDir };
24
+ }
@@ -0,0 +1,22 @@
1
+ // The states the showcase can show, by name: the route to render and the
2
+ // answers to the API calls it makes, as the server would really send them.
3
+ //
4
+ // Make the trailer's fixtures one story: every shot is the same session at a
5
+ // later moment, with the same records and people, built from one function.
6
+ // A record that changes between two cuts reads as a different session. Give
7
+ // each state change its own fixture: sounds and animations come from the
8
+ // difference between two states, so a missing event is a missing sound.
9
+
10
+ export const FIXTURES = {
11
+ home: {
12
+ path: '/',
13
+ api: {
14
+ '/': {
15
+ title: '<%= @app_title %>',
16
+ subtitle: 'Your Belt app is running.',
17
+ stack: { api_gateway: true, lambda: true, dynamodb: true },
18
+ next_steps: [],
19
+ },
20
+ },
21
+ },
22
+ };
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <!-- The app's screens rendered from fixtures, for belt-trailer. Keep the
3
+ same fonts and head as the real index.html. -->
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>Showcase</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="./showcase.jsx"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,50 @@
1
+ // The showcase: the app rendered from a fixture, chosen by name
2
+ // (?view=<fixture>), with no backend. window.__setView(name) switches to
3
+ // another fixture without reloading, so the app's components see a state
4
+ // change and animate (and play their sounds) as on a real update.
5
+ //
6
+ // Two hooks for the trailer, behind query flags so the showcase stays quiet
7
+ // otherwise:
8
+ // ?sound=1 mount the app's own sound hooks here, so it plays what it would
9
+ // window.__… an app-defined trigger a beat can fire on cue with
10
+ // fire('__name', ...args), rather than a timer from load
11
+ import React, { useEffect, useState } from 'react';
12
+ import ReactDOM from 'react-dom/client';
13
+ import { MemoryRouter } from 'react-router-dom';
14
+ import App from '../../src/App';
15
+ import '../../src/index.css';
16
+ import { FIXTURES } from './fixtures.js';
17
+
18
+ const params = new URLSearchParams(location.search);
19
+ // ?sound=1: call your app's sound hooks in Showcase when this is true (it's
20
+ // fixed for the page, so a hook behind it is safe).
21
+ const SOUND = params.get('sound') === '1';
22
+
23
+ // API calls answered from the current fixture: `api` maps a path (after
24
+ // VITE_API_URL) to its JSON. Anything else goes on to trailer.config.mjs.
25
+ let current = FIXTURES[params.get('view')] || Object.values(FIXTURES)[0];
26
+ const network = window.fetch.bind(window);
27
+ window.fetch = async (input, init) => {
28
+ const url = new URL(typeof input === 'string' ? input : input.url, location.href);
29
+ const key = url.pathname.replace(/^\/api/, '') || '/';
30
+ if (current?.api && key in current.api) {
31
+ return new Response(JSON.stringify(current.api[key]), { headers: { 'Content-Type': 'application/json' } });
32
+ }
33
+ return network(input, init);
34
+ };
35
+
36
+ function Showcase() {
37
+ const [view, setView] = useState(params.get('view') || Object.keys(FIXTURES)[0]);
38
+ useEffect(() => { window.__setView = setView; }, []);
39
+ const fixture = FIXTURES[view];
40
+ if (!fixture) return <p>No fixture called {view}. Add it to scripts/showcase/fixtures.js.</p>;
41
+ current = fixture;
42
+ // A new path remounts the app; the same path with new data is an update.
43
+ return (
44
+ <MemoryRouter key={fixture.path} initialEntries={[fixture.path]}>
45
+ <App />
46
+ </MemoryRouter>
47
+ );
48
+ }
49
+
50
+ ReactDOM.createRoot(document.getElementById('root')).render(<Showcase />);
@@ -0,0 +1,37 @@
1
+ # <%= @app_title %> launch trailer: storyboard
2
+
3
+ Write this before scripting any beat, and get it agreed. How: `belt trailer guide`
4
+ (the belt-trailer skill, references/storyboard.md). The beats in
5
+ trailer.config.mjs follow this table, by name.
6
+
7
+ ## Who it's for and what it offers
8
+
9
+ - **Who:** TODO: one sentence; what are they doing when they'd want it?
10
+ - **The offer, in one line:** TODO: usually the site's meta description or store subtitle
11
+ - **What makes it different**, in order of how fast it reads on screen:
12
+ 1. TODO
13
+ - **Not leading with:** TODO (usually the rules, settings or onboarding)
14
+
15
+ ## The cut
16
+
17
+ TODO: 5–7 beats, 20–30 s. Every product shot is the same session at a later
18
+ moment, built from one fixture function: TODO name it.
19
+
20
+ | Time | Beat | Fixture(s) | Camera | On screen | Why it's here |
21
+ |---|---|---|---|---|---|
22
+ | 0–3.3 s | `hook` | `home` | wide, then push in on the subject | TODO | something happens in the first second |
23
+ | 3.3–6.8 s | `end` | `home` (blurred) | still | logo · the offer · terms · <%= @url %> | the call to action, held long enough to type |
24
+
25
+ ## Sound
26
+
27
+ | Beat | Music | Effects the viewer should hear |
28
+ |---|---|---|
29
+ | `hook` | TODO | TODO |
30
+ | `end` | TODO | |
31
+
32
+ Where every audio file comes from, and whether it can be used in advertising:
33
+ TODO (a person answers this before anything is published).
34
+
35
+ ## Open questions
36
+
37
+ - TODO
@@ -0,0 +1,22 @@
1
+ /* The trailer's titles, in the app's own look. Loaded after belt-trailer's
2
+ defaults (engine/titles.html), so anything here wins. Fonts are served from
3
+ the showcase build, i.e. from public/. */
4
+
5
+ /* @font-face { font-family: 'Brand'; font-weight: 400 900; src: url(/fonts/brand.woff2) format('woff2'); } */
6
+
7
+ :root {
8
+ --title-font: system-ui, sans-serif;
9
+ --line-font: var(--title-font);
10
+ --line-weight: 800;
11
+ --kicker-color: #f5d27a;
12
+ /* Each word of a title is filled with this, so a gradient reads as metal. */
13
+ --word-fill: linear-gradient(180deg, #ffffff 0%, #f1f1f1 55%, #cfcfcf 100%);
14
+ --word-glow: rgba(255, 255, 255, 0.25);
15
+ --rule: linear-gradient(90deg, transparent, #f5d27a 30%, #fff3c9 50%, #f5d27a 70%, transparent);
16
+ /* The dark wash behind the words, as r, g, b. */
17
+ --shade: 9, 6, 16;
18
+ --end-text: #f4f0e6;
19
+ --end-muted: #cfc7b4;
20
+ --url-text: #1d1405;
21
+ --url-fill: linear-gradient(180deg, #fff1c4 0%, #f5d27a 55%, #d1a23f 100%);
22
+ }
@@ -0,0 +1,69 @@
1
+ // <%= @app_title %>'s launch trailer: `belt trailer film`, `belt trailer cut`,
2
+ // `belt trailer check`. Start with `belt trailer guide` (how to write the
3
+ // storyboard, fixtures and beats, and review what you film), and see the gem's
4
+ // examples/posh-nosh/ for a finished trailer.
5
+ //
6
+ // Write the storyboard (trailer-storyboard.md) before the beats: five to
7
+ // seven shots, 20–30 s, opening on something happening in the first second
8
+ // and ending on the offer and the address. Hold every title, fully landed,
9
+ // about two seconds.
10
+
11
+ export default {
12
+ // The app's screens with no backend: <%= @showcase_build %> builds them,
13
+ // and ?view=<fixture> picks one (fixtures.js beside it).
14
+ showcase: { build: '<%= @showcase_build %>', query: { sound: 1 } },
15
+
16
+ // Canned answers for any API call a fixture doesn't answer itself. The
17
+ // first key that matches wins; a key ending in * is a prefix.
18
+ api: {
19
+ '/api/*': {},
20
+ },
21
+
22
+ // Where the files behind the sounds the app plays live. If someone can
23
+ // replace sounds on the live site, add live: { origin, paths }.
24
+ sounds: { bundled: 'public' },
25
+
26
+ titles: {
27
+ css: './trailer-titles.css',
28
+ endCard: {
29
+ logo: null, // e.g. '/images/logo.png', served from public/
30
+ lines: ['<%= @app_title %> — what it is, in one line', 'Free · No account needed'],
31
+ url: '<%= @url %>',
32
+ },
33
+ },
34
+
35
+ // Music starts on the first frame: a silent cold open reads as a broken
36
+ // video. Name tracks (files in this directory) and say which plays under
37
+ // each beat; null is silence. cam.mark('hush') in a beat stops it there.
38
+ music: {
39
+ tracks: {
40
+ // theme: 'public/sounds/theme.mp3',
41
+ },
42
+ plan: { <%= @beats.map { |b| "#{b}: null" }.join(', ') %> },
43
+ },
44
+
45
+ beats: [
46
+ // The hook: something happens in the first second.
47
+ { name: 'hook', view: 'home', play: async ({ cam, ease }) => {
48
+ await cam.focus('#root', { pad: 0 });
49
+ await cam.hold(300);
50
+ // Frame an element after its entrance animation has finished. A push of
51
+ // more than about 1.5× takes at least 0.9 s.
52
+ // pad, top, right and bottom are fractions of the subject's size: here
53
+ // room below it for the words.
54
+ await cam.focus('h1', { ms: 1200, pad: 0.1, bottom: 2, ease: ease.inOut });
55
+ await cam.hold(400);
56
+ // Words sit where nothing important is, never on the subject: side (a
57
+ // panel on the right of a wide frame), band (across a tall one), top
58
+ // or bottom.
59
+ cam.title({ kicker: '<%= @app_title %>', line: 'Say what it does', place: 'bottom' });
60
+ await cam.hold(2600);
61
+ } },
62
+ // The end card over the app, softened behind it.
63
+ { name: 'end', view: 'home', css: '#root { filter: blur(5px) brightness(0.6); }', play: async ({ cam }) => {
64
+ await cam.focus('#root', { pad: 0 });
65
+ cam.endCard();
66
+ await cam.hold(3500);
67
+ } },
68
+ ],
69
+ };
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Trailer
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'trailer/version'
4
+
5
+ module Belt
6
+ # Films a launch trailer of a web app from its own screens and sounds.
7
+ #
8
+ # The filming, camera, titles, sound capture, cut and checks are a Node
9
+ # engine (engine/) that knows nothing about Belt: it takes an app directory
10
+ # with a trailer.config.mjs and loads the app's own Playwright from there.
11
+ # It runs from the gem's directory and is never copied into the app. This
12
+ # Ruby side is the Belt layer: `belt generate trailer` and `belt trailer`.
13
+ module Trailer
14
+ class Error < StandardError; end
15
+
16
+ ROOT = File.expand_path('../..', __dir__)
17
+ ENGINE_DIR = File.join(ROOT, 'engine')
18
+ # The agent skill, also installable with `npx skills add stowzilla/belt-trailer`.
19
+ SKILL_DIR = File.join(ROOT, 'skills', 'belt-trailer')
20
+ ENGINE_BIN = File.join(ENGINE_DIR, 'bin', 'trailer.mjs')
21
+ CONFIG_FILE = 'trailer.config.mjs'
22
+ TOOLS = {
23
+ 'node' => 'Node 20 or later (https://nodejs.org)',
24
+ 'ffmpeg' => 'ffmpeg, built with drawtext (e.g. `brew install ffmpeg`, `apt install ffmpeg`)',
25
+ 'ffprobe' => 'ffprobe (comes with ffmpeg)'
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # The argv that runs an engine command on an app directory.
31
+ def engine_command(command, app_dir, args = [])
32
+ ['node', ENGINE_BIN, command, '--app', app_dir, *args]
33
+ end
34
+
35
+ # What's missing to film in `app_dir`, as messages; empty when ready.
36
+ def missing_requirements(app_dir, config: CONFIG_FILE)
37
+ missing = TOOLS.reject { |tool, _| executable?(tool) }.values
38
+ return missing << "#{app_dir}/ (no such directory)" unless File.directory?(app_dir)
39
+
40
+ unless playwright?(app_dir)
41
+ missing << "Playwright in #{app_dir} " \
42
+ "(cd #{app_dir} && npm install -D playwright && npx playwright install chromium)"
43
+ end
44
+ unless File.exist?(File.expand_path(config, app_dir))
45
+ missing << "#{File.join(app_dir, config)} (run `belt generate trailer`)"
46
+ end
47
+ missing
48
+ end
49
+
50
+ # Whether Node would find Playwright from `app_dir`: in its node_modules
51
+ # or any parent's, as with a hoisted workspace.
52
+ def playwright?(app_dir)
53
+ dir = File.expand_path(app_dir)
54
+ loop do
55
+ return true if File.exist?(File.join(dir, 'node_modules', 'playwright', 'package.json'))
56
+ return false if File.dirname(dir) == dir
57
+
58
+ dir = File.dirname(dir)
59
+ end
60
+ end
61
+
62
+ def executable?(name)
63
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
64
+ path = File.join(dir, name)
65
+ File.file?(path) && File.executable?(path)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'belt/trailer'