← back to Dw Marketing Reels

videos/dw-new-arrivals-reveal/.hyperframes/caption-skin.html

223 lines

<!--
  Cartesian — caption skin (preset-local source).

  This is the preset's own lower-third karaoke caption look. product-launch-video's
  Step 2 copies it into the project as caption-skin.html; captions.mjs then fills the
  three reserved holes, wraps the fragment in a <template>, and writes
  compositions/captions.html:
    · the empty GROUPS array            → the engine's scene-aware word groups
    · the 0 duration + 0 width/height   → real total + canvas size
    · the empty data-brand-tokens style → :root tokens derived from the project's frame.md

  Token contract — reference ONLY this fixed vocab (captions.mjs injects it from
  frame.md, so the Step-2 brand overlay flows through; each var has a cartesian literal
  fallback for standalone preview):
    --cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
    --font-body · --cap-band-top · --cap-band-height
  Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
  root data-composition-id="captions", timeline at window.__timelines["captions"].
  State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
  never tl.call() callbacks (seek does not fire them → wrong state on render).

  Visual: a museum-catalog lower-third — a warm-stone card on a single 1px taupe
  hairline border (cartesian's universal structural device), SHARP corners, ZERO
  shadow, ZERO fill beyond the stone canvas. Playfair Display 400, sentence case, ink
  glyphs. Upcoming words sit in faint ink; the current word is marked QUIETLY — a faint
  stone wash with a 1px taupe hairline underline (restraint, not a loud block); spoken
  words settle to solid ink, the wash and underline cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>

<!-- captions.mjs injects the project's :root tokens here -->
<style data-brand-tokens></style>

<style>
  #captions-root {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
  .caption-layer {
    position: absolute;
    inset: 0;
    z-index: 20;
    pointer-events: none;
  }
  /* the keep-out band, from captionBand(H) — frame content stays above it */
  .caption-stage {
    position: absolute;
    left: 0;
    right: 0;
    top: var(--cap-band-top, 900px);
    height: var(--cap-band-height, 180px);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .caption-group {
    /* every group is absolutely stacked over the SAME band — exactly one shown at a
       time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
  }

  /* THE stone card — single 1px taupe hairline border (cartesian's universal
     structural device), SHARP corners, ZERO shadow, ZERO fill beyond the stone canvas */
  .caption-pill {
    max-width: 78%;
    padding: 20px 44px 24px;
    background: var(--cap-canvas, #ede8e0);
    border: 1px solid var(--cap-accent-2, #b8b0a4);
    border-radius: 0;
  }
  .caption-line {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.1em 0.34em;
    font-family: var(--font-display, "Playfair Display"), Georgia, serif;
    font-weight: 400;
    font-size: clamp(40px, 4.4vw, 58px);
    line-height: 1.16;
    letter-spacing: -0.01em;
  }

  /* upcoming — faint ink, no mark */
  .caption-word {
    display: inline-block;
    padding: 0 0.06em;
    border-bottom: 1px solid transparent;
    color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 42%, var(--cap-canvas, #ede8e0));
  }
  /* current word — a QUIET marker: a faint warm-stone wash behind solid ink, plus a
     1px taupe hairline underline (restraint, not a loud block; SHARP, NO shadow) */
  .caption-word.is-active {
    color: var(--cap-ink, #1a1a1a);
    background: color-mix(in srgb, var(--cap-accent, #8a8178) 18%, var(--cap-canvas, #ede8e0));
    border-bottom: 1px solid var(--cap-accent, #8a8178);
  }
  /* already spoken — solid ink, wash + underline cleared */
  .caption-word.is-spoken {
    color: var(--cap-ink, #1a1a1a);
    background: transparent;
    border-bottom: 1px solid transparent;
  }

  /* optional per-word accents (dormant until a word-classer tags them) */
  /* brand / key term — Playfair Display italic, cartesian's only display inflection */
  .caption-word.cap-brand {
    font-style: italic;
  }
  /* numerals / data — the body sans voice, slightly recessed */
  .caption-word.cap-num {
    font-family: var(--font-body, "Inter"), ui-sans-serif, sans-serif;
    color: color-mix(in srgb, var(--cap-ink, #1a1a1a) 72%, var(--cap-canvas, #ede8e0));
  }
  /* emphasis — a 1px taupe hairline underline (no second color; stone-and-ink only) */
  .caption-word.cap-cta {
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-decoration-color: var(--cap-accent, #8a8178);
    text-underline-offset: 0.12em;
  }

  @media (max-aspect-ratio: 9/16) {
    .caption-pill {
      max-width: 92%;
    }
    .caption-line {
      font-size: clamp(34px, 5.4vw, 48px);
    }
  }
</style>

<div
  id="captions-root"
  data-composition-id="captions"
  data-timeline-locked
  data-start="0"
  data-duration="0"
  data-fps="30"
  data-width="0"
  data-height="0"
>
  <div class="caption-layer" aria-hidden="true">
    <div id="caption-stage" class="caption-stage"></div>
  </div>
</div>

<script>
  // captions.mjs injects the engine's scene-aware groups + real duration here.
  var GROUPS = [];
  var DURATION = 0;

  (function () {
    var stage = document.getElementById("caption-stage");

    // build DOM: one .caption-group per group, .caption-word per word
    GROUPS.forEach(function (group, g) {
      var groupEl = document.createElement("div");
      groupEl.className = "caption-group";
      groupEl.id = "caption-group-" + g;
      var pill = document.createElement("div");
      pill.className = "caption-pill";
      var line = document.createElement("div");
      line.className = "caption-line";
      (group.words || []).forEach(function (w, i) {
        var span = document.createElement("span");
        span.className = "caption-word";
        span.id = "caption-word-" + g + "-" + i;
        span.textContent = String(w.text);
        line.appendChild(span);
      });
      pill.appendChild(line);
      groupEl.appendChild(pill);
      stage.appendChild(groupEl);
    });

    window.__timelines = window.__timelines || {};
    var tl = gsap.timeline({ paused: true });

    GROUPS.forEach(function (group, g) {
      var groupEl = document.getElementById("caption-group-" + g);
      var words = group.words || [];
      var next = GROUPS[g + 1];
      var isLast = g === GROUPS.length - 1;
      var start = Math.max(0, Number(group.start));
      var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
      if (end <= start) end = start + 0.01;

      // group on/off — exactly one group visible at a time
      tl.set(groupEl, { opacity: 1 }, start);
      tl.set(groupEl, { opacity: 0 }, end);

      // 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
      // engine's frame-by-frame seek; .call callbacks would NOT fire).
      words.forEach(function (w, i) {
        var el = document.getElementById("caption-word-" + g + "-" + i);
        var at = Math.max(start, Number(w.start));
        tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
        tl.set(el, { className: "caption-word is-active" }, at); // current
        tl.fromTo(el, { scale: 0.985 }, { scale: 1, duration: 0.2, ease: "power1.out" }, at);
        if (i + 1 < words.length) {
          var nextAt = Math.max(start, Number(words[i + 1].start));
          tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
        }
      });
      if (words.length) {
        var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
        var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
        tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
      }
    });

    // full-span anchor so the sub-comp timeline spans the whole video
    tl.to({}, { duration: DURATION }, 0);
    window.__timelines["captions"] = tl;
  })();
</script>