Lower thirds
ConfirmedLower thirds at rest and built, in 16:9 and 9:16.
Primary render. Select it to open full size.
Every render from the champion round
How to use it, from the piece README
Name and title, location, and client credit lower thirds, burned-in social captions with a word highlight, and broadcast-safe subtitles, at 16:9 and 9:16. They move by the motion grammar in ../../../motion-sting/v/best/, the same beat, the same four curves, and the same order.
Open these first
| File | What it is |
|---|---|
renders/spec.pdf | The seven page spec. Frame counts at 24, 30, and 60fps, sizes, fit rules, caption and subtitle rules, and the Resolve import note on page 7. |
renders/preview-16x9.mp4, renders/preview-9x16.mp4 | Every template running over real photos from brief/real-photos/. |
renders/contact-16x9.png, renders/contact-9x16.png | 12 evenly spaced frames from each reel, with the keyframe timeline. |
renders/photo-interior.png, photo-aerial.png, photo-portrait.png, photo-boundary.png | The same seven frames on a tight interior, a wide aerial, a portrait original, and a lot with a red boundary burned in. |
deliver/prores/ | ProRes 4444 with alpha, one file per template and ratio, HD at 30p. |
deliver/png/ | The same files as PNG sequences with alpha. |
Put a lower third on a job
- Find the template in
deliver/prores/, for examplelt-name-title-16x9-hd-30p.mov. - Drop it on the track above the footage in Resolve. Composite Mode Normal. Do not scale or move it.
- For a new name, open
content.js, copy an entry, and change the words. Check it in Chrome first:stage.html?name=Jacob%20Dow&detail=Founder,%20Marathon%20Creative&b=12at 1920 x 1080. - Render it:
bash build.sh alphafrom this folder. Add a frame rate and size if the job needs them,bash build.sh alpha 24 uhd. Add a name part to render one file,bash build.sh alpha 30 hd name-title.
What the template does for you
- Long names. One line at full size, then a small shrink, then two balanced lines at full size. Past two lines it refuses: the preview shows a white Will not export card with the reason, and
build.shstops instead of rendering a broken file. - Red never touches the picture. The rule and the eyebrow live inside the navy plate in Red Bright, the one red allowed on navy. Land listings with a red lot line burned in stay clean.
- Captions and subtitles lift. If a lower third is on screen during a caption page or a subtitle cue, that page sits above the lower third. It only moves on a page change.
- Captions page themselves. Two lines at most, a sentence ends its page, the type never shrinks.
- Subtitles check themselves. 42 characters a line at 16:9, 32 at 9:16, two lines, 5/6s to 7s, 17 characters a second. Overlong cues are split at the nearest clause. The spec, page 6, lists every cue with its check.
Real word timing for captions
Preview captions are timed at a natural speaking pace. For a real job, run Create Subtitles from Audio in Resolve, fix the words, export an SRT with one word per cue, and paste it into content.js as 'my-reel': { srt: '...' }. Then bash build.sh alpha. The red bar follows the real voice.
Rules that do not bend
- Never move the rule or the eyebrow off the plate, and never make the plate transparent.
- Never put a lower third outside title safe at 16:9 or the reel safe box at 9:16.
- Two lower thirds never overlap. The second starts on the frame after the first one clears.
- No client credit on a client's own deliverable. Marathon recedes on client work.
- Montserrat only. Every colour comes from
../../../tokens/v/best/tokens.css.
Change something
Sizes live in SIZE at the top of lt.js. Timing and curves live in the token layer, build/tokens/v/best/tokens.json, and flow through motion.js in the sting. Copy lives in content.js. Preview scenes live in scenes.js, still frames in stills.js. After a change, run bash build.sh all and then bash stills.sh to rebuild every render and the spec.
For Jacob to confirm
- The inventory scoped motion as direction without rendered video. This round was asked for preview MP4s, ProRes 4444, and PNG sequences as well. Keep them or drop them.
- Sample copy carries working figures and titles: the 48 hour edit turnaround, same day lead callback, the 1.9 acre homesite, the Rosehill Reserve location line, and Taylor's title as Brand lead.
- 9:16 safe areas reflect platform overlays in October 2026. The one word per cue SRT export is to be confirmed in Philip's Resolve version.
Files to download and code to copy
- Whole piece, source files3885 files, zip, 36.2 KB. Paths inside follow the repo layout, so put tokens.css beside them as the README says.Photos and image frames left out to stay under the 24MB file limit. They live in brief/real-photos/ and the piece folder.Download
- preview-9x16.mp4H.264 preview for review, not a master, 8.6 MBDownload
- preview-16x9.mp4H.264 preview for review, not a master, 9.5 MBDownload
- spec.pdfPDF, 126.4 MBbuild/motion-lower-thirds/v/best/renders/spec.pdf
Copy-paste code
lt-type.css7 lines, 684 bytes
/* Type styles for lower thirds, captions, and subtitles. Shared by the stage and by any page that measures them. */
.lt-eyebrow { color: var(--mc-accent-on-inverse); font-weight: var(--mc-type-eyebrow-weight); letter-spacing: var(--mc-type-eyebrow-tracking); text-transform: uppercase; line-height: 1.25; }
.lt-name { color: var(--mc-text-inverse); font-weight: var(--mc-weight-extrabold); letter-spacing: -0.01em; line-height: 1.08; }
.lt-detail { color: var(--mc-text-inverse-secondary); font-weight: var(--mc-weight-semibold); line-height: 1.3; }
.lt-capm { font-weight: var(--mc-weight-extrabold); letter-spacing: -0.005em; }
.lt-subm { font-weight: var(--mc-weight-semibold); }
lt.css41 lines, 3.1 KB
/* Lower thirds, captions, and subtitles. Every colour is a token. Sizes are set by lt.js from the frame. */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
body { font-family: var(--mc-font); background: transparent; font-variant-numeric: var(--mc-numeric-tabular); }
.stage { position: fixed; inset: 0; overflow: hidden; }
.stage.bg-navy { background: var(--mc-surface-inverse); }
.photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* Lower third. One navy plate, red sits only on the plate, never on the picture. */
.lt { position: absolute; will-change: clip-path; }
.lt .plate { position: absolute; inset: 0; background: var(--mc-surface-inverse); }
.lt .content { position: relative; }
.lt .rulewin { position: relative; overflow: hidden; }
.lt .rule { background: var(--mc-accent-on-inverse); will-change: transform; }
.win { overflow: hidden; position: relative; }
.win > .in { will-change: transform; white-space: nowrap; }
/* Social captions. Navy box per line, white words, a Red Bright bar under the word being spoken. */
.cap { position: absolute; left: 0; right: 0; text-align: center; }
.cap .line { display: block; }
.cap .box { display: inline-block; background: var(--mc-surface-inverse); color: var(--mc-text-inverse); font-weight: var(--mc-weight-extrabold); letter-spacing: -0.005em; line-height: 1.15; white-space: nowrap; }
.cap .w.up { color: var(--mc-text-inverse-tertiary); }
.cap .hl { position: absolute; background: var(--mc-accent-on-inverse); }
/* Broadcast subtitles. No colour, no motion, a strong navy scrim per line. */
.sub { position: absolute; left: 0; right: 0; text-align: center; }
.sub .line { display: block; }
.sub .box { display: inline-block; background: var(--mc-scrim-photo-strong); color: var(--mc-text-inverse); font-weight: var(--mc-weight-semibold); line-height: 1.25; white-space: nowrap; }
/* Guides and refusals. Preview only, never in an alpha export. */
.guide { position: absolute; border: var(--mc-border-width-hairline) dashed var(--mc-white); opacity: 0.7; pointer-events: none; }
.guide.title { border-style: solid; }
.ui-band { position: absolute; left: 0; right: 0; background: rgb(var(--mc-white-rgb) / 0.14); }
.glabel { position: absolute; color: var(--mc-white); font-weight: var(--mc-weight-semibold); letter-spacing: var(--mc-type-eyebrow-tracking); text-transform: uppercase; white-space: nowrap; }
.refuse { position: absolute; background: var(--mc-white); color: var(--mc-text-heading); font-weight: var(--mc-weight-bold); line-height: 1.3; border-left: var(--mc-border-width-bar) solid var(--mc-accent); }
.refuse b { display: block; color: var(--mc-text-label); font-weight: var(--mc-weight-bold); letter-spacing: var(--mc-type-eyebrow-tracking); text-transform: uppercase; }
.refuse span { display: block; font-weight: var(--mc-weight-regular); color: var(--mc-text-primary); }
.lt.refused { outline: var(--mc-border-width-bar) dashed var(--mc-white); }
[hidden] { display: none !important; }
spec-extra.css22 lines, 1.9 KB
/* Additions to the sting spec styles for this piece. Tokens only. */
.frame img.tall { width: 100%; }
.strip .frame .cap, .cap6 .frame .cap, .ladder .frame .cap { font-size: var(--mc-print-type-caption-size); }
.cap6 { display: grid; grid-template-columns: repeat(6, 1fr); column-gap: var(--mc-space-03); }
.ladder { row-gap: var(--mc-space-05); }
.step { display: inline-block; min-width: 1.5em; height: 1.5em; line-height: 1.5em; text-align: center; border-radius: var(--mc-radius-pill); background: var(--mc-surface-inverse); color: var(--mc-text-inverse); font-weight: var(--mc-weight-bold); margin-right: 0.4em; font-size: 0.9em; }
.step.red { background: var(--mc-surface-table-header); }
.swatch { display: inline-block; width: 0.9em; height: 0.9em; vertical-align: -0.1em; margin-right: 0.35em; outline: var(--mc-border-width-hairline) solid var(--mc-border-subtle); }
.callout .big { font-size: var(--mc-print-type-h1-size); }
.page:last-of-type { break-after: auto; }
.crop { position: relative; overflow: hidden; }
.frame .crop img { position: absolute; max-width: none; }
.frame .crop + .cap .zoom { display: block; color: var(--mc-text-tertiary); }
td.nw, #keyTable td:nth-child(3) { white-space: nowrap; }
#cueTable td:nth-child(2) { width: 52%; }
#cueTable td:nth-child(3) { white-space: nowrap; }
.page:first-of-type .callout { margin-top: var(--mc-space-05); }
/* Page 1 fit. The 9:16 still matches the 16:9 still's height so the row, the table, and the callout all end above the 0.85in bottom margin and the footer prints clear. */
.page:first-of-type .p1tall img.tall { width: auto; height: calc((var(--mc-doc-page-width) - 2 * var(--mc-doc-margin-side) - 11 * var(--mc-doc-gutter)) / 12 * 8 * 9 / 16 + 7 * var(--mc-doc-gutter) * 9 / 16); }
.page:first-of-type .sec { margin-top: var(--mc-space-05); }
.page:first-of-type .callout { padding: var(--mc-space-05) var(--mc-space-07); }
viz-extra.css5 lines, 415 bytes
/* Additions to the sting's viz.css for the shortened hold. */
.g-gap { position: absolute; top: -1.5em; bottom: 5.8em; border-left: var(--mc-border-width-strong) dotted var(--mc-text-tertiary); z-index: 3; }
.g-gap span { position: absolute; top: 0; left: 0.4em; font-size: 0.8em; font-weight: var(--mc-weight-bold); color: var(--mc-text-tertiary); white-space: nowrap; }
.g-gap span { left: auto; right: 0.4em; }
contact.html83 lines, 6.2 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Marathon lower thirds and captions, contact sheet</title>
<link rel="stylesheet" href="../../../tokens/v/best/tokens.css">
<link rel="stylesheet" href="../../../motion-sting/v/best/viz.css">
<link rel="stylesheet" href="viz-extra.css">
<link rel="stylesheet" href="lt-type.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body { width: 2400px; font-family: var(--mc-font); background: var(--mc-surface-page); color: var(--mc-text-primary); padding: var(--mc-space-12) var(--mc-space-12) var(--mc-space-10); font-variant-numeric: var(--mc-numeric-tabular); }
.top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: var(--mc-space-10); }
.top img { height: 46px; display: block; margin-bottom: var(--mc-space-08); }
.eyebrow { margin: 0 0 var(--mc-space-04); color: var(--mc-text-label); font-size: 17px; font-weight: var(--mc-type-eyebrow-weight); letter-spacing: var(--mc-type-eyebrow-tracking); text-transform: uppercase; }
h1 { margin: 0; color: var(--mc-text-heading); font-size: 64px; font-weight: var(--mc-weight-extrabold); letter-spacing: var(--mc-type-h1-tracking); line-height: 1.05; }
.note { text-align: right; color: var(--mc-text-secondary); font-size: 22px; line-height: 1.5; }
.note strong { color: var(--mc-text-heading); font-weight: var(--mc-weight-bold); }
.divider { width: 90px; height: 5px; background: var(--mc-border-accent); margin: 0 0 var(--mc-space-08); }
.grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); column-gap: 28px; row-gap: var(--mc-space-08); }
.cell img { width: 100%; display: block; }
.cap { display: grid; grid-template-columns: auto 1fr; column-gap: var(--mc-space-05); margin-top: var(--mc-space-04); align-items: baseline; }
.b { font-size: 26px; font-weight: var(--mc-weight-extrabold); color: var(--mc-text-heading); letter-spacing: -0.01em; }
.f { font-size: 16px; color: var(--mc-text-tertiary); font-weight: var(--mc-weight-semibold); text-align: right; }
.what { grid-column: 1 / -1; font-size: 18px; color: var(--mc-text-secondary); margin-top: var(--mc-space-02); line-height: 1.4; }
.what b { color: var(--mc-text-heading); font-weight: var(--mc-weight-semibold); }
.timeline { margin-top: var(--mc-space-11); padding-top: var(--mc-space-08); border-top: var(--mc-border-width-hairline) solid var(--mc-border-subtle); font-size: 19px; --g-lab: 11em; --g-row: 2em; }
.timeline .eyebrow { margin-bottom: var(--mc-space-08); }
.foot { margin-top: var(--mc-space-08); display: flex; justify-content: space-between; color: var(--mc-text-tertiary); font-size: 17px; font-weight: var(--mc-weight-semibold); }
</style>
</head>
<body>
<div class="top">
<div>
<img src="../../../tokens/v/best/logos/marathon-logo-dark.svg" alt="Marathon Creative">
<p class="eyebrow" id="eb"></p>
<h1>One navy plate. Red lives on the plate, never on the picture.</h1>
</div>
<div class="note" id="note"></div>
</div>
<div class="divider"></div>
<div class="grid" id="grid"></div>
<div class="timeline">
<p class="eyebrow">Lower third keyframes, beats across, frames under</p>
<div id="gantt"></div>
</div>
<div class="foot"><span id="footL"></span><span>Contact sheet, 12 evenly spaced frames, first frame to last</span></div>
<script src="../../../motion-sting/v/best/motion.js"></script>
<script src="content.js"></script>
<script src="scenes.js"></script>
<script src="lt.js"></script>
<script src="viz.js"></script>
<script>
document.fonts.load('800 40px Montserrat').then(function () {
var q = new URLSearchParams(location.search), tag = q.get('r') || '16x9', P = tag === '9x16';
var g = P ? LT.geo(1080, 1920) : LT.geo(1920, 1080), FPS = 30, scenes = LT_SCENES[P ? '916' : '16'];
var n = 0, bounds = [];
scenes.forEach(function (s) { var c = Math.round(Math.ceil(LT.sceneDur(s, g, 0.5, 0.5) * FPS - 1e-6)); bounds.push([n, n + c, s]); n += c; });
document.body.style.setProperty('--cols', P ? 6 : 4);
document.getElementById('eb').textContent = 'Motion, lower thirds and captions, ' + (P ? '9:16' : '16:9') + ' preview reel';
var grid = document.getElementById('grid');
for (var k = 0; k < 12; k++) {
var f = 1 + Math.round(k * (n - 1) / 11), idx = f - 1, sc = bounds.filter(function (b) { return idx >= b[0] && idx < b[1]; })[0];
var local = (idx - sc[0]) / FPS, inner = local - 0.5, s = sc[2], state = '';
if (s.lt && !s.cap) { var T = LT.ltTimes(LT_CONTENT.lowerThirds[s.lt]), bt = (inner - (s.ltAt || 0)) / MC.BEAT;
state = bt < 0 ? 'Clean frame before the plate.' : bt < T.resolved ? 'Building, beat ' + bt.toFixed(1) + '.' : bt < T.exit ? 'At rest, holding.' : bt <= T.end ? 'Exit, everything together.' : 'Clean frame after the exit.'; }
var sec = Math.floor(idx / FPS), ff = idx - sec * FPS;
var c = document.createElement('div'); c.className = 'cell';
c.innerHTML = '<img src="renders/frames/contact-' + tag + '/c' + String(k).padStart(2, '0') + '-f' + String(f).padStart(5, '0') + '.png" alt="">' +
'<div class="cap"><span class="b">' + String(k + 1).padStart(2, '0') + '</span><span class="f">f' + idx + ' at 30fps, 00:00:' + String(sec).padStart(2, '0') + ':' + String(ff).padStart(2, '0') + '</span>' +
'<span class="what"><b>' + s.label + '.</b> ' + state + ' Photo ' + s.photo.replace('.jpg', '') + '.</span></div>';
grid.appendChild(c);
}
var T = LT.ltTimes(LT_CONTENT.lowerThirds.jacob), B = MC.frames(1, 24) + ' / ' + MC.frames(1, 30) + ' / ' + MC.frames(1, 60);
document.getElementById('note').innerHTML = '<strong>One beat = ' + B + ' frames at 24, 30, 60fps.</strong><br>Built in ' + T.resolved + ' beats, ' + (T.resolved * MC.BEAT).toFixed(1) + 's. Holds at least ' + (MC.HOLD_LT * MC.BEAT).toFixed(1) + 's.<br>Out in ' + MC.EXIT + ' beats, everything together.';
LTViz.gantt(document.getElementById('gantt'), T, {});
document.getElementById('footL').textContent = 'Marathon Creative, lower thirds and captions v1. Reel is ' + n + ' frames at 30fps, rendered from stage.html over photos from brief/real-photos.';
});
</script>
</body>
</html>
photos.html69 lines, 5.6 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Marathon lower thirds and captions, photo robustness</title>
<link rel="stylesheet" href="../../../tokens/v/best/tokens.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body { width: 2400px; font-family: var(--mc-font); background: var(--mc-surface-page); color: var(--mc-text-primary); padding: var(--mc-space-12); }
.top { display: grid; grid-template-columns: 1fr auto; column-gap: var(--mc-space-10); align-items: end; margin-bottom: var(--mc-space-08); }
.top img.logo { height: 40px; display: block; margin-bottom: var(--mc-space-07); }
.eyebrow { margin: 0 0 var(--mc-space-04); color: var(--mc-text-label); font-size: 17px; font-weight: var(--mc-type-eyebrow-weight); letter-spacing: var(--mc-type-eyebrow-tracking); text-transform: uppercase; }
h1 { margin: 0 0 var(--mc-space-04); color: var(--mc-text-heading); font-size: 60px; font-weight: var(--mc-weight-extrabold); letter-spacing: var(--mc-type-h1-tracking); line-height: 1.05; }
.lede { margin: 0; font-size: 26px; font-weight: var(--mc-weight-light); color: var(--mc-text-secondary); max-width: 1300px; line-height: 1.4; }
.src { text-align: right; }
.src img { height: 220px; display: block; margin-left: auto; }
.src p { margin: var(--mc-space-03) 0 0; font-size: 16px; color: var(--mc-text-tertiary); font-weight: var(--mc-weight-semibold); }
.divider { width: 90px; height: 5px; background: var(--mc-border-accent); margin: 0 0 var(--mc-space-08); }
.g16 { display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; row-gap: var(--mc-space-07); }
.g916 { display: grid; grid-template-columns: repeat(4, 1fr); column-gap: 28px; margin-top: var(--mc-space-09); align-items: start; }
.cell img { width: 100%; display: block; }
.cap { margin-top: var(--mc-space-03); font-size: 19px; color: var(--mc-text-secondary); }
.cap b { color: var(--mc-text-heading); font-weight: var(--mc-weight-bold); margin-right: 0.5em; }
.verdict { border-top: var(--mc-border-width-bar) solid var(--mc-surface-inverse); padding-top: var(--mc-space-05); font-size: 21px; line-height: 1.5; }
.verdict h3 { margin: 0 0 var(--mc-space-03); font-size: 26px; color: var(--mc-text-heading); font-weight: var(--mc-weight-bold); }
.verdict p { margin: 0 0 var(--mc-space-04); }
.verdict.red { border-top-color: var(--mc-border-accent); }
.foot { margin-top: var(--mc-space-09); display: flex; justify-content: space-between; color: var(--mc-text-tertiary); font-size: 17px; font-weight: var(--mc-weight-semibold); border-top: var(--mc-border-width-hairline) solid var(--mc-border-subtle); padding-top: var(--mc-space-04); }
</style>
</head>
<body>
<div class="top">
<div>
<img class="logo" src="../../../tokens/v/best/logos/marathon-logo-dark.svg" alt="Marathon Creative">
<p class="eyebrow" id="eb"></p>
<h1 id="h1"></h1>
<p class="lede" id="lede"></p>
</div>
<div class="src"><img id="srcimg" alt=""><p id="srccap"></p></div>
</div>
<div class="divider"></div>
<div class="g16" id="g16"></div>
<div class="g916" id="g916"></div>
<div class="foot"><span>Marathon Creative, lower thirds and captions v1. Same templates, same copy, on every photo. Nothing was tuned per photo.</span><span id="footR"></span></div>
<script src="stills.js"></script>
<script>
var k = new URLSearchParams(location.search).get('p') || 'interior', p = LT_STILLS.photos[k];
var order = ['interior', 'aerial', 'portrait', 'boundary'];
document.getElementById('eb').textContent = 'Photo robustness test, ' + (order.indexOf(k) + 1) + ' of 4';
document.getElementById('h1').textContent = p.label + ', ' + p.file.replace('.jpg', '');
document.getElementById('lede').textContent = p.note + ' Seven frames, the same seven on every test photo: three lower thirds, captions with the lift rule, overlong captions, and subtitles.';
document.getElementById('srcimg').src = 'photos/' + p.file;
document.getElementById('srccap').textContent = 'The original, uncropped. 16:9 crop ' + p.pos16 + ', 9:16 crop ' + p.pos916 + '.';
document.getElementById('footR').textContent = 'renders/photo-' + k + '.png';
LT_STILLS.shots.forEach(function (s) {
var c = document.createElement('div'); c.className = 'cell';
c.innerHTML = '<img src="renders/frames/stills/' + k + '-' + s[0] + '-' + s[1] + '.png" alt=""><div class="cap"><b>' + (s[1] === 16 ? '16:9' : '9:16') + '</b>' + s[3] + '</div>';
document.getElementById(s[1] === 16 ? 'g16' : 'g916').appendChild(c);
});
var v = document.createElement('div'); v.className = 'verdict' + (k === 'boundary' ? ' red' : '');
v.innerHTML = k === 'boundary'
? '<h3>Why the red line does not clash</h3><p>Red never sits on the picture. The rule and the eyebrow live inside the navy plate, in Red Bright, the one red allowed on navy. The lot line in the photo meets navy, not red.</p><p>The caption highlight is a bar inside a navy box, so it never touches the line either.</p><p>Do not move the rule outside the plate to sit on footage. That is the case the template is built to prevent.</p>'
: '<h3>What holds it together</h3><p>Every word sits on solid navy or the 82% navy scrim, so contrast does not depend on the photo.</p><p>The plate is sized by its copy and pinned to the safe line, so it lands in the same place on every shot.</p><p>Nothing is tuned per photo. Changing the crop is the editor’s only lever, and it never moves the type.</p>';
document.getElementById('g916').appendChild(v);
</script>
</body>
</html>
spec.html160 lines, 11.3 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Marathon lower thirds, captions, and subtitles, spec v1</title>
<link rel="stylesheet" href="../../../tokens/v/best/tokens.css">
<link rel="stylesheet" href="../../../motion-sting/v/best/viz.css">
<link rel="stylesheet" href="../../../motion-sting/v/best/spec.css">
<link rel="stylesheet" href="viz-extra.css">
<link rel="stylesheet" href="lt-type.css">
<link rel="stylesheet" href="spec-extra.css">
</head>
<body>
<!-- Page 1. What it is. -->
<section class="page">
<div class="inner">
<div class="head">
<img src="../../../tokens/v/best/logos/marathon-logo-dark.svg" alt="Marathon Creative">
<div class="meta">Motion, lower thirds and captions v1, Tier 3<br>For Philip, built in DaVinci Resolve</div>
</div>
<p class="eyebrow">Lower thirds and captions</p>
<h1>One navy plate. Red lives on the plate, never on the picture.</h1>
<p class="lede">Three lower thirds, social captions, and broadcast subtitles, at 16:9 and 9:16, moving by the sting's beat, curves, and order: rule, then name, then detail.</p>
<div class="divider"></div>
<div class="g12">
<div class="c8 frame"><img src="renders/frames/stills/interior-lt-rest-16.png" alt=""><p class="cap"><strong>16:9, name and title, at rest.</strong> Pinned to the bottom left corner of title safe, sized by its copy, never wider than half the frame.</p></div>
<div class="c4 frame p1tall"><img src="renders/frames/stills/portrait-lt-long-916.png" alt="" class="tall"><p class="cap"><strong>9:16, long name.</strong> On the reel safe line.</p></div>
</div>
<div class="sec"><table id="kinds" class="tight"></table></div>
<div class="callout">
<div class="big" id="bigBeats"></div>
<p id="bigText"></p>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 1 of 7</span></div>
</section>
<!-- Page 2. Build and exit. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Build and exit</p>
<h2>Plate, rule, name, detail. One beat apart.</h2>
<p class="small" style="max-width: 6.4in">The navy plate wipes on left to right on the pace curve. The red rule travels in from off the plate one beat later, full length on every frame, the way the sting's bars do. The name rises through a fixed mask on the next beat and the detail line one beat after that. Nothing outside the plate is ever drawn, so no word or rule touches the picture during the build.</p>
<div class="strip" id="build16"></div>
<div class="gwrap" id="gantt"></div>
<div class="sec"><table id="keyTable" class="tight"></table></div>
<div class="sec g12">
<div class="c4 rule-card red"><h3>Hold</h3><p id="holdText"></p></div>
<div class="c4 rule-card"><h3>Exit</h3><p id="exitText"></p></div>
<div class="c4 rule-card"><h3>Back to back</h3><p>Two lower thirds never overlap. The second one starts on the frame after the first one clears. If they are for the same person, use one plate and a longer hold.</p></div>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 2 of 7</span></div>
</section>
<!-- Page 3. Long names and sizes. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Long names</p>
<h2>Shrink a little, then wrap. Past two lines, it refuses.</h2>
<p class="small" style="max-width: 6.4in">The plate never breaks and the type never squeezes below its minimum. The template runs these four steps in order and stops at the first one that fits. Step four is a refusal: the preview shows a white Will not export card, and <code>build.sh</code> stops with the reason instead of rendering a broken file.</p>
<div class="g12 sec ladder" id="ladder"></div>
<div class="sec">
<p class="eyebrow">Positions and sizes, in pixels</p>
<table id="sizeTable" class="tight"></table>
<p class="cap">Left and bottom are the plate's outer edge. 16:9 uses title safe, 90%. 9:16 uses the reel safe box from the social tokens. Name sizes read as full, then the floor it may shrink to.</p>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 3 of 7</span></div>
</section>
<!-- Page 4. Photos. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Photo robustness</p>
<h2>Proven on four kinds of photo, not tuned to one</h2>
<p class="small" style="max-width: 6.4in">The same template and the same copy on a tight interior, the busiest aerial in the set, a portrait original cropped hard, and a lot with a red boundary line burned in. Contrast never depends on the picture, because every word sits on navy. The full seven frame test for each photo is in <code>renders/photo-interior.png</code>, <code>photo-aerial.png</code>, <code>photo-portrait.png</code>, and <code>photo-boundary.png</code>.</p>
<div class="g12 sec" id="photos16"></div>
<div class="g12 sec" id="photos916"></div>
<div class="sec g12">
<div class="c6 rule-card red"><h3>The red boundary case</h3><p>Lot lines on Marathon's land listings are drawn in a red close to Marathon Red. If Marathon Red sat on the picture, the two reds would fight. So it never does: the rule and the eyebrow live inside the navy plate in Red Bright, the one red allowed on navy, and the caption highlight is a bar inside a navy box.</p></div>
<div class="c6 rule-card"><h3>The editor's only lever is the crop</h3><p>Reframe the footage so a face or a sign is not under the plate. Never move the plate off the safe line, never make it transparent, and never put the rule or the eyebrow on the footage to save space.</p></div>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 4 of 7</span></div>
</section>
<!-- Page 5. Social captions. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Social captions, burned in</p>
<h2>Two lines, a navy box, and a red bar under the word being said</h2>
<p class="small" style="max-width: 6.4in">For Reels, TikTok, Shorts, and the feed, where most people watch with the sound off. Spoken words are white, words still to come are 60% white, and a Red Bright bar sits under the current word. The bar slides to the next word in one beat on the standard curve. Pages cut, they do not animate, because speech sets the timing, not the grammar.</p>
<div class="cap6 sec" id="cap916"></div>
<div class="g12 sec" id="cap16"></div>
<div class="sec"><table id="capTable" class="tight"></table></div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 5 of 7</span></div>
</section>
<!-- Page 6. Subtitles. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Broadcast subtitles</p>
<h2>Plain, still, and readable on any picture</h2>
<p class="small" style="max-width: 6.4in">For YouTube, the website, client case studies, and anything a client may hand to a broadcaster. No colour, no highlight, no motion. White Montserrat SemiBold on an 82% navy box per line, centred on the safe line. A cue that will not fit two lines is split at its nearest clause, the type is never shrunk.</p>
<div class="g12 sec" id="sub16"></div>
<div class="g12 sec">
<div class="c12"><p class="eyebrow">Rules</p><table id="subRules" class="tight"></table></div>
<div class="c12"><p class="eyebrow">The case study cues at 16:9, as the template splits them</p><table id="cueTable" class="tight"></table></div>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 6 of 7</span></div>
</section>
<!-- Page 7. Resolve. -->
<section class="page">
<div class="inner">
<p class="eyebrow">Import note for DaVinci Resolve</p>
<h2>Drop in the alpha files, or build it native</h2>
<div class="g12 sec">
<div class="c7">
<ol class="steps">
<li><strong>Match the timeline.</strong> HD or UHD, 16:9 or 9:16, at the job's frame rate. The files in <code>deliver/</code> are HD at 30p. For 24p or 60p run <code>bash build.sh alpha 24</code>, for UHD add <code>uhd</code>, as in <code>bash build.sh alpha 30 uhd</code>. Every key is on a beat, so the move is identical at every rate.</li>
<li><strong>Lay the overlay.</strong> Drag a <code>.mov</code> from <code>deliver/prores/</code> onto the track above the footage. ProRes 4444 carries alpha, Resolve reads it with Composite Mode Normal. Do not scale or reposition it, it is already on the safe line. Never put an HD file on a UHD timeline, render the UHD set instead.</li>
<li><strong>New names.</strong> Edit <code>content.js</code>, add the person, then run <code>bash build.sh alpha</code>. If the copy is too long, the build stops and tells you what to cut. Open <code>stage.html?name=Jacob%20Dow&detail=Founder</code> in Chrome to check a name before you render.</li>
<li><strong>Native build, if you need to key it yourself.</strong> Fusion Text+ with the values in the table on the right. Background is a Rectangle, rule is a second Rectangle masked by the first. Key from the frame table on page 2, curves from the sting spec, page 4.</li>
<li><strong>Subtitles.</strong> Import the SRT onto a subtitle track. In the track's Style tab set the values on the right. Burn in only when the platform cannot take a sidecar file.</li>
<li><strong>Social captions.</strong> Create Subtitles from Audio, check and fix the words, export the SRT with one word per cue, paste it into <code>content.js</code> as <code>{ srt: '...' }</code>, and run <code>bash build.sh alpha</code>. The word highlight then follows the real voice.</li>
</ol>
</div>
<div class="c5">
<p class="eyebrow">Native values, HD 16:9</p>
<table id="native" class="tight"></table>
<p class="eyebrow sec">Files in this folder</p>
<ul class="files" id="files"></ul>
</div>
</div>
<div class="note">
<p class="eyebrow">Editor note, for Jacob to confirm before this ships</p>
<ul>
<li>The inventory scoped motion to direction without rendered video. This round was asked for preview MP4s, ProRes 4444, and PNG sequences as well. Keep them or drop them, the direction stands either way.</li>
<li>Sample copy carries working figures and titles: the 48 hour edit turnaround, same day lead callback, the 1.9 acre homesite, and Taylor's title as Brand lead. Confirm each before any of it is used on a client job.</li>
<li>9:16 safe areas reflect platform overlays in October 2026. The SRT one word per cue export is to be confirmed in Philip's Resolve version.</li>
</ul>
</div>
</div>
<div class="foot"><span>Marathon Creative, lower thirds, captions, and subtitles v1</span><span>Page 7 of 7</span></div>
</section>
<script src="../../../motion-sting/v/best/motion.js"></script>
<script src="content.js"></script>
<script src="stills.js"></script>
<script src="lt.js"></script>
<script src="viz.js"></script>
<script src="spec.js"></script>
</body>
</html>
stage.html33 lines, 1.6 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Marathon lower thirds and captions, frame stage</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../tokens/v/best/tokens.css">
<link rel="stylesheet" href="lt-type.css">
<link rel="stylesheet" href="lt.css">
</head>
<body>
<!--
Deterministic frame stage. Size the window to the frame (1920x1080, 3840x2160, 1080x1920, 2160x3840) and call
window.__seek(seconds), or pass t=<seconds> or b=<beats after the lead>.
seq=16 | seq=916 play a preview reel from scenes.js
lt=<id> a lower third from content.js, ltAt=<seconds> delays it
cap=<id> social captions from content.js, capAt=<seconds>
sub=<id> broadcast subtitles from content.js, subAt=<seconds>
name=, eyebrow=, detail= type your own lower third, overrides lt
photo=<file> a file in photos/, pos=<object-position>
bg=none transparent, for ProRes 4444 and PNG sequences. bg=navy for a plain plate check.
lead=, tail= seconds of clean frame before and after, default 0.5
guides=1 title safe, action safe, reel safe, and platform UI
-->
<div class="stage" id="stage"><img class="photo" id="photo" alt="" hidden><div id="layers"></div><div id="guides"></div></div>
<script src="../../../motion-sting/v/best/motion.js"></script>
<script src="content.js"></script>
<script src="scenes.js"></script>
<script src="lt.js"></script>
<script src="stage.js"></script>
</body>
</html>
content.js39 lines, 3.3 KB
/* Every word that appears on screen in this piece. Edit here, never inside lt.js.
Lower thirds: eyebrow is optional, name is one or two lines, detail is one or two lines.
Captions: plain sentences, the engine times and pages them. Subtitles: cues in seconds, the engine
breaks lines and splits overlong cues. Copy for a client job matches the client's voice, not ours. */
window.LT_CONTENT = {
lowerThirds: {
/* Name and title */
'jacob': { kind: 'Name and title', eyebrow: '', name: 'Jacob Dow', detail: 'Founder, Marathon Creative' },
'taylor': { kind: 'Name and title', eyebrow: '', name: 'Taylor Treybig', detail: 'Brand lead, Marathon Creative' },
/* Location */
'rosehill': { kind: 'Location', eyebrow: 'Location', name: 'Rosehill Reserve', detail: 'Master-planned community, northwest Houston' },
'cangelosi-showroom': { kind: 'Location', eyebrow: 'Location', name: 'Cangelosi showroom', detail: 'Natural stone and tile, Houston, Texas' },
'homesite': { kind: 'Location', eyebrow: 'Location', name: 'Golf course homesite', detail: '1.9 acres, wooded, fairway frontage' },
/* Client credit */
'credit-cangelosi': { kind: 'Client credit', eyebrow: 'Client', name: 'Cangelosi', detail: 'Showroom photography and video by Marathon Creative' },
'credit-rosehill': { kind: 'Client credit', eyebrow: 'Client', name: 'Rosehill Reserve', detail: 'Drone, photo, and launch video by Marathon Creative' },
/* Stress tests. A long real-world name and a name that is too long to ship. */
'long': { kind: 'Long name test', eyebrow: 'Interview', name: 'Maria Guadalupe Castellanos-Whitfield', detail: 'Director of Commercial Design and Fabrication, Cangelosi Stone and Tile' },
'long-wrap': { kind: 'Long name test', eyebrow: 'Interview', name: 'Maria Guadalupe Castellanos de la Fuente', detail: 'Director of Commercial Design, Cangelosi' },
'too-long': { kind: 'Too long, refused', eyebrow: 'Interview', name: 'Maria Guadalupe Castellanos-Whitfield de la Fuente Montgomery-Abernathy', detail: 'Director of Commercial Design, Fabrication, Installation, and Warranty Service, Cangelosi Stone and Tile, Houston, Texas' }
},
captions: {
/* Jacob, talking to camera for Marathon's own reel. */
'inhouse': 'We shoot it, we edit it, and we post it ourselves. Nothing gets handed to a freelancer, so Tuesday’s shoot is on your page by Friday.',
/* Overlong on purpose: the kind of sentence a client says in one breath. */
'overlong': 'Cangelosi carries hundreds of natural stone and porcelain slabs, so we lit every aisle on its own and colour matched each slab in DaVinci Resolve before a single frame went out.'
},
subtitles: {
/* Broadcast-safe subtitles for a 16:9 case study. Times in seconds from the start of the clip. */
'case-study': [
{ s: 0.5, e: 5.2, text: 'Every listing gets photos, drone, and a walkthrough video from the same crew.' },
{ s: 5.4, e: 9.2, text: 'One shoot, one invoice, and the edit comes back in 48 hours.' },
{ s: 9.4, e: 20.4, text: 'We build the follow-up in PaceCRM, so every lead from the ad lands with a person who calls it back the same day, and nobody has to remember to check a spreadsheet on Monday.' }
]
}
};
lt.js325 lines, 18.2 KB
/* Lower third, caption, and subtitle engine. Reads the motion grammar from ../../../motion-sting/v/best/motion.js
(window.MC: beat, curves, durations) and every colour from tokens.css. Exposes window.LT.
Units: u is one pixel of a 1080 tall 16:9 frame, or one pixel of a 1080 wide 9:16 frame, so HD and UHD
come out identical in proportion. */
(function () {
var MC = window.MC, C = window.LT_CONTENT;
var BEAT = MC.BEAT, SWEEP = MC.SWEEP, ENTER = MC.ENTER, EXIT = MC.EXIT;
/* Sizes per orientation, in u. These are the only layout numbers in the piece. */
var SIZE = {
land: { name: 54, nameMin: 40, detail: 28, eyebrow: 18, padX: 36, padY: 30, ruleW: 72, ruleH: 6, gapRule: 20, gapEb: 10, gapDetail: 10,
cap: 46, capPadX: 18, capPadT: 8, capPadB: 18, capGap: 6, hlH: 5, sub: 40, subPadX: 14, subPadY: 5, subGap: 4, subChars: 42, stack: 32 },
port: { name: 66, nameMin: 50, detail: 34, eyebrow: 22, padX: 40, padY: 36, ruleW: 84, ruleH: 7, gapRule: 24, gapEb: 12, gapDetail: 12,
cap: 62, capPadX: 20, capPadT: 10, capPadB: 22, capGap: 8, hlH: 6, sub: 46, subPadX: 16, subPadY: 6, subGap: 5, subChars: 32, stack: 36 }
};
var SUB_MIN = 5 / 6, SUB_MAX = 7, SUB_CPS = 17, SUB_GAP_FRAMES = 2;
/* Frame geometry. 16:9 uses title safe, 90%. 9:16 uses the reel safe box from the social tokens. */
function geo(W, H) {
var P = H > W, g = { W: W, H: H, P: P };
if (P) {
g.u = W / MC.ms('--mc-social-reel-w');
g.left = MC.ms('--mc-social-reel-safe-side') * g.u; g.right = W - g.left;
g.top = MC.ms('--mc-social-reel-safe-top') * g.u; g.bottom = H - MC.ms('--mc-social-reel-safe-bottom') * g.u;
g.maxW = g.right - g.left; g.capMax = g.maxW;
} else {
g.u = H / 1080;
g.left = W * 0.05; g.right = W * 0.95; g.top = H * 0.05; g.bottom = H * 0.95;
g.maxW = W * 0.5; g.capMax = W * 0.62;
}
g.s = P ? SIZE.port : SIZE.land;
return g;
}
function div(cls, html) { var d = document.createElement('div'); if (cls) d.className = cls; if (html != null) d.innerHTML = html; return d; }
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<'); }
function pr(a, b, curve, x) { return MC.ease(MC.bez(curve), (x - a) / (b - a)); }
/* Text measurement with the real font, the real weight, and the real tracking. */
var meas;
function textW(text, cls, px) {
if (!meas) { meas = document.createElement('span'); meas.style.cssText = 'position:absolute;left:-99999px;top:0;white-space:nowrap;visibility:hidden'; document.body.appendChild(meas); }
meas.className = cls; meas.style.fontSize = px + 'px'; meas.textContent = text;
return meas.getBoundingClientRect().width;
}
function greedy(words, cls, px, maxW) {
var lines = [], cur = '';
words.forEach(function (w) { var t = cur ? cur + ' ' + w : w; if (cur && textW(t, cls, px) > maxW) { lines.push(cur); cur = w; } else cur = t; });
if (cur) lines.push(cur); return lines;
}
function balance2(words, cls, px) {
var best = null;
for (var i = 1; i < words.length; i++) {
var a = words.slice(0, i).join(' '), b = words.slice(i).join(' ');
var m = Math.max(textW(a, cls, px), textW(b, cls, px) * 0.97);
if (!best || m < best.m) best = { m: m, lines: [a, b] };
}
return best ? best.lines : [words.join(' ')];
}
function widest(lines, cls, px) { return Math.max.apply(null, lines.map(function (l) { return textW(l, cls, px); })); }
function wrap(text, cls, px, maxW, maxLines) {
var words = text.split(/\s+/).filter(Boolean);
if (textW(text, cls, px) <= maxW) return { lines: [text], w: textW(text, cls, px), ok: true };
var lines = greedy(words, cls, px, maxW);
if (lines.length === 2) { var b = balance2(words, cls, px); if (widest(b, cls, px) <= maxW) lines = b; }
var w = widest(lines, cls, px);
return { lines: lines, w: Math.min(w, maxW), ok: lines.length <= maxLines && w <= maxW + 0.5 };
}
/* ---------- Lower third ---------- */
/* Beats. Plate first, rule one beat later, name one beat after that, detail last. Resolve order from the grammar:
rule, then name, then detail. Hold is the token or one second per three words, whichever is longer. */
function ltTimes(p) {
var words = [p.eyebrow, p.name, p.detail].join(' ').split(/\s+/).filter(Boolean).length;
var hold = Math.max(MC.HOLD_LT, Math.ceil((words / 3) / BEAT - 1e-9));
var res = 3 + ENTER;
return { plate: [0, SWEEP], rule: [1, 1 + SWEEP], name: [2, 2 + ENTER], detail: [3, 3 + ENTER], resolved: res,
hold: hold, exit: res + hold, end: res + hold + EXIT, words: words };
}
/* Fit rules, in order. 1. One line at full size. 2. One line, shrunk, never below the minimum.
3. Two lines at full size, balanced. 4. Anything longer is refused, not squeezed. */
function fitLT(p, g) {
var s = g.s, u = g.u, inner = g.maxW - 2 * s.padX * u, refused = [], step = 1;
var namePx = s.name * u, nameLines = [p.name], nw = textW(p.name, 'lt-name', namePx);
if (nw > inner) {
var shrunk = Math.floor(namePx * inner / nw);
if (shrunk >= s.nameMin * u) { namePx = shrunk; nw = textW(p.name, 'lt-name', namePx); step = 2; }
else {
var r = wrap(p.name, 'lt-name', namePx, inner, 2); nameLines = r.lines; nw = r.w; step = 3;
if (!r.ok) { refused.push('Name needs ' + r.lines.length + ' lines. Two is the limit. Use the name the person goes by.'); step = 4; }
}
}
var dpx = s.detail * u, dr = p.detail ? wrap(p.detail, 'lt-detail', dpx, inner, 2) : { lines: [], w: 0, ok: true };
if (!dr.ok) refused.push('Detail runs to ' + dr.lines.length + ' lines. Two is the limit. Keep the role and the company, cut the rest.');
var epx = s.eyebrow * u, ew = p.eyebrow ? textW(p.eyebrow, 'lt-eyebrow', epx) : 0;
if (ew > inner) refused.push('Eyebrow is longer than one line. One or two words only.');
var contentW = Math.min(inner, Math.max(nw, dr.w, ew, s.ruleW * u));
return { namePx: namePx, nameLines: nameLines, detailPx: dpx, detailLines: dr.lines, eyebrowPx: epx,
plateW: Math.ceil(contentW + 2 * s.padX * u), refused: refused, step: step };
}
function makeLT(p, g, host) {
var s = g.s, u = g.u, f = fitLT(p, g);
var el = div('lt'); el.style.left = g.left + 'px'; el.style.bottom = (g.H - g.bottom) + 'px'; el.style.width = f.plateW + 'px';
el.appendChild(div('plate'));
var c = div('content'); c.style.padding = (s.padY * u) + 'px ' + (s.padX * u) + 'px'; el.appendChild(c);
var rw = div('rulewin'), rule = div('rule');
rw.style.height = s.ruleH * u + 'px'; rw.style.marginBottom = s.gapRule * u + 'px'; rw.style.overflow = 'visible';
rule.style.width = s.ruleW * u + 'px'; rule.style.height = '100%'; rw.appendChild(rule); c.appendChild(rw);
function win(cls, px, html, gap) {
var w = div('win'), i = div('in ' + cls, html);
w.style.fontSize = px + 'px'; w.style.paddingTop = w.style.paddingBottom = '0.1em';
w.style.marginTop = (gap ? gap * u : 0) - 0.1 * px + 'px'; w.style.marginBottom = -0.1 * px + 'px';
w.appendChild(i); c.appendChild(w); return w;
}
var eb = p.eyebrow ? win('lt-eyebrow', f.eyebrowPx, esc(p.eyebrow), 0) : null;
var nm = win('lt-name', f.namePx, f.nameLines.map(esc).join('<br>'), eb ? s.gapEb : 0);
var dt = f.detailLines.length ? win('lt-detail', f.detailPx, f.detailLines.map(esc).join('<br>'), s.gapDetail) : null;
host.appendChild(el);
var lt = { el: el, rule: rule, wins: [eb, nm].filter(Boolean), det: dt, T: ltTimes(p), f: f, p: p, g: g,
height: el.offsetHeight, top: g.bottom - el.offsetHeight, ruleTravel: (s.padX + s.ruleW) * u };
if (f.refused.length) el.classList.add('refused');
return lt;
}
/* b is local time in beats from the first frame of the lower third. */
function animLT(lt, b) {
var T = lt.T;
var vis = b >= 0 && b <= T.end;
lt.el.style.visibility = vis ? 'visible' : 'hidden'; if (!vis) return;
var pPlate = pr(T.plate[0], T.plate[1], '--mc-ease-pace', b), pRule = pr(T.rule[0], T.rule[1], '--mc-ease-pace', b);
var pName = pr(T.name[0], T.name[1], '--mc-ease-enter', b), pDet = pr(T.detail[0], T.detail[1], '--mc-ease-enter', b);
var out = pr(T.exit, T.end, '--mc-ease-exit', b);
lt.el.style.clipPath = 'inset(0 ' + (Math.max(1 - pPlate, out) * 100).toFixed(3) + '% 0 0)';
lt.rule.style.transform = 'translate3d(' + (-lt.ruleTravel * (1 - pRule) - lt.ruleTravel * out).toFixed(2) + 'px,0,0)';
function rise(w, p) { if (!w) return; var h = w.offsetHeight; w.firstChild.style.transform = 'translate3d(0,' + (h * (1 - p) + h * out).toFixed(2) + 'px,0)'; }
lt.wins.forEach(function (w) { rise(w, pName); });
rise(lt.det, pDet);
}
/* ---------- Social captions ---------- */
/* Word timing for the preview, a natural pace of about 2.7 words a second. In a real job the times come
from Resolve's transcription, the look does not change. */
function capWords(text) {
if (text && text.srt) return srtWords(text.srt);
var t = 0, out = [];
text.split(/\s+/).filter(Boolean).forEach(function (w) {
var d = 0.14 + 0.045 * w.replace(/[^A-Za-z0-9]/g, '').length;
out.push({ w: w, s: t, e: t + d }); t += d;
if (/[.?!]$/.test(w)) t += 0.35; else if (/[,;:]$/.test(w)) t += 0.15;
});
return out;
}
/* Real word timing: an SRT exported from Resolve with one word per cue. Each cue becomes one word. */
function srtWords(srt) {
function sec(x) { var m = x.trim().match(/(\d+):(\d+):(\d+)[,.](\d+)/); return +m[1] * 3600 + +m[2] * 60 + +m[3] + +m[4] / 1000; }
var out = [];
srt.replace(/\r/g, '').split(/\n\s*\n/).forEach(function (blk) {
var L = blk.trim().split('\n'); if (L.length < 3) return;
var tt = L[1].split('-->'); var s = sec(tt[0]), e = sec(tt[1]);
var ws = L.slice(2).join(' ').split(/\s+/).filter(Boolean);
ws.forEach(function (w, i) { out.push({ w: w, s: s + (e - s) * i / ws.length, e: s + (e - s) * (i + 1) / ws.length }); });
});
return out;
}
/* Paging. Two lines at most, a sentence never runs into the next page, type never shrinks. */
function capChunks(words, g) {
var s = g.s, px = s.cap * g.u, maxLine = g.capMax - 2 * s.capPadX * g.u, chunks = [], cur = null;
function close() { if (cur && cur.words.length) chunks.push(cur); cur = null; }
words.forEach(function (wd, i) {
if (!cur) cur = { words: [], lines: [''] };
var li = cur.lines.length - 1, t = cur.lines[li] ? cur.lines[li] + ' ' + wd.w : wd.w;
if (cur.lines[li] && textW(t, 'lt-capm', px) > maxLine) {
if (cur.lines.length < 2) cur.lines.push(wd.w);
else { close(); cur = { words: [], lines: [wd.w] }; }
} else cur.lines[li] = t;
cur.words.push(i);
if (/[.?!]$/.test(wd.w)) close();
});
close();
chunks.forEach(function (c, k) {
if (c.lines.length === 2) { var b = balance2(c.words.map(function (i) { return words[i].w; }), 'lt-capm', px); if (widest(b, 'lt-capm', px) <= maxLine) c.lines = b; }
c.s = words[c.words[0]].s;
c.e = k < chunks.length - 1 ? words[chunks[k + 1].words[0]].s : words[c.words[c.words.length - 1]].e + 0.6;
});
return chunks;
}
function makeCap(id, g, host, liftAt) {
var words = capWords(C.captions[id] || id), chunks = capChunks(words, g);
return { kind: 'cap', id: id, words: words, chunks: chunks, g: g, host: host, cur: -1, dom: null, liftAt: liftAt,
end: chunks[chunks.length - 1].e };
}
function buildChunk(cap, k, bottom) {
var g = cap.g, s = g.s, u = g.u, ch = cap.chunks[k], px = s.cap * u;
var el = div('cap'); el.style.bottom = (g.H - bottom) + 'px'; el.style.fontSize = px + 'px';
var wi = 0, spans = [];
ch.lines.forEach(function (line, li) {
var L = div('line'); if (li) L.style.marginTop = s.capGap * u + 'px';
var box = document.createElement('span'); box.className = 'box';
box.style.padding = (s.capPadT * u) + 'px ' + (s.capPadX * u) + 'px ' + (s.capPadB * u) + 'px';
line.split(' ').forEach(function (w, j) {
if (j) box.appendChild(document.createTextNode(' '));
var sp = document.createElement('span'); sp.className = 'w'; sp.textContent = w; box.appendChild(sp);
spans.push({ el: sp, line: li, box: box, idx: ch.words[wi++] });
});
L.appendChild(box); el.appendChild(L);
});
var hl = div('hl'); hl.style.height = s.hlH * u + 'px'; el.appendChild(hl);
cap.host.appendChild(el);
var o = el.getBoundingClientRect();
spans.forEach(function (sp) {
var r = sp.el.getBoundingClientRect(), br = sp.box.getBoundingClientRect();
sp.x = r.left - o.left; sp.w = r.width; sp.y = br.bottom - o.top - (s.capPadB * u + s.hlH * u) / 2;
});
return { el: el, spans: spans, hl: hl };
}
/* t is local seconds. lt is the lower third sharing the frame, used for the lift rule. */
function animCap(cap, t, lt, ltOffset) {
var k = -1;
cap.chunks.forEach(function (c, i) { if (t >= c.s && t < c.e) k = i; });
if (k !== cap.cur) {
if (cap.dom) cap.dom.el.remove(); cap.dom = null; cap.cur = k;
if (k >= 0) cap.dom = buildChunk(cap, k, liftBottom(cap.g, cap.chunks[k], lt, ltOffset));
}
if (!cap.dom) return;
var act = -1;
cap.dom.spans.forEach(function (sp, i) { var wd = cap.words[sp.idx]; if (t >= wd.s) act = i; sp.el.classList.toggle('up', t < wd.s); });
if (act < 0) { cap.dom.hl.style.visibility = 'hidden'; return; }
var a = cap.dom.spans[act], prev = cap.dom.spans[act - 1], x = a.x, w = a.w;
if (prev && prev.line === a.line) {
var p = pr(0, 1, '--mc-ease-standard', (t - cap.words[a.idx].s) / BEAT);
x = prev.x + (a.x - prev.x) * p; w = prev.w + (a.w - prev.w) * p;
}
var hl = cap.dom.hl; hl.style.visibility = 'visible';
hl.style.left = x + 'px'; hl.style.width = w + 'px'; hl.style.top = (a.y - cap.g.s.hlH * cap.g.u / 2) + 'px';
}
/* The lift rule. Captions and subtitles sit on the safe line. If a lower third is on screen at any moment of a
page or cue, that whole page sits above the lower third instead. It only moves on a page change. */
function liftBottom(g, span, lt, off) {
if (lt) {
var a = off + lt.T.plate[0] * BEAT, b = off + lt.T.end * BEAT;
if (span.s < b && span.e > a) return lt.top - g.s.stack * g.u;
}
return g.bottom;
}
/* ---------- Broadcast subtitles ---------- */
function breakLines(text, max) {
if (text.length <= max) return [text];
var best = null, words = text.split(' ');
for (var i = 1; i < words.length; i++) {
var a = words.slice(0, i).join(' '), b = words.slice(i).join(' ');
if (a.length > max || b.length > max) continue;
var score = Math.abs(a.length - b.length) + (/[,.;:?!]$/.test(a) ? -8 : 0) + (a.length > b.length ? 2 : 0);
if (!best || score < best.score) best = { score: score, lines: [a, b] };
}
return best ? best.lines : null;
}
/* A cue that will not fit two lines is split at the nearest clause, time shared by characters. Type never shrinks. */
function prepCues(cues, max) {
var out = [], gap = SUB_GAP_FRAMES / 30;
cues.forEach(function (c) {
(function split(text, s, e, from) {
var lines = breakLines(text, max);
if (lines) { out.push({ s: s, e: e, text: text, lines: lines, split: from }); return; }
var words = text.split(' '), best = null, half = text.length / 2;
for (var i = 1; i < words.length; i++) {
var a = words.slice(0, i).join(' '), sc = Math.abs(a.length - half) - (/[,.;:]$/.test(a) ? half * 0.6 : 0);
if (!best || sc < best.sc) best = { sc: sc, i: i };
}
var A = words.slice(0, best.i).join(' '), B = words.slice(best.i).join(' ');
var mid = s + (e - s) * A.length / text.length;
split(A, s, mid - gap / 2, true); split(B, mid + gap / 2, e, true);
})(c.text, c.s, c.e, false);
});
out.forEach(function (c) {
var d = c.e - c.s, n = c.text.length; c.flags = [];
if (d < SUB_MIN) c.flags.push('under 5/6s'); if (d > SUB_MAX) c.flags.push('over 7s');
if (n / d > SUB_CPS) c.flags.push((n / d).toFixed(1) + ' chars/s, over ' + SUB_CPS);
});
return out;
}
function makeSub(id, g, host) {
var cues = prepCues(C.subtitles[id], g.s.subChars);
return { kind: 'sub', id: id, cues: cues, g: g, host: host, cur: -1, el: null, end: cues[cues.length - 1].e + 0.4 };
}
function animSub(sub, t, lt, off) {
var k = -1; sub.cues.forEach(function (c, i) { if (t >= c.s && t < c.e) k = i; });
if (k === sub.cur) return; sub.cur = k;
if (sub.el) sub.el.remove(); sub.el = null; if (k < 0) return;
var g = sub.g, s = g.s, u = g.u, c = sub.cues[k], el = div('sub');
el.style.bottom = (g.H - liftBottom(g, c, lt, off)) + 'px'; el.style.fontSize = s.sub * u + 'px';
c.lines.forEach(function (l, i) {
var L = div('line'); if (i) L.style.marginTop = s.subGap * u + 'px';
var b = document.createElement('span'); b.className = 'box'; b.textContent = l; b.style.padding = (s.subPadY * u) + 'px ' + (s.subPadX * u) + 'px';
L.appendChild(b); el.appendChild(L);
});
sub.host.appendChild(el); sub.el = el;
}
/* Scene length in seconds: lead, the longest layer, tail. stage.js and the contact sheets share this. */
function sceneDur(s, g, lead, tail) {
var end = 0;
if (s.lt) end = Math.max(end, (s.ltAt || 0) + ltTimes(C.lowerThirds[s.lt]).end * BEAT);
if (s.cap) end = Math.max(end, (s.capAt || 0) + capChunks(capWords(C.captions[s.cap]), g).slice(-1)[0].e);
if (s.sub) end = Math.max(end, (s.subAt || 0) + prepCues(C.subtitles[s.sub], g.s.subChars).slice(-1)[0].e + 0.4);
return lead + end + tail;
}
window.LT = { geo: geo, sceneDur: sceneDur, SIZE: SIZE, fitLT: fitLT, ltTimes: ltTimes, makeLT: makeLT, animLT: animLT, capWords: capWords, capChunks: capChunks,
makeCap: makeCap, animCap: animCap, makeSub: makeSub, animSub: animSub, prepCues: prepCues, breakLines: breakLines,
SUB_MIN: SUB_MIN, SUB_MAX: SUB_MAX, SUB_CPS: SUB_CPS, SUB_GAP_FRAMES: SUB_GAP_FRAMES, textW: textW };
})();
scenes.js23 lines, 1.7 KB
/* The preview reels. Each scene is one template on one real photo. Every photo-bearing template is run on a
tight interior, a wide aerial, a portrait original, and the lot with the red boundary burned in. */
window.LT_SCENES = {
'16': [
{ label: 'Name and title, tight interior', photo: 'mc_retouched_05.jpg', lt: 'jacob' },
{ label: 'Location, wide aerial', photo: 'mc_rosehill_16.jpg', lt: 'rosehill' },
{ label: 'Location, red boundary in the photo', photo: 'mc_fullres_12.jpg', pos: '50% 0%', lt: 'homesite' },
{ label: 'Long name, portrait original', photo: 'mc_retouched_08.jpg', pos: '50% 40%', lt: 'long' },
{ label: 'Captions with a client credit, lift rule', photo: 'mc_retouched_03.jpg', cap: 'inhouse', lt: 'credit-cangelosi', ltAt: 1.6 },
{ label: 'Overlong captions, brightest photo', photo: 'mc_retouched_04.jpg', cap: 'overlong' },
{ label: 'Broadcast subtitles, wide aerial', photo: 'mc_rosehill_17.jpg', sub: 'case-study' }
],
'916': [
{ label: 'Name and title, portrait original', photo: 'mc_retouched_08.jpg', lt: 'taylor' },
{ label: 'Location, portrait aerial', photo: 'mc_rosehill_18.jpg', lt: 'rosehill' },
{ label: 'Location, red boundary in the photo', photo: 'mc_fullres_12.jpg', pos: '22% 50%', lt: 'homesite' },
{ label: 'Long name, tight interior', photo: 'mc_retouched_05.jpg', pos: '30% 50%', lt: 'long' },
{ label: 'Captions with a client credit, lift rule', photo: 'mc_rosehill_16.jpg', cap: 'inhouse', lt: 'credit-rosehill', ltAt: 1.6 },
{ label: 'Overlong captions, brightest photo', photo: 'mc_retouched_04.jpg', cap: 'overlong' },
{ label: 'Broadcast subtitles, tight interior', photo: 'mc_retouched_03.jpg', sub: 'case-study' }
]
};
spec.js118 lines, 11.3 KB
/* Fills every number in spec.html from lt.js, motion.js, and tokens.css. Nothing is typed twice. */
document.fonts.load('800 40px Montserrat').then(function () {
var MC = window.MC, LT = window.LT, C = window.LT_CONTENT, V = window.LTViz;
function $(id) { return document.getElementById(id); }
function table(el, head, rows, numFrom) {
var h = '<thead><tr>' + head.map(function (c, i) { return '<th' + (i >= numFrom ? ' class="num"' : '') + '>' + c + '</th>'; }).join('') + '</tr></thead><tbody>';
rows.forEach(function (r) { h += '<tr>' + r.map(function (c, i) { return '<td class="' + (i === 0 ? 'lab' : i >= numFrom ? 'num' : 'dim') + '">' + c + '</td>'; }).join('') + '</tr>'; });
el.innerHTML = h + '</tbody>';
}
/* crop is [x, y, w, h] in frame pixels, so a small lower third can be shown at a readable size. */
function frame(src, title, text, cls, crop, W, H) {
var d = V.h('div', 'frame' + (cls ? ' ' + cls : '')), img = '<img src="renders/frames/stills/' + src + '" alt="">';
if (crop) {
W = W || 1920; H = H || 1080;
img = '<div class="crop" style="aspect-ratio:' + crop[2] + '/' + crop[3] + '"><img src="renders/frames/stills/' + src + '" alt="" style="width:' + (W / crop[2] * 100) + '%;left:' + (-crop[0] / crop[2] * 100) + '%;top:' + (-crop[1] / crop[3] * 100) + '%"></div>';
}
d.innerHTML = img + '<p class="cap"><span class="bt">' + title + '</span>' + (text || '') + '</p>'; return d;
}
var CROP_LT = [0, 600, 840, 480], CROP_FIT = [0, 432, 1152, 648], CROP_SUB = [400, 630, 1120, 450];
function fr(b) { return MC.frames(b, 24) + ' / ' + MC.frames(b, 30) + ' / ' + MC.frames(b, 60); }
function hex(tok) { return getComputedStyle(document.documentElement).getPropertyValue(tok).trim().replace(/^#/, '').toUpperCase(); }
var T = LT.ltTimes(C.lowerThirds.jacob), g16 = LT.geo(1920, 1080), g916 = LT.geo(1080, 1920);
/* Page 1 */
table($('kinds'), ['Template', 'Eyebrow', 'Name line', 'Detail line', 'Use it for'], [
['Name and title', 'none', C.lowerThirds.jacob.name, C.lowerThirds.jacob.detail, 'Anyone on camera. The name they go by, role, company.'],
['Location', C.lowerThirds.rosehill.eyebrow, C.lowerThirds.rosehill.name, C.lowerThirds.rosehill.detail, 'Establishing shots. Place, then one fact: type, acreage, or square feet.'],
['Client credit', C.lowerThirds['credit-cangelosi'].eyebrow, C.lowerThirds['credit-cangelosi'].name, C.lowerThirds['credit-cangelosi'].detail, 'Marathon case studies and the reel. Never on client deliverables.']
], 9);
$('bigBeats').innerHTML = T.resolved + ' / ' + MC.HOLD_LT + ' / ' + MC.EXIT + '<small>Beats to build, hold, and exit</small>';
$('bigText').innerHTML = '<strong>One beat is ' + MC.ms('--mc-video-stagger') + 'ms, ' + fr(1) + ' frames at 24, 30, and 60fps.</strong> A lower third builds in ' + T.resolved + ' beats, ' + (T.resolved * MC.BEAT).toFixed(1) + 's, holds at least ' + (MC.HOLD_LT * MC.BEAT).toFixed(1) + 's, and leaves in ' + MC.EXIT + ' beats. At 30fps the shortest one is ' + MC.frames(T.end, 30) + ' frames from first frame to clear.';
/* Page 2 */
[[1, 'Beat 1', 'Plate wiping on. Rule starts.'], [2, 'Beat 2', 'Name starts to rise.'], [3, 'Beat 3', 'Detail starts.'], [4, 'Beat 4', 'Plate lands.'], [T.resolved, 'Beat ' + T.resolved, 'Resolved. Nothing moves.']].forEach(function (b) {
$('build16').appendChild(frame('build16-b' + b[0] + '.png', b[1], '<span class="fr">f' + fr(b[0]).replace(/ \/ /g, ', ') + '</span>' + b[2], '', CROP_LT));
});
V.gantt($('gantt'), T, {});
var rows = V.rows(T).map(function (r) { var o = [r.label, r.what, r.curve]; [24, 30, 60].forEach(function (f) { o.push(MC.frames(r.a, f), MC.frames(r.b, f)); }); return o; });
function mark(l, a, b, w, c) { var o = [l, w, c]; [24, 30, 60].forEach(function (f) { o.push(MC.frames(a, f), b == null ? '' : MC.frames(b, f)); }); return o; }
rows.push(mark('Resolved', T.resolved, null, 'Every part at rest', ''));
rows.push(mark('Exit', T.exit, T.end, 'Plate wipes back, rule slides left, type drops', 'ease-exit'));
table($('keyTable'), ['Part', 'Move', 'Curve', '24 in', '24 out', '30 in', '30 out', '60 in', '60 out'], rows, 3);
$('holdText').textContent = 'At least ' + MC.HOLD_LT + ' beats, ' + (MC.HOLD_LT * MC.BEAT).toFixed(1) + 's, or one second per three words on the plate if that is longer, rounded up to a whole beat. The long name test carries ' + LT.ltTimes(C.lowerThirds.long).words + ' words, so it holds ' + LT.ltTimes(C.lowerThirds.long).hold + ' beats.';
$('exitText').textContent = 'Everything leaves together in ' + MC.EXIT + ' beats, ' + fr(MC.EXIT) + ' frames, on the exit curve. The plate wipes back to the left, the type drops through its masks. No stagger on the way out.';
/* Page 3 */
[['jacob', 1, 'Fits at full size', 'One line at full size. Most names stop here. The plate is as wide as its longest line.'], ['long', 2, 'Shrunk, still one line', 'Too long at full size, but it fits above the floor, so the name shrinks and stays on one line.'],
['long-wrap', 3, 'Two lines, full size', 'Would need to go below the floor, so the name breaks into two balanced lines at full size and the plate grows upward.'],
['too-long', 4, 'Refused', 'Four lines of name and three of detail. The preview shows the reason. The build will not render it.']].forEach(function (s) {
var f = LT.fitLT(C.lowerThirds[s[0]], g16), d = frame('fit16-' + s[0] + '.png', '<span class="step' + (s[1] === 4 ? ' red' : '') + '">' + f.step + '</span>' + s[2], s[3], 'c6', CROP_FIT);
$('ladder').appendChild(d);
});
var fmts = [[1920, 1080, 'HD 16:9'], [3840, 2160, 'UHD 16:9'], [1080, 1920, 'HD 9:16'], [2160, 3840, 'UHD 9:16']];
table($('sizeTable'), ['Format', 'Left, bottom', 'Max width', 'Name, floor', 'Detail', 'Eyebrow', 'Padding x, y', 'Rule w x h'], fmts.map(function (f) {
var g = LT.geo(f[0], f[1]), s = g.s, u = g.u, r = function (v) { return Math.round(v * u); };
return [f[2] + ', ' + f[0] + ' x ' + f[1], Math.round(g.left) + ', ' + Math.round(g.bottom), Math.round(g.maxW), r(s.name) + ', ' + r(s.nameMin), r(s.detail), r(s.eyebrow), r(s.padX) + ', ' + r(s.padY), r(s.ruleW) + ' x ' + r(s.ruleH)];
}), 1);
/* Page 4 */
var P = LT_STILLS.photos;
Object.keys(P).forEach(function (k) {
$('photos16').appendChild(frame(k + '-lt-rest-16.png', P[k].label, P[k].file.replace('.jpg', ''), 'c3'));
$('photos916').appendChild(frame(k + '-cap-lift-916.png', P[k].label + ', 9:16', 'Captions lifted over a credit', 'c3'));
});
/* Page 5 */
[[0.9, 'First word'], [1.6, 'Bar travels'], [3.3, 'Next page'], [5.2, 'Mid sentence'], [7.6, 'Two lines full'], [10.2, 'Last page']].forEach(function (c) {
$('cap916').appendChild(frame('cap916-' + c[0] + '.png', c[1], '<span class="fr">' + c[0].toFixed(1) + 's</span>'));
});
[[0.9, 'Page one, first word'], [1.5, 'Bar on the third word'], [2.4, 'Second line'], [6.2, 'Second sentence']].forEach(function (c) {
$('cap16').appendChild(frame('cap16-' + c[0] + '.png', c[1], '<span class="fr">' + c[0].toFixed(1) + 's, 16:9, bottom of frame</span>', 'c3', CROP_SUB));
});
function px(g, k) { return Math.round(g.s[k] * g.u); }
var chunks916 = LT.capChunks(LT.capWords(C.captions.overlong), g916);
table($('capTable'), ['Rule', '16:9 HD', '9:16 HD', 'Why'], [
['Type', 'ExtraBold ' + px(g16, 'cap') + 'px', 'ExtraBold ' + px(g916, 'cap') + 'px', 'Read at phone size with the sound off. Sentence case, never all caps.'],
['Box', 'Navy, ' + px(g16, 'capPadX') + 'px sides', 'Navy, ' + px(g916, 'capPadX') + 'px sides', 'Solid navy per line, so white holds on white marble.'],
['Line length', Math.round(g16.capMax) + 'px', Math.round(g916.capMax) + 'px', 'Measured with the real font, not counted in characters.'],
['Lines per page', '2', '2', 'A sentence ends its page. The overlong test pages into ' + chunks916.length + ' at 9:16.'],
['Highlight bar', px(g16, 'hlH') + 'px Red Bright', px(g916, 'hlH') + 'px Red Bright', 'Slides word to word in 1 beat, ' + fr(1) + ' frames, ease-standard.'],
['Words to come', '60% white', '60% white', 'The eye can read ahead without losing its place.'],
['Position', 'Title safe line', 'Reel safe line', 'Lifts above a lower third on a page change, ' + px(g16, 'stack') + ' and ' + px(g916, 'stack') + 'px clear.']
], 9);
/* Page 6 */
[[3, 'Cue 1, two balanced lines'], [7, 'Cue 2'], [12, 'Overlong cue, part 1'], ['lift', 'Lifted over a lower third']].forEach(function (c) {
$('sub16').appendChild(frame('sub16-' + c[0] + '.png', c[1], '<span class="fr">Bottom of frame, 16:9</span>', 'c3', c[0] === 'lift' ? [100, 600, 1200, 482] : CROP_SUB));
});
table($('subRules'), ['Rule', 'Value'], [
['Characters per line', g16.s.subChars + ' at 16:9, ' + g916.s.subChars + ' at 9:16'],
['Lines', 'Two at most, bottom line the longer'],
['Shortest cue', '5/6s, ' + [24, 30, 60].map(function (f) { return Math.round(LT.SUB_MIN * f); }).join(' / ') + ' frames'],
['Longest cue', LT.SUB_MAX + 's'],
['Reading speed', LT.SUB_CPS + ' characters a second or less'],
['Gap between cues', LT.SUB_GAP_FRAMES + ' frames'],
['Type', 'SemiBold ' + px(g16, 'sub') + 'px at HD 16:9, ' + px(g916, 'sub') + 'px at HD 9:16'],
['Box', 'Navy at 82%, per line'],
['Motion', 'None. Cut on, cut off.']
], 9);
var cues = LT.prepCues(C.subtitles['case-study'], g16.s.subChars);
table($('cueTable'), ['Cue', 'Lines as shown', 'Check', 'In, s', 'Out, s', 'Frames at 30', 'Chars/s'], cues.map(function (c, i) {
return [String(i + 1), c.lines.join('<br>'), (c.flags.length ? '<strong>' + c.flags.join(', ') + '</strong>' : 'Passes') + (c.split ? ', split' : ''), c.s.toFixed(2), c.e.toFixed(2), Math.round((c.e - c.s) * 30), (c.text.length / (c.e - c.s)).toFixed(1)];
}), 3);
/* Page 7 */
var s = g16.s;
table($('native'), ['Part', 'Setting'], [
['Plate', 'Rectangle, <span class="swatch" style="background:var(--mc-navy-100)"></span>' + hex('--mc-navy-100') + ', 100%'],
['Rule', 'Rectangle ' + s.ruleW + ' x ' + s.ruleH + ', <span class="swatch" style="background:var(--mc-accent-on-inverse)"></span>' + hex('--mc-accent-on-inverse')],
['Eyebrow', 'Montserrat Bold ' + s.eyebrow + 'px, caps, tracking 0.2em, ' + hex('--mc-accent-on-inverse')],
['Name', 'Montserrat ExtraBold ' + s.name + 'px, floor ' + s.nameMin + ', white'],
['Detail', 'Montserrat SemiBold ' + s.detail + 'px, white at 78%'],
['Plate left, bottom', Math.round(g16.left) + ', ' + Math.round(g16.bottom) + ', padding ' + s.padX + ' x ' + s.padY],
['Subtitle style', 'Montserrat SemiBold ' + s.sub + ', white, background ' + hex('--mc-navy-100') + ' at 82%']
], 9);
['<code>deliver/prores/</code> ProRes 4444 with alpha, one per template and ratio', '<code>deliver/png/</code> the same, as PNG sequences', '<code>renders/preview-16x9.mp4</code>, <code>preview-9x16.mp4</code>', '<code>renders/contact-16x9.png</code>, <code>contact-9x16.png</code>', '<code>renders/photo-*.png</code> the four photo tests', '<code>content.js</code> every word on screen', '<code>stage.html</code> any frame, any copy'].forEach(function (f) { var li = document.createElement('li'); li.innerHTML = f; $('files').appendChild(li); });
});
stage.js80 lines, 5.2 KB
/* Stage glue: builds a scene, or a reel of scenes, and exposes window.__seek for capture.mjs. */
(function () {
/* Wait for Montserrat at every weight in use, layout is measured with the real font. */
var weights = [600, 700, 800].map(function (w) { return document.fonts.load(w + ' 40px Montserrat'); });
window.__seek = function (t) { return ready.then(function () { return window.__seek(t); }); };
var ready = Promise.all(weights).then(init);
function init() {
var MC = window.MC, LT = window.LT, C = window.LT_CONTENT;
var q = new URLSearchParams(location.search);
var stage = document.getElementById('stage'), layers = document.getElementById('layers'), photo = document.getElementById('photo');
var g = LT.geo(window.innerWidth, window.innerHeight), bg = q.get('bg') || 'photo';
var num = function (k, d) { return q.has(k) ? parseFloat(q.get(k)) : d; };
var scenes;
if (q.get('seq')) { scenes = window.LT_SCENES[q.get('seq')].map(function (x) { return Object.assign({}, x); }); if (q.has('scene')) scenes = [scenes[+q.get('scene')]]; }
else scenes = [{ label: 'single', photo: q.get('photo') || '', pos: q.get('pos') || '', lt: q.get('lt') || '', ltAt: num('ltAt', 0),
cap: q.get('cap') || '', capAt: num('capAt', 0), sub: q.get('sub') || '', subAt: num('subAt', 0) }];
if (q.get('name')) { C.lowerThirds.custom = { eyebrow: q.get('eyebrow') || '', name: q.get('name'), detail: q.get('detail') || '' }; scenes[0].lt = 'custom'; }
var lead = num('lead', 0.5), tail = num('tail', 0.5);
/* Durations, computed before anything is drawn. */
var t0 = 0;
scenes.forEach(function (s) { s.start = t0; s.dur = LT.sceneDur(s, g, lead, tail); t0 += s.dur; });
var total = t0;
var cur = -1, live = null;
function build(i) {
var s = scenes[i]; layers.innerHTML = ''; cur = i;
if (bg === 'photo' && s.photo) { photo.src = 'photos/' + s.photo; photo.style.objectPosition = s.pos || '50% 50%'; photo.hidden = false; }
else photo.hidden = true;
stage.className = 'stage' + (bg === 'navy' || (bg === 'photo' && !s.photo) ? ' bg-navy' : '');
live = { s: s };
if (s.lt) live.lt = LT.makeLT(C.lowerThirds[s.lt], g, layers);
if (s.cap) live.cap = LT.makeCap(s.cap, g, layers);
if (s.sub) live.sub = LT.makeSub(s.sub, g, layers);
var refused = live.lt && live.lt.f.refused;
window.__refused = refused && refused.length ? refused : null;
if (refused && refused.length && bg !== 'none') {
var r = document.createElement('div'); r.className = 'refuse';
var fs = 20 * g.u; r.style.fontSize = fs + 'px'; r.style.left = g.left + 'px'; r.style.bottom = (g.H - live.lt.top + 16 * g.u) + 'px';
r.style.maxWidth = (g.P ? g.maxW : g.W * 0.5) + 'px'; r.style.padding = (14 * g.u) + 'px ' + (18 * g.u) + 'px';
r.innerHTML = '<b>Will not export</b>' + refused.map(function (x) { return '<span>' + x + '</span>'; }).join('');
layers.appendChild(r);
}
}
function seek(T) {
var i = 0; while (i < scenes.length - 1 && T >= scenes[i].start + scenes[i].dur) i++;
if (i !== cur) build(i);
var s = scenes[i], lt0 = s.start + lead, t = T - lt0;
if (live.lt) LT.animLT(live.lt, (t - (s.ltAt || 0)) / MC.BEAT);
if (live.cap) LT.animCap(live.cap, t - (s.capAt || 0), live.lt, (s.ltAt || 0) - (s.capAt || 0));
if (live.sub) LT.animSub(live.sub, t - (s.subAt || 0), live.lt, (s.ltAt || 0) - (s.subAt || 0));
var imgs = Array.prototype.filter.call(document.images, function (im) { return !im.hidden && im.src; });
return Promise.all(imgs.map(function (im) { return im.decode ? im.decode().catch(function () {}) : 0; }));
}
window.__seek = seek; window.__duration = total; window.__scenes = scenes; window.__geo = g;
if (q.get('guides') === '1') {
var G = document.getElementById('guides');
function box(x, y, w, h, cls) { var d = document.createElement('div'); d.className = 'guide ' + (cls || ''); d.style.cssText += ';left:' + x + 'px;top:' + y + 'px;width:' + w + 'px;height:' + h + 'px'; G.appendChild(d); }
function lab(t, x, y) { var d = document.createElement('div'); d.className = 'glabel'; d.textContent = t; d.style.left = x + 'px'; d.style.top = y + 'px'; d.style.fontSize = 15 * g.u + 'px'; G.appendChild(d); }
if (!g.P) {
var W = g.W, H = g.H;
box(W * 0.035, H * 0.035, W * 0.93, H * 0.93); box(g.left, g.top, g.right - g.left, g.bottom - g.top, 'title');
lab('Action safe 93%', W * 0.035 + 10 * g.u, H * 0.035 + 8 * g.u); lab('Title safe 90%', g.left + 10 * g.u, g.top + 34 * g.u);
} else {
[[0, g.top, 'Platform UI'], [g.bottom, g.H - g.bottom, 'Caption and buttons']].forEach(function (b) {
var d = document.createElement('div'); d.className = 'ui-band'; d.style.top = b[0] + 'px'; d.style.height = b[1] + 'px'; G.appendChild(d);
lab(b[2], g.left, b[0] + b[1] / 2 - 9 * g.u);
});
box(g.left, g.top, g.maxW, g.bottom - g.top, 'title'); lab('Reel safe', g.left + 10 * g.u, g.top + 10 * g.u);
}
}
var start = q.has('b') ? (scenes[0].start + lead + (scenes[0].ltAt || 0) + parseFloat(q.get('b')) * MC.BEAT) : num('t', 0);
seek(start);
}
})();
stills.js20 lines, 1.6 KB
/* The still frames the photo sheets and the spec use. One list, read by stills.sh (through node) and by the pages. */
window.LT_STILLS = {
photos: {
interior: { file: 'mc_retouched_05.jpg', pos16: '50% 50%', pos916: '30% 50%', label: 'Tight interior', note: 'Cangelosi showroom, dense pattern and hard verticals behind the type.' },
aerial: { file: 'mc_rosehill_16.jpg', pos16: '50% 50%', pos916: '50% 50%', label: 'Wide aerial', note: 'Rosehill Reserve, the busiest frame in the set, rooftops edge to edge.' },
portrait: { file: 'mc_retouched_08.jpg', pos16: '50% 40%', pos916: '50% 50%', label: 'Portrait original', note: 'A vertical showroom shot, bright white slabs, cropped hard at 16:9.' },
boundary: { file: 'mc_fullres_12.jpg', pos16: '50% 0%', pos916: '22% 50%', label: 'Red boundary burned in', note: 'The lot line is drawn in a red close to Marathon Red and runs straight through the lower third zone.' }
},
/* id, frame size, stage query, caption */
shots: [
['lt-rest', 16, 'lt=jacob&b=12', 'Name and title at rest'],
['lt-build', 16, 'lt=long&b=3.5', 'Long name, mid build, beat 3.5'],
['cap-lift', 16, 'cap=inhouse<=credit-cangelosi<At=1.6&t=4.1', 'Captions lifted over a client credit'],
['sub', 16, 'sub=case-study&t=12', 'Broadcast subtitles, split cue'],
['lt-long', 916, 'lt=long&b=12', 'Long name at rest'],
['cap-lift', 916, 'cap=inhouse<=credit-rosehill<At=1.6&t=4.1', 'Captions over a credit'],
['cap', 916, 'cap=overlong&t=5.2', 'Overlong captions, paged']
]
};
viz.js44 lines, 3.3 KB
/* Timeline chart for the lower third, drawn with the sting's viz.css so both pieces read the same way.
Every number comes from lt.js and motion.js. */
(function () {
var MC = window.MC, LT = window.LT;
function h(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function pct(v, max) { return (v / max * 100) + '%'; }
function rows(T) {
return [
{ label: 'Navy plate', a: T.plate[0], b: T.plate[1], curve: 'ease-pace', what: 'Wipes on, left to right' },
{ label: 'Red rule', a: T.rule[0], b: T.rule[1], curve: 'ease-pace', what: 'Position X, from off the plate' },
{ label: 'Eyebrow and name', a: T.name[0], b: T.name[1], curve: 'ease-enter', what: 'Position Y, rises through its mask', word: 1 },
{ label: 'Detail line', a: T.detail[0], b: T.detail[1], curve: 'ease-enter', what: 'Position Y, rises through its mask', word: 1 }
];
}
/* The hold is drawn short and labelled, so the moves stay readable. cut is the number of hold beats left out. */
function gantt(host, T, opt) {
opt = opt || {};
var shown = opt.holdShown || 5, keep = T.resolved + shown, cut = T.exit - (keep + 1), max = T.end - cut, wrap = h('div', 'gantt');
function X(b) { return b >= T.exit ? b - cut : Math.min(b, keep + 0.5); }
function row(label, cls) { var r = h('div', 'g-row ' + (cls || '')); r.appendChild(h('div', 'g-lab', label)); var tr = h('div', 'g-track'); r.appendChild(tr); wrap.appendChild(r); return tr; }
function seg(tr, a, b, cls, txt) { var s = h('div', 'g-seg ' + cls, txt || ''); s.style.left = pct(X(a), max); s.style.width = pct(X(b) - X(a), max); tr.appendChild(s); }
rows(T).forEach(function (r) {
var tr = row(r.label, r.word ? 'word' : '');
seg(tr, r.a, r.b, 'move', r.curve); seg(tr, r.b, T.exit, 'hold'); seg(tr, T.exit, T.end, 'exit');
});
var ticks = []; for (var b = 0; b <= T.end; b++) if (b <= keep || b >= T.exit) ticks.push(b);
var ruler = row('Beat', 'ruler');
ticks.forEach(function (b) { var t = h('div', 'g-tick', String(b)); t.style.left = pct(X(b), max); ruler.appendChild(t); });
[24, 30, 60].forEach(function (fps) {
var rr = row(fps + 'fps frame', 'ruler fps');
ticks.forEach(function (b) { if (b % (opt.every || 1) && b !== T.end && b !== T.exit) return; var t = h('div', 'g-tick', String(MC.frames(b, fps))); t.style.left = pct(X(b), max); rr.appendChild(t); });
});
var lines = h('div', 'g-lines');
ticks.forEach(function (b) { var l = h('div', 'g-line'); l.style.left = pct(X(b), max); lines.appendChild(l); });
[[T.resolved, 'Resolved', ''], [T.exit, 'Exit', ''], [T.end, 'Clear', ' end']].forEach(function (m) {
var k = h('div', 'g-mark' + m[2]); k.style.left = pct(X(m[0]), max); k.appendChild(h('span', '', m[1])); lines.appendChild(k);
});
var gap = h('div', 'g-gap', '<span>Hold runs ' + T.hold + ' beats</span>'); gap.style.left = pct(keep + 0.5, max); lines.appendChild(gap);
wrap.appendChild(lines); host.appendChild(wrap);
if (!opt.noKey) host.appendChild(h('div', 'g-key', '<span class="sw move"></span>Move <span class="sw hold"></span>Hold at rest <span class="sw exit"></span>Exit, everything together'));
}
window.LTViz = { gantt: gantt, rows: rows, h: h };
})();
info.mjs15 lines, 1.1 KB
// Prints the duration and fit report of one stage URL, so build.sh knows how many frames to capture.
// Usage: node info.mjs '<stage.html?...>' <width> <height>
import { chromium } from 'playwright-core';
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
const [input, w, h] = process.argv.slice(2);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-chrome-'));
const ctx = await chromium.launchPersistentContext(profile, {
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true, viewport: { width: +w, height: +h } });
const page = await ctx.newPage();
const [file, query] = input.split('?');
await page.goto('file://' + path.resolve(file) + (query ? '?' + query : ''), { waitUntil: 'networkidle' });
const out = await page.evaluate(async () => { await window.__seek(0); return { duration: window.__duration, refused: window.__refused, scenes: window.__scenes.map(s => ({ label: s.label, start: s.start, dur: s.dur })) }; });
console.log(JSON.stringify(out));
await ctx.close(); fs.rmSync(profile, { recursive: true, force: true });
2 more source files are in the zip above.













