diff --git a/.gitignore b/.gitignore index 7ad9f5ad58..81a0b2301e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/marketing/README.md b/marketing/README.md new file mode 100644 index 0000000000..b9b8bca809 --- /dev/null +++ b/marketing/README.md @@ -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//video.json` | The spec of the video of a release: screenshots, slides, highlight boxes | +| `releases//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. diff --git a/marketing/release-video/PROMPT.md b/marketing/release-video/PROMPT.md new file mode 100644 index 0000000000..09a85911b8 --- /dev/null +++ b/marketing/release-video/PROMPT.md @@ -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://: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//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//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//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//video.json`. + - This writes `coolify--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. diff --git a/marketing/release-video/render.mjs b/marketing/release-video/render.mjs new file mode 100644 index 0000000000..7334d0aa36 --- /dev/null +++ b/marketing/release-video/render.mjs @@ -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 +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); diff --git a/marketing/release-video/shoot.mjs b/marketing/release-video/shoot.mjs new file mode 100644 index 0000000000..e69d454251 --- /dev/null +++ b/marketing/release-video/shoot.mjs @@ -0,0 +1,78 @@ +// Takes screenshots of a running Coolify instance from a release spec. +// Usage: node marketing/release-video/shoot.mjs [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); diff --git a/marketing/release-video/slides.mjs b/marketing/release-video/slides.mjs new file mode 100644 index 0000000000..8366392df7 --- /dev/null +++ b/marketing/release-video/slides.mjs @@ -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 +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 `
Coolify ${v}
+
${s.tag}

${s.title}

+
+
`; +} + +function slideBody(s) { + if (s.type === 'title') { + return `
Product update
+

Coolify ${v}

${s.subtitle}

`; + } + if (s.type === 'list') { + return `
${s.tag}
    ${s.items.map((i) => `
  • ${i}
  • `).join('')}
`; + } + if (s.type === 'outro') { + return `

${s.title}

+

${s.lines.join('
')}

+

coolify.io

`; + } + 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(`${slideBody(s)}`); + 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); diff --git a/marketing/releases/v4.7/contact-sheet.jpg b/marketing/releases/v4.7/contact-sheet.jpg new file mode 100644 index 0000000000..96f78daa46 Binary files /dev/null and b/marketing/releases/v4.7/contact-sheet.jpg differ diff --git a/marketing/releases/v4.7/video.json b/marketing/releases/v4.7/video.json new file mode 100644 index 0000000000..59d33f2bf7 --- /dev/null +++ b/marketing/releases/v4.7/video.json @@ -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": [ + "Rotate key replaces a private key on every server that uses it", + "GitHub Deployments sync for deployments and previews", + "Delete unused TLS certificates on the Traefik proxy page", + "Notifications when an automatic update starts, fails or finishes", + "Skip TLS certificate verification 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.", + "Note: older queued webhook deployments are now cancelled by default." + ] } + ] +}