Motion · Encerramento

Outro group photo launch

Elements from across the whole film fly in from every direction to pose around the wordmark, staged like a launch-event finale.

960×54016:930 fps4.83s · 145 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#outro

Fonte: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composição Remotion (React/TSX) — vídeo de prévia, sem player no navegador

When to use

Outro / brand sign-off; a "family photo" closing shot for multi-feature products

Intention

Pull one representative element from every feature shown so far and call them back for a group photo, with the wordmark landing last as the headliner — the last thing the viewer remembers before leaving is "these all belong to the same product." The spec has to be launch-event grade: energy pushed to the highest point in the whole film.

Duration & energy

  • Duration: About 4.8s (145f)
  • Energy: Peak (the highest point of the whole film)

Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)

意图

把看过的每个功能各抽一个代表元素叫回来合影,字标压轴登场——观众离场前最后记住的是"这些东西属于同一个产品"。规格必须是发布会级:能量推到全片最高。

动效核心

  • 9 个页面元素(nav/卡片/内容条/搜索框/统计条/文档头)从四方带旋转飞入,落地强调色(模板片为琥珀)glow
  • 字标 letterpress 逐字登场,全员"退后排"让主角;rule 长出、延长线射出
  • crane 运镜:合影层整体 rotateX 4°→0 + scale 落下,之后缓推
  • 发布会氛围三件套:开场光带扫过、字标背后舞台光、金尘上飘
  • 背景页面 blur 化作景深

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 元素飞入 | 9 元素 cue 从 4 起每 3f 一个,各飞 12f;起点偏移 ±360–500px;渲染顺序=cue 顺序,后到者叠上 | 每个已展示过的功能都要有代表元素(Q8 自检项),缺谁观众都会察觉 | | 飞入缓动 | bezier(0.34,1.4,0.44,1) | 代码注释点名教训:旧 bezier(0.25,0.9,0.3,1) "never crossed 1" 没有真过冲——落地要弹 y1 必须 >1 | | 飞行姿态 | 飞行中 rot×2→settled rot(±5° 内)、scale ×1.12→1;ghost 残影滞后 8% blur 8px;落地强调色 glow 6f(0.35→0)multiply | 落定角度收进 ±5° 内——合影要整齐但不刻板 | | 退后排 | 字标登场时 42–50f 全员 opacity −12%、saturate −8% | 主角登场配角必须让位,不让位字标压不住 9 个元素 | | crane 运镜 | perspective(1400px) rotateX(4°→0) scale(1.06→1) 前 40f 落下,之后缓推 +0.035 | crane 幅度就这么小(4°)已经够"落机位"读感,大了会晕(推测) | | 氛围三件套 | 光带 2–14f(600px 宽 overlay 峰值 opacity 0.12);舞台光 42→50→58f 0→0.5→0.25;金尘 20 颗 2–3px、opacity 0.15–0.35、全参数 index 派生确定性 | 粒子/光效参数必须确定性派生(硬规则:渲染必须可复现);金尘超过 30 颗开始像雪(推测) | | 字标 | 字母 delay=42+i·1.8、8f;rule 58→70f 长出、两端 190px 延长线 8f 射出 6f 淡去;字距 62–66f 呼吸;背景页 24f 内 blur 0→14;落定后 sign-off hold 30f | 字标落定 hold 满 1 秒(R1 定案);outro 不加解说 caption 保持干净(C1 例外项) |

声音

固定三拍句式:riser-cine 铺进组装(模板片钉在 f945)→ impact-deep-whoosh 钉字标 stamp(f980,vol 0.55 全片峰值)→ sparkle 点 rule(f1005)。这是模板片定稿后唯一从未改动的段落句式(S2;详见 sound-design 4.3)。

已知坑

  • 初版收尾几乎总是偏保守(Q8:安静签名式→合影→发布会三级加码)——起稿就按"像一个产品发布"的规格给足 crane/舞台光/粒子
  • 结构先于特效:先有"四方飞入合影"结构再加码氛围;直接堆特效没有骨架
  • outro tagline 与字标下方文案查重(P4 dedupe 判例),同一句话全片只出现一次

参考实现

demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx(原 template/src/aifl/live/SceneOutroLive.tsx;30f sign-off hold 时长见 template/src/aifl/Main.tsx 时间线)

Reference implementation (Remotion)

  • demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx

The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.

Tudo nesta página também é legível por máquina. Aponte seu agente para o JSON ou adicione o servidor MCP do gimgs e peça para buscar templates de motion.

  1. Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
  2. Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
  3. Registre o componente como <Composition> em um projeto Remotion e renderize com `npx remotion render`, ou reimplemente a receita no seu próprio engine.
curl -s https://gimgs.net/motion/outro-group-photo-launch.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("outro-group-photo-launch")

Prompt

Use the video-shotcraft shot recipe "Outro group photo launch" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/outro-group-photo-launch.json
Raw TSX: https://gimgs.net/motion/outro-group-photo-launch/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx; imports demos/_fixtures and assets/lib)
Spec: 960x540, 30 fps, 4.83s

1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls).
2. Adapt it for: <describe what you want>.
3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).

JSON · Markdown · llms.txt

14,465 chars · Apache-2.0Abrir template bruto ↗
// ===== demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx =====
// outro-group-photo-launch —— 全片元素四方飞来围住字标合影,crane 落机位 + 舞台光 +
// 金尘做成发布会收场。outro/品牌收尾,多功能产品"全家福"式终镜,能量推到全片最高。
// 参考实现从 template SceneOutroLive 剥离(self-contained):
// 9 个页面元素从四方带旋转飞入(cue 4 起每 3f 一个,飞 12f,bezier y1>1 真过冲,
// 渲染顺序=cue 顺序后到者叠上),落地强调色 glow(0.35→0);字标 letterpress 逐字
// 登场(delay=42+i·1.8)全员退后排(opacity −12% / saturate −8%);rule 长出 +
// 190px 延长线射出;crane 运镜 perspective rotateX(4°→0) + scale 落下后缓推;
// 发布会三件套:开场光带扫过、字标背后舞台光、20 颗确定性金尘上飘;背景页 blur 化
// 景深;落定后 sign-off hold 30f。outro 不加解说 caption 保持干净。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';

export const OUTRO_GROUP_PHOTO_LAUNCH_DURATION = 145;

const SERIF = 'ui-serif, Georgia, "Times New Roman", serif';
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
const LETTERS = 'AI Foundation Lab'.split('');
const PAGE_H = layout.projects.pageH;
const WBR_PAGE_H = layout.wbr.pageH;

const FLY_EASE = Easing.bezier(0.34, 1.4, 0.44, 1);
const CRANE_EASE = Easing.bezier(0.3, 0, 0.2, 1);

type FlyEl = {
  key: string;
  file: string;
  w: number;
  h: number;
  cx: number;
  cy: number;
  scale: number;
  rot: number;
  dx: number;
  dy: number;
  radius: number;
  cue: number;
  wbrCrop?: boolean;
};

// render order = cue order, so later arrivals stack on top
const ELS: FlyEl[] = [
  { key: 'nav', file: 'nav.png', w: 1920, h: 61, cx: 960, cy: 84, scale: 0.62, rot: 0, dx: 0, dy: -120, radius: 10, cue: 4 },
  { key: 'card4', file: 'card4-hires.png', w: 358, h: 312, cx: 300, cy: 400, scale: 0.72, rot: -5, dx: -500, dy: 0, radius: 16, cue: 7 },
  { key: 'card1', file: 'card1.png', w: 358, h: 288, cx: 1630, cy: 380, scale: 0.68, rot: 4, dx: 500, dy: 0, radius: 16, cue: 10 },
  { key: 'paper1', file: 'paper1.png', w: 1104, h: 225, cx: 1460, cy: 730, scale: 0.5, rot: -3, dx: 450, dy: 260, radius: 12, cue: 13 },
  { key: 'card7', file: 'card7.png', w: 358, h: 312, cx: 260, cy: 800, scale: 0.6, rot: 3, dx: -400, dy: 300, radius: 16, cue: 16 },
  { key: 'paper3', file: 'paper3.png', w: 1104, h: 225, cx: 620, cy: 930, scale: 0.48, rot: 2, dx: 0, dy: 320, radius: 12, cue: 19 },
  { key: 'search', file: 'float-search.png', w: 1016, h: 44, cx: 760, cy: 180, scale: 0.55, rot: -1.5, dx: 0, dy: -240, radius: 10, cue: 22 },
  { key: 'stats', file: 'float-stats.png', w: 315, h: 56, cx: 1560, cy: 950, scale: 0.9, rot: -2, dx: 380, dy: 0, radius: 10, cue: 25 },
  { key: 'wbr', file: 'wbr-full.png', w: 688, h: 54, cx: 1620, cy: 170, scale: 0.8, rot: 2.5, dx: 360, dy: -200, radius: 8, cue: 28, wbrCrop: true },
];

// 20 gold dust motes, all parameters index-derived (deterministic)
const DUST = Array.from({ length: 20 }, (_, i) => ({
  x: (i * 439 + 137) % 1920,
  y0: (i * 613 + 271) % 1080,
  rise: 0.3 + (i % 5) * 0.11,
  swayAmp: 9 + (i % 4) * 5,
  swayFreq: 0.022 + (i % 3) * 0.008,
  phase: (i * 0.83) % (Math.PI * 2),
  size: 2 + (i % 3) * 0.5,
  opacity: 0.15 + ((i * 7) % 5) * 0.05,
}));

export const OutroGroupPhotoLaunch: React.FC = () => {
  const frame = useCurrentFrame();
  const duration = OUTRO_GROUP_PHOTO_LAUNCH_DURATION;

  const blur = interpolate(frame, [0, 24], [0, 14], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.4, 1),
  });
  const rule = interpolate(frame, [58, 70], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
  });
  const tag = interpolate(frame, [68, 80], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
  const fadeOut = interpolate(frame, [duration - 12, duration], [1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const recede = interpolate(frame, [42, 50], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // crane on the whole group-photo layer
  const craneT = interpolate(frame, [0, 40], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: CRANE_EASE,
  });
  const pushT = interpolate(frame, [40, duration], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const camScale = 1.06 - 0.06 * craneT + 0.035 * pushT;
  const camTilt = 4 * (1 - craneT);

  // opening light sweep
  const sweepX = interpolate(frame, [2, 14], [-700, 2020], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.6, 1),
  });
  const sweepOpacity = interpolate(frame, [2, 5, 11, 14], [0, 0.12, 0.12, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // stage light behind the wordmark
  const stageLight = interpolate(frame, [42, 50, 58], [0, 0.5, 0.25], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const vignette = interpolate(frame, [42, 54], [0, 0.1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // rule extension lines
  const ruleExt = interpolate(frame, [58, 66], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
  });
  const ruleExtFade = interpolate(frame, [66, 72], [1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // one breath of letter-spacing once the wordmark is fully set
  const wordSpacing = interpolate(frame, [62, 66], [-0.01, 0.005], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
  });

  return (
    <AbsoluteFill style={{ opacity: fadeOut }}>
      {/* group-photo layer under a slow crane-in camera */}
      <AbsoluteFill
        style={{
          transform: `perspective(1400px) rotateX(${camTilt}deg) scale(${camScale})`,
          transformOrigin: '50% 45%',
        }}
      >
        {/* blur 只作用背景页:PageCam2D 自闭合,飞入元素放在未过滤的兄弟层
            (屏幕空间坐标,与 template SceneOutroLive 同构)——放进 children
            会连"合影元素"一起糊掉,只剩字标清晰 */}
        <PageCam2D src="textures/live/projects-full.png" pageH={PAGE_H} keys={[{ frame: 0, cx: 960, cy: 700, zoom: 0.75 }]} blur={blur} />
        {/* warm scrim under the flying elements */}
        <AbsoluteFill style={{ background: 'radial-gradient(1200px 800px at 50% 48%, rgba(250,247,242,0.82), rgba(250,247,242,0.55) 60%, rgba(250,247,242,0.35))', pointerEvents: 'none' }} />

        {/* group photo: elements fly in from all sides and settle around the wordmark */}
        <AbsoluteFill style={{ pointerEvents: 'none' }}>
          {ELS.map((el) => {
            if (frame < el.cue) return null;
            const t = interpolate(frame, [el.cue, el.cue + 12], [0, 1], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: FLY_EASE,
            });
            const opacity = interpolate(frame, [el.cue, el.cue + 3], [0, 1], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
            });
            const x = el.dx * (1 - t);
            const y = el.dy * (1 - t);
            const rot = el.rot * (2 - t);
            const scale = el.scale * (1.12 - 0.12 * t);
            const air = Math.max(0, 1 - t);
            const shadow =
              air > 0.01
                ? `0 ${10 + 26 * air}px ${24 + 46 * air}px rgba(30,25,18,${0.16 + 0.1 * air}), 0 2px 6px rgba(30,25,18,.08)`
                : '0 10px 24px rgba(30,25,18,.16), 0 2px 6px rgba(30,25,18,.08)';
            const settledOpacity = opacity * (1 - 0.12 * recede);
            const saturate = 1 - 0.08 * recede;
            const texture = el.wbrCrop
              ? {
                  background: `#fff url(${staticFile(`textures/live/${el.file}`)}) -576px -173px / 1920px ${WBR_PAGE_H}px no-repeat`,
                  border: '1px solid oklch(90% .008 82)',
                }
              : null;
            const linT = interpolate(frame, [el.cue, el.cue + 12], [0, 1], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
            });
            const showGhost = linT > 0.05 && linT < 0.95;
            const glow = interpolate(frame, [el.cue + 12, el.cue + 18], [0.35, 0], {
              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
            });
            const showGlow = frame >= el.cue + 12 && frame < el.cue + 18;
            const glowR = el.w * el.scale * 0.5;

            return (
              <div key={el.key}>
                {showGhost ? (
                  <div
                    style={{
                      position: 'absolute', left: el.cx - el.w / 2, top: el.cy - el.h / 2,
                      width: el.w, height: el.h,
                      transform: `translate(${x + el.dx * 0.08}px, ${y + el.dy * 0.08}px) rotate(${rot}deg) scale(${scale})`,
                      transformOrigin: 'center center', borderRadius: el.radius, overflow: 'hidden',
                      opacity: 0.2 * Math.max(0, 1 - linT), filter: 'blur(8px)', ...texture,
                    }}
                  >
                    {el.wbrCrop ? null : (
                      <Img src={staticFile(`textures/live/${el.file}`)} style={{ position: 'absolute', inset: 0, width: el.w, height: el.h, display: 'block' }} />
                    )}
                  </div>
                ) : null}
                <div
                  style={{
                    position: 'absolute', left: el.cx - el.w / 2, top: el.cy - el.h / 2,
                    width: el.w, height: el.h,
                    transform: `translate(${x}px, ${y}px) rotate(${rot}deg) scale(${scale})`,
                    transformOrigin: 'center center', borderRadius: el.radius, overflow: 'hidden',
                    boxShadow: shadow, opacity: settledOpacity, filter: `saturate(${saturate})`, ...texture,
                  }}
                >
                  {el.wbrCrop ? null : (
                    <Img src={staticFile(`textures/live/${el.file}`)} style={{ position: 'absolute', inset: 0, width: el.w, height: el.h, display: 'block' }} />
                  )}
                </div>
                {showGlow ? (
                  <div
                    style={{
                      position: 'absolute', left: el.cx - glowR, top: el.cy - glowR,
                      width: glowR * 2, height: glowR * 2, borderRadius: '50%',
                      background: 'radial-gradient(circle, oklch(78% 0.13 70 / 0.9), oklch(78% 0.13 70 / 0) 70%)',
                      opacity: glow, mixBlendMode: 'multiply',
                    }}
                  />
                ) : null}
              </div>
            );
          })}
        </AbsoluteFill>
      </AbsoluteFill>

      {/* gold dust drifting up in front of the group photo */}
      <AbsoluteFill style={{ pointerEvents: 'none' }}>
        {DUST.map((d, i) => {
          const y = (((d.y0 - frame * d.rise) % 1080) + 1080) % 1080;
          const x = d.x + Math.sin(frame * d.swayFreq + d.phase) * d.swayAmp;
          return (
            <div
              key={i}
              style={{
                position: 'absolute', left: x, top: y, width: d.size, height: d.size,
                borderRadius: '50%', background: 'oklch(75% 0.08 85)', opacity: d.opacity,
              }}
            />
          );
        })}
      </AbsoluteFill>

      {/* opening light sweep */}
      {sweepOpacity > 0 ? (
        <AbsoluteFill style={{ pointerEvents: 'none', mixBlendMode: 'overlay' }}>
          <div
            style={{
              position: 'absolute', top: 0, bottom: 0, left: sweepX - 300, width: 600,
              background: 'linear-gradient(90deg, rgba(255,244,224,0), rgba(255,244,224,1) 50%, rgba(255,244,224,0))',
              opacity: sweepOpacity,
            }}
          />
        </AbsoluteFill>
      ) : null}

      {/* stage light behind the wordmark */}
      {stageLight > 0 ? (
        <AbsoluteFill
          style={{
            pointerEvents: 'none',
            background: 'radial-gradient(700px 360px at 960px 470px, rgba(255,246,228,0.95), rgba(255,246,228,0.35) 55%, rgba(255,246,228,0) 75%)',
            opacity: stageLight,
          }}
        />
      ) : null}

      {/* closing vignette */}
      {vignette > 0 ? (
        <AbsoluteFill
          style={{
            pointerEvents: 'none',
            background: 'radial-gradient(1400px 900px at 50% 50%, rgba(62,48,32,0) 55%, rgba(62,48,32,0.7) 100%)',
            opacity: vignette,
          }}
        />
      ) : null}

      {/* wordmark sign-off */}
      <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', pointerEvents: 'none' }}>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontFamily: SERIF, fontSize: 148, fontWeight: 600, color: 'oklch(18% 0.006 82)', letterSpacing: `${wordSpacing}em`, display: 'flex' }}>
            {LETTERS.map((ch, i) => {
              const delay = Math.round(42 + i * 1.8);
              const t = interpolate(frame, [delay, delay + 8], [0, 1], {
                extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.2, 0.75, 0.3, 1),
              });
              return (
                <span
                  key={i}
                  style={{
                    opacity: t, transform: `translateY(${(1 - t) * 28}px) scale(${1.35 - 0.35 * t})`,
                    filter: `blur(${(1 - t) * 8}px)`, display: 'inline-block', whiteSpace: 'pre',
                  }}
                >
                  {ch}
                </span>
              );
            })}
          </div>
          <div style={{ position: 'relative', height: 6, width: 260, margin: '34px auto 0' }}>
            <div style={{ position: 'absolute', inset: 0, borderRadius: 3, background: 'oklch(52% 0.115 65)', transform: `scaleX(${rule})` }} />
            {ruleExt > 0 && ruleExtFade > 0 ? (
              <>
                <div style={{ position: 'absolute', top: 2.5, height: 1, right: '100%', width: 190 * ruleExt, background: 'oklch(52% 0.115 65)', opacity: ruleExtFade }} />
                <div style={{ position: 'absolute', top: 2.5, height: 1, left: '100%', width: 190 * ruleExt, background: 'oklch(52% 0.115 65)', opacity: ruleExtFade }} />
              </>
            ) : null}
          </div>
          <div style={{ fontFamily: MONO, fontSize: 25, letterSpacing: '0.14em', color: 'oklch(50% 0.006 82)', marginTop: 30, opacity: tag, textTransform: 'uppercase' }}>
            Team Research Console
          </div>
        </div>
      </AbsoluteFill>
    </AbsoluteFill>
  );
};


  1. Read the recipe
    Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
  2. Get the code
    tool: git
    git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install
  3. Register + render
    tool: remotion
    npx remotion render <CompositionId> out.mp4 # component: demos/outro/outro-group-photo-launch/OutroGroupPhotoLaunch.tsx
  4. Or re-implement
    Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
paramtypedefaultdescription
—

Mais nesta categoria