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.
- checksums.yaml +7 -0
- data/AGENTS.md +78 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +223 -0
- data/engine/bin/trailer.mjs +45 -0
- data/engine/lib/args.mjs +18 -0
- data/engine/lib/camera.mjs +140 -0
- data/engine/lib/check.mjs +134 -0
- data/engine/lib/clock.mjs +38 -0
- data/engine/lib/config.mjs +97 -0
- data/engine/lib/cut.mjs +169 -0
- data/engine/lib/ease.mjs +6 -0
- data/engine/lib/ffmpeg.mjs +46 -0
- data/engine/lib/film.mjs +161 -0
- data/engine/lib/index.mjs +5 -0
- data/engine/lib/showcase.mjs +83 -0
- data/engine/lib/sound.mjs +145 -0
- data/engine/lib/titles.mjs +43 -0
- data/engine/package.json +14 -0
- data/engine/titles.html +144 -0
- data/examples/posh-nosh/trailer-titles.css +26 -0
- data/examples/posh-nosh/trailer.config.mjs +168 -0
- data/lib/belt/commands/trailer_command.rb +123 -0
- data/lib/belt/generators/trailer_generator.rb +215 -0
- data/lib/belt/trailer/templates/showcase/build.mjs +24 -0
- data/lib/belt/trailer/templates/showcase/fixtures.js.erb +22 -0
- data/lib/belt/trailer/templates/showcase/showcase.html +14 -0
- data/lib/belt/trailer/templates/showcase/showcase.jsx +50 -0
- data/lib/belt/trailer/templates/trailer-storyboard.md.erb +37 -0
- data/lib/belt/trailer/templates/trailer-titles.css +22 -0
- data/lib/belt/trailer/templates/trailer.config.mjs.erb +69 -0
- data/lib/belt/trailer/version.rb +7 -0
- data/lib/belt/trailer.rb +69 -0
- data/lib/belt-trailer.rb +3 -0
- data/skills/belt-trailer/SKILL.md +128 -0
- data/skills/belt-trailer/references/beats.md +136 -0
- data/skills/belt-trailer/references/reviewing.md +90 -0
- data/skills/belt-trailer/references/showcase-and-fixtures.md +80 -0
- data/skills/belt-trailer/references/storyboard.md +87 -0
- 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
|
+
};
|
data/lib/belt/trailer.rb
ADDED
|
@@ -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
|
data/lib/belt-trailer.rb
ADDED