Add TikTok video skills (caption-animation, countdown-video, lower-thirds, short-form-video)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011XhxckAYjGMLrLECWuKBzg
This commit is contained in:
Claude committed 2026-10-18 21:47:45 +00:00
1 parent 0b42fdeae1
commit 53388fe977
18 files changed
+1842

No files matched your search

@@ -0,0 +1,205 @@
# 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)
@@ -0,0 +1,146 @@
# 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.