Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/skills/_perfil-oney.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Perfil do criador (lido por todas as skills /reel-oney, /reel-editorial-oney, /reel-explicativo-oney, /video-colagem-oney, /anuncios-oney)

Antes do primeiro vídeo, procure `perfil.json` na raiz do kit. Se não existir, pergunte ao aluno
UMA vez, numa mensagem só, e salve:

```json
{
"nome": "Nome que aparece no perfil",
"usuario": "arroba_sem_o_arroba",
"nicho": "Ex.: Marketing Digital",
"seguidores": "Ex.: 12,4 mil (como aparece no Instagram)",
"pasta_prints": "C:\\Users\\<voce>\\Pictures\\Screenshots",
"avatar": "caminho da foto de perfil (opcional)"
}
```

Use esses dados sempre que um template mostrar nome, @, avatar ou número de seguidores.
Nunca invente números, logos, capas ou prints. Se faltar algo, peça ao aluno o print exato.
109 changes: 109 additions & 0 deletions .claude/skills/anuncios-oney/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: anuncios-oney
description: Edit a talking-head recording into an AD in Oney Araujo's style (anúncio / criativo) — a headline title in the first 5 s, then animated SCHEMATICS above the head that illustrate what the person is saying (flows, before/after, não ✕ / sim ✓, mock app screens, real prints with view counters) instead of text that repeats the captions; word-by-word captions, Meta Reels safe zones, no pauses or retakes. Use when the user says /anuncios-oney", "anúncio", "criativo", or asks for the ad-style edit. For organic Reels use reel-oney instead.
---

# Anúncios no estilo do Oney

First read `../_perfil-oney.md` and load (or create) `perfil.json`. Also ask, if not given: product name,
the call to action, and the product images/prints to show (cover, mockup, results, prints of past videos
with their view counts). Put them in `assets/img/` replacing the placeholders (see the list below).

`src/index.template.html` (copied from `template/esquemas.template.html`) is a full working ad from the
course author ("Edição Viral com Claude", 1:48) with placeholders where his images were. Keep its CSS
system and helpers; rewrite the headline and the schematics for the new script. The older text-beat
ad is in `src/classico.template.html` (use only if the user asks for that look).

## Structure: headline, then schematics (mandatory)

1. **0 → ~5 s: headline only.** One big Advercase Bold title (white line + yellow line, ~90–125 px,
optional yellow underline sweep on the key word). No diagram, no UI, no images.
2. **After the headline: one schematic per spoken idea, changing on the word.** The top zone SHOWS the
mechanism; it never re-types the sentence (the captions already say it). Text inside a schematic is
limited to short labels (1–3 words: "bruto · 4:05", "gaguejo", "com IA", "seu vídeo").
3. When the product sells a process, show the **complexity of the old way next to the simplicity of the
new one** (e.g. crowded manual timeline + clock to 40:00 + ✕ vs one request + 10:00 + ✓).

Schematic patterns in the template (reuse and re-time them):

| Idea said | Schematic |
|---|---|
| "I did X → sent to Y → got Z" | Flow: phone card (raw frame) → dashed arrow + travelling dot → round node → arrow → result phone card |
| removes errors / pauses | Waveform panel: problem parts turn blue/red with tags, then shrink away; timer counts down to the new length |
| happens automatically | Phone mock where elements appear by themselves + "0 cliques" counter |
| old way × new way | Two panels: dense multi-track timeline + clock + ✕ stamp vs a single request box + ✓ stamp |
| objections ("sem tempo", "não sei") | **Não ✕ / Sim ✓**: problem cards each get a red ✕ → arrow → solution card with green ✓ |
| doubt → proof | Chat bubble with the doubt → test card with ✓ |
| "you must explain what you want" | Generic request → grey generic result ✕ vs a detailed instruction file typing → ✓ |
| list of offerings | Dashed empty slots that fill with real frames/images, one per spoken item |
| course / product | Real cover + chips flying into it + rising chart |
| social proof | Real prints with view counters ticking up (crop the print's own counter out) |
| copy a style | Reference → node → the user's frame receiving the same styling + "= mesmo estilo" |
| offer / launch | Price-tag shape + rubber-stamp discount → post mock with upload bar → "✓ publicado" |
| "grava, edita, posta" | Three icon nodes lit on each word |

Before rendering, read every beat and ask: "does the top zone SHOW it, or just repeat the caption?"

Placeholders in `assets/img/` (replace with the user's real images; never invent logos, covers or results):
`bruto.jpg` (made automatically from the recording) · `editado.jpg` (frame of an edited video) · `capa.jpg`
(product cover, portrait) · `exemplo1-4.jpg` (frames of the user's own videos) · `referencia.jpg` ·
`print1-3.png` (prints of the user's videos) · `avatar.png` · `produto.jpg`, `antes.png`, `agora.png` (classic
template). Write the user's @ from `perfil.json` where the template says `seu_usuario`. If an image is
missing, drop or redesign that schematic instead of showing a placeholder.

## Safe zones (Meta Reels ads — mandatory)

Keep **14 % top, 35 % bottom, 6 % each side** free of text. On 1080×1920 the safe box is
**x 65–1015, y 270–1248**: every word, number, badge, CTA and the schematics' labels must sit inside it
(including scale/rotation peaks). Only decoration (scrim, panel shadows) may bleed. Captions go on the chest
just under the chin, centred, and above y ~1640 (the post's name/caption start below). Check with
`node $S/safe-sheet.mjs renders/<draft>.mp4 safe.jpg t1 t2 …` (red box on each frame).

## Look

| Element | Rule |
|---|---|
| Top zone | y 280 → head top (measure per video on a contact sheet). Soft top scrim `linear-gradient(180deg, rgba(0,0,0,.66) → transparent)`. |
| Schematic kit | Dark panels `rgba(16,16,19,.92)` radius 22 with a 1 px light border; phone cards 160×284 (9:16) with real frames; round orange node `#d97757` for the AI step; dashed arrows with a travelling yellow dot; red `#ff453a` ✕ and green `#30d158` ✓ round stamps that pop on the word; UI labels in Arial bold. Repeated decoration (waveform bars, timeline clips) uses the template's seeded `rnd()`, never `Math.random` or GSAP `from:"random"`. |
| Type | Advercase Bold for headline, labels, captions. White; accent **yellow `#ffd21f`**. |
| Motion | Short entrances: rise + fade 0.45–0.6 s (`power3.out` / `expo.out`), stamps `back.out` pop, counters ticking, bars filling, arrows growing. Exits 0.28 s fade. Beats change on the spoken word. |
| Captions | One word at a time, Advercase Bold 46 px, white with dark stroke, just under the chin (`#cap{top:…}`, set per video). |
| CTA | Comment-box mock typing the keyword ("EU QUERO") + direct-message card, or a white "Saiba mais ›" bar. |

## Cuts

Remove every pause (−30 dB / 0.15 s) and every retake, ≤ 90 s if possible. When a sentence is said twice,
keep the LAST (corrected) version. Run `build-cut.mjs --no-jl`, re-transcribe `clean.mp4` and read it for
doubled phrases / clipped words. The full transcript HIDES retakes: also scan the whole clean video in short
windows in ONE call, `$PY $S/transcribe-local-whisper.py clean.mp4 --windows 1,3,5,7,…` (every 2 s to the
end), and compare.

## Speed rules (students may have slow PCs — these override the steps below)

- The raw transcription in the scaffold step is automatically `--fast` (small model when there is no NVIDIA GPU):
it is only used to choose the cut. The re-transcription of `clean.mp4` (captions) uses the accurate settings —
run it once per cut version, not after every small change.
- Check splices / retakes in ONE call (one model load) with `--windows`. Never transcribe windows one by one.
- Do not render a full draft just to look at frames. Use `npx hyperframes snapshot --at t1,t2,… -o snapshots`
(one time per beat) and look at the PNGs. Render the video once, when the snapshots are right:
`npx hyperframes render --output renders/<slug>-final.mp4`. Render again only for requested changes.
- raw.mp4 / clean.mp4 encoding picks the fastest encoder automatically. Do not override it.
- Before each slow step, tell the user in one short Portuguese line what is running and that it may take a few
minutes ("Transcrevendo o vídeo — no seu PC pode levar uns minutos."), so the wait never looks like a freeze.

## Workflow (run from the kit root)

`S=.claude/skills/anuncios-oney/scripts` · `PY=~/.venvs/whisper/Scripts/python.exe` (Mac: `~/.venvs/whisper/bin/python`)

1. `node $S/new-anuncio.mjs "<video>" <slug>` → project with raw.mp4, raw.json, words.txt, the templates,
placeholder images and `assets/img/bruto.jpg`.
2. Contact sheet of raw.mp4 to find head top / chin; set the top zone and `#cap` top.
3. Write `assets/keeps.json`; in `assets/`: `node ../../../$S/build-cut.mjs --no-jl`.
4. In `assets/`: `PYTHONIOENCODING=utf-8 $PY ../../../$S/transcribe-local-whisper.py clean.mp4 transcript.json`;
read it; run the `--windows` scan; fix keeps until clean; mishearings → `assets/caption-fixes.json`.
5. Rewrite `src/index.template.html`: headline for 0–5 s, then a schematic per spoken idea; fill name/@/avatar
from `perfil.json`; times from `node $S/anchors.mjs "frase" …`. Swap imagery in `assets/img/`.
6. `node $S/build-index.mjs` → `npx hyperframes lint` (0 errors) → `npx hyperframes snapshot --at …` at every beat
and LOOK (face never covered, labels readable, numbers correct, no placeholder left) → render → `safe-sheet.mjs`
on the beat times → final `renders/<slug>-final.mp4`.
7. Hand-off (Portuguese, short): path, what was cut, assets used, anything uncertain. Never publish.
11 changes: 11 additions & 0 deletions .claude/skills/anuncios-oney/scripts/anchors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Print output-timeline times for spoken phrases, to time overlays.
// Run in the project root: node <skill>/scripts/anchors.mjs "menos seguidores" "do que eu" ...
import { readFileSync } from "node:fs";
const w = JSON.parse(readFileSync("assets/transcript.json", "utf8")).words;
const n = (s) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9%]/g, "");
for (const phrase of process.argv.slice(2)) {
const p = phrase.split(/\s+/).map(n);
const hits = [];
for (let i = 0; i < w.length; i++) if (p.every((x, j) => w[i + j] && n(w[i + j].text) === x)) hits.push(`${w[i].start.toFixed(2)}–${w[i + p.length - 1].end.toFixed(2)}`);
console.log(`${phrase.padEnd(32)} ${hits.join(" ") || "??"}`);
}
67 changes: 67 additions & 0 deletions .claude/skills/anuncios-oney/scripts/build-cut.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Cut raw.mp4 into clean.mp4 from keeps.json, removing every measured pause, with J/L cuts at edit points.
// Run inside <project>/assets: node <skill>/scripts/build-cut.mjs [--jl 4] [--no-jl]
// Inputs: raw.mp4, keeps.json ({fps, keeps:[{from,to,why}]}), silences.txt (made here if missing).
// Outputs: clean.mp4, edit-decisions.json (audio timeline = caption timeline).
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { videoEncoder, HWDEC } from "./encoder.mjs";

const args = process.argv.slice(2);
const JL = args.includes("--no-jl") ? 0 : Number(args[args.indexOf("--jl") + 1] || 4); // frames
const { keeps, fps = 30 } = JSON.parse(readFileSync("keeps.json", "utf8"));
const frame = (t) => Math.round(t * fps) / fps;

// 1. measured silences (ffmpeg silencedetect, -30 dB, 0.15 s)
if (!existsSync("silences.txt")) {
const r = spawnSync("ffmpeg", ["-v", "info", "-i", "raw.mp4", "-vn", "-af", "silencedetect=noise=-30dB:d=0.15", "-f", "null", "-"], { encoding: "utf8" });
const s = [...r.stderr.matchAll(/silence_start: ([\d.]+)[\s\S]*?silence_end: ([\d.]+)/g)].map((m) => `${m[1]} ${m[2]}`);
writeFileSync("silences.txt", s.join("\n"));
}
// Keep a short tail after speech and a slightly longer lead-in before the next onset.
const TAIL = 0.05, LEAD = 0.08;
const silences = readFileSync("silences.txt", "utf8").trim().split(/\s*\n/).filter(Boolean).map((l) => l.trim().split(" ").map(Number))
.map(([a, b]) => [a + TAIL, b - LEAD]).filter(([a, b]) => b - a > 0.06);

// 2. audio pieces = keep ranges minus silences
const pieces = [];
for (const k of keeps) {
let cur = k.from;
for (const [a, b] of silences) {
if (b <= cur || a >= k.to) continue;
if (a > cur) pieces.push({ s: frame(cur), e: frame(a), why: k.why });
cur = b;
}
if (k.to > cur) pieces.push({ s: frame(cur), e: frame(k.to), why: k.why });
}
const segs = pieces.filter((p) => p.e - p.s >= 2 / fps);

// 3. video pieces: same durations, but at real edit points the picture cut is offset from the sound cut.
// Alternate J (sound of the next shot leads) and L (picture of the next shot leads).
const d = JL / fps;
const vsegs = segs.map((g) => ({ s: g.s, e: g.e }));
let flip = 0;
for (let i = 0; i < segs.length - 1 && d > 0; i++) {
const gap = segs[i + 1].s - segs[i].e;
if (gap < 0.25) continue; // not a visible edit point
if (flip++ % 2 === 0) { vsegs[i].e += d; vsegs[i + 1].s += d; } // J: old picture holds while new sound starts
else { vsegs[i].e -= d; vsegs[i + 1].s -= d; } // L: new picture arrives before its sound
}

let out = 0;
const decisions = segs.map((g, i) => {
const r = { source_start: g.s, source_end: g.e, video_start: +vsegs[i].s.toFixed(3), video_end: +vsegs[i].e.toFixed(3), output_start: +out.toFixed(3), output_end: +(out + g.e - g.s).toFixed(3), reason: g.why };
out += g.e - g.s;
return r;
});

const f = segs.map((g, i) => {
const v = vsegs[i], dur = g.e - g.s;
return `[0:v]trim=${v.s.toFixed(4)}:${v.e.toFixed(4)},setpts=PTS-STARTPTS[v${i}];` +
`[0:a]atrim=${g.s}:${g.e},asetpts=PTS-STARTPTS,afade=t=in:d=0.012,afade=t=out:st=${(dur - 0.012).toFixed(3)}:d=0.012[a${i}];`;
}).join("") + segs.map((_, i) => `[v${i}]`).join("") + `concat=n=${segs.length}:v=1:a=0[v];` + segs.map((_, i) => `[a${i}]`).join("") + `concat=n=${segs.length}:v=0:a=1[a]`;
writeFileSync("cut-filter.txt", f);
execFileSync("ffmpeg", ["-y", "-v", "error", "-i", "raw.mp4", "-filter_complex_script", "cut-filter.txt",
"-map", "[v]", "-map", "[a]", ...videoEncoder(), "-r", String(fps),
"-c:a", "aac", "-b:a", "192k", "-shortest", "clean.mp4"], { stdio: "inherit" });
writeFileSync("edit-decisions.json", JSON.stringify({ source: "assets/raw.mp4", fps, jl_frames: JL, duration: +out.toFixed(3), segments: decisions }, null, 1));
console.log(`segments=${segs.length} jl_edits=${flip} duration=${out.toFixed(2)}s`);
29 changes: 29 additions & 0 deletions .claude/skills/anuncios-oney/scripts/build-index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Inlines captions (one clip per word) and duration into index.html from assets/transcript.json.
// Run in the project root: node <skill>/scripts/build-index.mjs
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const t = JSON.parse(readFileSync("assets/transcript.json", "utf8"));
const dur = t.audio_duration_secs;
// Whisper mishearings, fixed for captions only: assets/caption-fixes.json {"wrong": "right", "drop": ""}
const fix = existsSync("assets/caption-fixes.json") ? JSON.parse(readFileSync("assets/caption-fixes.json", "utf8")) : {};
const words = [];
for (let i = 0; i < t.words.length; i++) {
const w = t.words[i], n = t.words[i + 1];
if (w.text === "e" && n && n.text === "-mail") { words.push({ text: "e-mail", start: w.start }); i++; continue; }
// Glue a lone "%" onto its number ("100" + "%" -> "100%").
if (n && n.text === "%") { words.push({ text: w.text + "%", start: w.start }); i++; continue; }
const txt = w.text.replace(/^-/, "");
const f = txt in fix ? fix[txt] : txt;
if (f) words.push({ text: f, start: w.start });
}
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
const caps = words.map((w, i) => {
const next = words[i + 1] ? words[i + 1].start : dur;
const d = Math.min(next - w.start, 1.2);
if (d <= 0.01) return "";
return `<div class="w clip" id="w${i}" data-start="${w.start.toFixed(3)}" data-duration="${d.toFixed(3)}" data-track-index="5">${esc(w.text)}</div>`;
}).join("\n");
writeFileSync("index.html", readFileSync("src/index.template.html", "utf8").replaceAll("__DUR__", dur.toFixed(3)).replace("__CAPTIONS__", caps));
const meta = JSON.parse(readFileSync("meta.json", "utf8"));
Object.assign(meta, { id: "reel", width: 1080, height: 1920, fps: 30 });
writeFileSync("meta.json", JSON.stringify(meta, null, 2));
console.log("index.html built, duration", dur, "captions", words.length);
28 changes: 28 additions & 0 deletions .claude/skills/anuncios-oney/scripts/encoder.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Fastest H.264 encoder this machine actually supports, for intermediate files (raw.mp4, clean.mp4).
// Tries the GPU / integrated-graphics encoders first (NVIDIA, Intel Quick Sync, AMD, Apple), then falls
// back to libx264 ultrafast. Each candidate is probed with a tiny test encode, because ffmpeg lists
// encoders the hardware may not have. Force CPU with EDITE_REELS_ENCODER=cpu.
import { spawnSync } from "node:child_process";

const CPU = ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "18"];
const CANDIDATES = [
["h264_nvenc", ["-preset", "p4", "-rc", "vbr", "-cq", "20", "-b:v", "0"]],
["h264_qsv", ["-global_quality", "22"]],
["h264_amf", ["-quality", "balanced", "-rc", "cqp", "-qp_i", "20", "-qp_p", "22"]],
["h264_videotoolbox", ["-b:v", "14M"]],
];
let cached;

export function videoEncoder() {
if (cached) return cached;
if (process.env.EDITE_REELS_ENCODER === "cpu") return (cached = CPU);
for (const [name, args] of CANDIDATES) {
const probe = spawnSync("ffmpeg", ["-v", "error", "-f", "lavfi", "-i", "color=c=black:s=320x320:d=0.2",
"-pix_fmt", "yuv420p", "-c:v", name, ...args, "-f", "null", "-"]);
if (probe.status === 0) return (cached = ["-c:v", name, ...args]);
}
return (cached = CPU);
}

// Hardware-accelerated decoding where available (phone HEVC/4K decodes much faster); ffmpeg falls back silently.
export const HWDEC = ["-hwaccel", "auto"];
24 changes: 24 additions & 0 deletions .claude/skills/anuncios-oney/scripts/find-prints.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Search your screenshot index (OCR text) for candidate prints.
// Usage: node find-prints.mjs "seguidores" "1,6 mi" [--min-w 600] [--since 2026-01-01] [--limit 15]
// Score = number of query terms found in the OCR text (accent/case-insensitive). Always LOOK at a candidate before using it.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const idx = JSON.parse(readFileSync(join(here, "..", "..", "reel-oney", "prints-index.json"), "utf8").replace(/^/, ""));
const argv = process.argv.slice(2);
const opt = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
const flags = new Set(["--min-w", "--since", "--limit"]);
const terms = argv.filter((a, i) => !a.startsWith("--") && !flags.has(argv[i - 1]));
const norm = (s) => (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
const minW = Number(opt("--min-w", 0)), since = opt("--since", ""), limit = Number(opt("--limit", 15));

const hits = idx
.filter((x) => x.w >= minW && (!since || x.date >= since))
.map((x) => ({ x, score: terms.reduce((n, t) => n + (norm(x.text).includes(norm(t)) ? 1 : 0), 0) }))
.filter((h) => h.score > 0 || !terms.length)
.sort((a, b) => b.score - a.score || b.x.date.localeCompare(a.x.date))
.slice(0, limit);
for (const { x, score } of hits) console.log(`${score} | ${x.date} | ${x.w}x${x.h} | ${x.file}\n ${norm(x.text).replace(/\s+/g, " ").slice(0, 160)}`);
if (!hits.length) console.log("no matches");
Loading