Claude Skill

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.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download sgaabdu4-building-flutter-apps-.agents_skills_marketing-video-c396097.zip · 31 KB
Part of sgaabdu4/building-flutter-apps — 14 skills

Install

skills CLI npx skills add https://github.com/sgaabdu4/building-flutter-apps/tree/main/.agents/skills/marketing-video
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sgaabdu4-building-flutter-apps@llmmart
Git 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.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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related