{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typography",
  "type": "registry:lib",
  "title": "Typography",
  "description": "Fit text to a fixed frame and give it a complete kinetic lifecycle.",
  "registryDependencies": [
    "@vanillasky/motion"
  ],
  "files": [
    {
      "path": "src/lib/typography/index.ts",
      "type": "registry:lib",
      "target": "vanillasky/typography/index.ts",
      "content": "/** Text fitting, formatting, timing, and kinetic type lifecycles. */\nexport * from \"../scene-templates/text-utils\";\nexport * from \"../scene-templates/text-archetypes\";\n"
    },
    {
      "path": "src/lib/scene-templates/text-archetypes.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/text-archetypes.ts",
      "content": "/**\n * Text archetypes — 7 complete lifecycles (entrance + hold + exit).\n *\n * Each archetype is a single named effect that owns its full lifecycle: how\n * text arrives, how it sits, how it leaves. The AI picks one name and that's\n * the whole package.\n *\n * Motion design principles applied per archetype:\n *  - Distinct easing per phase (entrance ≠ exit, archetype-tuned)\n *  - Anticipation / overshoot where the role calls for it\n *  - Hold-phase breathing — no dead frames, ever\n *  - Compound motion — multiple properties (scale, opacity, translate,\n *    letter-spacing) move together with subtle offsets\n *  - Exits are absolute-timed (in seconds), not a fixed % of scene, so\n *    motion stays readable on long scenes too\n *\n * Filter:blur is unavailable (template determinism), so depth illusions are\n * carried by scale magnitude + letter-spacing + opacity ramp shape.\n */\n\nimport {\n  interpolate,\n  spring,\n  Easing,\n  SPRING_SNAPPY,\n  SPRING_SMOOTH,\n  SPRING_BOUNCY,\n  SPRING_CRISP,\n} from \"../motion\";\n\n// ─── Archetype names ────────────────────────────────────────────\n\nexport const TEXT_ARCHETYPES = [\n  \"subtle\",\n  \"typewriter\",\n  \"wordStagger\",\n  \"slam\",\n  \"cinematic\",\n  \"heroWord\",\n] as const;\n\nexport type TextArchetype = (typeof TEXT_ARCHETYPES)[number];\nexport type TextCanvas = \"tight\" | \"open\";\nexport type TextRole = \"hook\" | \"body\" | \"closer\";\n\n// Maps legacy text-effect names (from saved configs pre-migration) onto the\n// new archetype set. Anything not in this map or TEXT_ARCHETYPES falls back\n// to `subtle`.\nconst LEGACY_TO_ARCHETYPE: Record<string, TextArchetype> = {\n  \"fade-in\": \"subtle\",\n  \"drift-in\": \"subtle\",\n  \"slide-up\": \"subtle\",\n  \"slide-down\": \"subtle\",\n  \"cut-in\": \"subtle\",\n  \"zoom-in\": \"slam\",\n  \"zoom-crisp\": \"slam\",\n  \"zoom-through\": \"slam\",\n  \"bounce-drop\": \"slam\",\n  typewriter: \"typewriter\",\n  \"word-stagger\": \"wordStagger\",\n  \"word-slam\": \"wordStagger\",\n  \"word-drop\": \"wordStagger\",\n  \"oversized-word-reveal\": \"heroWord\",\n};\n\n/**\n * Normalize an archetype-ish input to a valid TextArchetype. Handles:\n *  - undefined / null / empty → \"subtle\"\n *  - already-valid archetype name → unchanged\n *  - legacy effect name (e.g. \"fade-in\") → mapped equivalent\n *  - unknown string → \"subtle\" fallback\n */\nexport function normalizeArchetype(input: unknown): TextArchetype {\n  if (typeof input !== \"string\" || input.length === 0) return \"subtle\";\n  if ((TEXT_ARCHETYPES as readonly string[]).includes(input)) return input as TextArchetype;\n  return LEGACY_TO_ARCHETYPE[input] ?? \"subtle\";\n}\n\n// ─── Spec data ──────────────────────────────────────────────────\n\nexport interface ArchetypeSpec {\n  name: TextArchetype;\n  description: string;\n  /** Entrance phase duration in seconds. */\n  entrance: number;\n  /** Exit phase duration in seconds. */\n  exit: number;\n  /** Where this archetype is allowed to run. */\n  allowedCanvas: TextCanvas[];\n  /** Suggested role(s) in the video arc. */\n  roles: TextRole[];\n  /** Font-size multiplier envelope vs. the slot's natural body size. */\n  sizeRange: [number, number];\n  /**\n   * Minimum DURATION the effect itself needs (seconds), with SHORT text.\n   * For text-dependent archetypes (typewriter, wordStagger, heroWord) the\n   * actual minimum scales with content length — use `minDurationFor()` for\n   * an accurate per-text computation.\n   */\n  minDuration: number;\n  /**\n   * Maximum DURATION the effect itself uses (seconds). On a longer scene,\n   * the effect runs for at most this long, then disappears — leaving the\n   * remaining scene time for the AI to fill with another effect. Prevents\n   * \"ages to build up\" on long scenes.\n   *\n   * If text is so long that `minDurationFor()` exceeds this value, the\n   * minimum wins (text needs the time).\n   */\n  maxDuration: number;\n}\n\n// Timings tuned to research findings (Material 3 motion tokens, AE motion-design\n// conventions, \"exits faster than entrances by 60–75%\" rule). Values target\n// 2.5s scene by default; phase math scales for longer scenes.\nexport const ARCHETYPE_SPECS: Record<TextArchetype, ArchetypeSpec> = {\n  subtle: {\n    name: \"subtle\",\n    description: \"Quiet caption. Visual is the star, text supports.\",\n    entrance: 0.5,\n    exit: 0.7,\n    allowedCanvas: [\"tight\", \"open\"],\n    roles: [\"body\", \"closer\"],\n    sizeRange: [0.7, 1.0],\n    minDuration: 1.5,\n    maxDuration: 5,\n  },\n  typewriter: {\n    name: \"typewriter\",\n    description: \"Char-by-char reveal with blinking cursor. Slide-left cascade exit, top line first.\",\n    entrance: 1.2,\n    exit: 0.85,\n    allowedCanvas: [\"tight\", \"open\"],\n    roles: [\"body\", \"hook\"],\n    sizeRange: [0.8, 1.0],\n    minDuration: 1.8,\n    maxDuration: 6,\n  },\n  wordStagger: {\n    name: \"wordStagger\",\n    description: \"Sequential word build with active-word focus. Slide-left cascade exit, top line first.\",\n    entrance: 1.4,\n    exit: 0.7,\n    allowedCanvas: [\"tight\", \"open\"],\n    roles: [\"body\"],\n    sizeRange: [0.8, 1.0],\n    minDuration: 1.8,\n    maxDuration: 6,\n  },\n  slam: {\n    name: \"slam\",\n    description: \"In-place impact with violent squash-and-stretch, frame shake, and letter crunch.\",\n    entrance: 0.5,\n    exit: 0.5,\n    allowedCanvas: [\"tight\", \"open\"],\n    roles: [\"hook\", \"body\"],\n    sizeRange: [0.9, 1.2],\n    minDuration: 1.2,\n    maxDuration: 4,\n  },\n  cinematic: {\n    name: \"cinematic\",\n    description: \"Trailer FlyIn from depth → recedes back into background.\",\n    entrance: 0.7,\n    exit: 0.8,\n    allowedCanvas: [\"open\"],\n    roles: [\"hook\", \"body\", \"closer\"],\n    sizeRange: [0.9, 1.1],\n    minDuration: 1.8,\n    maxDuration: 6,\n  },\n  heroWord: {\n    name: \"heroWord\",\n    description: \"Single word fills frame. Simple fast exit so words get the most time.\",\n    entrance: 0.35,\n    exit: 0.25,\n    allowedCanvas: [\"open\"],\n    roles: [\"hook\", \"body\", \"closer\"],\n    sizeRange: [2.0, 3.0],\n    minDuration: 1.0,\n    maxDuration: 5,\n  },\n};\n\n// ─── Lifecycle constants ────────────────────────────────────────\n\nconst ENTRANCE_START = 0.02;\n// Phase caps prevent very short scenes from making in/out exceed the scene.\nconst ENTRANCE_PHASE_CAP = 0.4;\nconst EXIT_PHASE_CAP = 0.4;\n\n// ─── Render output shapes ───────────────────────────────────────\n\nexport interface BlockStyle {\n  opacity: number;\n  transform: string;\n  letterSpacing?: string;\n  /**\n   * `willChange` hint to keep the layer GPU-composited during animation.\n   * Used by cinematic where the scale-collapse needs to stay smooth on\n   * mobile Safari (which otherwise downgrades the layer mid-animation).\n   */\n  willChange?: string;\n}\n\nexport interface PerWordStyle {\n  text: string;\n  style: BlockStyle;\n}\n\nexport type ArchetypeRender =\n  | { kind: \"block\"; block: BlockStyle; text: string }\n  | {\n      kind: \"typewriter\";\n      visibleChars: number;\n      cursor: boolean;\n      opacity: number;\n      transform: string;\n      /**\n       * Per-character exit overrides — present during the exit phase to\n       * drive a slide-left cascade in reading order. One entry per char\n       * in `text`. Undefined during entrance/hold.\n       */\n      charExits?: { opacity: number; translateX: number }[];\n    }\n  | {\n      kind: \"words\";\n      words: PerWordStyle[];\n      blockOpacity: number;\n      blockTransform: string;\n    }\n  | {\n      kind: \"hero\";\n      word: string;\n      index: number;\n      total: number;\n      opacity: number;\n      transform: string;\n      letterSpacing?: string;\n    };\n\n// ─── Phase + timing helpers ─────────────────────────────────────\n\ninterface Phases {\n  entrancePhase: number;\n  exitPhase: number;\n  exitStart: number;\n  inExit: boolean;\n}\n\nfunction computePhases(\n  spec: ArchetypeSpec,\n  sceneDuration: number,\n  progress: number,\n  phaseScale = 1,\n): Phases {\n  // `phaseScale` is style.motion (calm 1.4 / normal 1 / punchy 0.7). It\n  // stretches or compresses how much of the scene the entrance and exit\n  // occupy; the caps below still bound them, so a calm video never spends\n  // the whole scene animating in.\n  const entrancePhase = Math.min(ENTRANCE_PHASE_CAP, (spec.entrance * phaseScale) / sceneDuration);\n  const exitPhase = Math.min(EXIT_PHASE_CAP, (spec.exit * phaseScale) / sceneDuration);\n  const exitStart = 1 - exitPhase;\n  return { entrancePhase, exitPhase, exitStart, inExit: progress >= exitStart };\n}\n\nfunction rawEntrance(progress: number, entrancePhase: number, startAt = ENTRANCE_START): number {\n  return interpolate(progress, [startAt, startAt + entrancePhase], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n}\n\nfunction rawExit(progress: number, exitStart: number): number {\n  if (progress < exitStart) return 0;\n  return interpolate(progress, [exitStart, 1], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n}\n\n// Normalized hold time 0→1 over the hold phase; 0 outside.\nfunction holdT(\n  progress: number,\n  entrancePhase: number,\n  exitStart: number,\n  startAt = ENTRANCE_START,\n): number {\n  const holdStart = startAt + entrancePhase;\n  if (progress < holdStart || progress > exitStart || exitStart <= holdStart) return 0;\n  return (progress - holdStart) / (exitStart - holdStart);\n}\n\n// Sine breathe — returns offset around 0; magnitude is peak-to-peak.\nfunction breathe(t: number, magnitude: number, cycles = 1): number {\n  return (Math.sin(t * Math.PI * 2 * cycles) * magnitude) / 2;\n}\n\n// ─── Renderer ───────────────────────────────────────────────────\n\nexport function renderArchetype(\n  archetypeRaw: TextArchetype | string | undefined,\n  progress: number,\n  scale: number,\n  text: string,\n  sceneDuration: number,\n  /** style.motion's phase multiplier. 1 (the default) is the authored timing. */\n  phaseScale = 1,\n): ArchetypeRender {\n  const archetype = normalizeArchetype(archetypeRaw);\n  const spec = ARCHETYPE_SPECS[archetype];\n  const phases = computePhases(spec, sceneDuration, progress, phaseScale);\n\n  switch (archetype) {\n    case \"subtle\":\n      return renderSubtle(progress, scale, text, phases);\n    case \"typewriter\":\n      return renderTypewriter(progress, scale, text, phases, sceneDuration);\n    case \"wordStagger\":\n      return renderWordStagger(progress, scale, text, phases, sceneDuration);\n    case \"slam\":\n      return renderSlam(progress, scale, text, phases);\n    case \"cinematic\":\n      return renderCinematic(progress, scale, text, phases);\n    case \"heroWord\":\n      return renderHeroWord(progress, text, phases);\n  }\n}\n\n// ─── subtle ─────────────────────────────────────────────────────\n// Pure fade in / fade out — true to the name. No translation, no breathe,\n// no transition jump. The supporting caption that doesn't compete with the\n// visual. Only thing animating is opacity.\nfunction renderSubtle(\n  progress: number,\n  _scale: number,\n  text: string,\n  phases: Phases,\n): ArchetypeRender {\n  const raw = rawEntrance(progress, phases.entrancePhase);\n  const ex = rawExit(progress, phases.exitStart);\n\n  const opacityIn = Easing.out(Easing.cubic)(raw);\n  const opacityOut = 1 - Easing.in(Easing.cubic)(ex);\n  const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n  return {\n    kind: \"block\",\n    block: {\n      opacity,\n      transform: \"none\",\n    },\n    text,\n  };\n}\n\n// ─── typewriter ─────────────────────────────────────────────────\n// Char reveal with deterministic cursor blink. Exit slides each char off\n// the left edge in reading order — same cascade pattern as wordStagger\n// (top line clears first by virtue of reading-order index ascending).\nfunction renderTypewriter(\n  progress: number,\n  scale: number,\n  text: string,\n  phases: Phases,\n  sceneDuration: number,\n): ArchetypeRender {\n  const raw = rawEntrance(progress, phases.entrancePhase);\n\n  // Constant typing pace — linear, no easing. Decelerating eases make the\n  // last chars feel sluggish, which kills the typewriter rhythm.\n  const visibleChars = Math.max(0, Math.floor(raw * text.length));\n\n  // Time-based 1.5Hz blink (OS terminal convention). Stays consistent\n  // regardless of scene length.\n  const seconds = progress * sceneDuration;\n  const cursorOn = Math.sin(seconds * Math.PI * 3) > 0;\n  const stillTyping = visibleChars < text.length;\n\n  // Entry opacity ramps fast (we want chars solid as they appear).\n  const opacityIn = Math.min(1, raw * 8);\n\n  // Cursor visible during typing and the tail of the hold; gone on exit.\n  const cursor = cursorOn && (stillTyping || (!phases.inExit && opacityIn > 0.5));\n\n  // Per-character exit cascade — slides each char left, staggered by\n  // reading-order index. Compresses if the exit window can't fit the full\n  // sequence (very long text on a short scene).\n  let charExits: { opacity: number; translateX: number }[] | undefined;\n  if (phases.inExit && text.length > 0) {\n    const STAGGER_OUT = 0.025;\n    const PER_CHAR_OUT = 0.30;\n    const SLIDE_LEFT_DISTANCE = 350 * scale;\n    const actualExitSec = phases.exitPhase * sceneDuration;\n    const idealExitSec = (text.length - 1) * STAGGER_OUT + PER_CHAR_OUT;\n    const outScale = Math.min(1, actualExitSec / idealExitSec);\n    const stagOutNorm = (STAGGER_OUT * outScale) / sceneDuration;\n    const perCharOutNorm = (PER_CHAR_OUT * outScale) / sceneDuration;\n\n    charExits = Array.from({ length: text.length }, (_, i) => {\n      const charExitStart = phases.exitStart + i * stagOutNorm;\n      const charExitEnd = charExitStart + perCharOutNorm;\n      const charExitRaw = interpolate(progress, [charExitStart, charExitEnd], [0, 1], {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n      });\n      const charExitEased = Easing.in(Easing.cubic)(charExitRaw);\n      return {\n        opacity: 1 - charExitEased,\n        translateX: -charExitEased * SLIDE_LEFT_DISTANCE,\n      };\n    });\n  }\n\n  return {\n    kind: \"typewriter\",\n    visibleChars,\n    cursor,\n    opacity: opacityIn,\n    transform: \"none\",\n    charExits,\n  };\n}\n\n// ─── wordStagger ────────────────────────────────────────────────\n// Dynamic entrance: total time depends on word count (more words = more\n// time to read each one), bounded so it doesn't run forever on long scenes\n// or get crammed on short ones. As the NEXT word arrives, prior words dim\n// to ~45% opacity — the kinetic-typography \"active word\" focus.\n// Exit slides each word off the left edge in reading order. Words wrap via\n// CSS so we never know line breaks at render time, but indices ARE in\n// reading order — staggering by index gives a top-line-first cascade for\n// free: words on line 1 occupy the lowest indices, so they exit before\n// any word on line 2.\nfunction renderWordStagger(\n  progress: number,\n  scale: number,\n  text: string,\n  phases: Phases,\n  sceneDuration: number,\n): ArchetypeRender {\n  const words = text.split(/\\s+/).filter(Boolean);\n  const wordCount = Math.max(words.length, 1);\n\n  // Per-word timing constants (seconds). Min/max stagger keeps the cadence\n  // in the Hollywood-typography sweet spot (~280–450ms per word land)\n  // regardless of scene length.\n  const STAGGER_MIN = 0.28;\n  const STAGGER_MAX = 0.45;\n  const PER_WORD_ANIM = 0.5;\n\n  // Floor entrance using the minimum stagger.\n  const minEntrance = (wordCount - 1) * STAGGER_MIN + PER_WORD_ANIM;\n\n  // Target: ~55% of the scene for entrance, so longer scenes get a longer\n  // build-up. Without this the entrance feels rushed against the long hold.\n  const TARGET_ENTRANCE_FRACTION = 0.55;\n  const targetEntrance = sceneDuration * TARGET_ENTRANCE_FRACTION;\n\n  // Ceiling: leave room for exit + meaningful hold so the FINAL word sits\n  // as the focal point.\n  const exitSec = ARCHETYPE_SPECS.wordStagger.exit;\n  const minHold = 0.5;\n  const maxEntrance = Math.max(0.6, sceneDuration - exitSec - minHold);\n\n  // Pick proportional target, bounded by [minEntrance, maxEntrance].\n  let entranceSec = Math.min(maxEntrance, Math.max(minEntrance, targetEntrance));\n\n  // Cap the implied per-word stagger at STAGGER_MAX so words don't sit too\n  // long between lands on very long scenes.\n  if (wordCount > 1) {\n    const impliedStagger = (entranceSec - PER_WORD_ANIM) / (wordCount - 1);\n    if (impliedStagger > STAGGER_MAX) {\n      entranceSec = (wordCount - 1) * STAGGER_MAX + PER_WORD_ANIM;\n    }\n  }\n\n  // Recompute phases for this archetype (overrides default phases).\n  const entrancePhase = entranceSec / sceneDuration;\n  const exitPhase = Math.min(0.4, exitSec / sceneDuration);\n  const exitStart = 1 - exitPhase;\n\n  // Word entrance window: each word's own animation gets PER_WORD_ANIM\n  // seconds, the rest of entrancePhase is the staggered offset distribution.\n  const staggerIn =\n    wordCount > 1 ? (entranceSec - PER_WORD_ANIM) / (wordCount - 1) / sceneDuration : 0;\n  const wordEntranceDur = PER_WORD_ANIM / sceneDuration;\n\n  // Per-word exit cadence. STAGGER_OUT is short so words within a single\n  // wrapped line clear nearly together — the perceived gap between lines\n  // comes from the words *between* line breaks, not the per-word delay.\n  // Compress proportionally if the scene's exit window can't fit the full\n  // cascade (e.g. very short scene with many words).\n  const STAGGER_OUT = 0.06;\n  const PER_WORD_OUT = 0.32;\n  const SLIDE_LEFT_DISTANCE = 350 * scale;\n  const actualExitSec = exitPhase * sceneDuration;\n  const idealExitSec = (wordCount - 1) * STAGGER_OUT + PER_WORD_OUT;\n  const outScale = Math.min(1, actualExitSec / idealExitSec);\n  const stagOutNorm = (STAGGER_OUT * outScale) / sceneDuration;\n  const perWordOutNorm = (PER_WORD_OUT * outScale) / sceneDuration;\n\n  const ht = holdT(progress, entrancePhase, exitStart);\n\n  // Active-word focus: previous words dim to ACTIVE_DIM as the next word\n  // arrives — reads as a \"grey\" handoff via opacity reduction, directs\n  // attention to the latest word without making prior ones unreadable.\n  // Bumped from 0.70 → 0.85: 70% became muddy on busy photo backgrounds\n  // (especially mid-tone areas where the dimmed white blends into the bg).\n  // 85% keeps the active-word hierarchy while staying legible on media.\n  const ACTIVE_DIM = 0.85;\n\n  const perWord = words.map((w, i) => {\n    // Entrance window for THIS word\n    const wordStart = ENTRANCE_START + i * staggerIn;\n    const wordEnd = wordStart + wordEntranceDur;\n    const wordRaw = interpolate(progress, [wordStart, wordEnd], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    });\n    const sp = spring(wordRaw, SPRING_CRISP);\n    const yIn = interpolate(sp, [0, 1], [55 * scale, 0]);\n    const rotIn = interpolate(sp, [0, 1], [-3, 0]);\n    const opacityIn = Math.min(1, wordRaw * 7);\n\n    // Subtle hold breathe (won't even register but keeps each word \"alive\")\n    const yBreathe = breathe(ht, 1 * scale, 1) * Math.cos((i / wordCount) * Math.PI);\n\n    // Dim instantly when the NEXT word starts entering — tiny 50ms ramp so\n    // the transition isn't a hard cut, but visually reads as \"snaps to grey\n    // the moment the new word appears.\"\n    let dimProgress = 0;\n    if (i < words.length - 1) {\n      const nextStart = ENTRANCE_START + (i + 1) * staggerIn;\n      const SNAP_RAMP = 0.02; // ~50ms at 2.5s scene\n      dimProgress = interpolate(progress, [nextStart, nextStart + SNAP_RAMP], [0, 1], {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n      });\n    }\n    const focusOpacity = 1 - dimProgress * (1 - ACTIVE_DIM);\n\n    // Per-word exit: each word starts sliding left at exitStart + i*stagOut,\n    // accelerates with ease-in-cubic so it feels yanked off-frame.\n    const wordExitStart = exitStart + i * stagOutNorm;\n    const wordExitEnd = wordExitStart + perWordOutNorm;\n    const wordExitRaw = interpolate(progress, [wordExitStart, wordExitEnd], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    });\n    const wordExitEased = Easing.in(Easing.cubic)(wordExitRaw);\n    const exitX = -wordExitEased * SLIDE_LEFT_DISTANCE;\n    const isExiting = wordExitRaw > 0;\n\n    // Final composition\n    const opacity = opacityIn * focusOpacity * (1 - wordExitEased);\n    const y = yIn + (isExiting ? 0 : yBreathe);\n    const rot = isExiting ? 0 : rotIn;\n\n    return {\n      text: w,\n      style: {\n        opacity,\n        transform: `translate(${exitX}px, ${y}px) rotate(${rot}deg)`,\n      },\n    };\n  });\n\n  return {\n    kind: \"words\",\n    words: perWord,\n    blockOpacity: 1,\n    blockTransform: \"none\",\n  };\n}\n\n// ─── slam ──────────────────────────────────────────────────\n// In-place impact, NOT a depth/zoom motion (cinematic already owns that\n// lane). Word appears at readable size and HITS — drama lives in the\n// squash-and-stretch arc:\n//   approach pose (vertically stretched, scaleY > 1, scaleX < 1)\n//   → impact (violent horizontal pancake, scaleX 1.22 / scaleY 0.74,\n//     frame shake + letter crunch firing simultaneously)\n//   → rebound (counter-stretched scaleY 1.12 / scaleX 0.94)\n//   → damped settle to 1.0\n// No vertical translation — the squash arc + camera shake + tilt kick\n// carry the impact without any top-to-bottom drop.\nfunction renderSlam(\n  progress: number,\n  scale: number,\n  text: string,\n  phases: Phases,\n): ArchetypeRender {\n  const raw = rawEntrance(progress, phases.entrancePhase);\n  const ex = rawExit(progress, phases.exitStart);\n  const ht = holdT(progress, phases.entrancePhase, phases.exitStart);\n\n  // Squash-and-stretch arc. Keyframes (raw): 0 = approach pose, 0.30 =\n  // impact, 0.45 = rebound, 0.60 = secondary oscillation, 1.0 = rest.\n  // scaleX/scaleY are anti-correlated at every keyframe — that's what\n  // sells \"weight\" vs. cinematic's uniform scale collapse.\n  const sxIn = interpolate(raw, [0, 0.30, 0.45, 0.60, 1.0], [0.92, 1.22, 0.94, 1.02, 1.0]);\n  const syIn = interpolate(raw, [0, 0.30, 0.45, 0.60, 1.0], [1.18, 0.74, 1.12, 0.99, 1.0]);\n  // Tilt-and-correct: small entry tilt, levels at impact, micro overshoot.\n  const rotIn = interpolate(raw, [0, 0.30, 0.45, 1.0], [-1.5, 0, 0.8, 0]);\n  // Transient letter crunch AT impact, releases open by rebound. Distinct\n  // from cinematic's wide→tight gradual tracking collapse.\n  const lsInEm = interpolate(raw, [0, 0.20, 0.30, 0.45, 1.0], [0, -0.04, -0.06, 0, 0]);\n\n  // Camera shake — full intensity at impact, fast decay over the next 20%\n  // of entrance. Sin/cos at offset frequencies feel noisy while staying\n  // deterministic for export.\n  const shakeWindow = Math.max(0, 1 - Math.max(0, raw - 0.30) / 0.20);\n  const shakeX = Math.sin(raw * 70) * 14 * scale * shakeWindow;\n  const shakeY = Math.cos(raw * 80) * 10 * scale * shakeWindow;\n\n  // Punchy opacity ramp so text is solid through the size change.\n  const opacityIn = Math.min(1, raw * 8);\n\n  // Hold: gentle 0.8% uniform scale breathe.\n  const sBreathe = 1 + breathe(ht, 0.008, 1);\n\n  // Exit: ease-in-expo shrink + late-bias fade — decisive leave.\n  const easedExit = Easing.in(Easing.exp)(ex);\n  const sOut = interpolate(easedExit, [0, 1], [1, 0.85]);\n  const opacityOut = interpolate(ex, [0, 0.55, 1], [1, 0.8, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  const sx = phases.inExit ? sOut : sxIn * sBreathe;\n  const sy = phases.inExit ? sOut : syIn * sBreathe;\n  const tx = phases.inExit ? 0 : shakeX;\n  const ty = phases.inExit ? 0 : shakeY;\n  const rot = phases.inExit ? 0 : rotIn;\n  const ls = phases.inExit ? 0 : lsInEm;\n  const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n  return {\n    kind: \"block\",\n    block: {\n      opacity,\n      transform: `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(${sx}, ${sy})`,\n      letterSpacing: `${ls}em`,\n    },\n    text,\n  };\n}\n\n// ─── cinematic ──────────────────────────────────────────────────\n// Hollywood Movie-Trailer FlyIn (vuild-style without filter:blur).\n// Entry: ease-out-circ — weighty deceleration, classic cinema arrival.\n//        Scale 3.0 → 1.0, tracking +40 → 0 (letters wide-spaced from depth\n//        collapse to readable). Slow opacity ramp finishes by progress 0.4\n//        so text becomes \"real\" as it nears the camera plane.\n// Hold:  ±0.6px tracking wobble + 1% scale breathe.\n// Exit:  ease-in-expo — text holds, then RECEDES into the background.\n//        Scale 1.0 → 0.4 (zooms out / shrinks away), tracking spreads back\n//        out (0 → +24) like objects dissipating into the distance, opacity\n//        holds high then drops late so the recede motion stays visible.\nfunction renderCinematic(\n  progress: number,\n  scale: number,\n  text: string,\n  phases: Phases,\n): ArchetypeRender {\n  const raw = rawEntrance(progress, phases.entrancePhase);\n  const ex = rawExit(progress, phases.exitStart);\n\n  // Entry: ease-out-circle — slow start (text far away), strong deceleration into place\n  const easedIn = Easing.out(Easing.circle)(raw);\n  const sIn = interpolate(easedIn, [0, 1], [3.0, 1]);\n  const yIn = interpolate(easedIn, [0, 1], [18 * scale, 0]);\n  // Slow opacity ramp — text becomes \"real\" only as it approaches the camera\n  const opacityIn = interpolate(raw, [0, 0.4], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  // No hold-phase breathing — subpixel rendering on tiny scale changes\n  // reads as jitter on stable text. Cinematic holds dead-still during the\n  // visible phase.\n\n  // Exit: ease-in-expo — held, then RECEDES into the background\n  const easedExit = Easing.in(Easing.exp)(ex);\n  const sOut = interpolate(easedExit, [0, 1], [1, 0.4]);\n  // Subtle drift back/up to reinforce \"going away\"\n  const yOut = interpolate(easedExit, [0, 1], [0, -10 * scale]);\n  // Opacity holds 100% for first 50% of exit, then drops to 0\n  const opacityOut = interpolate(ex, [0, 0.5, 1], [1, 0.85, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  const s = phases.inExit ? sOut : sIn;\n  const y = phases.inExit ? yOut : yIn;\n  const opacity = opacityIn * (phases.inExit ? opacityOut : 1);\n\n  // No CSS filter. Earlier iterations layered a ~2.5px gaussian blur at the\n  // lifecycle edges for a Hollywood motion-blur feel, but iOS Safari is\n  // notoriously bad at compositing `filter` with an animating `transform` —\n  // the zoom would stall mid-motion on mobile while desktop and the\n  // server-side Puppeteer export rendered fine. The cinematic effect's\n  // identity is the scale collapse + slow opacity ramp + ease-out-circ\n  // timing, all of which work without the filter. willChange hints both\n  // properties are animating so the browser keeps the layer GPU-composited.\n  return {\n    kind: \"block\",\n    block: {\n      opacity,\n      transform: `translateY(${y}px) scale(${s})`,\n      willChange: \"transform, opacity\",\n    },\n    text,\n  };\n}\n\n// ─── heroWord ───────────────────────────────────────────────────\n// One word at a time, each lands with anticipation overshoot. Mid-sequence\n// words hand off with brief brightness flash before fade. Final word's exit\n// participates in the global exit window — scales up + fades for the finale.\nfunction renderHeroWord(\n  progress: number,\n  text: string,\n  phases: Phases,\n): ArchetypeRender {\n  const words = text.split(/\\s+/).filter(Boolean);\n  if (words.length === 0) {\n    return { kind: \"hero\", word: \"\", index: 0, total: 0, opacity: 0, transform: \"scale(1)\" };\n  }\n\n  // Words occupy the time between entrance start and exit start.\n  const sequenceStart = ENTRANCE_START + phases.entrancePhase * 0.2;\n  const sequenceEnd = phases.exitStart;\n  const sequenceDur = Math.max(0.01, sequenceEnd - sequenceStart);\n  const slot = sequenceDur / words.length;\n\n  // Determine active word.\n  const localProgress = Math.max(0, Math.min(0.999, (progress - sequenceStart) / sequenceDur));\n  const idx = Math.min(words.length - 1, Math.floor(localProgress / (1 / words.length)));\n  const local = (localProgress * words.length) - idx;\n  const isLast = idx === words.length - 1;\n\n  // Per-word entrance: anticipation + spring overshoot\n  const ENTER_FRAC = 0.28;\n  const HOLD_FRAC = 0.55;\n  const HANDOFF_FRAC = 0.85;\n\n  let scale: number;\n  let opacity: number;\n\n  if (local < ENTER_FRAC) {\n    const entRaw = local / ENTER_FRAC;\n    const sp = spring(entRaw, SPRING_CRISP);\n    // Overshoot: 0.5 → 1.06 → 1.0\n    scale = sp < 0.7 ? interpolate(sp, [0, 0.7], [0.5, 1.06]) : interpolate(sp, [0.7, 1], [1.06, 1.0]);\n    opacity = Math.min(1, entRaw * 6);\n  } else if (local < HOLD_FRAC) {\n    // Hold: 0.5% breathe per word\n    const ht = (local - ENTER_FRAC) / (HOLD_FRAC - ENTER_FRAC);\n    scale = 1 + breathe(ht, 0.01, 1);\n    opacity = 1;\n  } else if (local < HANDOFF_FRAC && !isLast) {\n    // Brief brightness pulse + fade for handoff to next word\n    const handoffRaw = (local - HOLD_FRAC) / (HANDOFF_FRAC - HOLD_FRAC);\n    scale = interpolate(handoffRaw, [0, 1], [1, 1.04]);\n    // Pulse: 1 → 1.15 (brightness via opacity > 1 is faked by holding 1) → fade\n    opacity = handoffRaw < 0.3 ? 1 : interpolate(handoffRaw, [0.3, 1], [1, 0]);\n  } else if (!isLast) {\n    // Trailing nothing-frame for non-last words\n    scale = 1.04;\n    opacity = 0;\n  } else {\n    // Last word: hold until global exit kicks in\n    scale = 1;\n    opacity = 1;\n  }\n\n  // Last word participates in the global exit — simple fast fade with a\n  // tiny scale lift. No anticipation, no tracking compression: the goal is\n  // to give the WORDS the most time, not the exit animation.\n  if (isLast && phases.inExit) {\n    const ex = rawExit(progress, phases.exitStart);\n    const easedExit = Easing.in(Easing.cubic)(ex);\n    scale = interpolate(easedExit, [0, 1], [1, 1.08]);\n    opacity = 1 - easedExit;\n  }\n\n  return {\n    kind: \"hero\",\n    word: words[idx],\n    index: idx,\n    total: words.length,\n    opacity,\n    transform: `scale(${scale})`,\n  };\n}\n\n// ─── Effect duration helpers ────────────────────────────────────\n//\n// Per-archetype min/max DURATION (seconds) — the time the effect itself\n// uses, not the scene length. On long scenes the effect runs at most for\n// `maxDuration` and then disappears, signaling the AI to fill the rest\n// with another effect.\n//\n//  - minDurationFor(archetype, text): minimum effect duration accounting\n//    for text length (text-dependent archetypes scale up).\n//  - effectiveDuration(archetype, text, sceneDuration): how long the\n//    effect actually runs in the given scene — bounded by [minFor, max].\n//  - canFit(items, sceneSeconds): whether a sequence of (archetype, text)\n//    plays fits within a scene.\n\nconst TYPEWRITER_SEC_PER_CHAR = 0.08; // matches the linear char reveal pace\nconst HEROWORD_PER_WORD_SLOT = 0.6; // each word in heroWord needs ~0.6s on screen\nconst WORDSTAGGER_MIN_HOLD = 0.5; // matches renderWordStagger's minHold\n\n/**\n * Compute the minimum DURATION (seconds) the effect needs for this text.\n * Text-dependent archetypes scale up; others return their static\n * spec.minDuration.\n */\nexport function minDurationFor(archetype: TextArchetype, text: string): number {\n  const spec = ARCHETYPE_SPECS[archetype];\n  const wordCount = Math.max(1, text.split(/\\s+/).filter(Boolean).length);\n\n  switch (archetype) {\n    case \"typewriter\": {\n      const revealSec = Math.max(0.6, text.length * TYPEWRITER_SEC_PER_CHAR);\n      return revealSec + 0.3 + spec.exit;\n    }\n    case \"wordStagger\": {\n      return (wordCount - 1) * 0.28 + 0.5 + WORDSTAGGER_MIN_HOLD + spec.exit;\n    }\n    case \"heroWord\": {\n      return wordCount * HEROWORD_PER_WORD_SLOT + spec.exit;\n    }\n    default:\n      return spec.minDuration;\n  }\n}\n\n/**\n * How long the effect actually runs inside a given scene. The effect uses\n * at most spec.maxDuration; if the scene is shorter than that, it uses\n * the scene length; if the text needs more (long typewriter, many words),\n * the minimum wins.\n *\n * On long scenes, sceneDuration > effectiveDuration(...) means the AI\n * has remaining time to fill with another effect.\n */\nexport function effectiveDuration(\n  archetype: TextArchetype,\n  text: string,\n  sceneDuration: number,\n): number {\n  const spec = ARCHETYPE_SPECS[archetype];\n  const minFor = minDurationFor(archetype, text);\n  const cappedMax = Math.max(spec.maxDuration, minFor);\n  return Math.min(sceneDuration, cappedMax);\n}\n\nexport interface ArchetypePlay {\n  archetype: TextArchetype;\n  text: string;\n}\n\n/**\n * Whether a sequence of archetype plays fits in the given scene duration.\n * Used by the AI/tool layer to validate before committing to a plan.\n *\n * @example\n *   canFit(\n *     [\n *       { archetype: \"subtle\", text: \"Hi\" },\n *       { archetype: \"heroWord\", text: \"Launch\" },\n *     ],\n *     4.0,\n *   ); // → true if both fit; false otherwise\n */\nexport function canFit(items: ArchetypePlay[], sceneSeconds: number): boolean {\n  const total = items.reduce(\n    (sum, item) => sum + minDurationFor(item.archetype, item.text),\n    0,\n  );\n  return total <= sceneSeconds;\n}\n\n/**\n * Total minimum seconds for a sequence — useful when canFit returns false\n * and you need to know how much time is missing.\n */\nexport function totalMinDurationFor(items: ArchetypePlay[]): number {\n  return items.reduce(\n    (sum, item) => sum + minDurationFor(item.archetype, item.text),\n    0,\n  );\n}\n"
    },
    {
      "path": "src/lib/scene-templates/text-utils.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/text-utils.ts",
      "content": "/**\n * Shared text utilities. Kept intentionally small.\n */\n\n/**\n * Strip the legacy `|effect` suffix from a text value. Older saved configs\n * encoded per-entry effects as `\"Hello|zoom-in\"`; the field still arrives in\n * social-* templates from the saved-config path. For values without `|` this\n * is a no-op.\n */\nexport function stripPipe(text: string): string {\n  const idx = text.indexOf(\"|\");\n  return idx === -1 ? text : text.slice(0, idx);\n}\n\n/**\n * Responsive font size for caption-style text — used by chart-counter to\n * size its sub-label below the big number. Mirrors the prior helper from\n * the deleted text-entrance module so behavior is preserved.\n *\n * @param text   the text to size\n * @param width  render width (1080 portrait, smaller in preview)\n * @param role   \"headline\" or \"subtitle\" — biases the curve\n * @param height optional height for proper short-edge scaling\n */\nexport function getResponsiveFontSize(\n  text: string,\n  width: number,\n  role: \"headline\" | \"subtitle\" = \"headline\",\n  height?: number,\n): number {\n  const s = height ? Math.min(width, height) / 1080 : width / 1080;\n  const words = text.split(/\\s+/).filter(Boolean).length;\n  const chars = text.length;\n\n  if (role === \"subtitle\") {\n    if (chars > 60) return 24 * s;\n    if (chars > 30) return 28 * s;\n    return 32 * s;\n  }\n\n  if (words <= 2 && chars <= 12) return Math.max(36 * s, 96 * s);\n  if (words <= 3 && chars <= 20) return Math.max(36 * s, 80 * s);\n  if (words <= 5 && chars <= 35) return Math.max(36 * s, 64 * s);\n  if (words <= 8 && chars <= 60) return Math.max(36 * s, 48 * s);\n  return 36 * s;\n}\n\n/**\n * Shrink `baseFontSize` so that `text` rendered at that size fits within\n * `maxWidth` pixels on one line. Returns the original size if the text\n * already fits; otherwise scales proportionally down to `minScale × base`.\n *\n * Approach: estimate text width as `chars × fontSize × charWidthRatio`.\n * 0.55 is a safe over-estimate for Helvetica/Arial-family sans-serif at\n * normal weights — picks slightly smaller than strictly necessary so URLs\n * with wide caps (W, M) still fit. Override `charWidthRatio` for narrower\n * fonts (e.g. condensed) or wider ones (display).\n *\n * Used by templates with fixed-width containers that can overflow on long\n * AI-generated copy (URLs in cta-*, prompt text in promptInput, caller\n * name in incomingCall). Cheap, deterministic, no canvas measurement —\n * runs identically in renderToStaticMarkup and in the browser.\n */\nexport function fitTextSize(\n  text: string,\n  baseFontSize: number,\n  maxWidth: number,\n  opts?: { minScale?: number; charWidthRatio?: number },\n): number {\n  if (!text) return baseFontSize;\n  const minScale = opts?.minScale ?? 0.5;\n  const charWidthRatio = opts?.charWidthRatio ?? 0.55;\n  const estWidth = text.length * baseFontSize * charWidthRatio;\n  if (estWidth <= maxWidth) return baseFontSize;\n  const scaled = (maxWidth / estWidth) * baseFontSize;\n  return Math.max(scaled, baseFontSize * minScale);\n}\n\n/**\n * Compact a number for social-style counters (likes, replies, views).\n * Match TweetCard.formatCount — kept in sync. See that function for the\n * boundary-rounding rationale (never let the leading number reach 4\n * digits; 999,999 rolls up to \"1M\", not \"1000K\").\n */\nexport function formatCompact(n: number): string {\n  if (!Number.isFinite(n)) return String(n);\n  const abs = Math.abs(n);\n  const sign = n < 0 ? \"-\" : \"\";\n  if (abs < 1_000) return `${sign}${Math.round(abs)}`;\n  if (abs < 10_000) {\n    const rounded = Number((abs / 1_000).toFixed(1));\n    if (rounded < 10) return `${sign}${rounded.toString().replace(/\\.0$/, \"\")}K`;\n  }\n  if (abs < 1_000_000) {\n    const k = Math.round(abs / 1_000);\n    if (k < 1_000) return `${sign}${k}K`;\n  }\n  if (abs < 10_000_000) {\n    const rounded = Number((abs / 1_000_000).toFixed(1));\n    if (rounded < 10) return `${sign}${rounded.toString().replace(/\\.0$/, \"\")}M`;\n  }\n  if (abs < 1_000_000_000) {\n    const m = Math.round(abs / 1_000_000);\n    if (m < 1_000) return `${sign}${m}M`;\n  }\n  return `${sign}${(abs / 1_000_000_000).toFixed(1).replace(/\\.0$/, \"\")}B`;\n}\n"
    }
  ],
  "meta": {
    "vanillasky": {
      "layer": "lib",
      "tier": "free",
      "domain": "typography",
      "level": "composed",
      "audiences": [
        "custom-scenes",
        "react-developers"
      ],
      "dependencies": [
        "motion"
      ],
      "useWhen": "Use it for any text-led scene where copy must fit a known box, animate as a coherent lifecycle, or be checked against a fixed scene duration.",
      "avoidWhen": "Do not shrink copy indefinitely or choose an archetype without checking its allowed canvas and minimum duration."
    }
  }
}
