mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
chore(marketing): add the release update video kit
Add scripts that take screenshots of a dev instance, render slides, and join them into a video, plus PROMPT.md with the steps for an AI agent. Keep the v4.7 spec and contact sheet so older videos can be checked and rendered again. Videos and build output stay out of git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b370c7ea6a
commit
79dabc1972
@@ -56,3 +56,9 @@ ref
|
||||
|
||||
# Service parser template snapshots: generated locally by scripts/parser-snapshot-diff, not committed
|
||||
/tests/Fixtures/parser/expected/templates/
|
||||
|
||||
# Marketing videos and build output make the repository large
|
||||
/marketing/**/*.mp4
|
||||
/marketing/**/*.mov
|
||||
/marketing/**/*.webm
|
||||
/marketing/**/build/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Marketing
|
||||
|
||||
Material for Coolify release announcements.
|
||||
|
||||
| Path | Content |
|
||||
| --- | --- |
|
||||
| `release-video/` | Scripts that make a release update video, and `PROMPT.md`, the instructions for an AI agent |
|
||||
| `releases/<version>/video.json` | The spec of the video of a release: screenshots, slides, highlight boxes |
|
||||
| `releases/<version>/contact-sheet.jpg` | All slides of the video in one image. Use it to check an older video without the video file |
|
||||
|
||||
Videos (`*.mp4`) and the `build/` directories are not in git (see the root `.gitignore`), because they make the repository large. To get the video of an older release again, run the three scripts with its `video.json` (see `release-video/PROMPT.md`).
|
||||
|
||||
To make the video of a new release, give an AI agent this prompt:
|
||||
|
||||
> Read `marketing/release-video/PROMPT.md` and make the release update video for v4.8.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Make a release update video
|
||||
|
||||
Use these instructions to make a short product update video for a Coolify release. The video shows the main new features. Each feature gets one slide with a short title and a screenshot of the real UI. A yellow box marks the feature in the screenshot.
|
||||
|
||||
The v4.7 video is the reference: `marketing/releases/v4.7/video.json` and `marketing/releases/v4.7/contact-sheet.jpg`. Read both before you start.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A running Coolify dev instance with the release code (see "Development Environment" in `AGENTS.md`). The main checkout serves `https://<host>:8000`.
|
||||
- `ffmpeg` on the host.
|
||||
- Playwright Chromium: `npm install && npx playwright install chromium`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Find the features.
|
||||
- Read the section of the release in `CHANGELOG.md`, or the `.changes/` files when the release is not out yet.
|
||||
- Select 8 to 12 features that a user can see in the UI. Prefer `Added` and the large `Changed` items. Put one breaking change that users must know in the outro.
|
||||
- Do not feature new one-click service templates. The maintainer removed the Orbit slide from v4.7.
|
||||
- Put features that you cannot show in a screenshot on the "Also in" list slide (6 items at most).
|
||||
|
||||
2. Prepare the dev instance.
|
||||
- Run the pending migrations: `./scripts/dev exec php artisan migrate --force`.
|
||||
- Make sure that the pages show real data, not empty states. For example, start a resource, run a scan, or create a record. Use the UI, or tinker as `www-data` (`./scripts/dev exec su -s /bin/sh www-data -c "php artisan tinker --execute '...'"`). Tinker as root makes root-owned SSH files, and then pages that use SSH show empty results.
|
||||
- Make sure that the servers show as reachable, because a red `Unavailable` status looks bad in a video.
|
||||
|
||||
3. Write the spec.
|
||||
- Copy `marketing/releases/v4.7/video.json` to `marketing/releases/<version>/video.json`. Change `version`, `baseUrl`, `shots`, and `slides`.
|
||||
- Find the URLs with `./scripts/dev exec php artisan route:list --except-vendor`. The UUIDs in the URLs come from the dev database.
|
||||
- Shot steps: `{"click": "text"}` (buttons first, then list options, then links), `{"clickSelector": "css"}`, `{"fill": ["css", "value"]}`, `{"scrollTo": "text"}`, `{"wait": ms}`.
|
||||
- Slide types: `title` (with `subtitle`), feature (with `tag`, `title`, `shot`, `box`), `list` (with `tag`, `items`), `outro` (with `title`, `lines`). Optional: `seconds`, and `shiftDown` (pixels) to move the crop down.
|
||||
- `box` is `[x, y, width, height]` in a 1920x1080 basis, whatever the size of the screenshot PNG. The slide zooms to the box and always hides the main sidebar (x < 272).
|
||||
|
||||
4. Take the screenshots: `node marketing/release-video/shoot.mjs marketing/releases/<version>/video.json [shot,shot]`.
|
||||
- The script logs in with `test@example.com` / `password`. Set `COOLIFY_URL`, `COOLIFY_EMAIL`, or `COOLIFY_PASSWORD` to use other values.
|
||||
- The script fails a shot on an HTTP error. Also look at each PNG in `build/shots/`: a page can show an error, an empty state, or a closed dialog with HTTP 200. If a page is broken, fix the bug or tell the user. Do not put a broken page in the video.
|
||||
- Some dialogs do not open in headless Chromium (for example, the private key edit dialog in v4.7). Put that feature on the list slide.
|
||||
|
||||
5. Render the slides: `node marketing/release-video/slides.mjs marketing/releases/<version>/video.json`.
|
||||
- Look at the slides in `build/slides/`. Adjust each `box` until it surrounds the feature and does not cut text. Render again.
|
||||
|
||||
6. Render the video: `node marketing/release-video/render.mjs marketing/releases/<version>/video.json`.
|
||||
- This writes `coolify-<version>-update.mp4` (not in git) and `contact-sheet.jpg` (in git).
|
||||
- Read `contact-sheet.jpg` for a last check. Give the user the path of the MP4.
|
||||
|
||||
7. Commit only `video.json` and `contact-sheet.jpg`. Never commit the MP4 or `build/`.
|
||||
|
||||
## Style rules (feedback from the maintainer)
|
||||
|
||||
- Readable for humans: one short title for each slide, no description lines. Long text is too much to read in 5 seconds.
|
||||
- Use the Geist font of the app (`slides.mjs` embeds it). Fallback fonts make letters collide.
|
||||
- Take screenshots at 2x scale (`shoot.mjs` does this). Low-resolution screenshots look blurry after the zoom.
|
||||
- Do not show half of the sidebar. The crop in `slides.mjs` hides it.
|
||||
- Use the dark theme, 1920x1080, about 5 seconds for each feature slide, 0.6-second fades, no audio. A full video is about 60 to 75 seconds.
|
||||
@@ -0,0 +1,38 @@
|
||||
// Joins the slides into an MP4 with fades, and writes a contact sheet for review.
|
||||
// Usage: node marketing/release-video/render.mjs <spec.json>
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const specPath = path.resolve(process.argv[2]);
|
||||
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
||||
const dir = path.dirname(specPath);
|
||||
const slides = path.join(dir, 'build', 'slides');
|
||||
const durations = spec.slides.map((s) => s.seconds ?? { title: 4, list: 8, outro: 5 }[s.type] ?? 5);
|
||||
const fade = 0.6;
|
||||
|
||||
const inputs = [];
|
||||
const filters = [];
|
||||
durations.forEach((t, i) => {
|
||||
inputs.push('-loop', '1', '-t', String(t), '-i', path.join(slides, String(i).padStart(2, '0') + '.png'));
|
||||
filters.push(`[${i}:v]format=yuv420p,fps=30,setsar=1[v${i}]`);
|
||||
});
|
||||
let prev = 'v0';
|
||||
let offset = 0;
|
||||
for (let i = 1; i < durations.length; i++) {
|
||||
offset += durations[i - 1] - fade;
|
||||
filters.push(`[${prev}][v${i}]xfade=transition=fade:duration=${fade}:offset=${offset.toFixed(2)}[x${i}]`);
|
||||
prev = `x${i}`;
|
||||
}
|
||||
const video = path.join(dir, `coolify-${spec.version}-update.mp4`);
|
||||
execFileSync('ffmpeg', ['-loglevel', 'error', '-y', ...inputs, '-filter_complex', filters.join(';'), '-map', `[${prev}]`,
|
||||
'-c:v', 'libx264', '-crf', '18', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', video], { stdio: 'inherit' });
|
||||
|
||||
// Contact sheet: every slide in a 4-column grid, for a quick visual check.
|
||||
const files = fs.readdirSync(slides).filter((f) => f.endsWith('.png')).sort();
|
||||
const cols = 4;
|
||||
const layout = files.map((_, i) => `${(i % cols) * 480}_${Math.floor(i / cols) * 270}`).join('|');
|
||||
execFileSync('ffmpeg', ['-loglevel', 'error', '-y', ...files.flatMap((f) => ['-i', path.join(slides, f)]), '-filter_complex',
|
||||
files.map((_, i) => `[${i}:v]scale=480:270[s${i}]`).join(';') + ';' + files.map((_, i) => `[s${i}]`).join('') +
|
||||
`xstack=inputs=${files.length}:layout=${layout}:fill=black`, '-q:v', '4', path.join(dir, 'contact-sheet.jpg')], { stdio: 'inherit' });
|
||||
console.log(video);
|
||||
@@ -0,0 +1,78 @@
|
||||
// Takes screenshots of a running Coolify instance from a release spec.
|
||||
// Usage: node marketing/release-video/shoot.mjs <spec.json> [shotName,shotName...]
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const specPath = path.resolve(process.argv[2]);
|
||||
const only = process.argv[3]?.split(',');
|
||||
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
||||
const out = path.join(path.dirname(specPath), 'build', 'shots');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const base = process.env.COOLIFY_URL ?? spec.baseUrl;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1600, height: 900 },
|
||||
deviceScaleFactor: 2, // 2x keeps UI text sharp after the slide zooms in
|
||||
ignoreHTTPSErrors: true,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(8000);
|
||||
|
||||
await page.goto(base + '/login');
|
||||
await page.fill('input[name=email]', process.env.COOLIFY_EMAIL ?? 'test@example.com');
|
||||
await page.fill('input[name=password]', process.env.COOLIFY_PASSWORD ?? 'password');
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Step types: {click: "visible text"}, {clickSelector: "css"}, {fill: ["css", "value"]},
|
||||
// {scrollTo: "visible text"}, {wait: ms}
|
||||
async function runStep(step) {
|
||||
if (step.click) {
|
||||
// Buttons first, then list options, then links, then any visible element with exactly this text.
|
||||
const candidates = [
|
||||
page.locator('button:visible', { hasText: step.click }),
|
||||
page.getByRole('option', { name: step.click }),
|
||||
page.locator('a:visible', { hasText: step.click }),
|
||||
page.locator(':visible', { hasText: new RegExp(`^${step.click}$`) }),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (await candidate.count()) {
|
||||
await candidate.first().click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (step.clickSelector) {
|
||||
await page.locator(step.clickSelector).first().click();
|
||||
} else if (step.fill) {
|
||||
await page.fill(step.fill[0], step.fill[1]);
|
||||
} else if (step.scrollTo) {
|
||||
await page.getByText(step.scrollTo).first().scrollIntoViewIfNeeded();
|
||||
await page.evaluate(() => window.scrollBy(0, -120));
|
||||
} else if (step.wait) {
|
||||
await page.waitForTimeout(step.wait);
|
||||
}
|
||||
await page.waitForTimeout(900);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const shot of spec.shots) {
|
||||
if (only && !only.includes(shot.name)) continue;
|
||||
try {
|
||||
const response = await page.goto(base + shot.url, { waitUntil: 'networkidle' }).catch(() => null);
|
||||
if (response && response.status() >= 400) throw new Error(`HTTP ${response.status()}, the page is broken`);
|
||||
await page.waitForTimeout(800);
|
||||
for (const step of shot.steps ?? []) {
|
||||
await runStep(step).catch((e) => { throw new Error(`step ${JSON.stringify(step)}: ${e.message}`); });
|
||||
}
|
||||
await page.screenshot({ path: path.join(out, shot.name + '.png') });
|
||||
console.log('ok ', shot.name, page.url());
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log('FAIL', shot.name, e.message.split('\n')[0]);
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,78 @@
|
||||
// Renders 1920x1080 slide PNGs from a release spec and the screenshots of shoot.mjs.
|
||||
// Usage: node marketing/release-video/slides.mjs <spec.json>
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const specPath = path.resolve(process.argv[2]);
|
||||
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
||||
const build = path.join(path.dirname(specPath), 'build');
|
||||
const shots = path.join(build, 'shots');
|
||||
const out = path.join(build, 'slides');
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const font = fs.readFileSync(path.join(repo, 'resources/fonts/geist-sans-variable.woff2')).toString('base64');
|
||||
const v = spec.version;
|
||||
|
||||
const css = `@font-face{font-family:Geist;src:url(data:font/woff2;base64,${font}) format('woff2');font-weight:100 900}
|
||||
*{margin:0;box-sizing:border-box}
|
||||
body{width:1920px;height:1080px;background:radial-gradient(1200px 700px at 80% -10%,#3b1d75 0%,#0b0b10 55%);font-family:Geist,sans-serif;color:#fff;overflow:hidden}
|
||||
.k{display:inline-block;font-size:24px;font-weight:600;color:#c4a6ff;background:#6b16ed33;border:1px solid #8b5cf680;padding:6px 16px;border-radius:999px;letter-spacing:.5px}
|
||||
h1{font-size:58px;font-weight:800;letter-spacing:-0.5px;margin-top:16px}
|
||||
p.s{font-size:30px;color:#c9c9d6;margin-top:12px;line-height:1.35;max-width:1700px}
|
||||
.wrap{position:absolute;left:50%;transform:translateX(-50%);top:222px;width:1600px;height:830px;border-radius:18px;overflow:hidden;border:1px solid #ffffff22;box-shadow:0 30px 80px #000c}
|
||||
.wrap img{position:absolute;display:block;max-width:none}
|
||||
.box{position:absolute;border:4px solid #fbbf24;border-radius:10px;box-shadow:0 0 0 9999px #00000070,0 0 30px #fbbf2499}
|
||||
.head{position:absolute;left:100px;top:56px;right:100px}
|
||||
.brand{position:absolute;right:60px;top:40px;font-size:24px;color:#ffffff88;font-weight:700}
|
||||
.c{display:flex;flex-direction:column;justify-content:center;height:100%;padding:0 160px}
|
||||
ul{margin-top:40px;font-size:36px;line-height:1.75;color:#e4e4ee;list-style:none}
|
||||
li:before{content:"› ";color:#a78bfa;font-weight:800}`;
|
||||
|
||||
// Screenshot coordinates (box) use a 1920x1080 basis, whatever the PNG resolution is.
|
||||
// The main sidebar ends at x=272 in that basis; the crop always hides it.
|
||||
const W = 1600, H = 830, SIDEBAR = 272;
|
||||
function featureSlide(s) {
|
||||
const img = 'data:image/png;base64,' + fs.readFileSync(path.join(shots, s.shot + '.png')).toString('base64');
|
||||
const f = W / 1920;
|
||||
const [x, y, w, h] = s.box;
|
||||
const z = Math.max(1.17, Math.min(1.7, (W * 0.9) / (w * f), (H * 0.75) / (h * f)));
|
||||
const sc = f * z;
|
||||
let ox = Math.max(0, Math.min(1920 * sc - W, (x + w / 2) * sc - W / 2));
|
||||
ox = Math.min(Math.max(ox, SIDEBAR * sc), 1920 * sc - W);
|
||||
const oy = Math.max(0, Math.min(1080 * sc - H, (y + h / 2) * sc - H / 2 + (s.shiftDown ?? 0)));
|
||||
return `<div class="brand">Coolify ${v}</div>
|
||||
<div class="head"><div class="k">${s.tag}</div><h1>${s.title}</h1></div>
|
||||
<div class="wrap"><img src="${img}" style="width:${1920 * sc}px;height:${1080 * sc}px;left:${-ox}px;top:${-oy}px">
|
||||
<div class="box" style="left:${x * sc - ox - 6}px;top:${y * sc - oy - 6}px;width:${w * sc + 12}px;height:${h * sc + 12}px"></div></div>`;
|
||||
}
|
||||
|
||||
function slideBody(s) {
|
||||
if (s.type === 'title') {
|
||||
return `<div class="c"><div class="k" style="width:fit-content">Product update</div>
|
||||
<h1 style="font-size:120px;margin-top:30px">Coolify ${v}</h1><p class="s" style="font-size:44px;margin-top:24px">${s.subtitle}</p></div>`;
|
||||
}
|
||||
if (s.type === 'list') {
|
||||
return `<div class="c"><div class="k" style="width:fit-content">${s.tag}</div><ul>${s.items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
|
||||
}
|
||||
if (s.type === 'outro') {
|
||||
return `<div class="c" style="align-items:center;text-align:center"><h1 style="font-size:96px">${s.title}</h1>
|
||||
<p class="s" style="font-size:40px;margin-top:28px">${s.lines.join('<br>')}</p>
|
||||
<p class="s" style="margin-top:60px;font-size:34px;color:#ffffff99">coolify.io</p></div>`;
|
||||
}
|
||||
return featureSlide(s);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
for (const [i, s] of spec.slides.entries()) {
|
||||
await page.setContent(`<html><head><style>${css}</style></head><body>${slideBody(s)}</body></html>`);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: path.join(out, String(i).padStart(2, '0') + '.png') });
|
||||
}
|
||||
await browser.close();
|
||||
console.log(spec.slides.length, 'slides in', out);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "v4.7",
|
||||
"baseUrl": "https://devserver.tail661ee3.ts.net:8000",
|
||||
"notes": "Recorded on the main dev instance on 2026-11-09. The UUIDs in the URLs belong to that dev database, find new ones for another instance.",
|
||||
"shots": [
|
||||
{ "name": "dash", "url": "/" },
|
||||
{ "name": "appvuln", "url": "/project/project/environment/obkbiox1b9slwsahokdyir74/application/jnsixakmgsxrp87a9duoqesx/vulnerabilities", "steps": [{ "click": "All findings" }] },
|
||||
{ "name": "fleet", "url": "/vulnerabilities" },
|
||||
{ "name": "srvscan", "url": "/server/localhost/security/image-scanning" },
|
||||
{ "name": "storage", "url": "/storages", "steps": [{ "click": "New Storage" }, { "click": "S3" }, { "click": "SFTP" }] },
|
||||
{ "name": "advanced", "url": "/project/project/environment/obkbiox1b9slwsahokdyir74/application/rust-rocket/advanced", "steps": [{ "scrollTo": "Queued webhook deployments" }] },
|
||||
{ "name": "newlist", "url": "/project/project/environment/obkbiox1b9slwsahokdyir74/new" },
|
||||
{ "name": "valkey", "url": "/project/project/environment/obkbiox1b9slwsahokdyir74/new", "steps": [{ "fill": ["input[placeholder=\"Search resources\"]", "redis"] }] },
|
||||
{ "name": "task", "url": "/project/project/environment/obkbiox1b9slwsahokdyir74/application/jnsixakmgsxrp87a9duoqesx/scheduled-tasks", "steps": [{ "click": "Add Task" }] },
|
||||
{ "name": "email", "url": "/settings/email" },
|
||||
{ "name": "appear", "url": "/profile/appearance" }
|
||||
],
|
||||
"slides": [
|
||||
{ "type": "title", "subtitle": "Image scanning, more backup targets, smarter deploys, and a lot more." },
|
||||
{ "tag": "Dashboard", "title": "Star what you use most", "shot": "dash", "box": [300, 80, 1190, 140] },
|
||||
{ "tag": "Security", "title": "Container image vulnerability scanning", "shot": "appvuln", "box": [318, 90, 1556, 460] },
|
||||
{ "tag": "Security", "title": "One security view for your team", "shot": "fleet", "box": [300, 80, 1580, 510] },
|
||||
{ "tag": "Security", "title": "Rules for each server", "shot": "srvscan", "box": [316, 88, 1556, 420] },
|
||||
{ "tag": "Backups", "title": "SFTP, FTPS and WebDAV backup storages", "shot": "storage", "box": [440, 290, 1040, 470] },
|
||||
{ "tag": "Deployments", "title": "Only the latest commit is built", "shot": "advanced", "box": [316, 360, 1556, 200], "shiftDown": 60 },
|
||||
{ "tag": "New resource", "title": "A clean list view, now with Forgejo and Codeberg", "shot": "newlist", "box": [316, 736, 1556, 70] },
|
||||
{ "tag": "Databases", "title": "Valkey for the Redis database type", "shot": "valkey", "box": [316, 318, 1556, 72] },
|
||||
{ "tag": "Scheduled tasks", "title": "Run tasks without a shell", "shot": "task", "box": [574, 528, 770, 64] },
|
||||
{ "tag": "Email", "title": "More than one SMTP server, plus Resend", "shot": "email", "box": [316, 92, 1556, 160] },
|
||||
{ "tag": "Appearance", "title": "UI scale", "shot": "appear", "box": [316, 440, 1556, 280] },
|
||||
{ "type": "list", "tag": "Also in v4.7", "items": [
|
||||
"<b>Rotate key</b> replaces a private key on every server that uses it",
|
||||
"GitHub Deployments sync for deployments and previews",
|
||||
"<b>Delete unused</b> TLS certificates on the Traefik proxy page",
|
||||
"Notifications when an automatic update starts, fails or finishes",
|
||||
"<b>Skip TLS certificate verification</b> for S3 storages with a private CA",
|
||||
"Gitea App endpoints in the API and MCP tools, and image scanning in the API"
|
||||
] },
|
||||
{ "type": "outro", "title": "Update to v4.7 today", "lines": [
|
||||
"Read the full changelog in the release notes.",
|
||||
"<span style=\"color:#a78bfa\">Note: older queued webhook deployments are now cancelled by default.</span>"
|
||||
] }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user