Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5cbd8744 | ||
|
|
9f2186b9f8 | ||
|
|
c3def2c5c4 | ||
|
|
55f7434bf2 | ||
|
|
1500ba1054 | ||
|
|
6f3e3bc858 |
No files matched your search
@@ -1,24 +0,0 @@
|
||||
# Caption Animation
|
||||
|
||||
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Add animated captions," "make TikTok captions," or "karaoke captions."
|
||||
- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
|
||||
- "Burn in captions to a video" or "sync captions to a voiceover/narration."
|
||||
- "Turn an SRT/transcript into animated text."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Turn this Whisper transcript into karaoke captions with a per-word pop."
|
||||
- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
|
||||
- "Burn word-level captions onto my voiceover and export a sidecar SRT."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
|
||||
- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
name: caption-animation
|
||||
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Caption Animation
|
||||
|
||||
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
|
||||
|
||||
## When to use
|
||||
|
||||
- Word-by-word or karaoke captions on short-form vertical video (9:16).
|
||||
- Burning open captions onto a voiceover/narration track from a transcript.
|
||||
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
|
||||
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
|
||||
|
||||
## The pipeline
|
||||
|
||||
| Stage | Job | Tool |
|
||||
|---|---|---|
|
||||
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
|
||||
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
|
||||
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
|
||||
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
|
||||
| Place | Safe-area, readable type | layout rules below |
|
||||
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
|
||||
|
||||
## Two non-negotiable rules
|
||||
|
||||
1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
|
||||
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
|
||||
|
||||
## Word timing from a transcript
|
||||
|
||||
Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
|
||||
|
||||
```ts
|
||||
// Caption token — the one shape everything downstream consumes
|
||||
type Token = { text: string; startMs: number; endMs: number };
|
||||
|
||||
// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
|
||||
const toMs = (t: string) => {
|
||||
const [h, m, rest] = t.split(":");
|
||||
const [s, ms] = rest.split(",");
|
||||
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
|
||||
};
|
||||
```
|
||||
|
||||
If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
|
||||
|
||||
## Per-word pop-in (Remotion)
|
||||
|
||||
Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
|
||||
|
||||
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = (token.startMs / 1000) * fps; // word's own entrance frame
|
||||
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
|
||||
const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
transform: `scale(${scale})`,
|
||||
opacity: interpolate(p, [0, 1], [0, 1]),
|
||||
color: active ? "#FFE45E" : "#FFFFFF", // active-word highlight
|
||||
transition: "color 80ms linear",
|
||||
}}>{token.text} </span>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
|
||||
|
||||
## Pages, not walls of text
|
||||
|
||||
Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
|
||||
|
||||
```ts
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
const { pages } = createTikTokStyleCaptions({
|
||||
captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
|
||||
});
|
||||
```
|
||||
|
||||
## Web/CSS pop (no Remotion)
|
||||
|
||||
For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
|
||||
|
||||
```css
|
||||
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
|
||||
.word.active { color:#FFE45E; }
|
||||
```
|
||||
```js
|
||||
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
|
||||
```
|
||||
|
||||
## Readable type and safe-area placement (9:16, 1080×1920)
|
||||
|
||||
| Property | Value | Why |
|
||||
|---|---|---|
|
||||
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
|
||||
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
|
||||
| Stroke | 2–6px solid black outline | Survives any background |
|
||||
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
|
||||
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
|
||||
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
|
||||
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
|
||||
| Side safe | Keep within center 80% width | Avoids right-rail icons |
|
||||
|
||||
Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
|
||||
|
||||
## Sync to a voiceover / narration
|
||||
|
||||
The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
|
||||
|
||||
## Burn-in vs sidecar SRT/VTT
|
||||
|
||||
| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|
||||
|---|---|---|
|
||||
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
|
||||
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
|
||||
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
|
||||
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
|
||||
|
||||
Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Word-level timestamps (real, from transcription) — no even-split fakery.
|
||||
- Per-word pop with micro-overshoot; one accent color for the active word.
|
||||
- 1–4 words per page; never a wall of text.
|
||||
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
|
||||
- Center band, clear of bottom ~280px and side rails.
|
||||
- Audio laid under composition; timestamps in the audio timebase.
|
||||
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
|
||||
|
||||
```bash
|
||||
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
|
||||
npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
|
||||
npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
|
||||
npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
|
||||
|
||||
# Inspect each PNG:
|
||||
# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
|
||||
# - burn-in legible: bold sans, stroke+shadow holds, no clipping
|
||||
# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
|
||||
|
||||
npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
|
||||
npx remotion render Captions out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
|
||||
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
|
||||
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
|
||||
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
|
||||
@@ -1,254 +0,0 @@
|
||||
# Word-Timed Captions — End-to-End Build
|
||||
|
||||
A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
|
||||
|
||||
## 1. The one data shape
|
||||
|
||||
Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
|
||||
|
||||
```ts
|
||||
export type Token = { text: string; startMs: number; endMs: number };
|
||||
export type Page = { startMs: number; endMs: number; tokens: Token[] };
|
||||
```
|
||||
|
||||
Remotion's `@remotion/captions` uses a compatible `Caption` type:
|
||||
|
||||
```ts
|
||||
type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
|
||||
```
|
||||
|
||||
## 2. Transcribe to word-level timing (Whisper)
|
||||
|
||||
Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
|
||||
|
||||
```bash
|
||||
npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
|
||||
```
|
||||
|
||||
```ts
|
||||
import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
|
||||
|
||||
const { transcription } = await transcribe({
|
||||
inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
|
||||
model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
|
||||
tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
|
||||
whisperPath: "./whisper.cpp",
|
||||
});
|
||||
const { captions } = toCaptions({ transcription }); // → Caption[]
|
||||
```
|
||||
|
||||
Tips:
|
||||
- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
|
||||
- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
|
||||
- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
|
||||
|
||||
Map any token shape into `Token`:
|
||||
|
||||
```ts
|
||||
const toTokens = (caps: Caption[]): Token[] =>
|
||||
caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
|
||||
```
|
||||
|
||||
## 3. Parse an existing SRT into tokens
|
||||
|
||||
When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
|
||||
|
||||
```ts
|
||||
const toMs = (t: string) => {
|
||||
const [h, m, rest] = t.trim().split(":");
|
||||
const [s, ms] = rest.split(",");
|
||||
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
|
||||
};
|
||||
|
||||
export function parseSrt(src: string): Token[] {
|
||||
return src.trim().split(/\n\s*\n/).map(block => {
|
||||
const lines = block.split("\n");
|
||||
const time = lines.find(l => l.includes("-->"))!;
|
||||
const [a, b] = time.split("-->");
|
||||
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
|
||||
return { text, startMs: toMs(a), endMs: toMs(b) };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Page the tokens (1–4 words at a time)
|
||||
|
||||
Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
|
||||
|
||||
```ts
|
||||
export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
|
||||
const pages: Page[] = [];
|
||||
let cur: Token[] = [];
|
||||
for (const t of tokens) {
|
||||
const span = cur.length ? t.endMs - cur[0].startMs : 0;
|
||||
if (cur.length >= maxWords || span > maxGapMs) {
|
||||
if (cur.length) pages.push(pageOf(cur)); cur = [];
|
||||
}
|
||||
cur.push(t);
|
||||
}
|
||||
if (cur.length) pages.push(pageOf(cur));
|
||||
return pages;
|
||||
}
|
||||
const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
|
||||
```
|
||||
|
||||
Remotion equivalent:
|
||||
|
||||
```ts
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
|
||||
// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
|
||||
```
|
||||
|
||||
## 5. Remotion component: word-timed with active highlight
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
|
||||
spring, interpolate, staticFile } from "remotion";
|
||||
|
||||
const CAPTION_STYLE: React.CSSProperties = {
|
||||
fontFamily: "Montserrat, Inter, sans-serif",
|
||||
fontWeight: 800,
|
||||
fontSize: 72, // ~8% of 1920px height
|
||||
color: "#fff",
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
WebkitTextStroke: "5px #000", // heavy outline survives any background
|
||||
paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
|
||||
textShadow: "0 4px 16px rgba(0,0,0,.55)",
|
||||
lineHeight: 1.1,
|
||||
padding: "0 80px", // keep inside center 80% width
|
||||
};
|
||||
|
||||
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const enter = (token.startMs / 1000) * fps;
|
||||
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`,
|
||||
opacity: interpolate(p, [0, 1], [0, 1]),
|
||||
color: active ? "#FFE45E" : undefined,
|
||||
transition: "color 80ms linear",
|
||||
}}>{token.text} </span>
|
||||
);
|
||||
};
|
||||
|
||||
export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const f = (ms: number) => Math.round((ms / 1000) * fps);
|
||||
const frame = useCurrentFrame();
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Audio src={staticFile(audio)} /> {/* same audio the transcript came from */}
|
||||
{pages.map((pg, i) => (
|
||||
<Sequence key={i} from={f(pg.startMs)} durationInFrames={Math.max(1, f(pg.endMs - pg.startMs))}>
|
||||
<AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "62%" }}>
|
||||
<div style={CAPTION_STYLE}>
|
||||
{pg.tokens.map((t, j) => {
|
||||
const now = (frame / fps) * 1000;
|
||||
return <Word key={j} token={t} active={now >= t.startMs && now < t.endMs} />;
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Because the `<Audio>` and the timestamps share one timebase, voice and captions stay locked with no manual nudging. Re-record the VO → re-transcribe → done.
|
||||
|
||||
## 6. Web/CSS path (live DOM player)
|
||||
|
||||
```js
|
||||
function renderTrack(tokens, mountEl) {
|
||||
tokens.forEach(t => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "word";
|
||||
el.textContent = t.text + " ";
|
||||
el.style.animationDelay = `${t.startMs}ms`; // each word pops on its own start
|
||||
mountEl.append(el);
|
||||
});
|
||||
}
|
||||
// drive .active off the media element's currentTime
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
const ms = audio.currentTime * 1000;
|
||||
[...track.children].forEach((el, i) =>
|
||||
el.classList.toggle("active", ms >= tokens[i].startMs && ms < tokens[i].endMs));
|
||||
});
|
||||
```
|
||||
|
||||
```css
|
||||
.cap { font: 800 72px/1.1 Montserrat, sans-serif; text-transform: uppercase;
|
||||
color:#fff; -webkit-text-stroke:5px #000; paint-order:stroke fill;
|
||||
text-shadow:0 4px 16px rgba(0,0,0,.55); text-align:center; }
|
||||
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
|
||||
.word.active { color:#FFE45E; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
## 7. Emit a sidecar SRT / VTT from the same tokens
|
||||
|
||||
Burn-in alone does not satisfy ADA/WCAG and many platforms ignore uploaded tracks — so ship both. Group tokens into readable cues and serialize:
|
||||
|
||||
```ts
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
const stamp = (ms: number, sep: string) => {
|
||||
const h = Math.floor(ms / 3.6e6), m = Math.floor(ms % 3.6e6 / 6e4),
|
||||
s = Math.floor(ms % 6e4 / 1000), x = ms % 1000;
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}${sep}${pad(x, 3)}`;
|
||||
};
|
||||
|
||||
export const toSrt = (pages: Page[]) => pages.map((p, i) =>
|
||||
`${i + 1}\n${stamp(p.startMs, ",")} --> ${stamp(p.endMs, ",")}\n${p.tokens.map(t => t.text).join(" ")}`
|
||||
).join("\n\n") + "\n";
|
||||
|
||||
export const toVtt = (pages: Page[]) => "WEBVTT\n\n" + pages.map(p =>
|
||||
`${stamp(p.startMs, ".")} --> ${stamp(p.endMs, ".")}\n${p.tokens.map(t => t.text).join(" ")}`
|
||||
).join("\n\n") + "\n";
|
||||
```
|
||||
|
||||
Use SRT for the broadest platform support; VTT when styling/positioning cues or serving on the web.
|
||||
|
||||
## 8. Per-platform safe areas (1080×1920, 9:16)
|
||||
|
||||
| Platform | Bottom keep-clear | Top keep-clear | Side keep-clear | Notes |
|
||||
|---|---|---|---|---|
|
||||
| TikTok | ~340px | ~120px | ~110px right rail | Captions, audio title, action buttons crowd the bottom-right |
|
||||
| Instagram Reels | ~280px | ~130px | ~60px | Username/CTA bar at bottom |
|
||||
| YouTube Shorts | ~240px | ~100px | ~120px right rail | Title + channel at bottom-left |
|
||||
| Facebook Reels | ~300px | ~130px | ~80px | Similar to Reels |
|
||||
|
||||
Safe rule across all: place caption baseline at ~62–70% of frame height, keep text within the center 80% width, and never let it enter the bottom ~15%.
|
||||
|
||||
## 9. Readable-type spec sheet
|
||||
|
||||
| Property | Recommended | Floor |
|
||||
|---|---|---|
|
||||
| Weight | 800 (ExtraBold) | 700 |
|
||||
| Size @1080p | 56–80px | 45px |
|
||||
| Stroke | 4–6px solid #000 | 2px |
|
||||
| Shadow | `0 4px 16px rgba(0,0,0,.55)` | soft 1px |
|
||||
| Active accent | one color (e.g. #FFE45E) or filled box | — |
|
||||
| Lines on screen | 1–2 | 2 max |
|
||||
| Words per page | 1–4 | 5 max |
|
||||
|
||||
Set `paint-order: stroke fill` so a thick stroke sits behind the glyph instead of eating it. Test on a bright frame and a busy frame; if it survives both, it ships.
|
||||
|
||||
## 10. Render
|
||||
|
||||
```bash
|
||||
npx remotion render src/index.ts Captions out/captions.mp4 \
|
||||
--props='{"pages": ..., "audio": "voiceover.wav"}'
|
||||
```
|
||||
|
||||
A 10-minute video with animated captions can take 30–60 min to render locally; render long-form in segments, or render on Remotion Lambda for parallel speed.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can auto-time captions to a narration track and one-click burn them in from a template — change the text/audio and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=caption-animation)
|
||||
@@ -1,25 +0,0 @@
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make a countdown timer video" or "create a countdown animation."
|
||||
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
|
||||
- "Animate a mm:ss or dd:hh:mm:ss timer."
|
||||
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Build a 10-minute 'be right back' livestream countdown."
|
||||
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
|
||||
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
|
||||
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
|
||||
- `references/digit-flip.md` — digit flip/roll transition techniques.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
name: countdown-video
|
||||
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Countdown Video
|
||||
|
||||
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
|
||||
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
|
||||
- Any animated counter where the number must land on zero on the exact frame.
|
||||
|
||||
## The one rule: drive the number from the clock, never a tick
|
||||
|
||||
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
|
||||
|
||||
| Context | Authoritative clock | Remaining time |
|
||||
|---|---|---|
|
||||
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
|
||||
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
|
||||
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
|
||||
|
||||
Never store remaining time in state and decrement it. Recompute it every frame.
|
||||
|
||||
## Frame-accurate countdown (Remotion → MP4)
|
||||
|
||||
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
|
||||
durationInSeconds,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
|
||||
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
|
||||
|
||||
## Web timer (live page, requestAnimationFrame)
|
||||
|
||||
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
|
||||
|
||||
```js
|
||||
const start = performance.now();
|
||||
let lastShown = -1;
|
||||
function tick(now) {
|
||||
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
|
||||
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
|
||||
if (remaining > 0) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
```
|
||||
|
||||
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
|
||||
|
||||
## Time formatting
|
||||
|
||||
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
|
||||
|
||||
```js
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
const formatDHMS = (s) => {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
```
|
||||
|
||||
| Format | Use | Drop fields when |
|
||||
|---|---|---|
|
||||
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
|
||||
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
|
||||
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
|
||||
|
||||
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
|
||||
|
||||
## Digit roll / flip transitions
|
||||
|
||||
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
|
||||
|
||||
```css
|
||||
.reel { height: 1em; overflow: hidden; }
|
||||
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
|
||||
/* col holds stacked 0..9; shift by -value em */
|
||||
```
|
||||
|
||||
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
|
||||
|
||||
## Looping background
|
||||
|
||||
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
|
||||
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
|
||||
- Duration is an input prop, not a hardcoded constant.
|
||||
- Digits use `tabular-nums`; layout does not shift as numbers change.
|
||||
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
|
||||
- Background loop period divides the timeline evenly; no seam at the wrap.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
|
||||
|
||||
```bash
|
||||
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
|
||||
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
|
||||
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
|
||||
|
||||
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
|
||||
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
|
||||
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
|
||||
# - tabular-nums: layout does not jitter between frames
|
||||
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
|
||||
|
||||
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
|
||||
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at frame 0, mid, and last — no errors.
|
||||
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
|
||||
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
|
||||
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
|
||||
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
|
||||
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
|
||||
@@ -1,205 +0,0 @@
|
||||
# Countdown Cookbook
|
||||
|
||||
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
|
||||
|
||||
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
|
||||
|
||||
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
|
||||
const s = Math.max(0, totalSeconds);
|
||||
if (mode === "mm:ss") {
|
||||
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
|
||||
}
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
// drop leading day field once it is zero, for a cleaner read
|
||||
return d > 0
|
||||
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
|
||||
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
|
||||
};
|
||||
|
||||
export type CountdownProps = {
|
||||
durationInSeconds: number;
|
||||
format?: "mm:ss" | "dd:hh:mm:ss";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Countdown: React.FC<CountdownProps> = ({
|
||||
durationInSeconds,
|
||||
format = "mm:ss",
|
||||
label = "",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// ceil so the last whole second ("1") is shown for its full duration,
|
||||
// and the display reaches "0" exactly on the final frame.
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const ended = remaining === 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#0b0d17",
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 180,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
|
||||
lineHeight: 1,
|
||||
color: ended ? "#ff4d4d" : "#fff",
|
||||
}}
|
||||
>
|
||||
{ended ? formatTime(0, format) : formatTime(remaining, format)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
|
||||
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Countdown } from "./Countdown";
|
||||
|
||||
const FPS = 30;
|
||||
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Countdown"
|
||||
component={Countdown}
|
||||
durationInFrames={DURATION_SECONDS * FPS}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Render any duration/format without touching code — duration is a prop:
|
||||
|
||||
```bash
|
||||
npx remotion render Countdown out/sale.mp4 \
|
||||
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
|
||||
```
|
||||
|
||||
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
|
||||
|
||||
## 2. Count down to a fixed date (live web component)
|
||||
|
||||
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
|
||||
|
||||
```js
|
||||
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
|
||||
// so the deadline is unambiguous regardless of the viewer's timezone.
|
||||
function startCountdown(targetISO, el) {
|
||||
const target = new Date(targetISO).getTime();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
let lastShown = -1;
|
||||
|
||||
function tick() {
|
||||
const remainingMs = Math.max(0, target - Date.now());
|
||||
const s = Math.floor(remainingMs / 1000);
|
||||
if (s !== lastShown) {
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
|
||||
lastShown = s;
|
||||
}
|
||||
if (remainingMs > 0) requestAnimationFrame(tick);
|
||||
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
```
|
||||
|
||||
Timezone notes:
|
||||
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
|
||||
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
|
||||
|
||||
## 3. "Starting soon" stream screen
|
||||
|
||||
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
|
||||
|
||||
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
|
||||
durationInSeconds,
|
||||
handle,
|
||||
brand,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// seamless looping gradient: angle cycles over loopFrames with no seam
|
||||
const loopFrames = fps * 8;
|
||||
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
|
||||
color: "#fff",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
|
||||
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
|
||||
STARTING SOON
|
||||
</div>
|
||||
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
|
||||
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
|
||||
</div>
|
||||
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
|
||||
|
||||
## 4. Seamless looping background
|
||||
|
||||
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
|
||||
|
||||
```tsx
|
||||
const loopFrames = fps * 6; // one full cycle every 6 seconds
|
||||
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
|
||||
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
|
||||
```
|
||||
|
||||
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
|
||||
@@ -1,146 +0,0 @@
|
||||
# Digit Flip & Roll Transitions
|
||||
|
||||
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
|
||||
|
||||
Pick by tone:
|
||||
|
||||
| Effect | Feel | Best for |
|
||||
|---|---|---|
|
||||
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
|
||||
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
|
||||
|
||||
## Shared idea
|
||||
|
||||
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
|
||||
|
||||
## Odometer reel (Remotion, frame-driven)
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
|
||||
|
||||
const DIGIT_H = 160; // px per glyph
|
||||
const ROLL_FRAMES = 12; // how long a single digit roll takes
|
||||
|
||||
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
|
||||
// of the current second.
|
||||
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
|
||||
value,
|
||||
prev,
|
||||
secondProgress,
|
||||
}) => {
|
||||
// secondProgress: 0→1 across the current whole second.
|
||||
// Start the roll near the end of the second so the number "settles" on change.
|
||||
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
});
|
||||
// continuous position between prev and value
|
||||
const shown = prev + (value - prev) * t;
|
||||
const offset = -shown * DIGIT_H;
|
||||
|
||||
return (
|
||||
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
|
||||
<div style={{ transform: `translateY(${offset}px)` }}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
style={{
|
||||
height: DIGIT_H,
|
||||
lineHeight: `${DIGIT_H}px`,
|
||||
textAlign: "center",
|
||||
fontSize: 140,
|
||||
fontWeight: 800,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Renders an mm:ss reel where each digit rolls.
|
||||
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const elapsed = frame / fps;
|
||||
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
|
||||
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
|
||||
const secondProgress = elapsed % 1; // 0→1 within the current second
|
||||
|
||||
const digits = (s: number) =>
|
||||
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
|
||||
|
||||
const cur = digits(remaining);
|
||||
const prev = digits(prevRemaining);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
|
||||
{cur.map((d, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
|
||||
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
|
||||
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
|
||||
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
|
||||
|
||||
## Flip card (split-flap)
|
||||
|
||||
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
|
||||
|
||||
```css
|
||||
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
|
||||
.flip .card {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
|
||||
.flip .bottom { clip-path: inset(50% 0 0 0); }
|
||||
/* on change, animate the leaf folding down */
|
||||
.flip .leaf {
|
||||
position: absolute; inset: 0; transform-origin: bottom;
|
||||
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
|
||||
}
|
||||
@keyframes fold {
|
||||
0% { transform: rotateX(0deg); }
|
||||
100% { transform: rotateX(-90deg); }
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// swap the digit and replay the fold whenever the value changes
|
||||
function setFlip(el, newDigit) {
|
||||
if (el.dataset.v === String(newDigit)) return; // no change → no flip
|
||||
el.dataset.v = String(newDigit);
|
||||
el.querySelector(".top").textContent = newDigit;
|
||||
el.querySelector(".bottom").textContent = newDigit;
|
||||
const leaf = el.querySelector(".leaf");
|
||||
leaf.textContent = newDigit;
|
||||
leaf.style.animation = "none";
|
||||
void leaf.offsetWidth; // reflow → restart the animation
|
||||
leaf.style.animation = "";
|
||||
}
|
||||
```
|
||||
|
||||
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
|
||||
|
||||
## Choosing
|
||||
|
||||
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
|
||||
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
|
||||
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Lower Thirds
|
||||
|
||||
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers. For creators producing interviews, panels, podcasts, and broadcast-style titles.
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make an animated lower third" or "add lower thirds."
|
||||
- "Create a name tag/name lower third" or "do a speaker name overlay."
|
||||
- "Build broadcast titles" or "animate a title/name/role caption."
|
||||
- "Template lower thirds across a list of names."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Add a lower third with the speaker's name and role, branded to my colors."
|
||||
- "Build a staggered slide-in name plate for a podcast guest."
|
||||
- "Template one lower-third design across a roster of 12 panelists."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — enter/exit animation, in/out timing and dwell, safe-area conventions, and brand styling.
|
||||
- `references/lower-third-component.md` — a reusable lower-third component.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=lower-thirds)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
name: lower-thirds
|
||||
description: This skill should be used when the user asks to "make an animated lower third", "add lower thirds", "create a name tag/name lower third", "build broadcast titles", "animate a title/name/role caption", "do a speaker name overlay", or "template lower thirds across a list of names". Covers enter/exit animation (slide/wipe/mask reveal), in/out timing & dwell, safe-area & broadcast conventions, name-vs-role type hierarchy, brand styling, and rendering one design across many names.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Lower Thirds
|
||||
|
||||
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers.
|
||||
|
||||
## When to use
|
||||
|
||||
- Speaker/interview name tags, panel rosters, talking-head captions.
|
||||
- Broadcast-style titles, news straps, podcast/webinar name plates.
|
||||
- Templating one design across a list of names/titles → many clips (the payoff).
|
||||
|
||||
## Anatomy
|
||||
|
||||
A lower third is two text lines plus a graphic bed, placed in the lower band:
|
||||
|
||||
| Element | Role | Type treatment |
|
||||
|---|---|---|
|
||||
| **Name** (primary) | Who | Largest, heaviest, tightest tracking |
|
||||
| **Role/title** (secondary) | What/where | ~45–60% of name size, lighter weight, often a brand accent color |
|
||||
| **Bar / panel / accent** | Reads against any background | Solid or 70–85% opacity; an accent rule ties to brand |
|
||||
|
||||
Keep it to two lines. A name line + role line is the whole job — extra lines erode the hierarchy and crowd the safe area.
|
||||
|
||||
## Three rules that prevent cheap-looking titles
|
||||
|
||||
1. **Never hard-cut a title on or off.** A bare appear/disappear reads as a mistake. Even a 12-frame fade or slide makes it feel produced.
|
||||
2. **Stagger, don't slam.** Animate the bar first, then the name, then the role, each offset by 3–6 frames. Everything arriving at once feels flat; a short cascade feels designed.
|
||||
3. **Hold long enough to read twice.** Roughly 3–5s on screen (read it aloud twice). Too short and it's missed; too long and it nags. Animate in/out, never mid-segment.
|
||||
|
||||
## Enter & exit animations
|
||||
|
||||
Pick one motion language and reuse it. The most reliable, premium-reading reveals:
|
||||
|
||||
| Reveal | Feel | How |
|
||||
|---|---|---|
|
||||
| **Slide + fade** | Clean, default | Translate in ~24–40px while fading; ease-out on enter |
|
||||
| **Wipe bar** | Broadcast | A bar scales/sweeps in (`scaleX`/clip), text fades behind it |
|
||||
| **Mask reveal** | Premium | Clip text to a moving rectangle so letters "wipe on" |
|
||||
|
||||
Use asymmetric easing: a snappy `ease-out` (`cubic-bezier(.22,1,.36,1)`) on enter, a quicker `ease-in` on exit. Exit faster than you entered (~⅔ the duration) so the title gets out of the way.
|
||||
|
||||
```css
|
||||
/* Mask reveal: text wipes on left→right, no plugin needed */
|
||||
.lt-name { clip-path: inset(0 100% 0 0); animation: wipe .5s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
|
||||
```
|
||||
|
||||
## In/out timing & dwell
|
||||
|
||||
Think in three phases. Budget frames, not vibes (at 30fps):
|
||||
|
||||
| Phase | Budget | Note |
|
||||
|---|---|---|
|
||||
| Enter | 12–18f (0.4–0.6s) | Staggered: bar → name → role |
|
||||
| Hold | 90–150f (3–5s) | The readable dwell; scale with line length |
|
||||
| Exit | 8–12f (0.27–0.4s) | Faster than enter |
|
||||
|
||||
In code, drive every state from the current frame (or a paused timeline), not wall-clock time — a video renderer demands deterministic frames. Express enter/hold/exit as a single progress curve so the same component works live in a browser and frame-by-frame in a renderer.
|
||||
|
||||
## Safe area & broadcast conventions
|
||||
|
||||
The lower third must survive every screen and platform UI. Compose inside the **title-safe area** (inner ~90% / margin 5%) and respect platform chrome:
|
||||
|
||||
| Frame | Anchor | Avoid |
|
||||
|---|---|---|
|
||||
| 16:9 (1920×1080) | Left edge at ~5% margin; baseline ~80–85% down | Outer 5% (title-safe), action-safe outer 10% |
|
||||
| 9:16 (1080×1920) | Same left margin; **lift the band up** | Bottom ~18% (captions/UI), top ~12% |
|
||||
| 1:1 (1080×1080) | Lower band, center-left | Outer 5% |
|
||||
|
||||
Anchor to the **left** and grow rightward so names of different lengths stay pinned and never re-center awkwardly. Keep the role line within the same left margin as the name. For 9:16, do not park the title at the very bottom — platform captions and buttons sit there.
|
||||
|
||||
## Type hierarchy & brand styling
|
||||
|
||||
Hierarchy is the whole design. Make the name unmistakably dominant:
|
||||
|
||||
- **Size contrast** ≥ 1.6× (name vs role). If unsure, push the name bigger, not the role.
|
||||
- **Weight + color**: name in bold/near-white; role in a lighter weight and a **brand accent** color (one accent, used consistently).
|
||||
- **Contrast for legibility**: text rides over unknown footage. Use a solid/semi-opaque bar, a subtle scrim, or a soft text shadow so it reads over both bright and dark plates.
|
||||
- **Brand lock**: pin font, accent color, bar style, and corner radius in one theme object so every title across a series matches.
|
||||
|
||||
## Templating one design across a list
|
||||
|
||||
The reason to build this in code: change the text/data, re-export — no re-animating. Make name/role/theme **props**, never hardcoded, then map a roster to instances.
|
||||
|
||||
```jsx
|
||||
const ROSTER = [
|
||||
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
|
||||
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
|
||||
{ name: "Alan Turing", role: "Mathematician" },
|
||||
];
|
||||
// One <LowerThird> design; map the list → many instances (sequence them in a renderer)
|
||||
{ROSTER.map((p, i) => <LowerThird key={p.name} {...p} theme={BRAND} startFrame={i * 150} />)}
|
||||
```
|
||||
|
||||
In a renderer, make the roster an input prop and render once per row (`--props`) to emit one branded clip per person — same animation, same brand, only the text changes. See `references/lower-third-component.md` for the full runnable component and batch script.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Two lines only: dominant name, secondary role (≥1.6× size contrast).
|
||||
- Animated in and out (no hard cut); enter staggered bar→name→role; exit faster.
|
||||
- Hold 3–5s (readable twice); motion only at the ends, never mid-segment.
|
||||
- Left-anchored inside title-safe; 9:16 band lifted above the bottom-18% UI zone.
|
||||
- Text legible over any plate (bar/scrim/shadow); one brand accent throughout.
|
||||
- Name/role/theme are props — one template renders the whole roster.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
The lower third ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, name/role/theme as props) — enter/dwell/exit all driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per roster row). 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Sample the three phases so you catch a stagger or safe-area bug before encoding (or batching the whole roster).
|
||||
|
||||
```bash
|
||||
# Stills at enter / dwell / exit — WITH SHIPPED PROPS (a real roster row, not defaults)
|
||||
npx remotion still LowerThird out/f-enter.png --frame=10 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion still LowerThird out/f-dwell.png --frame=75 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion still LowerThird out/f-exit.png --frame=N --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
|
||||
# Inspect each PNG — focus the DWELL frame (fully on screen):
|
||||
# - hierarchy holds: name dominant (>=1.6x role), role in brand accent; both fully revealed, not mid-wipe
|
||||
# - brand lock: font, accent color, bar/scrim correct; legible over the plate
|
||||
# - 9:16 safe area: band LIFTED above the bottom ~20-35% UI zone, clear of top ~12% and the right action rail; left-anchored inside title-safe
|
||||
# - enter/exit frames show partial reveal (animation actually runs), not a hard cut
|
||||
|
||||
npx remotion render LowerThird out/lower-third.mp4 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
|
||||
npx remotion render LowerThird out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
**Batch roster**: verify ONE row via stills *before* rendering all names — catch a layout/overflow bug once, not N times. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the dwell + exit frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at enter, dwell, and exit — no missing font/brand assets.
|
||||
2. At the dwell frame: name dominant over role, brand accent + bar correct, fully (not partly) revealed.
|
||||
3. The band is inside the 9:16 safe area — lifted above the bottom UI zone, clear of top and right rail — at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; enter/exit show motion, never a hard cut.
|
||||
5. A real roster row (not `defaultProps`) renders correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/lower-third-component.md` — a complete runnable lower-third: a Remotion component with staggered spring enter, readable hold, and faster exit driven purely by frame; a typed theme object for brand lock; the list→many-instances sequencing pattern; a per-row batch render script; plus a dependency-free CSS/HTML version of the same design with mask-reveal and wipe-bar variants.
|
||||
@@ -1,268 +0,0 @@
|
||||
# Lower-Third Component — runnable code
|
||||
|
||||
A complete, production-grade lower third you can drop in. Two paths:
|
||||
|
||||
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
|
||||
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
|
||||
|
||||
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Remotion component (props-driven, frame-deterministic)
|
||||
|
||||
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
|
||||
|
||||
```tsx
|
||||
// LowerThird.tsx
|
||||
import {
|
||||
AbsoluteFill, Sequence, interpolate, spring,
|
||||
useCurrentFrame, useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
// --- Brand lock: change once, every title matches -------------------------
|
||||
export type Theme = {
|
||||
font: string;
|
||||
bar: string; // panel/bar color
|
||||
name: string; // primary text color
|
||||
role: string; // accent color for the role line
|
||||
radius: number;
|
||||
marginPct: number; // left/safe margin as % of width
|
||||
};
|
||||
export const BRAND: Theme = {
|
||||
font: "Inter, system-ui, sans-serif",
|
||||
bar: "rgba(12,14,22,0.86)",
|
||||
name: "#FFFFFF",
|
||||
role: "#6EE7F0", // single brand accent
|
||||
radius: 10,
|
||||
marginPct: 5,
|
||||
};
|
||||
|
||||
export type LowerThirdProps = {
|
||||
name: string;
|
||||
role: string;
|
||||
theme?: Theme;
|
||||
holdSeconds?: number; // readable dwell, default 4s
|
||||
};
|
||||
|
||||
// Staggered enter via spring; exit via a quick fade/slide near the end.
|
||||
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
// ENTER: spring 0→1, offset by delayFrames (the cascade)
|
||||
const enter = spring({
|
||||
frame: frame - delayFrames,
|
||||
fps,
|
||||
config: { damping: 200, stiffness: 180, mass: 0.6 },
|
||||
});
|
||||
|
||||
// EXIT: faster than enter — last 10 frames of the clip
|
||||
const exitStart = durationInFrames - 10;
|
||||
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const opacity = enter * exit;
|
||||
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
|
||||
return { opacity, x };
|
||||
};
|
||||
|
||||
const Line: React.FC<{
|
||||
delay: number; holdSeconds: number; children: React.ReactNode;
|
||||
size: number; weight: number; color: string; tracking?: number;
|
||||
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
|
||||
const { opacity, x } = useEnterExit(delay, holdSeconds);
|
||||
return (
|
||||
<div style={{
|
||||
opacity,
|
||||
transform: `translateX(${x}px)`,
|
||||
fontSize: size, fontWeight: weight, color,
|
||||
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LowerThird: React.FC<LowerThirdProps> = ({
|
||||
name, role, theme = BRAND, holdSeconds = 4,
|
||||
}) => {
|
||||
const { width, height } = useVideoConfig();
|
||||
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
|
||||
|
||||
const left = (width * theme.marginPct) / 100;
|
||||
const baseline = height * 0.80; // sit ~80% down, inside title-safe
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ fontFamily: theme.font }}>
|
||||
<div style={{
|
||||
position: "absolute", left, top: baseline,
|
||||
opacity: bar.opacity,
|
||||
transform: `translateX(${bar.x}px)`,
|
||||
}}>
|
||||
{/* panel behind text guarantees legibility over any plate */}
|
||||
<div style={{
|
||||
background: theme.bar, borderRadius: theme.radius,
|
||||
padding: "14px 22px 16px",
|
||||
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
|
||||
backdropFilter: "blur(6px)",
|
||||
}}>
|
||||
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
|
||||
{name}
|
||||
</Line>
|
||||
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
|
||||
{role}
|
||||
</Line>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Why each choice matters:
|
||||
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
|
||||
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
|
||||
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
|
||||
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
|
||||
|
||||
### Roster → many instances (one design, a whole panel)
|
||||
|
||||
Sequence the same component down a list. Each person gets a slot; only the text changes.
|
||||
|
||||
```tsx
|
||||
// Roster.tsx
|
||||
import { Series } from "remotion";
|
||||
import { LowerThird, BRAND } from "./LowerThird";
|
||||
|
||||
const ROSTER = [
|
||||
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
|
||||
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
|
||||
{ name: "Alan Turing", role: "Mathematician" },
|
||||
];
|
||||
|
||||
export const Roster: React.FC = () => (
|
||||
<Series>
|
||||
{ROSTER.map((p) => (
|
||||
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
|
||||
<LowerThird name={p.name} role={p.role} theme={BRAND} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
);
|
||||
```
|
||||
|
||||
### Batch: one branded clip per person
|
||||
|
||||
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# render one transparent-friendly lower-third clip per person in roster.json
|
||||
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
|
||||
set -euo pipefail
|
||||
mkdir -p out
|
||||
jq -c '.[]' roster.json | while read -r row; do
|
||||
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
|
||||
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
|
||||
done
|
||||
```
|
||||
|
||||
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS / HTML version (no dependencies)
|
||||
|
||||
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
|
||||
|
||||
```html
|
||||
<div class="lt" style="--accent:#6EE7F0">
|
||||
<div class="lt-bar">
|
||||
<div class="lt-name">Ada Lovelace</div>
|
||||
<div class="lt-role">Founder, Analytical Engines</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:root { font-family: Inter, system-ui, sans-serif; }
|
||||
|
||||
.lt {
|
||||
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
|
||||
}
|
||||
.lt-bar {
|
||||
display: inline-block;
|
||||
background: rgba(12,14,22,0.86);
|
||||
backdrop-filter: blur(6px);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 14px 22px 16px;
|
||||
/* bar leads the cascade: slide + fade in, then a faster fade out */
|
||||
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
|
||||
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
|
||||
}
|
||||
.lt-name {
|
||||
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
|
||||
line-height: 1.15; white-space: nowrap;
|
||||
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
|
||||
}
|
||||
.lt-role {
|
||||
font-size: 26px; font-weight: 500; color: var(--accent);
|
||||
line-height: 1.15; white-space: nowrap;
|
||||
opacity: 0;
|
||||
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
|
||||
}
|
||||
|
||||
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
|
||||
to { opacity: 1; transform: none; } }
|
||||
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
|
||||
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
|
||||
@keyframes lt-fade { to { opacity: 1; } }
|
||||
|
||||
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
|
||||
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
|
||||
</style>
|
||||
```
|
||||
|
||||
### Wipe-bar variant (broadcast feel)
|
||||
|
||||
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
|
||||
|
||||
```css
|
||||
.lt-bar.wipe {
|
||||
position: relative; overflow: hidden;
|
||||
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
|
||||
lt-out .3s ease-in 4.5s forwards;
|
||||
}
|
||||
.lt-bar.wipe::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: var(--accent);
|
||||
transform-origin: left;
|
||||
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
|
||||
}
|
||||
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
|
||||
100% { transform: scaleX(0); transform-origin: right; } }
|
||||
```
|
||||
|
||||
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Knob |
|
||||
|---|---|
|
||||
| Slower/faster reveal | enter duration 12–18f / `.5s` |
|
||||
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
|
||||
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
|
||||
| Rebrand everything | the `BRAND` theme object / `--accent` |
|
||||
| 9:16 placement | `baseline 0.80→0.70` / the media query |
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
|
||||
@@ -1,24 +0,0 @@
|
||||
# Short-Form Video
|
||||
|
||||
Engineer a 9:16 video that survives the swipe. Short-form is a retention machine, not a small launch film: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish. For creators making Reels, TikToks, and YouTube Shorts (7–60s, vertical).
|
||||
|
||||
## When it activates
|
||||
|
||||
- "Make a Reels/TikTok/YouTube Shorts video" or "build a short-form video template."
|
||||
- "Write a hook for a short" or "fix the first 3 seconds."
|
||||
- "Edit for retention," "add pattern interrupts," or "pace cuts for a vertical video."
|
||||
- "Make a video loop" or "set up 9:16 safe areas."
|
||||
|
||||
## Example prompts
|
||||
|
||||
- "Build a reusable 9:16 short-form template — one design, many topics."
|
||||
- "Fix the drop-off in this Reel: tighten pacing and add pattern interrupts."
|
||||
- "Write three hook options for a 30-second educational short."
|
||||
|
||||
## What's inside
|
||||
|
||||
- `SKILL.md` — the retention arc, hook grammar, and pacing rules.
|
||||
- `references/retention-pacing.md` — deeper guidance on retention editing and pacing.
|
||||
|
||||
---
|
||||
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=short-form-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
name: short-form-video
|
||||
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Short-Form Video
|
||||
|
||||
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
|
||||
|
||||
## When to use
|
||||
|
||||
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
|
||||
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
|
||||
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
|
||||
|
||||
## Story structure (the retention contract)
|
||||
|
||||
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
|
||||
|
||||
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
|
||||
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
|
||||
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
|
||||
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
|
||||
|
||||
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
|
||||
|
||||
| Move | Story job | Failure if missed |
|
||||
|---|---|---|
|
||||
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
|
||||
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
|
||||
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
|
||||
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
|
||||
|
||||
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
|
||||
|
||||
## The one metric that drives everything
|
||||
|
||||
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
|
||||
|
||||
## The retention arc
|
||||
|
||||
| Beat | Job | Budget (of a 20s short) |
|
||||
|---|---|---|
|
||||
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
|
||||
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
|
||||
| Body | Deliver value in beats; one idea per beat | 4–17s |
|
||||
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
|
||||
|
||||
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
|
||||
|
||||
## Hook grammar (first ~1s)
|
||||
|
||||
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
|
||||
|
||||
| Hook type | Shape | Best for |
|
||||
|---|---|---|
|
||||
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
|
||||
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
|
||||
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
|
||||
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
|
||||
|
||||
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
|
||||
|
||||
## Pattern interrupts & cut rhythm
|
||||
|
||||
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
|
||||
|
||||
```js
|
||||
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
|
||||
import { useCurrentFrame } from "remotion";
|
||||
const fps = 30;
|
||||
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
|
||||
const frame = useCurrentFrame();
|
||||
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
|
||||
```
|
||||
|
||||
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
|
||||
|
||||
```jsx
|
||||
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
|
||||
const PunchIn = ({ at, children }) => {
|
||||
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
|
||||
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
|
||||
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
|
||||
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Captions
|
||||
|
||||
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
|
||||
|
||||
## Loops & re-watch
|
||||
|
||||
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
|
||||
|
||||
## 9:16 safe areas
|
||||
|
||||
Compose inside the universal safe zone so platform UI never covers text or faces.
|
||||
|
||||
| Zone | Pixels (1080×1920) | Avoid |
|
||||
|---|---|---|
|
||||
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
|
||||
| Top | keep clear ~120px | profile / sound UI |
|
||||
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
|
||||
| Right | keep clear ~120px | like / comment / share rail |
|
||||
|
||||
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
|
||||
- One open loop / promise set in the first ~4s.
|
||||
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
|
||||
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
|
||||
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
|
||||
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
|
||||
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
|
||||
|
||||
```bash
|
||||
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
|
||||
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
|
||||
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
|
||||
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
|
||||
|
||||
# Inspect each PNG:
|
||||
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
|
||||
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
|
||||
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
|
||||
|
||||
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
|
||||
npx remotion render Short out/demo.gif --codec=gif # README proof clip
|
||||
```
|
||||
|
||||
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
|
||||
|
||||
**Before you finish:**
|
||||
1. Stills render cleanly at the hook, mid, and last frame — no errors.
|
||||
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
|
||||
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
|
||||
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
|
||||
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.
|
||||
@@ -1,209 +0,0 @@
|
||||
# Retention Pacing — Short-Form Video Reference
|
||||
|
||||
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
|
||||
|
||||
## 1. A complete `<Short>` composition
|
||||
|
||||
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
|
||||
|
||||
```tsx
|
||||
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig,
|
||||
interpolate, spring, Sequence, Img,
|
||||
} from "remotion";
|
||||
|
||||
type Beat = { at: number; text: string; src?: string }; // at = seconds
|
||||
export type ShortProps = {
|
||||
hook: string; // on-screen by frame 1
|
||||
beats: Beat[]; // body, one idea each
|
||||
loopText: string; // sentence that feeds back to frame 0
|
||||
};
|
||||
|
||||
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
|
||||
const useShot = (cuts: number[]) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
let i = 0;
|
||||
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
|
||||
const startF = cuts[i] * fps;
|
||||
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
|
||||
const local = (frame - startF) / (endF - startF); // 0→1 within shot
|
||||
return { index: i, local, startF };
|
||||
};
|
||||
|
||||
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
|
||||
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
|
||||
startF, to = 1.1, children,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
|
||||
const scale = interpolate(s, [0, 1], [1, to]);
|
||||
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
|
||||
};
|
||||
|
||||
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
|
||||
const Hook: React.FC<{ text: string }> = ({ text }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
|
||||
const scale = interpolate(pop, [0, 1], [0.9, 1]);
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
|
||||
transform: `scale(${scale})`, transformOrigin: "0 0",
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
|
||||
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
|
||||
}}>{text}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
|
||||
const { index, startF } = useShot(cuts);
|
||||
const current = index === 0 ? null : beats[index - 1];
|
||||
|
||||
// seamless loop: match the LAST 0.5s back toward the opening frame
|
||||
const tailF = durationInFrames - frame;
|
||||
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
|
||||
<PunchIn startF={startF}>
|
||||
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
|
||||
</PunchIn>
|
||||
|
||||
{index === 0 && <Hook text={hook} />}
|
||||
|
||||
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
|
||||
{current && (
|
||||
<div style={{
|
||||
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
|
||||
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
|
||||
}}>{current.text}</div>
|
||||
)}
|
||||
|
||||
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
|
||||
<div style={{
|
||||
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
|
||||
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
|
||||
}}>{loopText}</div>
|
||||
|
||||
<SafeZone />{/* remove for final render */}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it with a duration that *is* the loop length:
|
||||
|
||||
```ts
|
||||
// Root.tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Short } from "./Short";
|
||||
export const Root = () => (
|
||||
<Composition
|
||||
id="Short" component={Short}
|
||||
durationInFrames={600} fps={30} width={1080} height={1920}
|
||||
defaultProps={{
|
||||
hook: "You're editing Shorts wrong —",
|
||||
beats: [
|
||||
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
|
||||
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
|
||||
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
|
||||
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
|
||||
],
|
||||
loopText: "…and that's why mine loop forever",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## 2. The 9:16 safe-area overlay (dev-only)
|
||||
|
||||
Render this on top while editing; delete it before the final export.
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill } from "remotion";
|
||||
export const SafeZone: React.FC = () => (
|
||||
<AbsoluteFill style={{ pointerEvents: "none" }}>
|
||||
{/* universal safe box: center 900×1400 in 1080×1920 */}
|
||||
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
|
||||
{/* platform UI bands to keep clear */}
|
||||
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
|
||||
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
|
||||
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
|
||||
## 3. Hook pattern library
|
||||
|
||||
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
|
||||
|
||||
| Pattern | Template | Note |
|
||||
|---|---|---|
|
||||
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
|
||||
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
|
||||
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
|
||||
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
|
||||
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
|
||||
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
|
||||
|
||||
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
|
||||
|
||||
## 4. Retention-curve debugging
|
||||
|
||||
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
|
||||
|
||||
| Symptom on the curve | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
|
||||
| Steady gentle decline | normal — leave it | — |
|
||||
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
|
||||
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
|
||||
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
|
||||
|
||||
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
|
||||
|
||||
## 5. Per-platform safe zones
|
||||
|
||||
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
|
||||
|
||||
| Platform | Top clear | Bottom clear | Right clear | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
|
||||
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
|
||||
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
|
||||
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
|
||||
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
|
||||
|
||||
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
|
||||
|
||||
## 6. Template × topic — batch output
|
||||
|
||||
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
|
||||
|
||||
```jsx
|
||||
// theme.ts — one source of truth so 50 shorts stay on-brand
|
||||
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
|
||||
```
|
||||
|
||||
```bash
|
||||
# render the same Short template for every topic file in /topics
|
||||
for f in topics/*.json; do
|
||||
name=$(basename "$f" .json)
|
||||
npx remotion render Short "out/$name.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/caption-animation
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/countdown-video
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/lower-thirds
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: remotion-video
|
||||
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
|
||||
---
|
||||
|
||||
# Remotion Video
|
||||
|
||||
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
|
||||
|
||||
## Core ideas
|
||||
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
|
||||
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
|
||||
- **`interpolate`**: maps a frame range to a value range (fade, slide).
|
||||
- **`spring`**: natural bouncy motion.
|
||||
- **`Sequence`**: shows children only during a frame range.
|
||||
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
|
||||
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
|
||||
|
||||
## Workflow
|
||||
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
|
||||
2. **Set up** (skip if the project exists):
|
||||
```
|
||||
npx create-video@latest
|
||||
cd <project> && npm install
|
||||
```
|
||||
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
|
||||
4. **Preview**: `npm run dev` (opens Remotion Studio).
|
||||
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
|
||||
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
|
||||
6. **Verify**: render one still first, check it looks right, then render the full video.
|
||||
|
||||
## Starter example
|
||||
`src/Root.tsx`
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Hello } from "./Hello";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Hello"
|
||||
component={Hello}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello, world" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`src/Hello.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Hello = ({ title }: { title: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Rules of thumb
|
||||
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
|
||||
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
|
||||
- Seconds to frames: `seconds * fps`.
|
||||
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
|
||||
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
|
||||
- Keep props in `defaultProps` so the user can edit text in the Studio.
|
||||
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
|
||||
|
||||
## Fonts
|
||||
Google Fonts: `npm i @remotion/google-fonts`, then
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
|
||||
```
|
||||
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
|
||||
|
||||
## Common problems
|
||||
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
|
||||
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
|
||||
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
|
||||
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
|
||||
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
|
||||
|
||||
## Templates
|
||||
Copy-paste starting points. Read the one that fits:
|
||||
- `templates/captions.md` - words highlighted in time with speech
|
||||
- `templates/charts.md` - animated bar chart
|
||||
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
|
||||
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
|
||||
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
|
||||
|
||||
## Docs
|
||||
https://www.remotion.dev/docs
|
||||
@@ -1,62 +0,0 @@
|
||||
# Template: Audio, music and voiceover
|
||||
|
||||
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
|
||||
|
||||
`src/Narrated.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
|
||||
import { getAudioDurationInSeconds } from "@remotion/media-utils";
|
||||
import type { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
type Props = { title: string; voice: string; music: string };
|
||||
|
||||
// Make the video exactly as long as the voiceover (+1 second of padding).
|
||||
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
|
||||
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
|
||||
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
|
||||
};
|
||||
|
||||
export const Narrated = ({ title, voice, music }: Props) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
|
||||
|
||||
{/* Background music: quiet, fades out over the last second. */}
|
||||
<Audio
|
||||
src={staticFile(music)}
|
||||
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
|
||||
/>
|
||||
|
||||
{/* Voiceover starts half a second in, full volume. */}
|
||||
<Sequence from={Math.round(fps * 0.5)}>
|
||||
<Audio src={staticFile(voice)} volume={1} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it (note `calculateMetadata`):
|
||||
```tsx
|
||||
<Composition
|
||||
id="Narrated"
|
||||
component={Narrated}
|
||||
calculateMetadata={calcNarratedMetadata}
|
||||
durationInFrames={300} // placeholder, replaced by calculateMetadata
|
||||
fps={30} width={1920} height={1080}
|
||||
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
|
||||
/>
|
||||
```
|
||||
|
||||
Install: `npm i @remotion/media-utils`
|
||||
|
||||
Getting a voiceover
|
||||
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
|
||||
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
|
||||
|
||||
Rules
|
||||
- Music volume 0.1-0.2 so the voice stays clear.
|
||||
- `<Sequence from={...}>` delays when a sound starts.
|
||||
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
|
||||
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Template: Captions
|
||||
|
||||
Words appear in time with speech. Give each word a start time in seconds.
|
||||
|
||||
`src/Captions.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Word = { text: string; start: number; end: number }; // seconds
|
||||
|
||||
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
|
||||
// Show a window of up to 5 words around the current one.
|
||||
const current = words.findIndex((w) => t >= w.start && t < w.end);
|
||||
const i = current === -1 ? 0 : current;
|
||||
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
|
||||
{visible.map((w, k) => (
|
||||
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
|
||||
{w.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
|
||||
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
|
||||
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Template: Animated bar chart
|
||||
|
||||
Bars grow one after another.
|
||||
|
||||
`src/BarChart.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Bar = { label: string; value: number };
|
||||
|
||||
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
|
||||
{data.map((d, i) => {
|
||||
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
|
||||
return (
|
||||
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
|
||||
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
|
||||
<div style={{ marginTop: 16 }}>{d.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Delay each bar with `frame - i * 6` (6 frames apart).
|
||||
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
|
||||
- Keep numbers readable: show the value as text, rounded.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Template: Title intro / outro card
|
||||
|
||||
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
|
||||
|
||||
`src/TitleCard.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const rise = spring({ frame, fps, config: { damping: 14 } });
|
||||
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
|
||||
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
|
||||
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Chain with the main video using `<Series>`:
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
# Template: Vertical clips (Reels / TikTok / Shorts)
|
||||
|
||||
Same code, different size. Register a vertical composition:
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="Vertical"
|
||||
component={MyScene}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={30}
|
||||
durationInFrames={30 * 30} // 30 seconds
|
||||
/>
|
||||
```
|
||||
|
||||
Render: `npx remotion render Vertical out/vertical.mp4`
|
||||
|
||||
Rules
|
||||
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
|
||||
- Use big text (60px+). Phone screens are small.
|
||||
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
|
||||
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
|
||||
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/short-form-video
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,46 @@
|
||||
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
|
||||
|
||||
## The restaurant analogy
|
||||
| Stage | Restaurant | This project |
|
||||
|---|---|---|
|
||||
| Phase 1 (done) | Food stand: one great dish, open today | The full single-page site with Keith's real content, photos and animations |
|
||||
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
||||
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
||||
|
||||
Front of house = what visitors see. Back of house = forms, email, storage, security.
|
||||
|
||||
## Core features
|
||||
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
|
||||
2. Marquee: two rows of GIFs sliding left/right as you scroll
|
||||
3. About: floating 3D icons + words that light up as you scroll
|
||||
4. Services: white panel, 5 numbered services
|
||||
5. Projects: 3 cards that stack and shrink as you scroll
|
||||
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
|
||||
|
||||
## Phased roadmap
|
||||
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
|
||||
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
|
||||
|
||||
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
|
||||
|
||||
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
|
||||
|
||||
## Test ladder (each step must pass before the next)
|
||||
1. Page loads with no errors
|
||||
2. Buttons / links / forms work
|
||||
3. Real external APIs/assets work (images load)
|
||||
4. Security check (no secrets in code, safe external links, dependency audit)
|
||||
|
||||
## Security notes
|
||||
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
||||
|
||||
## Phase 1 status (done)
|
||||
- Content comes from Keith's existing portfolio: profile, 8 capabilities, 3 roles + 5 earlier roles, 4 projects, 10 certifications, contact details (all in `src/data/content.ts`).
|
||||
- Each project card has its own illustrative diagram (drawn from the deliverables in Keith's portfolio text; swap for real screenshots when available).
|
||||
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
|
||||
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
|
||||
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
|
||||
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
|
||||
|
||||
## Not used yet
|
||||
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
|
||||
<title>Keith Rana -- IT Service Delivery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "keith-portfolio",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Builds ONE self-contained HTML page for the Claude cloud preview.
|
||||
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
|
||||
// Usage: node scripts/build-preview.mjs <output.html>
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const out = process.argv[2] ?? 'preview.html';
|
||||
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
|
||||
|
||||
const dir = 'dist/assets/';
|
||||
const files = readdirSync(dir);
|
||||
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
|
||||
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
|
||||
|
||||
const html = `<meta charset="utf-8">
|
||||
<title>Keith Rana Portfolio</title>
|
||||
<style>${css}
|
||||
html,body{background:#0C0C0C}</style>
|
||||
<div id="root"></div>
|
||||
<script type="module">${js}</script>
|
||||
`;
|
||||
writeFileSync(out, html);
|
||||
const mb = Buffer.byteLength(html) / 1048576;
|
||||
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
|
||||
@@ -0,0 +1,26 @@
|
||||
import HeroSection from './sections/HeroSection';
|
||||
import MarqueeSection from './sections/MarqueeSection';
|
||||
import AboutSection from './sections/AboutSection';
|
||||
import ServicesSection from './sections/ServicesSection';
|
||||
import ExperienceSection from './sections/ExperienceSection';
|
||||
import ProjectsSection from './sections/ProjectsSection';
|
||||
import CredentialsSection from './sections/CredentialsSection';
|
||||
import ContactSection from './sections/ContactSection';
|
||||
import { ContactProvider } from './components/ContactDialog';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ContactProvider>
|
||||
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
|
||||
<HeroSection />
|
||||
<MarqueeSection />
|
||||
<AboutSection />
|
||||
<ServicesSection />
|
||||
<ExperienceSection />
|
||||
<ProjectsSection />
|
||||
<CredentialsSection />
|
||||
<ContactSection />
|
||||
</main>
|
||||
</ContactProvider>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 641 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 277 KiB |
@@ -0,0 +1,30 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import type { MotionValue } from 'framer-motion';
|
||||
|
||||
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
|
||||
const start = index / total;
|
||||
const end = start + 1 / total;
|
||||
const opacity = useTransform(progress, [start, end], [0.2, 1]);
|
||||
return (
|
||||
<span className="relative">
|
||||
<span className="invisible">{char}</span>
|
||||
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
|
||||
const chars = Array.from(text);
|
||||
return (
|
||||
<p ref={ref} className={className} style={style} aria-label={text}>
|
||||
{chars.map((c, i) =>
|
||||
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useOpenContact } from './ContactDialog';
|
||||
|
||||
export default function ContactButton() {
|
||||
const openContact = useOpenContact();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openContact}
|
||||
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
||||
style={{
|
||||
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
||||
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
|
||||
outline: '2px solid white',
|
||||
outlineOffset: '-3px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Contact Me
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, Copy, Mail, X } from 'lucide-react';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
const OpenContext = createContext<() => void>(() => {});
|
||||
export const useOpenContact = () => useContext(OpenContext);
|
||||
|
||||
const subject = encodeURIComponent('Portfolio enquiry');
|
||||
const to = encodeURIComponent(profile.email);
|
||||
const options = [
|
||||
{ label: 'Open my email app', href: `mailto:${profile.email}?subject=${subject}`, newTab: false },
|
||||
{ label: 'Write in Gmail', href: `https://mail.google.com/mail/?view=cm&fs=1&to=${to}&su=${subject}`, newTab: true },
|
||||
{ label: 'Write in Yahoo Mail', href: `https://compose.mail.yahoo.com/?to=${to}&subject=${subject}`, newTab: true },
|
||||
];
|
||||
|
||||
const optionClass =
|
||||
'flex items-center justify-between gap-3 rounded-full border-2 border-[#D7E2EA] px-6 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:text-base';
|
||||
|
||||
export function ContactProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setCopied(false);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const hide = useCallback(() => {
|
||||
setOpen(false);
|
||||
opener.current?.focus?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, hide]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.email);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OpenContext.Provider value={show}>
|
||||
{children}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && hide()}
|
||||
>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="contact-title" className="relative flex w-full max-w-md flex-col gap-5 rounded-[32px] border-2 border-[#D7E2EA] bg-[#0C0C0C] p-6 sm:p-8">
|
||||
<button ref={closeRef} onClick={hide} aria-label="Close" className="absolute right-4 top-4 rounded-full p-2 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<X size={22} />
|
||||
</button>
|
||||
<h2 id="contact-title" className="hero-heading pr-10 text-4xl font-black uppercase leading-none sm:text-5xl">Email Keith</h2>
|
||||
<p className="font-light leading-relaxed text-[#D7E2EA]/80">Pick how you would like to write. Your message goes to the address below.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="contact-email"
|
||||
readOnly
|
||||
value={profile.email}
|
||||
aria-label="Email address"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="min-w-0 flex-1 rounded-full border border-[#D7E2EA]/40 bg-transparent px-5 py-3 text-[#D7E2EA]"
|
||||
/>
|
||||
<button onClick={copy} className="flex shrink-0 items-center gap-2 rounded-full border border-[#D7E2EA]/40 px-4 py-3 text-sm text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{options.map((o) => (
|
||||
<a key={o.label} href={o.href} className={optionClass} {...(o.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
|
||||
{o.label}
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpenContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { MotionProps } from 'framer-motion';
|
||||
import type { ComponentType, ElementType, ReactNode } from 'react';
|
||||
|
||||
type FadeInProps = {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
className?: string;
|
||||
as?: ElementType;
|
||||
};
|
||||
|
||||
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
|
||||
const getMotion = (el: ElementType) => {
|
||||
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
|
||||
return cache.get(el)!;
|
||||
};
|
||||
|
||||
export default function FadeIn({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 0.7,
|
||||
x = 0,
|
||||
y = 30,
|
||||
className,
|
||||
as = 'div',
|
||||
}: FadeInProps) {
|
||||
const Component = getMotion(as);
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
initial={{ opacity: 0, x, y }}
|
||||
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
||||
viewport={{ once: true, margin: '50px', amount: 0 }}
|
||||
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MagnetProps = {
|
||||
children: ReactNode;
|
||||
padding?: number;
|
||||
strength?: number;
|
||||
activeTransition?: string;
|
||||
inactiveTransition?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Magnet({
|
||||
children,
|
||||
padding = 100,
|
||||
strength = 2,
|
||||
activeTransition = 'transform 0.3s ease-out',
|
||||
inactiveTransition = 'transform 0.6s ease-in-out',
|
||||
className,
|
||||
}: MagnetProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!ref.current) return;
|
||||
const { left, top, width, height } = ref.current.getBoundingClientRect();
|
||||
const cx = left + width / 2;
|
||||
const cy = top + height / 2;
|
||||
const dx = Math.abs(cx - e.clientX);
|
||||
const dy = Math.abs(cy - e.clientY);
|
||||
if (dx < width / 2 + padding && dy < height / 2 + padding) {
|
||||
setActive(true);
|
||||
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
|
||||
} else {
|
||||
setActive(false);
|
||||
setPos({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
return () => window.removeEventListener('mousemove', onMove);
|
||||
}, [padding, strength]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
transition: active ? activeTransition : inactiveTransition,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export type DiagramKind = 'network' | 'server' | 'sccm' | 'voice';
|
||||
|
||||
const INK = '#D7E2EA';
|
||||
const TEAL = '#2BB3A6';
|
||||
const MUTED = 'rgba(215,226,234,0.55)';
|
||||
const FILL = '#14161a';
|
||||
|
||||
function Box({ x, y, w, h, label, sub, accent }: { x: number; y: number; w: number; h: number; label: string; sub?: string; accent?: boolean }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={14} fill={FILL} stroke={accent ? TEAL : INK} strokeWidth={accent ? 2.5 : 1.5} />
|
||||
<text x={x + w / 2} y={y + h / 2 + (sub ? -4 : 8)} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>{label}</text>
|
||||
{sub && <text x={x + w / 2} y={y + h / 2 + 22} textAnchor="middle" fontSize={18} fill={MUTED}>{sub}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ x1, y1, x2, y2, dashed }: { x1: number; y1: number; x2: number; y2: number; dashed?: boolean }) {
|
||||
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={MUTED} strokeWidth={2} strokeDasharray={dashed ? '6 6' : undefined} />;
|
||||
}
|
||||
|
||||
function Arrow({ x1, y, x2, label }: { x1: number; y: number; x2: number; label?: string }) {
|
||||
return (
|
||||
<g>
|
||||
<line x1={x1} y1={y} x2={x2 - 10} y2={y} stroke={TEAL} strokeWidth={3} />
|
||||
<path d={`M${x2 - 14} ${y - 8} L${x2} ${y} L${x2 - 14} ${y + 8} Z`} fill={TEAL} />
|
||||
{label && <text x={(x1 + x2) / 2} y={y - 14} textAnchor="middle" fontSize={20} fill={TEAL}>{label}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ cx, cy, r = 14 }: { cx: number; cy: number; r?: number }) {
|
||||
return <circle cx={cx} cy={cy} r={r} fill={FILL} stroke={TEAL} strokeWidth={2.5} />;
|
||||
}
|
||||
|
||||
function Wifi({ cx, cy }: { cx: number; cy: number }) {
|
||||
return (
|
||||
<g fill="none" stroke={TEAL} strokeWidth={2.5} strokeLinecap="round">
|
||||
<path d={`M${cx - 18} ${cy - 4} Q${cx} ${cy - 22} ${cx + 18} ${cy - 4}`} />
|
||||
<path d={`M${cx - 10} ${cy + 4} Q${cx} ${cy - 6} ${cx + 10} ${cy + 4}`} />
|
||||
<circle cx={cx} cy={cy + 12} r={2.5} fill={TEAL} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const diagrams: Record<DiagramKind, { label: string; body: ReactNode }> = {
|
||||
network: {
|
||||
label: 'Network topology: Fortinet firewall connects to Fortinet 48P switches, which connect to access points.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={190} y={30} w={220} h={64} label="Fortinet firewall" accent />
|
||||
<Line x1={300} y1={94} x2={300} y2={130} />
|
||||
<Line x1={150} y1={130} x2={450} y2={130} />
|
||||
<Line x1={150} y1={130} x2={150} y2={160} />
|
||||
<Line x1={450} y1={130} x2={450} y2={160} />
|
||||
<Box x={50} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
<Box x={350} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
{[90, 210, 390, 510].map((x, i) => (
|
||||
<g key={x}>
|
||||
<Line x1={i < 2 ? 150 : 450} y1={224} x2={x} y2={274} dashed />
|
||||
<Dot cx={x} cy={296} r={22} />
|
||||
<Wifi cx={x} cy={292} />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={372} textAnchor="middle" fontSize={20} fill={MUTED}>Access point uplift</text>
|
||||
<text x={300} y={400} textAnchor="middle" fontSize={20} fill={MUTED}>Network topology upgrade</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
server: {
|
||||
label: 'Server upgrade: virtual machines and services move from the existing server to the upgraded server, with updated network settings.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={70} w={190} h={200} label="" />
|
||||
<text x={115} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Existing server</text>
|
||||
<Box x={40} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={40} y={192} w={150} h={44} label="Services" />
|
||||
<Arrow x1={222} y={170} x2={378} label="Data migrated" />
|
||||
<Box x={390} y={70} w={190} h={200} label="" accent />
|
||||
<text x={485} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Upgraded server</text>
|
||||
<Box x={410} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={410} y={192} w={150} h={44} label="Services" />
|
||||
<Line x1={485} y1={270} x2={485} y2={320} dashed />
|
||||
<Box x={150} y={320} w={320} h={56} label="Updated network settings" />
|
||||
<Line x1={470} y1={348} x2={485} y2={348} dashed />
|
||||
</>
|
||||
),
|
||||
},
|
||||
sccm: {
|
||||
label: 'SCCM implementation: install and configure SCCM, prepare settings, then establish the task-sequence process for endpoints.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={200} y={26} w={200} h={64} label="SCCM" accent />
|
||||
<Line x1={300} y1={90} x2={300} y2={122} />
|
||||
<Box x={40} y={122} w={150} h={92} label="Install" sub="& configure" />
|
||||
<Box x={225} y={122} w={150} h={92} label="Prepare" sub="settings" />
|
||||
<Box x={410} y={122} w={150} h={92} label="Task" sub="sequence" accent />
|
||||
<Arrow x1={190} y={168} x2={225} />
|
||||
<Arrow x1={375} y={168} x2={410} />
|
||||
<Line x1={485} y1={214} x2={485} y2={262} dashed />
|
||||
<Line x1={150} y1={262} x2={450} y2={262} dashed />
|
||||
{[150, 250, 350, 450].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={262} x2={x} y2={292} dashed />
|
||||
<rect x={x - 30} y={292} width={60} height={40} rx={6} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<line x1={x - 40} y1={344} x2={x + 40} y2={344} stroke={TEAL} strokeWidth={3} strokeLinecap="round" />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={392} textAnchor="middle" fontSize={20} fill={MUTED}>Endpoints built through the task sequence</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
voice: {
|
||||
label: 'Microsoft Teams calling: number porting, dial-plan and call-flow design, then users supported by training, documentation and adoption monitoring.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={50} w={160} h={70} label="Number" sub="porting" />
|
||||
<Box x={220} y={50} w={160} h={70} label="Dial plan" accent />
|
||||
<Box x={420} y={50} w={160} h={70} label="Call flow" />
|
||||
<Arrow x1={180} y={85} x2={220} />
|
||||
<Arrow x1={380} y={85} x2={420} />
|
||||
<Line x1={300} y1={120} x2={300} y2={168} dashed />
|
||||
<Line x1={100} y1={168} x2={500} y2={168} dashed />
|
||||
{[100, 300, 500].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={168} x2={x} y2={196} dashed />
|
||||
<circle cx={x} cy={214} r={16} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<path d={`M${x - 28} ${264} Q${x} ${226} ${x + 28} ${264}`} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
</g>
|
||||
))}
|
||||
<text x={100} y={310} textAnchor="middle" fontSize={20} fill={INK}>User training</text>
|
||||
<text x={300} y={310} textAnchor="middle" fontSize={20} fill={INK}>Documentation</text>
|
||||
<text x={500} y={310} textAnchor="middle" fontSize={20} fill={INK}>Adoption monitoring</text>
|
||||
<text x={300} y={380} textAnchor="middle" fontSize={20} fill={MUTED}>Microsoft Teams calling</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProjectDiagram({ kind, className, style }: { kind: DiagramKind; className?: string; style?: CSSProperties }) {
|
||||
const d = diagrams[kind];
|
||||
return (
|
||||
<svg viewBox="0 0 600 420" role="img" aria-label={`Illustrative diagram. ${d.label}`} className={className} style={{ fontFamily: 'Kanit, sans-serif', ...style }}>
|
||||
{d.body}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import portrait from '../assets/portrait.jpg';
|
||||
import leadership from '../assets/leadership.webp';
|
||||
import systems from '../assets/systems.webp';
|
||||
import signalMark from '../assets/signal-mark.webp';
|
||||
import networkPhoto from '../assets/projects/network.jpg';
|
||||
import serverPhoto from '../assets/projects/server.jpg';
|
||||
import sccmPhoto from '../assets/projects/sccm.jpg';
|
||||
import voicePhoto from '../assets/projects/voice.jpg';
|
||||
|
||||
import type { DiagramKind } from '../components/ProjectDiagram';
|
||||
|
||||
export const images = { portrait, leadership, systems, signalMark };
|
||||
|
||||
export const profile = {
|
||||
name: 'Keith Rana',
|
||||
firstName: 'keith',
|
||||
role: 'IT Service Delivery Manager',
|
||||
location: 'Melbourne, Australia',
|
||||
email: 'keith_rana@yahoo.com',
|
||||
phone: '04 1668 7558',
|
||||
phoneHref: 'tel:+61416687558',
|
||||
tagline: 'an it service delivery manager keeping critical services steady while the work keeps moving',
|
||||
};
|
||||
|
||||
export const aboutText =
|
||||
'A results-driven IT professional with over 16 years of experience across telecommunications, education, and managed services, including more than 8 years leading technical teams. I make technology service delivery visible, stable, and continually better. Let\'s make delivery more dependable.';
|
||||
|
||||
export const stats = [
|
||||
{ value: '16+', label: 'Years in IT' },
|
||||
{ value: '8+', label: 'Years leading' },
|
||||
];
|
||||
|
||||
export const expertise = [
|
||||
{ name: 'IT Service Delivery', description: 'End-to-end accountability for service delivery against agreed SLAs, with reporting to senior stakeholders.' },
|
||||
{ name: 'Enterprise Infrastructure', description: 'Enterprise network, server and endpoint environments, including Fortinet, Zscaler and SCCM.' },
|
||||
{ name: 'ITIL & Change Control', description: 'ITIL process, measured change and service reporting that improve compliance and service quality.' },
|
||||
{ name: 'Incident & Escalation Leadership', description: 'Major incident management, workload prioritisation and escalations led with a calm, evidence-based approach.' },
|
||||
{ name: 'Vendor & Stakeholder Management', description: 'Clear ownership of the vendor relationships that sit behind every service the business relies on.' },
|
||||
{ name: 'Microsoft 365 & Intune', description: 'Hands-on depth in Microsoft 365, Azure and Intune across cloud applications and end-user devices.' },
|
||||
{ name: 'Cybersecurity & Access', description: 'Security, identity and access practices built into daily operations, backed by Microsoft security certifications.' },
|
||||
{ name: 'Team Development', description: 'Coaching, performance conversations, training and mentoring that grow capable, confident support teams.' },
|
||||
];
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
dates: 'Oct 2023 — Present',
|
||||
role: 'IT Service Delivery Manager',
|
||||
organisation: 'AFL Telecommunications',
|
||||
summary: 'Leading the daily delivery of business-critical IT services, balancing prompt resolution, availability, security, and a dependable experience for end users.',
|
||||
points: [
|
||||
'Own service operations across cloud applications, Windows and Linux environments, desktops, virtual apps, networks, and end-user support.',
|
||||
'Lead a support and service-desk team through coaching, performance conversations, workload prioritisation, and escalations.',
|
||||
'Use incident patterns, service-performance reporting, change controls, and feedback loops to improve ITIL compliance and service quality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'Dec 2021 — Sep 2023',
|
||||
role: 'Information Technology Manager',
|
||||
organisation: 'Suzanne Cory High School',
|
||||
summary: 'Managed technology services, documentation, knowledge systems, infrastructure capacity, and Microsoft 365 operations in a fast-moving education environment.',
|
||||
points: [
|
||||
'Oversaw Microsoft 365 services, endpoint operations, security, quality, and the administration and maintenance of core IT infrastructure.',
|
||||
'Led a skilled IT specialist team with a focus on strategic direction, mentoring, service effectiveness, and superior customer outcomes.',
|
||||
'Coordinated systems migrations, upgrades, and deployments to defined scope and schedule constraints.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'May 2018 — Nov 2021',
|
||||
role: 'IT Support Specialist — Team Lead',
|
||||
organisation: 'Wesley College Melbourne',
|
||||
summary: 'Directed a six-person IT support team and helped translate organisational priorities into reliable, well-documented technical service.',
|
||||
points: [
|
||||
'Established project scope, supported IT initiatives, and aligned delivery activity with strategic objectives.',
|
||||
'Worked across Casper Management, AirWatch MDM, Microsoft Intune, and operational processes informed by ITIL principles.',
|
||||
'Identified skills gaps, organised training, coordinated with project managers, and maintained clear project documentation.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const foundations = [
|
||||
{ role: 'IT Support Officer', organisation: 'Mentone Grammar School', dates: 'Apr 2017 — Dec 2017' },
|
||||
{ role: 'IT Project Manager', organisation: 'Archirayan Infotech', dates: 'Aug 2015 — Jul 2016' },
|
||||
{ role: 'IT Technical Support', organisation: 'Has Tech Solutions', dates: 'Aug 2014 — Jul 2015' },
|
||||
{ role: 'IT Technical Engineer', organisation: 'Hindustan Cyber Services', dates: 'Oct 2012 — Aug 2014' },
|
||||
{ role: 'Assistant Systems Analyst', organisation: 'Prutha Technologies', dates: 'Oct 2009 — Sep 2010' },
|
||||
];
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
category: 'Network',
|
||||
name: 'Network project',
|
||||
body: 'Fortinet Firewall upgrade, Fortinet 48P Switches upgrade, Fortinet access point uplift, and network topology upgrade.',
|
||||
delivered: ['Fortinet firewall upgrade', 'Fortinet 48P switches upgrade', 'Fortinet access point uplift', 'Network topology upgrade'],
|
||||
diagram: 'network' as DiagramKind,
|
||||
photo: networkPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Infrastructure',
|
||||
name: 'Server upgrade',
|
||||
body: 'Upgraded the server with existing VMs and services, migrated data, and configured updated network settings.',
|
||||
delivered: ['Server upgraded with existing VMs and services', 'Data migrated', 'Updated network settings configured'],
|
||||
diagram: 'server' as DiagramKind,
|
||||
photo: serverPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Endpoint',
|
||||
name: 'SCCM implementation',
|
||||
body: 'Installed and configured SCCM, prepared the required settings, and established the task-sequence process.',
|
||||
delivered: ['SCCM installed and configured', 'Required settings prepared', 'Task-sequence process established'],
|
||||
diagram: 'sccm' as DiagramKind,
|
||||
photo: sccmPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Voice',
|
||||
name: 'Microsoft Teams calling',
|
||||
body: 'Delivered number-porting support, call-flow and dial-plan design, user training, documentation, and adoption monitoring.',
|
||||
delivered: ['Number-porting support', 'Call-flow and dial-plan design', 'User training and documentation', 'Adoption monitoring'],
|
||||
diagram: 'voice' as DiagramKind,
|
||||
photo: voicePhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
export const certifications = [
|
||||
'Introduction to Cyber Security',
|
||||
'Windows Client',
|
||||
'Microsoft Teams Voice Engineer',
|
||||
'M365 Teams Administrator Associate',
|
||||
'Microsoft Azure Administrator Associate',
|
||||
'Microsoft Azure Security Engineer Associate',
|
||||
'M365 Security Administrator Associate',
|
||||
'Microsoft Information Protection Administrator Associate',
|
||||
'Project Management Essentials Certified',
|
||||
'Cloud Computing',
|
||||
];
|
||||
|
||||
export const education = { degree: 'Bachelor of Information Technology', school: 'Federation University', year: '2009' };
|
||||
@@ -0,0 +1,25 @@
|
||||
@import '@fontsource/kanit/latin-300.css';
|
||||
@import '@fontsource/kanit/latin-400.css';
|
||||
@import '@fontsource/kanit/latin-500.css';
|
||||
@import '@fontsource/kanit/latin-700.css';
|
||||
@import '@fontsource/kanit/latin-900.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root { background: #0C0C0C; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: 'Kanit', sans-serif; }
|
||||
|
||||
.hero-heading {
|
||||
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import AnimatedText from '../components/AnimatedText';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { aboutText, images, stats } from '../data/content';
|
||||
|
||||
export default function AboutSection() {
|
||||
return (
|
||||
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
||||
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[3%] w-[90px] sm:left-[2%] sm:w-[130px] md:left-[4%] md:w-[170px]">
|
||||
<img src={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[4%] left-[3%] w-[100px] sm:left-[6%] sm:w-[150px] md:left-[10%] md:w-[200px]">
|
||||
<img src={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[3%] w-[100px] sm:right-[2%] sm:w-[150px] md:right-[4%] md:w-[210px]">
|
||||
<img src={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-2xl border border-[#D7E2EA]/30 px-2.5 py-2 text-center sm:px-5 sm:py-3">
|
||||
<div className="hero-heading text-2xl font-black leading-none sm:text-5xl md:text-6xl">{s.value}</div>
|
||||
<div className="mt-1 whitespace-nowrap text-[9px] font-light uppercase tracking-wide text-[#D7E2EA] sm:text-xs">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
||||
<FadeIn delay={0} y={40}>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
About me
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<AnimatedText
|
||||
text={aboutText}
|
||||
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
|
||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
|
||||
<ContactButton />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ArrowUpRight, Phone } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
export default function ContactSection() {
|
||||
return (
|
||||
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
|
||||
Let’s talk
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
|
||||
Based in {profile.location}. Available for the right conversation.
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
<FadeIn className="w-full max-w-3xl">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
|
||||
<strong className="block break-words font-medium">{profile.email}</strong>
|
||||
</span>
|
||||
<ArrowUpRight size={20} className="shrink-0" />
|
||||
</a>
|
||||
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
|
||||
<strong className="block font-medium">{profile.phone}</strong>
|
||||
</span>
|
||||
<Phone size={18} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
|
||||
<span>{profile.name}</span>
|
||||
<span>{profile.role}</span>
|
||||
<span>{profile.location}</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { certifications, education } from '../data/content';
|
||||
|
||||
export default function CredentialsSection() {
|
||||
return (
|
||||
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Credentials
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
|
||||
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
|
||||
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
|
||||
<span className="font-light opacity-70">{education.school}, {education.year}</span>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
|
||||
{certifications.map((c, i) => (
|
||||
<FadeIn key={c} delay={(i % 2) * 0.08}>
|
||||
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
|
||||
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
|
||||
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { foundations, roles } from '../data/content';
|
||||
|
||||
export default function ExperienceSection() {
|
||||
return (
|
||||
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
|
||||
<FadeIn>
|
||||
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Experience
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{roles.map((r, i) => (
|
||||
<FadeIn key={r.role + r.organisation}>
|
||||
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<div className="flex flex-col gap-1 text-[#0C0C0C]">
|
||||
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
|
||||
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
|
||||
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
|
||||
<ul className="flex max-w-2xl flex-col gap-3">
|
||||
{r.points.map((p) => (
|
||||
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
|
||||
<Check size={18} className="mt-1 shrink-0" />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</FadeIn>
|
||||
))}
|
||||
|
||||
<FadeIn>
|
||||
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
|
||||
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
{foundations.map((f) => (
|
||||
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
|
||||
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
|
||||
<strong className="font-medium uppercase">{f.role}</strong>
|
||||
<span className="font-light opacity-70">{f.organisation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import Magnet from '../components/Magnet';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { images, profile } from '../data/content';
|
||||
|
||||
const links = [
|
||||
{ label: 'About', href: '#about' },
|
||||
{ label: 'Skills', href: '#skills' },
|
||||
{ label: 'Projects', href: '#projects' },
|
||||
{ label: 'Contact', href: '#contact' },
|
||||
];
|
||||
|
||||
export default function HeroSection() {
|
||||
return (
|
||||
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
|
||||
<FadeIn delay={0} y={-20}>
|
||||
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</FadeIn>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<FadeIn delay={0.15} y={40}>
|
||||
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[16.5vw] font-black uppercase leading-none tracking-tight sm:mt-4 md:-mt-5">
|
||||
Hi, i’m {profile.firstName}
|
||||
</h1>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-10 w-[min(280px,52vh)] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[min(360px,56vh)] sm:translate-y-0 md:w-[min(440px,58vh)] lg:w-[min(520px,58vh)]">
|
||||
<FadeIn delay={0.6} y={30}>
|
||||
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
||||
<img
|
||||
src={images.portrait}
|
||||
alt="Keith Rana at his work desk"
|
||||
className="aspect-square w-full border border-[#D7E2EA]/30 object-cover object-top"
|
||||
style={{ borderRadius: '999px 999px 32px 32px' }}
|
||||
/>
|
||||
</Magnet>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-4 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
||||
<FadeIn delay={0.35} y={20}>
|
||||
<p
|
||||
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
||||
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
|
||||
>
|
||||
{profile.tagline}
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.5} y={20} className="relative z-20">
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { expertise, images } from '../data/content';
|
||||
|
||||
const TILE_W = 420;
|
||||
const GAP = 12;
|
||||
|
||||
const photoTiles: { src: string; alt: string; position: string }[] = [
|
||||
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
|
||||
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
|
||||
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
|
||||
];
|
||||
|
||||
const photos = photoTiles.map((t) => (
|
||||
<img
|
||||
key={t.alt}
|
||||
src={t.src}
|
||||
alt={t.alt}
|
||||
loading="lazy"
|
||||
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
|
||||
style={{ objectPosition: t.position }}
|
||||
/>
|
||||
));
|
||||
|
||||
const skills = expertise.map((e, i) => (
|
||||
<div
|
||||
key={e.name}
|
||||
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
|
||||
return (
|
||||
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
|
||||
{[0, 1, 2].map((copy) => (
|
||||
<div key={copy} className="flex" style={{ gap: GAP }}>
|
||||
{tiles}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarqueeSection() {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (!ref.current) return;
|
||||
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
||||
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const width = (n: number) => n * (TILE_W + GAP) + GAP;
|
||||
|
||||
return (
|
||||
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
||||
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
|
||||
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ProjectDiagram from '../components/ProjectDiagram';
|
||||
import { projects } from '../data/content';
|
||||
|
||||
type Project = (typeof projects)[number];
|
||||
|
||||
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
|
||||
|
||||
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
|
||||
const targetScale = 1 - (total - 1 - index) * 0.03;
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="sticky top-24 min-h-[85vh]">
|
||||
<motion.div
|
||||
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:mb-6 md:flex-row md:items-center md:justify-between md:gap-10">
|
||||
<div className="flex items-center gap-4 sm:gap-8">
|
||||
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="min-w-0 text-[#D7E2EA]">
|
||||
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-xl px-1 font-light leading-relaxed text-[#D7E2EA]/70" style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.15rem)' }}>
|
||||
{project.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div className="flex flex-col gap-3 sm:w-[40%] sm:gap-4">
|
||||
<img
|
||||
src={project.photo}
|
||||
alt={`${project.name}: illustrative photo`}
|
||||
loading="lazy"
|
||||
className={`w-full object-cover ${radius}`}
|
||||
style={{ height: 'clamp(150px, 16vw, 230px)', objectPosition: project.photoPosition }}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-1 flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-5 sm:gap-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)', minHeight: 'clamp(160px, 22vw, 340px)' }}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-[#D7E2EA]/60 sm:text-sm">Delivered</span>
|
||||
<ul className="flex flex-col gap-2 sm:gap-3">
|
||||
{project.delivered.map((d) => (
|
||||
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.8rem, 1.3vw, 1.1rem)' }}>
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`order-first flex items-center justify-center border border-[#D7E2EA]/25 p-3 sm:order-none sm:w-[60%] sm:p-5 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<ProjectDiagram kind={project.diagram} className="h-auto w-full" style={{ maxHeight: "clamp(240px, 30vw, 430px)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectsSection() {
|
||||
return (
|
||||
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Projects
|
||||
</h2>
|
||||
</FadeIn>
|
||||
{projects.map((p, i) => (
|
||||
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { expertise } from '../data/content';
|
||||
|
||||
export default function ServicesSection() {
|
||||
return (
|
||||
<section id="skills" className="rounded-t-[40px] bg-white px-5 pt-20 sm:rounded-t-[50px] sm:px-8 sm:pt-24 md:rounded-t-[60px] md:px-10 md:pt-32">
|
||||
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Capability
|
||||
</h2>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{expertise.map((s, i) => (
|
||||
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
|
||||
<div
|
||||
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
||||
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
||||
>
|
||||
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-3 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caption-animation": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/caption-animation/SKILL.md",
|
||||
"computedHash": "e42c4828d3c6115dfb4d8fd3c3fd485b8e6a89c35a8835eee6dae39fb83210a4"
|
||||
},
|
||||
"countdown-video": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/countdown-video/SKILL.md",
|
||||
"computedHash": "dafb9127ededa76d0df08be73adc46e18d6ec3955153f6ecfd0f928984ce95b2"
|
||||
},
|
||||
"lower-thirds": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/lower-thirds/SKILL.md",
|
||||
"computedHash": "502c1a8a491e477dd855eb1a45469c09f6ff46a72ea841838d358d0a6a9fd790"
|
||||
},
|
||||
"short-form-video": {
|
||||
"source": "iart-ai/tiktok-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/short-form-video/SKILL.md",
|
||||
"computedHash": "893ea19615f3199159b9fe94f05e37a5e538e7e0c3af7f77f9f9bed7ff933423"
|
||||
}
|
||||
}
|
||||
}
|
||||