marketing-video
Make a voiced product marketing or explainer video from real product screens. Script, cut walkthrough clips or stage app screenshots with a cursor, pick voice takes, render branded motion graphics and diagrams, mix music and review every second before delivery.
Install
npx skills add https://github.com/sgaabdu4/building-flutter-apps/tree/main/.agents/skills/marketing-video
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sgaabdu4-building-flutter-apps@llmmart
git clone https://github.com/sgaabdu4/building-flutter-apps.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole sgaabdu4/building-flutter-apps collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Marketing Video
Output = real product screens + big brand type + one calm voice → reviewed MP4. Scripts run by path from any directory (node <skill>/scripts/<name>.mjs <work>); each video's work folder lives outside the skill.
Routes
Load only the routes the task reaches.
flowchart LR
T{Task} -->|Runtime / commands / work folder| S[setup]
T -->|New video| B[brief + script]
B -->|Recordings exist| RC[recordings]
B -->|Private data / no recordings| ST[staged screenshots]
RC --> V[voice]
ST --> V
V --> R[render, review, deliver]
T -->|Feedback on a draft| E[revisions]
E --> R
click S "references/setup.md"
click B "references/script.md"
click RC "references/recordings.md"
click ST "references/staged.md"
click V "references/voice.md"
click R "references/render.md"
click E "references/revisions.md"
Recordings come from Product Walkthrough Video takes or existing product videos.
Core rules
- Agent cannot hear → voice, pace + pronunciation = human's call. One sample before every line is generated; never call audio "better" without their listen.
- Real product only: screens, logos + fonts from the product and brand owners. Screens keep their own colours; the brand palette dresses only the graphics around them. Logo file, never a typed brand name; never an invented screen or feature.
- Cut sign-in, invite, loading + empty screens. Access behaviour only when the audience must learn it (training); never a typed password.
- Private data never appears: demo accounts or made-up records.
- Zoom = hand-placed box on what the voice names, whole box in frame. No pointer-following or automatic zoom; a staged cursor never drives the camera.
- Recorded clips → hard cuts. Staged shots/scenes → 0.4 s crossfades allowed. No slow fades to or from black.
- Explanatory graphics copy an approved brand design (deck slide, site section) or the template defaults; no new card, chip or panel styles.
- Plain, specific words, like a colleague showing the product. No hype, slogans or rule-of-three taglines; boldness = type size, layout, motion.
- Human-approved part → frozen. Change only what the latest feedback names.
Completion gate
All hold on the exact delivered file:
- script approved by the human; every claim backed by product material;
build.mjspasses;render.mjsreported no page errors;- every audit sheet opened, flat runs explained, stills checked at clicks, zooms + graphic scenes;
- mix transcript holds every line in order;
- delivered with draft number + change summary; voice quality stated as the human's judgment; music licence status stated.
Human step not done (no script approval, no voice listen) → deliver as a draft that names each unmet item; never call it complete.
Files (building-flutter-apps)
-
agents
-
openai.yaml 350 B
interface: display_name: "Marketing Video" short_description: "Script, stage, voice, render and review a product marketing or explainer video" default_prompt: "Use $marketing-video to make a voiced marketing video for this product from real product screens, then review every second before delivery." policy: allow_implicit_invocation: false
-
-
assets
-
template
-
engine.js 20.4 KB
const B = window.BRAND; const OPT = window.OPTIONS; const tl = gsap.timeline({ paused: true }); const FADE = OPT.transitions === 'fade' ? 0.4 : 0; const CARD = 1.35; const $ = (tag, cls, parent, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; parent.appendChild(e); return e; }; const pad2 = (n) => String(n).padStart(2, '0'); const logo = (key, h) => `<img class="logo-img" src="../${B.logos[key]}" style="height:${h}px" alt="">`; const lockup = (h) => (B.logos.b ? `${logo('a', h)}<span class="x">×</span>${logo('b', h)}` : logo('a', h)); function words(el) { const walk = (n) => { for (const c of [...n.childNodes]) { if (c.nodeType === 1 && c.tagName !== 'BR') walk(c); if (c.nodeType !== 3) continue; const frag = document.createDocumentFragment(); for (const w of c.textContent.split(/(\s+)/).filter(Boolean)) { if (/^\s+$/.test(w)) frag.appendChild(document.createTextNode(w)); else $('span', 'w', frag, `<span class="wi">${w}</span>`); } c.replaceWith(frag); } }; walk(el); return el.querySelectorAll('.wi'); } const reveal = (el, at, stagger = 0.07) => tl.from(words(el), { yPercent: 115, duration: 0.9, ease: 'expo.out', stagger }, at); const popIn = (targets, at, opts = {}) => tl.from( targets, { opacity: 0, y: 70, duration: 0.9, ease: 'expo.out', stagger: 0.12, ...opts, }, at, ); const draw = (path, at, duration) => { const length = path.getTotalLength(); gsap.set(path, { strokeDasharray: length, strokeDashoffset: length }); tl.to(path, { strokeDashoffset: 0, duration, ease: 'power2.inOut' }, at); }; const svgEl = (tag, attrs, parent) => { const e = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); parent.appendChild(e); return e; }; function sceneRoot(s, i) { const root = $('div', 'scene', document.getElementById('scenes')); tl.set(root, { visibility: 'visible' }, s.start); tl.set(root, { visibility: 'hidden' }, s.start + s.dur + FADE); if (FADE && i > 0) tl.fromTo(root, { opacity: 0 }, { opacity: 1, duration: FADE, ease: 'power1.inOut' }, s.start); return root; } function chapter(s, root) { const card = $( 'div', 'chapcard', root, `<div class="num">${pad2(s.num)}</div><div class="kick">${pad2(s.num)}${s.who ? ` — ${s.who}` : ''}</div><div class="big">${s.label}</div>`, ); tl.from(card.querySelector('.kick'), { yPercent: 120, opacity: 0, duration: 0.6, ease: 'expo.out' }, s.start + 0.05); tl.from(card.querySelector('.num'), { yPercent: 30, opacity: 0, duration: 1.0, ease: 'expo.out' }, s.start); reveal(card.querySelector('.big'), s.start + 0.12, 0.06); tl.to(card, { yPercent: -100, duration: 0.75, ease: 'expo.inOut' }, s.start + CARD - 0.35); } function slab(root, box, n, caption, speed, at, out) { const fast = speed >= 3 ? `<span class="fast">${speed}×</span>` : ''; const e = $('div', 'slab', root, `<span class="n">${n}</span><span class="t">${caption}</span>${fast}`); gsap.set(e, box.tall ? { left: 160, top: 640 } : { left: Math.max(40, box.left - 56), top: box.top + box.height - 80 }); tl.fromTo( e, { clipPath: 'inset(0 100% 0 0 round 22px)' }, { clipPath: 'inset(0 0% 0 0 round 22px)', duration: 0.6, ease: 'expo.out' }, at, ); reveal(e.querySelector('.t'), at, 0.04); tl.to( e, { clipPath: 'inset(0 0% 0 100% round 22px)', duration: 0.35, ease: 'expo.in', }, out, ); } function fit([w, h], maxW, maxH) { const k = Math.min(maxW / w, maxH / h); return { width: w * k, height: h * k }; } function screenBox(size, maxW, maxH, top) { if (size[0] / size[1] >= 0.8) { const f = fit(size, maxW, maxH); return { left: (1920 - f.width) / 2, top, ...f, tall: false }; } const f = fit(size, 760, 940); return { left: 1920 - f.width - 280, top: 40, ...f, tall: true }; } function side(s, root, box) { if (!box.tall) return; const e = $('div', 'side', root, `<div class="kick">${pad2(s.num)}${s.who ? ` — ${s.who}` : ''}</div><div class="big">${s.label}</div>`); reveal(e.querySelector('.kick'), s.start + CARD + 0.1); reveal(e.querySelector('.big'), s.start + CARD + 0.2, 0.06); } const players = []; function clipScene(s, root) { const [vw, vh] = s.clips[0].size; const box = screenBox([vw, vh], 1504, 940, 26); const { width: W, height: H } = box; const k = W / vw; side(s, root, box); const vp = $('div', 'viewport', root); gsap.set(vp, box); const screen = $('div', 'screen', vp); s.clips.forEach((c, i) => { const layer = $('div', 'clip', screen); const img = $('img', '', layer); gsap.set(img, { width: vw, height: vh }); const start = s.start + c.at; const end = start + c.dur; players.push({ img, start, c, k, W, H, shown: '' }); tl.set(layer, { opacity: 1 }, i === 0 ? s.start : start); if (i < s.clips.length - 1) tl.set(layer, { opacity: 0 }, end); const out = i < s.clips.length - 1 ? end - 0.3 : s.start + s.dur - 0.35; slab(root, box, i + 1, c.caption, c.speed, Math.max(start + 0.15, s.start + CARD + 0.3), out); }); } function shotScene(s, root) { const box = screenBox(s.shots[0].size, 1248, 780, 118); const { width: CW, height: CH } = box; side(s, root, box); const card = $('div', 'screen', root); gsap.set(card, box); const cam = $('div', 'cam', card); gsap.set(cam, { width: CW, height: CH }); const pill = $('div', 'pill', root, `<span class="n">${pad2(s.num)}</span><span class="t">${s.label}</span>`); gsap.set(pill, { left: box.left, top: 30, display: box.tall ? 'none' : 'flex' }); tl.from(card, { scale: 0.94, y: 40, duration: 0.9, ease: 'expo.out' }, s.start + CARD - 0.05); tl.from(pill, { opacity: 0, x: -30, duration: 0.6, ease: 'expo.out' }, s.start + CARD + 0.25); const cursor = $( 'div', 'cursor', cam, '<svg viewBox="0 0 24 24" width="38" height="38"><path d="M4 2l16 11.5-7.2 1.3 4.3 7.7-3 1.6-4.3-7.8L4 21z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>', ); const ripple = $('div', 'ripple', cam); gsap.set(cursor, { x: CW * 0.72, y: CH * 0.9, opacity: 0, transformOrigin: '7px 4px', }); let img = null; let captions = 0; s.shots.forEach((sh, i) => { const at = s.start + sh.at; const end = at + sh.dur; const k = CW / sh.frame; const prev = s.shots[i - 1]; const next = s.shots[i + 1]; if (prev?.src !== sh.src) { const old = img; img = $('img', '', cam); img.src = sh.src; gsap.set(img, { width: CW, height: CH }); cam.insertBefore(img, cursor); if (!old) tl.set(img, { opacity: 1 }, s.start); else { tl.to(img, { opacity: 1, duration: 0.4, ease: 'power1.inOut' }, at); tl.set(old, { opacity: 0 }, at + 0.4); } } if (sh.caption !== prev?.caption) { captions += 1; let last = i; while (s.shots[last + 1]?.caption === sh.caption) last += 1; const until = last === s.shots.length - 1 ? s.start + s.dur - 0.35 : s.start + s.shots[last].at + s.shots[last].dur - 0.3; slab(root, box, captions, sh.caption, 1, Math.max(at + 0.1, s.start + CARD + 0.35), until); } let t = at + (prev?.src === sh.src ? 0.1 : 0.45); if (sh.click) { const cx = (sh.click.x + sh.click.w / 2) * k; const cy = (sh.click.y + sh.click.h / 2) * k; tl.to(cursor, { opacity: 1, duration: 0.25 }, t); tl.to(cursor, { x: cx, y: cy, duration: 0.9, ease: 'power2.inOut' }, t); tl.to(cursor, { scale: 0.82, duration: 0.1, yoyo: true, repeat: 1 }, t + 0.95); tl.set(ripple, { x: cx, y: cy }, t + 0.95); tl.fromTo(ripple, { opacity: 1, scale: 0.3 }, { opacity: 0, scale: 1.7, duration: 0.6, ease: 'power2.out' }, t + 0.95); t += 1.3; } if (!sh.zoom) return; const zw = sh.zoom.w * k; const zh = sh.zoom.h * k; const z = Math.min(2.2, (CW * 0.85) / zw, (CH * 0.85) / zh); if (z < 1.15) return; const zx = (sh.zoom.x + sh.zoom.w / 2) * k; const zy = (sh.zoom.y + sh.zoom.h / 2) * k; const dx = Math.min(0, Math.max(CW - CW * z, CW / 2 - z * zx)); const dy = Math.min(0, Math.max(CH - CH * z, CH / 2 - z * zy)); tl.to(cursor, { opacity: 0, duration: 0.25 }, t); tl.to(cam, { scale: z, x: dx, y: dy, duration: 0.85, ease: 'power3.inOut' }, t); const keep = next?.src === sh.src && next.zoom && !next.click; if (!keep) tl.to(cam, { scale: 1, x: 0, y: 0, duration: 0.65, ease: 'power3.inOut' }, end - 0.7); }); } const builders = { intro(s, root) { const c = $('div', 'center', root); if (s.title) { const lk = $( 'div', 'lockup', c, `${logo('a', s.logoHeight ?? 250)}${s.kicker ? `<div class="kick">${s.kicker}</div>` : ''}<h1>${s.title}</h1>`, ); const mark = lk.querySelector('img'); tl.from( mark, { scale: 0.35, rotation: -40, opacity: 0, duration: 1.1, ease: 'back.out(1.5)', }, s.start + 0.15, ); if (s.kicker) reveal(lk.querySelector('.kick'), s.start + 0.8); reveal(lk.querySelector('h1'), s.start + 0.95, 0.09); tl.to(lk, { scale: 1.04, duration: s.dur, ease: 'none' }, s.start); return; } const row = $('div', 'logos', c, lockup(s.logoHeight ?? 190)); const parts = [...row.children]; tl.from(parts[0], { opacity: 0, scale: 0.8, duration: 0.8, ease: 'back.out(1.6)' }, s.start + 0.15); if (parts[1]) tl.from(parts[1], { opacity: 0, scale: 0.3, duration: 0.5, ease: 'back.out(2)' }, s.start + 1.2); if (parts[2]) tl.fromTo( parts[2], { clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)', duration: 1.0, ease: 'power2.inOut' }, s.start + 1.4, ); }, statement(s, root) { const c = $('div', 'center', root); reveal($('h1', '', c, s.title), s.start + 0.1); if (s.sub) popIn($('div', 'sub', c, s.sub), s.start + 0.9); if (s.cards) { const row = $('div', 'cards', c); s.cards.forEach((x, i) => { const el = $('div', 'card', row, `<div class="chip"><i></i>${x.tag}</div><div class="say">${x.text}</div>`); const t = s.start + 1.4 + i * 1.3; tl.fromTo( el, { clipPath: 'inset(100% 0 0 0 round 28px)', y: 40 }, { clipPath: 'inset(0% 0 0 0 round 28px)', y: 0, duration: 0.8, ease: 'expo.out', }, t, ); tl.from(el.querySelector('.chip'), { scale: 0.4, opacity: 0, duration: 0.5, ease: 'back.out(2)' }, t + 0.35); reveal(el.querySelector('.say'), t + 0.3, 0.04); }); } if (!s.bands) return; const wrap = $('div', 'bands', c); const rows = s.bands.map((b) => $( 'div', `band ${b.accent ? 'accent' : ''}`, wrap, `<div><div class="kind">${b.kind}</div><div class="logo">${logo(b.logo, 58)}</div></div><ul>${b.items.map((t) => `<li>${t}</li>`).join('')}</ul>`, ), ); popIn(rows, s.start + 1.0, { y: 40, stagger: 0.2 }); tl.from(wrap.querySelectorAll('li'), { opacity: 0, x: -14, duration: 0.4, ease: 'power2.out', stagger: 0.08 }, s.start + 1.5); }, title(s, root) { if (s.card) { const c = $('div', 'chapcard', root, `<div class="kick">${s.kicker ?? ''}</div><div class="big">${s.text}</div>`); reveal(c.querySelector('.kick'), s.start + 0.1); reveal(c.querySelector('.big'), s.start + 0.2, 0.09); return; } const c = $('div', 'center', root); const h = $('h1', 'huge', c, s.text); const rule = $('div', 'rule', c); reveal(h, s.start + 0.05, 0.09); tl.from(rule, { scaleX: 0, transformOrigin: '0 50%', duration: 0.7, ease: 'expo.out' }, s.start + 0.45); }, app(s, root) { chapter(s, root); if (s.shots) shotScene(s, root); else clipScene(s, root); }, flow(s, root) { const head = $('div', 'head', root); reveal($('h1', '', head, s.text), s.start + 0.1, 0.06); const n = s.nodes.length; const gap = 120; const w = Math.min(440, (1560 - (n - 1) * gap) / n); const left = (1920 - (n * w + (n - 1) * gap)) / 2; const svg = svgEl('svg', { class: 'lines', viewBox: '0 0 1920 1080' }, root); s.nodes.forEach((node, i) => { const x = left + i * (w + gap); const mark = node.logo ? logo(node.logo, 84) : ''; const rows = (node.rows ?? []).map(([a, b]) => `<div class="row">${a}<b>${b}</b></div>`).join(''); const el = $( 'div', `node${node.hero ? ' hero' : ''}`, root, `${mark}<div class="name">${node.name}</div><div class="note">${node.note ?? ''}</div><div class="rows">${rows}</div>`, ); gsap.set(el, { left: x, width: w }); const at = s.start + 0.9 + i * 1.3; tl.from(el, { opacity: 0, y: 80, scale: 0.94, duration: 0.9, ease: 'expo.out' }, at); tl.from(el.children, { opacity: 0, y: 24, duration: 0.6, ease: 'power3.out', stagger: 0.08 }, at + 0.2); if (node.badge) { const badge = $('div', 'badge', root, `<i></i>${node.badge}`); gsap.set(badge, { left: x + 44, top: 370 }); tl.from(badge, { scale: 0.3, opacity: 0, duration: 0.6, ease: 'back.out(2.2)' }, s.start + 1.3 * n + 0.4); } if (i === 0) return; const x0 = x - gap + 6; const x1 = x - 6; const line = svgEl('path', { d: `M${x0} 590 L${x1} 590`, class: 'stroke' }, svg); const tip = svgEl('path', { d: `M${x1 - 18} 576 L${x1} 590 L${x1 - 18} 604`, class: 'stroke' }, svg); draw(line, at - 0.35, 0.5); draw(tip, at - 0.05, 0.3); const dot = svgEl('circle', { cx: x0, cy: 590, r: 9, class: 'dot' }, svg); const from = at + 0.3; const reps = Math.max(0, Math.floor((s.start + s.dur - from) / 1.1) - 1); tl.fromTo( dot, { x: 0, opacity: 0 }, { x: x1 - x0 - 20, opacity: 1, duration: 1.1, ease: 'power1.inOut', repeat: reps, }, from, ); }); }, loop(s, root) { const head = $('div', 'head', root); gsap.set(head, { top: 90 }); reveal($('h1', '', head, s.text), s.start + 0.1, 0.06); const [cx, cy, rx, ry] = [960, 610, 560, 262]; const at = (a) => [cx + rx * Math.cos(a), cy + ry * Math.sin(a)]; const svg = svgEl('svg', { class: 'lines', viewBox: '0 0 1920 1080' }, root); svgEl('ellipse', { cx, cy, rx, ry, class: 'track' }, svg); const ring = svgEl( 'path', { d: `M${cx} ${cy - ry} A${rx} ${ry} 0 1 1 ${cx - 0.01} ${cy - ry}`, class: 'stroke', }, svg, ); const n = s.nodes.length; const step = (Math.PI * 2) / n; const a0 = -Math.PI / 2; const mark = $('div', 'loopmark', root, logo('a', 150)); tl.from(mark, { scale: 0.4, opacity: 0, duration: 0.8, ease: 'back.out(1.6)' }, s.start + 0.5); draw(ring, s.start + 0.7, 3.2); s.nodes.forEach((text, i) => { const [x, y] = at(a0 + i * step); const e = $('div', `lnode${i === s.you ? ' you' : ''}`, root, `<span class="k">${i + 1}</span>${text}`); gsap.set(e, { left: x, top: y }); tl.from(e, { scale: 0.5, opacity: 0, duration: 0.6, ease: 'back.out(1.8)' }, s.start + 0.7 + (3.2 * i) / n); const a = a0 + (i + 0.5) * step; const [ax, ay] = at(a); const angle = (Math.atan2(ry * Math.cos(a), -rx * Math.sin(a)) * 180) / Math.PI; const arrow = svgEl( 'path', { d: 'M-11 -13 L7 0 L-11 13', class: 'stroke', transform: `translate(${ax} ${ay}) rotate(${angle})`, }, svg, ); tl.from(arrow, { opacity: 0, duration: 0.3 }, s.start + 0.7 + (3.2 * (i + 0.5)) / n); }); const dot = svgEl('circle', { r: 13, class: 'dot' }, svg); const length = ring.getTotalLength(); const pos = { p: 0 }; const place = () => { const q = ring.getPointAtLength((((pos.p % 1) + 1) % 1) * length); dot.setAttribute('cx', q.x); dot.setAttribute('cy', q.y); }; place(); gsap.set(dot, { opacity: 0 }); tl.set(dot, { opacity: 1 }, s.start + 3.9); tl.fromTo( pos, { p: 0 }, { p: Math.max(1, Math.floor((s.dur - 3.9) / 3.4)), duration: s.dur - 3.9 + FADE, ease: 'none', onUpdate: place, }, s.start + 3.9, ); if (s.badge == null) return; const [bx, by] = at(a0 + s.you * step); const badge = $('div', 'badge', root, `<i></i>${s.badge}`); gsap.set(badge, { left: bx - 130, top: by + 50 }); tl.from(badge, { scale: 0.3, opacity: 0, duration: 0.6, ease: 'back.out(2.2)' }, s.start + 4.3); }, collage(s, root) { const c = $('div', 'center', root); const h = $('h1', 'over', c, s.text); const spots = [ [70, 64, -2], [1290, 64, 2], [70, 700, 2], [1290, 700, -2], ]; const cards = s.shots.slice(0, 4).map((src, i) => { const d = $('div', 'collage', root, `<img src="../${src}" alt="">`); gsap.set(d, { left: spots[i][0], top: spots[i][1], rotation: spots[i][2], }); return d; }); reveal(h, s.start + 0.1); tl.from( cards, { opacity: 0, y: (i) => (i < 2 ? -160 : 160), rotation: 0, duration: 1.0, ease: 'expo.out', stagger: 0.09, }, s.start + 0.25, ); tl.to(cards, { y: '-=14', duration: s.dur + FADE, ease: 'none' }, s.start); }, outro(s, root) { const c = $('div', 'center', root); const row = s.title ? $('div', 'lockup', c, `${logo('a', 200)}<div class="kick">${s.kicker ?? ''}</div><h1>${s.title}</h1>`) : $('div', 'logos', c, lockup(s.logoHeight ?? 160)); popIn(row, s.start + 0.1); if (s.text) reveal($('div', 'tag', c, s.text), s.start + 0.6, 0.05); if (s.cta) tl.from( $('div', 'cta', c, s.cta), { clipPath: 'inset(0 100% 0 0 round 999px)', duration: 0.8, ease: 'expo.inOut', }, s.start + 1.4, ); }, }; function subtitles(s) { const box = document.getElementById('subs'); s.subs.forEach((x, i) => { const e = $('div', 'subline', box, x.text); const next = s.subs[i + 1]; const joined = next && next.at - (x.at + x.dur) < 0.05; tl.fromTo(e, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.22, ease: 'power2.out' }, x.at); tl.to(e, { opacity: 0, duration: 0.18 }, joined ? x.at + x.dur - 0.18 : x.at + x.dur + 0.3); }); } function backdrop(total) { const bg = document.getElementById('backdrop'); bg.hidden = false; const blobs = [ [-160, -220, 820, 220, 140], [1250, 520, 900, -260, -120], [640, 760, 700, 180, -180], ]; for (const [x, y, size, dx, dy] of blobs) { const e = $('div', 'blob', bg); gsap.set(e, { left: x, top: y, width: size, height: size }); tl.to(e, { x: dx, y: dy, duration: total, ease: 'sine.inOut' }, 0); } tl.to(bg.querySelector('.grid'), { x: -32, y: -64, duration: total, ease: 'none' }, 0); } function paintClips(t) { const loads = []; for (const p of players) { const f = Math.max(0, Math.min(p.c.frames - 1, Math.floor((t - p.start) * 30))); const [z, cx, cy] = p.c.cam[f]; const src = `${p.c.src}${String(p.c.files[f]).padStart(5, '0')}.jpg`; if (p.shown !== src) { p.img.src = src; p.shown = src; loads.push(p.img.decode().catch(() => {})); } p.img.style.transform = `translate(${p.W / 2 - cx * z * p.k}px, ${p.H / 2 - cy * z * p.k}px) scale(${z * p.k})`; } return Promise.all(loads); } window.ready = (async () => { const root = document.documentElement.style; for (const [name, value] of Object.entries(B.colors ?? {})) root.setProperty(`--${name}`, value); if (B.displayWeight) root.setProperty('--weight', B.displayWeight); for (const [family, file] of [ ['Display', B.font], ['Body', B.bodyFont], ]) { if (file) document.fonts.add( await new FontFace(family, `url(../${file})`, { weight: '100 900', }).load(), ); } const total = window.SCENES.reduce((m, s) => Math.max(m, s.start + s.dur), 0); if (OPT.backdrop) backdrop(total); window.SCENES.forEach((s, i) => { builders[s.type](s, sceneRoot(s, i)); if (OPT.subtitles) subtitles(s); }); tl.seek(0); await paintClips(0); await document.fonts.ready; await Promise.all([...document.images].map((i) => i.decode().catch(() => {}))); return total; })(); window.seek = (t) => { tl.seek(t, false); return paintClips(t); }; -
index.html 11.6 KB · in bundle
-
-
storyboard.example.json 3.8 KB
{ "brand": { "font": "brand/Display.ttf", "logos": { "a": "brand/acme.svg", "b": "brand/globex.svg" }, "colors": { "ink": "#111827", "accent": "#2563eb", "highlight": "#93c5fd", "dark": "#1e3a8a", "tint": "#e0e7ff", "bg": "#f8fafc" } }, "subtitles": false, "transitions": "cut", "backdrop": false, "music": { "volume": 0.42 }, "voice": { "exaggeration": 0.7, "cfg": 0.4 }, "blankCrop": [240, 110, 1200, 790], "shotFrame": 1440, "scenes": [ { "id": "01", "type": "intro", "min": 5.6, "voAt": 1.3 }, { "id": "02", "type": "statement", "title": "Support every day,<br><span class=\"accent\">not just at appointments.</span>", "bands": [ { "kind": "Clinical authority", "logo": "b", "accent": true, "items": ["Assessment", "Guidance", "Eligibility"] }, { "kind": "Daily support", "logo": "a", "items": ["Plans", "Accountability", "Progress"] } ], "min": 7.5, "voAt": 0.7 }, { "id": "02b", "type": "flow", "text": "Acme reads your records.<br><span class=\"accent\">It never writes to them.</span>", "min": 9, "voAt": 0.6, "nodes": [ { "name": "Records", "note": "Where the data lives", "badge": "Read-only" }, { "name": "Acme", "note": "Checks every item", "hero": true, "logo": "a" }, { "name": "Your lists", "rows": [["To fix", "12"], ["Done", "40"]] } ] }, { "id": "03", "type": "title", "text": "Let’s see how it works", "min": 2.6, "voAt": 0.3, "tail": 0.6 }, { "id": "04", "type": "app", "num": 1, "label": "A referral arrives", "who": "Clinic doctor · Admin", "clips": [ { "video": "footage/referral.mp4", "from": 25.6, "to": 33.8, "speed": 2.5, "caption": "Refer a patient" }, { "video": "footage/referral.mp4", "from": 85, "to": 96, "speed": 2.2, "caption": "Invite sent", "focus": { "from": 89.2, "to": 92, "box": [300, 120, 1100, 260] }, "hold": 1.4 } ] }, { "id": "04b", "type": "app", "num": 2, "label": "Worklists", "groups": [ { "line": "04b-1", "shots": [ { "file": "lists.png", "caption": "One tab per list", "zoom": "Tabs" }, { "file": "lists.png", "caption": "Totals at a glance", "zoom": { "x": 70, "y": 140, "w": 560, "h": 34 } } ] }, { "line": "04b-2", "shots": [ { "file": "lists-filter.png", "caption": "Filter by owner", "click": "Owner filter" } ] } ] }, { "id": "04c", "type": "loop", "text": "How it improves", "min": 8, "voAt": 0.6, "nodes": ["Results come in", "Learning each night", "To review", "You approve", "Fewer items to fix"], "you": 3, "badge": "The team decides" }, { "id": "05", "type": "collage", "text": "Daily support.<br><span class=\"accent\">Clear progress.</span>", "shots": ["clips/04-1/00030.jpg", "clips/04-2/00060.jpg", "clips/04-1/00090.jpg", "clips/04-2/00010.jpg"], "min": 6 }, { "id": "06", "type": "outro", "text": "Daily accountability<br><b>for the care you already give.</b>", "cta": "Book a demo", "min": 7.5, "voAt": 1 } ] }
-
-
references
-
recordings.md 1001 B
# Recordings route Load when chapters are cut from product recordings. Run `clips.mjs` after every clip edit. Clip fields in an `app` scene's `clips`: `video`, `from`/`to` (source s), `speed`, `caption`, optional `focus`, `hold`, `blankCrop`, `blankInk`. - Pick the moment that proves the step: form filled → confirmation. Skip navigation, spinners, empty states. - Speed: typing + scrolling 3–4.5×; decisions + confirmations 1.6–2.5×. Speed tag shows from 3×. - Chapter ends on a confirmation state; too short to read → `hold` ≈ 1.4 s. - `blankCrop` = content region in source pixels, excluding persistent navigation → loading frames are dropped (`blankInk` default 0.012). - `focus` = `{ "from", "to", "box": [x, y, w, h] }` in source s + pixels, last 1–3 s of a payoff state. Zoom capped 1.45, eased; rest plays full frame. - Tall phone footage (width < 0.8 × height) plays large on the right; chapter name + captions sit in a left column. Landscape plays centred, full width. -
render.md 1.8 KB
# Render, review + deliver Load once scenes + lines exist, and after every revision. ## Build + render - `build.mjs` times scenes from clips, shots + lines → `render/scenes.js` + `audio/placement.json`. Fails on a missing, reused or overlapping line. - Options: `subtitles: true` (sentence subtitles), `transitions: "fade"` (0.4 s crossfade; default `"cut"`), `backdrop: true` (dot grid + slow blurred blobs in `tint`), `music.volume`. - `brand` sets colours, fonts + `displayWeight` (default 800; lower for a lighter display face). - Stills first: every graphic scene, chapter card, click + zoom. Fix layout before a full render. - `render.mjs` = deterministic 30 fps from the paused GSAP timeline; fails on page error or missing asset. - `mix.mjs` = looped music with crossfades, ducked under the voice, faded, linear gain to −14 LUFS (quiet music stays quiet). ## Review On the exact file to deliver: 1. `audit.mjs` → open every contact sheet (1 frame/s). Look for sign-in or secret screens, spinners, cropped content, overlaps, typed logos, zooms cutting off the named thing. 2. Flat-frame runs = deliberate only (first ~0.25 s of a scene before its text reveals). 3. Stills at every click, zoom + graphic scene: nothing cut off, headings clear, logos crisp. 4. Mix transcript: every line present, in order, names recognisable. Timing → trust `audio/placement.json`; Whisper timestamps drift. 5. Report per artifact what was and was not checked; voice quality = the human's. ## Deliver - Name `<Title> – product demo (draft N).mp4`; keep earlier drafts. - Chat apps cap near 30 MB → also send `ffmpeg -i final.mp4 -vf scale=1280:-2 -c:v libx264 -crf 28 -preset slow -c:a aac -b:a 128k -movflags +faststart final-small.mp4`. - Note = changes since the previous draft, checks run, what the human must confirm (music licence, claims). -
revisions.md 500 B
# Revisions Load when the human gives feedback on a draft. - Apply exactly the named changes. "Everything else is fine" freezes script lines, takes, timings, captions + designs. - Voice feedback → change one thing, sample, verdict, then roll out. - Graphic feedback → rebuild from the brand's approved source, not a new invention. - One complaint → change only the named lines, never a whole-script rewrite. - Re-run [review](render.md#review) for every scene whose timing or pixels changed. -
script.md 2.8 KB
# Brief + script Load for a new video, or when a script line changes. ## Brief Collect before writing: - audience + goal: buyers (marketing, default 2–2.5 min) or users learning the product (explainer, as long as every function needs, typically 3–4 min); - where it plays; sound off likely → `subtitles: true`; - brand owner's display font file, logos for every party, colours; - approved design source for graphics (deck, site section); - screens: recordings or a way to render the app over made-up data; - product's own claims (deck, site, docs); the script says nothing they do not support; - music: human picks/supplies the track, accepts its licence and says how loud ("very light" ≈ `music.volume` 0.12; default 0.42). Never lift a track from another project. Licence unknown → say so on delivery. Agent never accepts terms or ticks licence attestations. ## Scene types | Type | Fields | Use | | --- | --- | --- | | `intro` | `title` + `kicker` (one brand), or none (logo `a` × `b`) | Opening | | `statement` | `title`, optional `sub`, `cards` `[{tag, text}]`, `bands` | Problem, offer, why | | `title` | `text`, optional `card: true` + `kicker` | Bridge or section title | | `app` | `num`, `label`, optional `who`, `clips` or `groups` | One chapter per journey | | `flow` | `text`, `nodes` `[{name, note, hero, logo, rows, badge}]` | How data or work moves (2–4 nodes) | | `loop` | `text`, `nodes` (strings), `you` (index), `badge` | Repeating cycle | | `collage` | `text`, `shots` (paths relative to the work folder) | Recap | | `outro` | `title` + `kicker` or logos; `text`, `cta` | Close | ## Shape + wording - Marketing = intro → problem → offer → bridge title → 6–9 chapters → collage → outro. - Explainer = intro → why → flow → one chapter per function under section titles → loop or recap → outro. - Short (≤ 90 s) = intro → flow → 3–4 chapters → outro; drop the problem statement and collage. - Each scene adds something new: statement, flow and voice never list the same steps twice. - Outro = brand + next step (`cta`). Gaps in what the demo shows go in the delivery note, never on screen. - One line per non-app scene (key = `line` or scene id), ≤ 25 words. Clip chapter = one line; staged chapter = one line per group. - Text-only scene ≤ ~8 s (`build.mjs` prints `CHECK` above). Longer thought → `cards`, `bands` or a `flow` revealing with the voice, or move it into a chapter. - Open on something specific ("This is the Acme claims desk. It's where…"); "A and B, working together" = filler. - No hype words (seamless, powerful, smarter, magic). Integrations + features named exactly as shipped. Captions name the action ("Take this claim"). - Voice, caption + screen say the same thing; change them together. - Full script → human before any voice work. -
setup.md 2.8 KB
# Setup + commands Load when the runtime is missing or before the first command. Reuse existing environments; install only within task authorization. ## Runtime ```bash cd <skill> pnpm install --frozen-lockfile --ignore-scripts pnpm exec playwright install chromium brew install ffmpeg ``` Linux FFmpeg = `sudo apt-get install -y ffmpeg`. Voice + transcription = own Python environments, outside every repository: ```bash uv venv --python 3.11 ~/.cache/marketing-video-tts uv pip install --python ~/.cache/marketing-video-tts/bin/python chatterbox-tts export TTS_PYTHON=~/.cache/marketing-video-tts/bin/python uv tool install mlx-whisper ``` - Chatterbox downloads weights from Hugging Face on first use; runs on Apple GPU, CUDA or CPU. - `mlx-whisper` = Apple Silicon only. Elsewhere: `uv tool install openai-whisper` + `export WHISPER_BIN=whisper`. `WHISPER_BIN` may point at an existing `mlx_whisper`. - Sandbox hides the GPU or blocks browser launch (e.g. Codex `workspace-write` on macOS) → `takes.mjs`, `pick.mjs`, `render.mjs` fail. Request an unsandboxed run; weights already downloaded → `HF_HUB_OFFLINE=1`. ## Work folder One per video, outside this skill + out of version control (outside the repository or git-ignored): it holds footage, audio and renders. | Path | Owner | Content | | --- | --- | --- | | `storyboard.json` | you | Brand, options, scenes; shape = [storyboard.example.json](../assets/storyboard.example.json) | | `lines.json` | you | `[["01", "Voice line"], …]`; key = scene id, scene `line` or group `line` | | `brand/` | brand owner | Display font (+ optional body font), logos `a` (+ partner `b`) | | `footage/` | recordings | Source MP4s (recordings route) | | `shots/` | staging | Screen PNGs + optional `manifest.json` (staged route) | | `music/` | human | Licensed track | | `render/` | template | `build.mjs` copies the template on first run; customise only for approved designs | | `clips/ vo/ audio/ out/ stills/ audit/` | scripts | Generated | ## Commands `<skill>` = this package's path; any working directory. ```bash node <skill>/scripts/clips.mjs <work> node <skill>/scripts/takes.mjs <work> [ids] [first-seed] node <skill>/scripts/pick.mjs <work> node <skill>/scripts/build.mjs <work> node <skill>/scripts/render.mjs <work> --stills 3 12.5 node <skill>/scripts/render.mjs <work> <work>/out/silent.mp4 node <skill>/scripts/mix.mjs <work> <work>/music/track.mp3 <work>/out/silent.mp4 <work>/out/final.mp4 node <skill>/scripts/audit.mjs <work>/out/final.mp4 <work>/audit node <skill>/scripts/pick.mjs <work> <work>/out/final.mp4 ``` - `clips.mjs` = recordings route only. `build.mjs` refuses clips whose storyboard range changed since the last `clips.mjs`. - `build.mjs` runs before any voice exists (estimates missing lines) → check layout first. - Last command transcribes only the voiced spans of the mix → music-only stretches cannot invent text. -
staged.md 1.4 KB
# Staged screenshots route Load when real data is private or no recordings exist. Group shape in an `app` scene: `groups: [{ "line": "06-1", "shots": [{ "file", "caption", "click", "zoom", "dur" }] }]`. ## Capture - Render the real app over made-up records at 2×: a widget/component test that paints the whole app (e.g. Flutter `RepaintBoundary.toImage(pixelRatio: 2)` at 1440×900), or Playwright screenshots of a local build seeded with fake data. - Harness runs from a temporary copy; nothing stays in the repository. - Before each capture: wait for that step's own content to be visible, then `document.fonts.ready` + every `img` `complete && naturalWidth > 0`. Client-side steps render after `networkidle`, and an empty image list passes the check → photos captured blank. - Harness writes `shots/manifest.json` = `[{ "file", "targets": [{ "label", "x", "y", "w", "h" }] }]` in logical pixels. `shotFrame` = logical width (1440 for 2880-px PNGs). - Contact sheet of every shot before building: menu open, right tab, the state the caption names. ## Motion - `click` / `zoom` = a target label (`"click": "Sign in"`) or a box. - `click` glides the cursor to the target, presses, ripples. - `zoom` pushes in up to 2.2×, whole box in frame; box too large to gain 1.15× → no zoom. - Shot ≥ 2.6 s (more with click or zoom); shots stretch so the group's line fits. - Same `file` + new `zoom` = camera move, no cut. -
voice.md 1.1 KB
# Voice Load after the script is approved, or when a line's text changes. 1. `takes.mjs <work> 01` → send that one take to the human; wait for their verdict on voice, pace + name pronunciation. 2. All lines: six seeds each, whole line per generation, at `storyboard.voice` (default `{ "exaggeration": 0.7, "cfg": 0.4 }`). Change settings only after the human hears a sample. 3. `pick.mjs <work>` = Whisper per take → lowest word-error take per line (tie → take that fits its scene). `REGENERATE` = no clean take: check the wording, then `takes.mjs <work> <id> 7` (seeds 7–12) and pick again. 4. Names = plain spelling. Whisper keeps hearing a close variant of a correctly spoken name → `vo/heard-as.json` (`{"heard": "script"}`). Respell only after the human hears it wrong; sample 2–3 spellings first. 5. Liked take stays in `vo/takes/` (any `<id>_<label>.wav` competes); regenerate only changed lines. | Mistake | Replacement | | --- | --- | | Lines split per sentence, higher exaggeration, "livelier" takes by pitch | Whole-line takes at approved settings; human judges by ear | | Phonetic respelling by default | Plain spelling + `heard-as.json` |
-
-
scripts
-
audit.mjs 1.1 KB · in bundle
-
build.mjs 5.3 KB · in bundle
-
clips.mjs 3.2 KB · in bundle
-
media.mjs 1.4 KB · in bundle
-
mix.mjs 2.4 KB · in bundle
-
pick.mjs 3.8 KB · in bundle
-
render.mjs 2.9 KB · in bundle
-
takes.mjs 1.6 KB · in bundle
-
-
package.json 291 B
{ "name": "marketing-video", "version": "1.0.0", "private": true, "type": "module", "description": "Script, cut, voice, render and review a polished product marketing video from real walkthrough footage.", "dependencies": { "gsap": "3.15.0", "playwright": "1.63.0" } } -
pnpm-lock.yaml 931 B
lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: .: dependencies: gsap: specifier: 3.15.0 version: 3.15.0 playwright: specifier: 1.63.0 version: 1.63.0 packages: gsap@3.15.0: resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} playwright-core@1.63.0: resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} hasBin: true playwright@1.63.0: resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} engines: {node: '>=20'} hasBin: true snapshots: gsap@3.15.0: {} playwright-core@1.63.0: {} playwright@1.63.0: dependencies: playwright-core: 1.63.0 -
SKILL.md 3.1 KB
--- name: marketing-video description: Make a voiced product marketing or explainer video from real product screens. Script, cut walkthrough clips or stage app screenshots with a cursor, pick voice takes, render branded motion graphics and diagrams, mix music and review every second before delivery. disable-model-invocation: true --- # Marketing Video Output = real product screens + big brand type + one calm voice → reviewed MP4. Scripts run by path from any directory (`node <skill>/scripts/<name>.mjs <work>`); each video's work folder lives outside the skill. ## Routes Load only the routes the task reaches. ```mermaid flowchart LR T{Task} -->|Runtime / commands / work folder| S[setup] T -->|New video| B[brief + script] B -->|Recordings exist| RC[recordings] B -->|Private data / no recordings| ST[staged screenshots] RC --> V[voice] ST --> V V --> R[render, review, deliver] T -->|Feedback on a draft| E[revisions] E --> R click S "references/setup.md" click B "references/script.md" click RC "references/recordings.md" click ST "references/staged.md" click V "references/voice.md" click R "references/render.md" click E "references/revisions.md" ``` Recordings come from [Product Walkthrough Video](../product-walkthrough-video/SKILL.md) takes or existing product videos. ## Core rules - Agent cannot hear → voice, pace + pronunciation = human's call. One sample before every line is generated; never call audio "better" without their listen. - Real product only: screens, logos + fonts from the product and brand owners. Screens keep their own colours; the brand palette dresses only the graphics around them. Logo file, never a typed brand name; never an invented screen or feature. - Cut sign-in, invite, loading + empty screens. Access behaviour only when the audience must learn it (training); never a typed password. - Private data never appears: demo accounts or made-up records. - Zoom = hand-placed box on what the voice names, whole box in frame. No pointer-following or automatic zoom; a staged cursor never drives the camera. - Recorded clips → hard cuts. Staged shots/scenes → 0.4 s crossfades allowed. No slow fades to or from black. - Explanatory graphics copy an approved brand design (deck slide, site section) or the template defaults; no new card, chip or panel styles. - Plain, specific words, like a colleague showing the product. No hype, slogans or rule-of-three taglines; boldness = type size, layout, motion. - Human-approved part → frozen. Change only what the latest feedback names. ## Completion gate All hold on the exact delivered file: - script approved by the human; every claim backed by product material; - `build.mjs` passes; `render.mjs` reported no page errors; - every audit sheet opened, flat runs explained, stills checked at clicks, zooms + graphic scenes; - mix transcript holds every line in order; - delivered with draft number + change summary; voice quality stated as the human's judgment; music licence status stated. Human step not done (no script approval, no voice listen) → deliver as a draft that names each unmet item; never call it complete.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.