diff --git a/.agents/skills/opensquad-dashboard-design/SKILL.md b/.agents/skills/opensquad-dashboard-design/SKILL.md new file mode 100644 index 00000000..4162416e --- /dev/null +++ b/.agents/skills/opensquad-dashboard-design/SKILL.md @@ -0,0 +1,341 @@ +--- +name: opensquad-dashboard-design +description: Use when creating, modifying, or designing 2D virtual office scenes for the opensquad dashboard — room layouts, furniture placement, avatar integration, environment design, or any visual improvement. Also use when the dashboard looks empty, flat, lifeless, or lacks personality. +--- + +# Opensquad Dashboard Design — Visual Office Workflow + +Design and improve the opensquad dashboard's 2D virtual office through a structured workflow with visual verification. Every change is screenshot-verified before completion. + +## Workflow + +Follow these 6 phases in order. Do NOT skip phases or implement without diagnosis. + +> **Phase 7 — FULL SPRITE REVIEW** is an optional audit phase triggered only when the user explicitly requests a full sprite audit (e.g. "faz o full sprite review", "revisa todos os sprites"). When triggered, it replaces phases 1–6 for that session — skip directly to 7.1 INVENTORY. + +### Phase 1: UNDERSTAND + +- Read the user's request carefully +- Identify what they want: layout change, new furniture, atmosphere fix, specific problem +- If the request is ambiguous, ask ONE clarifying question before proceeding +- Do NOT start implementing — move to Phase 2 + +### Phase 2: DIAGNOSE + +Take a screenshot of the current dashboard state before any changes. + +**Auto-detect dashboard URL:** +1. Check `dashboard/vite.config.ts` for `server.port` +2. Check `dashboard/package.json` scripts for `--port` flags +3. Fallback: `http://localhost:5173` (Vite default) + +**Take screenshot via Playwright CLI** (Bash tool, NOT MCP Playwright tools): +```bash +npx playwright screenshot --browser chromium "URL" "/tmp/dashboard-before.png" --viewport-size "1400x900" +``` +If `npx playwright screenshot` is unavailable, use a small inline Playwright script. + +**If URL is unreachable:** tell the user to run `cd dashboard && npm run dev`. + +**Analyze the screenshot:** +- Identify visible problems (empty zones, depth issues, floating sprites, poor framing) +- Emphasize problems related to the user's specific request +- Present findings to the user WITH the screenshot image + +### Phase 3: PLAN + +Based on diagnosis, propose an action plan: +- List specific changes grouped by file/area +- Group related changes together (independent groups can be parallelized) +- Ask clarifying questions if the diagnosis revealed ambiguity +- **WAIT for explicit user approval before proceeding to Phase 4** + +### Phase 4: IMPLEMENT + +**Group changes by independence:** +- Group A: `RoomBuilder.ts` (furniture placement, layout) +- Group B: `assetKeys.ts` + copy new asset files to `dashboard/public/assets/` +- Group C: `palette.ts` (colors, constants) +- Group D: `AgentSprite.ts` (animations, badges) + +**Dispatch subagents** in parallel for independent groups. Each subagent receives: +- Description of changes to make +- The critical rules from the "Quick Reference" section below +- Instruction: read `.Codex/skills/opensquad-dashboard-design/reference.md` if you need the asset catalog or code examples + +**Respect dependencies:** assets copied first → keys registered in assetKeys.ts → placement in RoomBuilder.ts → visual adjustments. + +### Phase 5: VERIFY + +**Layer 1 — Screenshot:** +```bash +npx playwright screenshot --browser chromium "URL" "/tmp/dashboard-after.png" --viewport-size "1400x900" +``` + +**Layer 2 — Automated quality checklist:** +- [ ] No large empty zones visible +- [ ] Wall decorated (at least 1 item per wall section) +- [ ] Plants in corners (minimum 4) +- [ ] Lounge zone exists with seating + table + rug (if applicable) +- [ ] Depth sorting correct (lower objects render on top of higher ones) +- [ ] Sprite variety (max 2 identical items visible) +- [ ] Camera frames the room well with breathing room +- [ ] No hyperactive flickering or distracting animations + +**Layer 3 — User approval:** +Present to the user: +1. The after-screenshot +2. Summary of what changed +3. Checklist results (pass/fail per item) + +Ask: "O resultado ficou como esperado? Posso finalizar ou quer ajustes?" + +- User approves → **DONE** +- User wants adjustments → Phase 6 + +### Phase 6: LOOP + +- Collect the user's specific feedback +- Create a NEW focused plan addressing only the points raised +- Return to **Phase 4** (IMPLEMENT) +- Then **Phase 5** (VERIFY) +- Repeat until user approves + +### Phase 7: FULL SPRITE REVIEW + +> Triggered only on explicit user request. Replaces phases 2–6 for this session. + +#### 7.1 INVENTORY + +Read `dashboard/src/office/RoomBuilder.ts` and `dashboard/src/office/AgentSprite.ts` in full. Build a structured list of every sprite currently placed in the scene: + +For each sprite, record: +- **Key** — the asset key string (e.g. `furniture_monstera`, `desk_black_idle`) +- **Type** — `furniture` | `desk` | `avatar` | `decoration` +- **File + line** — where in the code it is placed +- **Scale** — the `.setScale()` value (or 1 if not set) +- **Depth** — the `.setDepth()` value +- **Position** — the (x, y) expression in code (e.g. `MARGIN / 2`, `roomW - 40`) +- **Origin** — the `.setOrigin(x, y)` value (or `(0.5, 0.5)` if not set) +- **Alpha** — the `.setAlpha()` value (or 1 if not set) + +Do NOT skip any sprite. Include sprites inside loops (e.g. per-agent desk sprites). +For `AgentSprite.ts`, record only the four `Image` objects per agent: `avatar`, `deskTable`, `desk`, and `coffeeMug`. Exclude `Graphics` and `Text` objects — these are not sprites and are evaluated separately only if a white-background artifact is suspected. + +#### 7.2 SCREENSHOT + +Auto-detect the dashboard URL (same logic as Phase 2): +1. Check `dashboard/vite.config.ts` for `server.port` +2. Check `dashboard/package.json` scripts for `--port` flags +3. Fallback: `http://localhost:5173` + +Take screenshot via Playwright CLI: +```bash +npx playwright screenshot --browser chromium "URL" "/tmp/sprite-review-before.png" --viewport-size "1400x900" +``` +If `npx playwright screenshot` is unavailable, use a small inline Playwright script. + +If URL is unreachable, tell the user to run `cd dashboard && npm run dev` and wait for confirmation before continuing. + +Present the screenshot to the user and say: "Capturei o estado atual. Iniciando avaliação de cada sprite..." + +#### 7.3 EVALUATE + +For every sprite in the inventory, evaluate 4 criteria against the screenshot. Assign **PASS**, **FAIL**, or **UNCERTAIN** to each. + +**Criterion 1 — Size** +Compare visual scale against neighboring elements in the screenshot: +- Avatar must not appear larger than the desk it sits at +- Plants/flowers must not visually dominate the scene (overshadow desks or avatars) +- Wall decorations must fit within the visible wall strip (not spill onto the floor) +- Accessories (mugs, backpacks, cushions) must be proportionally small relative to desks + +**Criterion 2 — Transparent Background** +Look for a visible white or solid-colored box surrounding the sprite in the screenshot: +- PASS: sprite renders cleanly with transparent surroundings +- FAIL: visible bounding box or white halo around the sprite +- Fix approach: inspect the Phaser code around that sprite for `setTint`, `setAlpha`, Graphics rectangles drawn immediately before the sprite, or incorrect `filterMode`. Fix is always in the code — PNG files are already transparent. + +**Criterion 3 — Semantic Position** +Apply these lightweight rules per sprite category: + +| Category | Rule | +|----------|------| +| Plants / flowers | Corners, room edges, or between desk rows — **never** on top of a work desk | +| Avatar (character sprite) | Visibly above the desk sprite — not floating mid-air or sitting on the floor | +| Monitor / desktop set | On top of the work desk at the correct vertical offset | +| Couch / armchair | Lounge zone (lower portion of the scene) — not in the work desk area | +| Rug / carpet | Below all furniture — never rendered visually above a character | +| Wall decorations (blinds, poster, bookshelf, clock) | Within the wall strip (`y ≤ WALL_H`) — not on the floor | +| Accessories (mug, backpack, lantern) | On or immediately beside a desk — not in the middle of a walkway | +| Desk base (deskTable) | Below the avatar, aligned with the work grid — not in the lounge zone or wall strip | + +**Criterion 4 — Unwanted Overlap** +Check if a sprite visually covers another in a way that breaks scene logic: +- Plant rendered over an avatar's face → FAIL +- Rug floating visually above a couch → FAIL +- Two identical sprites stacked at the same pixel position → FAIL +- Desk partially clipped by a wall decoration → FAIL +- No scene-breaking overlaps visible → PASS + +**UNCERTAIN rule:** If you cannot determine PASS or FAIL from the screenshot alone (e.g. the sprite is partially hidden, or the position rule doesn't clearly apply), mark it UNCERTAIN and note the specific question. You will ask the user about all UNCERTAIN sprites together in 7.4, before proceeding to 7.5. + +**Invisible sprite rule:** If a sprite from the inventory is not visible in the screenshot at all (e.g. a per-agent desk sprite outside the visible viewport), mark all four criteria UNCERTAIN with note: "sprite not visible in screenshot." + +#### 7.4 DOCUMENT + +Write the evaluation results to `/tmp/sprite-review.md` in this exact format: + +```markdown +# Sprite Review — YYYY-MM-DD HH:MM + +## Summary +- Total sprites: N +- PASS: N | FAIL: N | UNCERTAIN: N + +## Results + +### furniture_monstera (RoomBuilder.ts:112) +- Tamanho: PASS +- Fundo: PASS +- Posição: FAIL — planta posicionada sobre mesa de trabalho (x=320, y=180). Mover para canto. +- Sobreposição: FAIL — sobrepõe avatar do agente "Writer". Ajustar x/y. +- **Fix needed:** reposition to corner (x=MARGIN/2, y=deskAreaBottom - 20) + +### avatar_Jesse_blink (AgentSprite.ts:87) +- Tamanho: UNCERTAIN — parece grande, mas pode ser intencional +- Fundo: PASS +- Posição: PASS +- Sobreposição: PASS +``` + +If there are any UNCERTAIN sprites, present them to the user now with a concise question for each one. Wait for the user's answer, update the report, then proceed to 7.5. + +#### 7.5 FIX + +Group all FAIL sprites by fix type and dispatch subagents in parallel: + +| Group | File(s) | Problem types | +|-------|---------|---------------| +| A | `dashboard/src/office/RoomBuilder.ts` | Wrong position, wrong size, unwanted overlap — furniture/decorations | +| B | `dashboard/src/office/AgentSprite.ts` | Avatar mispositioned, wrong scale | +| C | `RoomBuilder.ts` + `AgentSprite.ts` | White background (Phaser rendering artifact) | + +**Groups A and B run in parallel.** Group C runs after A and B complete (white-background causes may span both files). + +Each subagent receives: +1. The list of sprite keys it must fix, with specific FAIL reasons from `/tmp/sprite-review.md` +2. The Quick Reference section from this skill (depth sorting, origin conventions, layout constants) +3. Instruction: read `.Codex/skills/opensquad-dashboard-design/reference.md` for the asset catalog and code examples +4. The rule: **never hardcode pixel values** — always use relative expressions (`MARGIN / 2`, `roomW / N`, `deskAreaBottom - offset`) + +If a group has no failing sprites, skip it. + +#### 7.6 RE-REVIEW + +After subagents complete, take a new screenshot: + +```bash +npx playwright screenshot --browser chromium "URL" "/tmp/sprite-review-iter-N.png" --viewport-size "1400x900" +``` + +(Increment N each iteration: `iter-1.png`, `iter-2.png`, etc.) + +Re-evaluate **only the sprites that previously had FAIL or UNCERTAIN status**. Do not re-evaluate sprites that already passed. + +Update `/tmp/sprite-review.md` with the new results. + +**Stuck detection:** If the same sprite fails the same criterion for the 2nd consecutive iteration, do NOT dispatch another subagent blindly. Instead, pause and report to the user: + +> "O sprite `` continua falhando no critério `` após 2 tentativas. [Descrição concisa do problema]. O que devo fazer?" + +Wait for the user's answer, then continue. + +**Loop:** If any sprites still FAIL after re-evaluation, return to **7.5 FIX** with only the remaining failures. Continue until all sprites are PASS. + +#### 7.7 APPROVE + +When all sprites reach PASS status: + +1. Present the final screenshot (`/tmp/sprite-review-iter-N.png` or `/tmp/sprite-review-before.png` if no iterations were needed) +2. Show a summary of what was fixed: + - List each sprite that was corrected, with before/after description + - List sprites that passed without changes +3. Show the final checklist with all items as PASS + +Ask: "Todos os sprites passaram na revisão. O resultado ficou como esperado? Posso finalizar ou quer ajustes?" + +- User approves → **DONE** +- User wants adjustments → collect feedback, return to **7.5 FIX** with a new targeted plan, then proceed through **7.6 RE-REVIEW** before returning to 7.7 + +## Quick Reference + +### Key Files + +| File | Purpose | +|------|---------| +| `dashboard/src/office/OfficeScene.ts` | Main Phaser scene — loads assets, builds room, places agents | +| `dashboard/src/office/RoomBuilder.ts` | Procedural floor/walls + sprite furniture placement | +| `dashboard/src/office/AgentSprite.ts` | Per-agent: desk sprite + avatar sprite + name badge + status | +| `dashboard/src/office/assetKeys.ts` | Asset manifest — all sprite keys and file paths | +| `dashboard/src/office/palette.ts` | Colors (status badges, floor, wall) + layout constants | +| `dashboard/src/office/PhaserGame.tsx` | React wrapper — Phaser game initialization | + +### Layout Constants (palette.ts) + +TILE = 32 // Base grid unit (px) +CELL_W = 96 // 3 tiles — desk cell width +CELL_H = 96 // 3 tiles — desk cell height +MARGIN = 96 // 3 tiles — room edge padding +WALL_H = 96 // 3 tiles — wall strip height + +### Depth Sorting + +Y-based painter's algorithm — objects lower on screen render on top: + + Wall decorations: setDepth(0) — behind everything + Rugs: setDepth(-0.5) — below furniture, above floor + Floor furniture: setDepth(y) — Y position + Agent desk: setDepth(y) — Y position + Avatar: setDepth(y + 1) — on top of desk + Name badge: setDepth(y + 99/101) — always on top + +**Rule:** anything closer to the bottom of the screen gets a higher depth value. + +### Sprite Placement + + // WALL DECORATIONS — anchor to bottom of wall, depth 0 + scene.add.image(x, WALL_H, key).setOrigin(0.5, 1).setDepth(0); + + // FLOOR ITEMS — anchor to bottom of sprite, depth = Y + scene.add.image(x, y, key).setOrigin(0.5, 1).setDepth(y); + + // UNDER FURNITURE (rugs) — depth = -0.5 + scene.add.image(x, y, key).setOrigin(0.5, 0.5).setDepth(-0.5); + +**Origin conventions:** +- Wall items: `setOrigin(0.5, 1)` — bottom-center anchored to wall base +- Floor items: `setOrigin(0.5, 1)` — bottom-center for correct Y-sorting +- Rugs/carpets: `setOrigin(0.5, 0.5)` — center-anchored + +**Relative positioning** — never hardcode pixels: +- `roomW / 2` for centered items +- `MARGIN / 2` for corner items +- `roomW / N` for evenly spaced wall decorations +- `deskAreaBottom + offset` for lounge zone items + +### Visual Density + +| Zone | Minimum Items | Examples | +|------|--------------|----------| +| Wall (per 5 sections) | 5 | blinds, bookshelf, whiteboard, clock, poster | +| Work area corners | 2 per corner | plant + flowers, or plant + trash can | +| Lounge zone | 4+ | couch + coffee table + rug + 2 plants | +| Between desk rows | 1 per gap | floor cushion, small plant, backpack | +| Room edges | 1 per 3 tiles | plants, lamps, small furniture | + +## Important Rules + +- **NEVER create pixel art from scratch** — always use existing assets from `dashboard/public/assets/` or the asset pack in `temp/pixel-assets/town/` +- **Every new sprite must be registered** in `assetKeys.ts` (FURNITURE_KEYS + FURNITURE_PATHS) +- **Consult reference.md** (`.Codex/skills/opensquad-dashboard-design/reference.md`) for the full asset catalog, code examples, and detailed technical guidance during implementation diff --git a/.agents/skills/opensquad-dashboard-design/reference.md b/.agents/skills/opensquad-dashboard-design/reference.md new file mode 100644 index 00000000..a81d630e --- /dev/null +++ b/.agents/skills/opensquad-dashboard-design/reference.md @@ -0,0 +1,328 @@ +# Opensquad Dashboard Design — Reference Guide + +Reference material for the opensquad-dashboard-design skill. Consult this file during implementation phases when you need asset catalogs, code examples, or detailed technical guidance. + +## Design Philosophy + +A 2D virtual office is a **spatial metaphor for teamwork**. Unlike a plain status dashboard, it communicates: + +- **Presence** — agents occupy physical space, they're *there* +- **Activity** — animations and desk state show who's working, idle, or done +- **Personality** — furniture, decorations, and layout choices create *culture* +- **Spatial relationships** — proximity implies collaboration; zones imply function + +### What Makes a Great Virtual Office + +GREAT OFFICE = functional zones + visual density + small details + warm lighting + lived-in clutter + +**Functional zones** — not just a grid of desks. Real offices have: +- Work area (desks, monitors, chairs) +- Social area (couch, coffee table, plants) +- Wall decorations (whiteboards, clocks, posters, bookshelves) +- Transition elements (rugs defining zones, plants as dividers) + +**Visual density** — empty space is the enemy. Every 3x3 tile area should have *something*: +- Corner plants, floor cushions, trash cans +- Wall posters, clocks, bulletin boards +- Cables, mugs, books, backpacks on or near desks + +**Small details** — the magic that makes pixel art feel alive: +- A rug under the couch area, not floating on bare floor +- Plants in corners where walls meet +- A coffee machine or water cooler near the social area +- Bookshelves against the wall, not floating mid-room + +**Lived-in clutter** — sterile = boring. Add: +- Books stacked on shelves, not perfectly aligned +- Multiple plant varieties (not the same plant repeated) +- Mixed furniture styles (not every desk identical) + +--- + +## Zone Planning Template + +``` +┌──────────────────────────────────────────┐ +│ WALL: blinds | bookshelf | whiteboard | clock | blinds │ +├──────────────────────────────────────────┤ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Desk1│ │Desk2│ │Desk3│ ← Work Zone│ +│ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Desk4│ │Desk5│ │Desk6│ │ +│ └─────┘ └─────┘ └─────┘ │ +│ │ +│ 🌿 ┌──────────┐ 🌿 │ +│ │ Couch + │ ← Lounge Zone │ +│ │ Table │ │ +│ 🌺 └──────────┘ 🌺 │ +└──────────────────────────────────────────┘ +``` + +--- + +## Phaser Rendering Config + +The dashboard must always use pixel-perfect rendering. If you touch `PhaserGame.tsx` or the Phaser config: + +```javascript +const config = { + pixelArt: true, // NEAREST filter on all textures — prevents blur on scale + roundPixels: true, // snap all objects to integer positions on GPU + antialias: false, // redundant with pixelArt:true but explicit +}; +``` + +**CSS fallback** (apply to the canvas element): +```css +canvas { image-rendering: pixelated; image-rendering: crisp-edges; } +``` + +| Property | What it Does | +|----------|-------------| +| `pixelArt: true` | Sets all textures to `NEAREST` filter. Prevents blur on scale. | +| `roundPixels: true` | Snaps all texture objects to integer positions on GPU. | +| `antialias: false` | WebGL nearest-neighbor interpolation. | + +--- + +## Rendering Pipeline + +``` +OfficeScene.preload() + → Load all sprites from public/assets/ (desks, avatars, furniture) + +OfficeScene.create() + → RoomBuilder.build(roomW, roomH) + → drawFloor() // Graphics: wood base + checkerboard pattern + → drawWalls() // Graphics: wall color + baseboard trim + → placeFurniture() // Sprites: wall decorations + floor items + → For each agent: new AgentSprite(scene, x, y, character, variant, agent) + → Desk sprite (black/white × idle/coding) + → Avatar sprite (character poses × 0.5 scale) + → Name badge + status dot (Graphics) + → Animation timer (talk/blink/wave cycles) + → Camera: zoom to fit room, center +``` + +--- + +## Animation System + +| Status | Animation | Cycle | +|--------|-----------|-------| +| `working` / `delivering` | talk ↔ blink | 500ms alternating | +| `done` | wave1 ↔ wave2 (4 frames) → hold blink | 400ms × 4, then static | +| `idle` | occasional talk → blink | random 2-4s intervals, 200ms talk | + +### Animation Principles + +- **Subtle > dramatic** — small movements (blink, occasional talk) feel alive without being distracting +- **Random intervals** — use `delay: 2000 + Math.random() * 2000` to avoid synchronized animations +- **Status-driven** — animation reflects agent state, not just decoration +- **Finite sequences** — "done" wave plays 4 frames then stops (don't loop forever) + +--- + +## Asset Library + +### Currently Installed (`dashboard/public/assets/`) + +**Desks (6):** black/white × idle/coding/coding-alt +**Avatars (11 characters):** Jesse, Riley, Rose, Tanya + 7 NPCs — each with blink, talk, wave1, wave2 +**Furniture (30+):** bookshelf, whiteboard, clock, plant1-3, flowers1-2, couch, rug, coffee_mug, blinds, coffee_table, armchair, backpacks, bulletin_board, cushions, lamps, monstera, picture_frame, posters, succulents, and more + +### Available Asset Pack (`temp/pixel-assets/town/`) + +**960+ sprites** ready to be copied into `dashboard/public/assets/`. Organized in 3 folders: + +#### `temp/pixel-assets/town/assets/` — Furniture & Decorations + +**Seating:** +- Armchairs: 10 colors × 4 orientations (black, blue, darkgrey, green, lightgrey, red, tan, white, yellow) +- Couches: 10 colors × 4 orientations (down, left, right, up) +- Floor cushions: 8 colors (blue, brown, dark_gray, green, light_gray, orange, purple, red) +- Chairs: office, bridge, brown, groovy, metal, red, white, wood — each with 4 orientations +- Stools, benches (park, red) + +**Tables:** +- Wood: round, square, conference, skinny, various sizes (`[3x2]`, `[6x3]`, `[8x2]`) +- Metal: round, square, horizontal, vertical +- Cloth-covered: 8 colors (`[3x1]`, `[3x3]`, `[3x8]`) +- Coffee tables, small tables + +**Desks & Workstations:** +- Grey, black, white, wood desks in various sizes +- Desk pods (vertical `[3x4]`) +- Art & design themed desks +- Cute wood light desks + +**Storage & Shelves:** +- Bookshelves: wood dark in 6 sizes + alt styles + side views +- IKEA-style shelves: black/gray/white/wood, with or without stuff +- CD shelves, cabinets, dressers, lockers (grey/yellow, 4 variants each) + +**Plants & Nature:** +- Potted plants: cat-tail, poof, spiky, spindly (dark/light) +- Monstera (2 sizes), magic plant (3 variants) +- Succulents: blue, bluegrey, green, spiky +- Bamboo, flowers (pot, vase), roses +- Trees: pine (3 seasons), topiary (3 seasons + planter) + +**Tech & Office:** +- Monitors: front/back/side views +- Laptops: front/back/side, white variants +- Computer keyboard + mouse sets +- Projectors (3 colors) + projection screen +- Whiteboards: 4 blank/written/graph variants + standalone +- TVs: flat screen, on counter, on table + +**Kitchen & Break Room:** +- Refrigerators: front/left/right +- Coffee makers, microwaves (3 orientations), water coolers +- Kitchen counters (horizontal, vertical, cute variants) +- Sinks, stoves, cutting boards +- Coffee mugs (blue, pink, red, white), kettles, water bottles +- Plates, bowls, cake variants + +**Decorations:** +- Clocks: blue, red, purple +- Lamps: 5 colors + lanterns +- Posters: 7+ designs with active/inactive states +- Bulletin boards, picture frames, maps +- Rugs: green/purple/red in multiple sizes, fancy patterns, houndstooth +- Carpets: beige, gray in various sizes +- Fireplaces: wood/black, lit/unlit +- Blinds: large/small, open/closed, white/wood + +**Portals & Doors:** +- Multiple door styles: brick, bluegrey, corpogrey, darkgrey, school +- Portals with colored shadows (black, white, yellow) +- Stairs: up/down/left/right + +#### `temp/pixel-assets/town/avatars/` — Characters + +- Named characters: Jesse, Riley, Rose, Tanya (multiple poses) +- NPCs: NPCE1-NPCE7 (multiple poses) +- Neutral variants: casual, hijab, staff, suits +- Seasonal costumes: Christmas, Halloween, Valentine, etc. + +#### `temp/pixel-assets/town/objects/` — Additional Items + +Extended collection of smaller objects and decorative items. + +--- + +## Asset Integration Workflow + +When adding new assets from the library: + +```bash +# 1. Browse available assets +ls temp/pixel-assets/town/assets/ | grep -i "plant" + +# 2. Copy to dashboard public folder +cp "temp/pixel-assets/town/assets/monstera.png" dashboard/public/assets/furniture/ + +# 3. Register in assetKeys.ts +# Add key to FURNITURE_KEYS and path to FURNITURE_PATHS + +# 4. Place in RoomBuilder.ts placeFurniture() +# Use Y-based depth sorting + +# 5. Test in browser — verify positioning and depth +``` + +### Asset Naming Convention + +Files in `dashboard/public/assets/`: + +``` +assets/ + desks/ desktop_set_{color}_down_{state}.png + furniture/ {item_name}.png (lowercase, underscores) + avatars/ {CharacterName}_{pose}.png +``` + +Keys in assetKeys.ts: + +``` +// Furniture keys: descriptive, snake_case-ish +FURNITURE_KEYS = { bookshelf: 'furniture_bookshelf', ... } + +// Desk keys: variant + state +DESK_KEYS = { blackIdle: 'desk_black_idle', ... } +``` + +--- + +## Room Sizing & Dynamic Layout + +The room auto-sizes based on agent count: + +```js +const roomW = maxCol * CELL_W + MARGIN * 2; +const roomH = maxRow * CELL_H + MARGIN * 2 + WALL_H + loungeSpace; +``` + +When placing furniture, use relative positions: +- `roomW / 2` for centered items +- `MARGIN / 2` for corner items +- `roomW / N` for evenly spaced wall decorations +- `deskAreaBottom + offset` for lounge zone items + +--- + +## Camera & Viewport + +The camera auto-fits the room: + +```js +const scaleX = cam.width / roomW; +const scaleY = cam.height / roomH; +const zoom = Math.min(scaleX, scaleY, 2); // Cap at 2x +cam.centerOn(roomW / 2, roomH / 2); +``` + +Keep in mind: +- Maximum zoom is 2x — sprites must look good at this scale +- Room is centered in viewport — leave breathing room at edges +- Sprites designed for 32px grid should be crisp at 1x and 2x +- **Always use integer zoom values** — non-integer zoom breaks pixel alignment +- `cam.roundPixels = true` must be set — prevents shimmer during any camera movement + +--- + +## Color & Atmosphere + +Current palette (palette.ts): +- Floor: warm wood `0xc4a882` with checkerboard alt `0xb89a72` +- Wall: soft cream `0xe4d8cc` with darker trim `0xa89888` +- Background: deep dark `0x1a1420` + +Rules: +- Warm tones dominate — wood, cream, earth colors +- Accent with greens (plants), blues (monitors, status), and warm yellows (lamps) +- Avoid cold greys or stark whites — everything should feel cozy +- Status colors are distinct and reserved: idle=purple, working=blue, done=green, checkpoint=orange + +--- + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Empty corners and margins | Add plants, lamps, floor cushions, small furniture | +| All identical desk variants | Alternate black/white, add desk accessories nearby | +| Furniture floating mid-room | Anchor to walls, group in zones, place rugs underneath | +| Wrong depth — items behind when they should be in front | Set depth = Y position for floor items, 0 for wall items | +| Using `setOrigin(0, 0)` for floor sprites | Use `setOrigin(0.5, 1)` — bottom-center for Y-sort correctness | +| Ignoring wall decorations | Fill the wall strip — blinds, shelves, boards, clocks, posters | +| Repeating the same plant sprite | Use plant1, plant2, plant3, monstera, succulents — variety matters | +| Adding sprites without registering in assetKeys.ts | Every sprite needs a key in `FURNITURE_KEYS` + path in `FURNITURE_PATHS` | +| Hardcoding pixel positions | Use relative positioning based on `roomW`, `MARGIN`, `WALL_H`, `CELL_*` | +| Giant sprites that don't match the 32px grid | Scale down with `setScale()` or resize the PNG to fit the tile grid | +| Blurry sprites after scaling | Ensure `pixelArt: true` in Phaser config and `image-rendering: pixelated` in CSS | +| Shimmer or jitter during rendering | Set `roundPixels: true` in Phaser config and `cam.roundPixels = true` | +| Non-integer camera zoom | Always use whole numbers (1, 2) — fractional zoom breaks pixel alignment | diff --git a/.agents/skills/opensquad-dev/SKILL.md b/.agents/skills/opensquad-dev/SKILL.md new file mode 100644 index 00000000..d5fada12 --- /dev/null +++ b/.agents/skills/opensquad-dev/SKILL.md @@ -0,0 +1,253 @@ +--- +name: opensquad-dev +description: "opensquad development checklist — verifies templates sync, package integrity, and distribution correctness." +--- + +# opensquad Development Checklist + +You are running the opensquad-dev verification skill inside the opensquad repository. +Your job is to detect and report distribution issues before they reach users. + +## How opensquad Distribution Works + +Understand this before checking anything: + +- **`templates/`** → Copied by `src/init.js:copyCommonTemplates()` during `npx opensquad init` + and by `src/update.js` during `npx opensquad update`. Everything in `templates/` except + `ide-templates/` is copied recursively to the user's project. This is the PRIMARY + distribution mechanism — if a file isn't in templates, users don't get it on init. + +- **`templates/ide-templates/`** → IDE-specific files. Copied selectively based on user's + IDE selection during init. Each subfolder (`Codex/`, `opencode/`, `codex/`, + `antigravity/`) maps to one IDE choice. + +- **`agents/`** (project root) → Predefined agent catalog, distributed via npm + (`package.json files[]`). Auto-installed during `npx opensquad init` and new agents + added during `npx opensquad update`. Protected from overwrites in + `src/update.js:PROTECTED_PATHS`. Users can also install manually via + `npx opensquad agents install`. + +- **`skills/`** (project root) → Bundled skills catalog, distributed via npm + (`package.json files[]`). Non-MCP skills are auto-installed during init. + MCP skills (type: mcp/hybrid or with env vars) are offered interactively + during init. Users can also install manually via `npx opensquad skills install`. + +- **`_opensquad/core/*`** → Framework core files. MUST have a mirror copy in + `templates/_opensquad/core/*`. Any change to a core file that isn't synced to templates + means `npx opensquad init` delivers STALE content to new users. + +- **`skills/*`** → Bundled skills catalog. Two distribution sub-types: + - **Multi-file skills** (have scripts/assets/agents dirs alongside SKILL.md, e.g. `opensquad-skill-creator`): + also have a mirror in `templates/skills/*` so the full directory structure is copied during init. + Check B verifies this sync. + - **Single-file skills** (only `SKILL.md`, no subdirs, e.g. `opensquad-agent-creator`): + distributed via `installSkill()` — no template copy needed. Check B does NOT apply to these. + +- **`package.json files[]`** → Controls what enters the npm package. + Currently: `bin/`, `src/`, `agents/`, `skills/`, `templates/`. + +- **`src/update.js:PROTECTED_PATHS`** → Directories NEVER overwritten during update: + `_opensquad/_memory`, `_opensquad/_investigations`, `agents`, `squads`. + +## Multi-IDE Architecture + +opensquad supports multiple IDEs. When a user runs `npx opensquad init`, they choose which IDE(s) to install for. Files are copied from two places: + +1. **Common templates** (`templates/`, excluding `ide-templates/`) — copied to every project regardless of IDE +2. **IDE-specific templates** (`templates/ide-templates/{ide}/`) — copied only for the selected IDE(s) + +### File Classification + +| Type | Definition | Location | +|------|-----------|----------| +| SHARED | Applies to all IDEs equally | `_opensquad/core/`, `templates/` (excluding `ide-templates/`) | +| IDE-SPECIFIC | Applies to one IDE only | `templates/ide-templates/{ide}/` | + +### Supported IDEs and Their Template Folders + +| IDE | Template Folder | Example Files | +|-----|----------------|---------------| +| Codex | `templates/ide-templates/Codex/` | `SKILL.md`, `AGENTS.md`, `.mcp.json` | +| Antigravity | `templates/ide-templates/antigravity/` | `.agent/rules/opensquad.md`, `.agent/workflows/opensquad.md` | +| OpenCode | `templates/ide-templates/opencode/` | `AGENTS.md`, `.opencode/commands/opensquad.md` | +| Codex | `templates/ide-templates/codex/` | `AGENTS.md` | + +### The Golden Rule + +> When a change is requested for a specific IDE, modify ONLY files inside `templates/ide-templates/{ide}/`. +> NEVER add conditional logic ("if antigravity, do X") to shared files. + +**Violation example:** +- User asks: "Add sequential execution support for Antigravity in the pipeline." +- ❌ Wrong: Edit `_opensquad/core/runner.pipeline.md` adding `if antigravity: run sequentially` +- ✅ Correct: Edit `templates/ide-templates/antigravity/.agent/rules/opensquad.md` with the sequential execution instructions + +**Legitimate shared file edit:** +- User asks: "Add a new researcher agent type to the architect." +- ✅ Fine: Edit `_opensquad/core/architect.agent.yaml` because the change benefits ALL IDEs equally. + +## Verification Process + +### Step 1: Detect what changed + +Run these commands to identify changed files: + +```bash +# Uncommitted changes (staged + unstaged) +git diff --name-only HEAD + +# If no uncommitted changes, check recent commits +git log --oneline -5 +git diff --name-only HEAD~5..HEAD +``` + +Collect all changed file paths into a list. + +### Step 2: Run applicable checks + +For each changed file, apply the matching verification rules below. +Only run checks that are relevant to the actual changes detected. + +#### Check A: Core file sync (`_opensquad/core/**` changed) + +For EACH changed file matching `_opensquad/core/**`: + +1. Compute the expected template path: `templates/{same relative path}` + Example: `_opensquad/core/runner.pipeline.md` → `templates/_opensquad/core/runner.pipeline.md` + +2. Run diff: + ```bash + diff "_opensquad/core/{file}" "templates/_opensquad/core/{file}" + ``` + +3. If diff shows differences: + - **FAIL**: Report the file and show the diff summary + - **Fix**: `cp _opensquad/core/{file} templates/_opensquad/core/{file}` + +4. If template file doesn't exist: + - **FAIL**: "Template missing for `_opensquad/core/{file}`" + - **Fix**: `mkdir -p templates/_opensquad/core/{dir} && cp _opensquad/core/{file} templates/_opensquad/core/{file}` + +#### Check B: Skills sync (`skills/**` changed) + +Only applies to **multi-file skills** — skills that have subdirectories (scripts/, assets/, agents/, etc.) +alongside their `SKILL.md`. These require a template mirror so the full directory is copied during init. + +Single-file skills (only `SKILL.md`, no subdirs) are distributed via `installSkill()` and do NOT need +a `templates/skills/` counterpart. Do not flag them as missing. + +For each changed **multi-file** skill: +- Source: `skills/{skill}/SKILL.md` +- Template: `templates/skills/{skill}/SKILL.md` + +#### Check C: Agents directory (`agents/**` changed) + +1. Read `package.json`, parse the `files` array +2. Verify `"agents/"` is present in the array +3. If missing: **FAIL** — `"agents/" not in package.json files[]` + +#### Check D: Init logic (`src/init.js` changed) + +1. Read `src/init.js` +2. Verify `copyCommonTemplates` function exists and references `TEMPLATES_DIR` +3. Verify `getTemplateEntries` recursively scans the templates directory +4. Flag if any new filtering logic was added that might exclude files + +#### Check E: Update logic (`src/update.js` changed) + +1. Read `src/update.js` +2. Extract the `PROTECTED_PATHS` array +3. Verify it includes all user-owned directories: + - `_opensquad/_memory` (user preferences and company context) + - `_opensquad/_investigations` (Sherlock investigation data) + - `agents` (user-installed/customized agents) + - `squads` (user-created squads) +4. If a new user-owned top-level directory was added to the project, + check if it should be in PROTECTED_PATHS + +#### Check F: Package manifest (`package.json` changed) + +1. Read `package.json`, parse `files` array +2. Verify these directories are present: `bin/`, `src/`, `agents/`, `skills/`, `templates/` +3. If any distributable directory exists at project root but is NOT in `files[]`: **FAIL** + +#### Check G: New top-level directory (any new directory at root) + +1. Run `ls -d */` at project root +2. For each directory, check: + - Is it in `package.json files[]`? (if it should be distributed) + - Is it in `PROTECTED_PATHS`? (if it's user-owned content) + - Is it in `templates/`? (if it should be copied during init) +3. Flag any directory that seems like it should be distributed but isn't configured + +#### Check H: Init auto-installs agents and skills (`src/init.js` changed) + +1. Read `src/init.js` +2. Verify it imports from `./agents.js`: `listAvailable` (aliased as `listAvailableAgents`) and `installAgent` +3. Verify `installAllAgents` function exists and is called in `init()` +4. Verify `installNonMcpSkills` function exists and is called in `init()` +5. Verify `installNonMcpSkills` filters out: `opensquad-skill-creator`, type `mcp`, type `hybrid`, skills with `env` +6. If any of these are missing: **FAIL** — "Init does not auto-install agents/skills" + +#### Check I: Update installs new agents/skills (`src/update.js` changed) + +1. Read `src/update.js` +2. Verify it imports from `./agents.js`: `listAvailable`, `listInstalled`, `installAgent` +3. Verify it imports from `./skills.js`: `listAvailable`, `listInstalled`, `installSkill`, `getSkillMeta` +4. Verify the update function installs missing agents (compares available vs installed) +5. Verify it installs missing non-MCP skills with same filtering as init +6. If any of these are missing: **FAIL** — "Update does not install new agents/skills" + +#### Check J: IDE contamination in shared files (any `_opensquad/core/**` or `templates/**` changed, excluding `templates/ide-templates/**`) + +Scan shared files for IDE-specific conditional logic that should live in `templates/ide-templates/{ide}/` instead. + +**Files to scan:** +- All files in `_opensquad/core/` +- All files in `templates/` EXCEPT those under `templates/ide-templates/` + +**What to look for:** Content that says "do X for IDE Y" or "if using IDE Y, behave differently." + +**Contamination keywords** (IDE names used as conditional subjects): +- `antigravity` or `.antigravity` +- `cursorrules` or `.cursor/` +- `windsurf` or `windsurfrules` +- `opencode` or `open-code` +- `codex` +- Conditional patterns: "se antigravity", "if antigravity", "for antigravity", "if cursor", "if windsurf", "if codex" + +**Exclusions:** Mentions inside this opensquad-dev SKILL.md itself (documentation), and any comment explicitly labeled as a cross-reference. + +**Pass:** No IDE-specific conditional logic found in shared files. +**Fail:** Report the file path, relevant line, and the correct location where the change should go instead (i.e., which `templates/ide-templates/{ide}/` file). + +### Step 3: Report results + +Present a clear summary: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔧 opensquad Dev Checklist +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Files changed: {N} +Checks run: {N} + +✅ Check A: Core file sync — {N}/{N} files in sync +❌ Check B: Skills sync — {file} out of sync + Fix: cp skills/{x}/SKILL.md templates/skills/{x}/SKILL.md +✅ Check F: Package manifest — all directories present +✅ Check J: IDE contamination — no IDE-specific logic in shared files + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Result: {N} issues found +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +If ALL checks pass, report clean: +``` +✅ All {N} checks passed — distribution is consistent. +``` + +If any check fails, list the fix commands at the end so the user +can approve them in batch. diff --git a/.agents/skills/opensquad/SKILL.md b/.agents/skills/opensquad/SKILL.md new file mode 100644 index 00000000..7ac21bf7 --- /dev/null +++ b/.agents/skills/opensquad/SKILL.md @@ -0,0 +1,246 @@ +--- +name: opensquad +description: "Opensquad — Multi-agent orchestration framework. Create and run AI squads for your business." +--- + +# Opensquad — Multi-Agent Orchestration + +You are now operating as the Opensquad system. Your primary role is to help users create, manage, and run AI agent squads. + +## Initialization + +On activation, perform these steps IN ORDER: + +1. Read the company context file: `{project-root}/_opensquad/_memory/company.md` +2. Read the preferences file: `{project-root}/_opensquad/_memory/preferences.md` +3. Check if company.md is empty or contains only the template — if so, trigger ONBOARDING flow +4. Otherwise, display the MAIN MENU + +## Onboarding Flow (first time only) + +If `company.md` is empty or contains ``: + +1. Welcome the user warmly to Opensquad +2. Ask their name (save to preferences.md) +3. Ask their preferred language for outputs (save to preferences.md) +4. Ask for their company name/description and website URL +5. Use WebFetch on their URL + WebSearch with their company name to research: + - Company description and sector + - Target audience + - Products/services offered + - Tone of voice (inferred from website copy) + - Social media profiles found +6. Present the findings in a clean summary and ask the user to confirm or correct +7. Save the confirmed profile to `_opensquad/_memory/company.md` +8. Show the main menu + +## Main Menu + +When the user types `/opensquad` or asks for the menu, present an interactive selector using AskUserQuestion with these options (max 4 per question): + +**Primary menu (first question):** +- **Create a new squad** — Describe what you need and I'll build a squad for you +- **Run an existing squad** — Execute a squad's pipeline +- **My squads** — View, edit, or delete your squads +- **More options** — Skills, company profile, settings, and help + +If the user selects "More options", present a second AskUserQuestion: +- **Skills** — Browse, install, create, and manage skills for your squads +- **Company profile** — View or update your company information +- **Settings & Help** — Language, preferences, configuration, and help + +## Command Routing + +Parse user input and route to the appropriate action: + +| Input Pattern | Action | +|---------------|--------| +| `/opensquad` or `/opensquad menu` | Show main menu | +| `/opensquad help` | Show help text | +| `/opensquad create ` | Run Create Squad — Phased Orchestration flow | +| `/opensquad list` | List all squads in `squads/` directory | +| `/opensquad run ` | Load Pipeline Runner → Execute squad | +| `/opensquad edit ` | Load Architect → Edit Squad flow | +| `/opensquad skills` | Load Skills Engine → Show skills menu | +| `/opensquad install ` | Install a skill from the catalog | +| `/opensquad uninstall ` | Remove an installed skill | +| `/opensquad delete ` | Confirm and delete squad directory | +| `/opensquad edit-company` | Re-run company profile setup | +| `/opensquad show-company` | Display company.md contents | +| `/opensquad settings` | Show/edit preferences.md | +| `/opensquad reset` | Confirm and reset all configuration | +| Natural language about squads | Infer intent and route accordingly | + +## Create Squad — Phased Orchestration + +When the user runs `/opensquad create`: + +### Phase 1: Discovery + +1. Check resume: does `squads/{name}/_build/discovery.yaml` already exist? + - If yes: read it, show summary, ask user to continue or redo + - If no: proceed with discovery + +2. **Collision guard:** List all existing subdirectories in `squads/` and pass the list of existing squad names to the Discovery subagent. This is mandatory — never skip this step. + +3. Dispatch Discovery subagent: + - Read `_opensquad/core/prompts/discovery.prompt.md` + - Also provide: `_opensquad/_memory/company.md`, `_opensquad/_memory/preferences.md` + - **Provide the list of existing squad folder names** so the agent can avoid collisions + - Follow the discovery prompt instructions (intelligent wizard, one question at a time) + - Output: `squads/{code}/_build/discovery.yaml` + +3. Validate: `discovery.yaml` exists and has required fields (purpose, domain, performance_mode) + +### Phase 2: Investigation (optional) + +Read `discovery.yaml` to check `investigation.mode`: + +**If `mode: sherlock`:** +For each target in `investigation.targets`: + 1. Detect platform from URL + 2. Dispatch Sherlock subagent with: + - `_opensquad/core/prompts/sherlock-shared.md` + - `_opensquad/core/prompts/sherlock-{platform}.md` (platform-specific extractor) + - URL, investigation_mode, output directory, squad name + 3. Use fast model tier for Sherlock subagents + 4. Subagents can run in parallel (one per URL) + 5. Wait for all to complete + 6. Validate per target: `raw-content.md` OR `error.md` exists + 7. If any target has `error.md`: inform user, ask to retry or skip + +**If `mode: manual`:** + 1. Ask user to paste reference content + 2. Save to `squads/{code}/_investigations/manual/raw-content.md` + +**If `mode: none`:** Skip to Phase 3 + +### Phase 3: Design + +1. Check resume: does `squads/{code}/_build/design.yaml` already exist? + - If yes: read it, show summary, ask user to continue or redo + +2. Dispatch Design subagent: + - Read `_opensquad/core/prompts/design.prompt.md` + - Provide: path to discovery.yaml, paths to investigation results (if any) + - The Design phase handles: best-practices consultation, web research, extraction, skill discovery, design presentation, template selection (optional — triggered when the squad includes an image skill) + - Output: `squads/{code}/_build/design.yaml` + +3. Validate: `design.yaml` exists and has agents and pipeline defined + +### Phase 4: Build + +1. Dispatch Build subagent: + - Read `_opensquad/core/prompts/build.prompt.md` + - Provide: path to design.yaml, path to discovery.yaml + - The Build phase generates all files and runs validation gates + - Output: `squads/{code}/squad.yaml` + all agent and pipeline files + +2. Final validation: + - `squad.yaml` exists + - All agent files referenced in squad-party.csv exist + - All pipeline step files exist + +3. Present completion summary to user + +### Resume Support + +If `/opensquad create` is called and `_build/` artifacts exist from a previous session: +- Discovery complete + Design missing → resume from Phase 3 +- Discovery + Design complete → resume from Phase 4 +- Show what was completed and ask user to continue or start over + +## Help Text + +When help is requested, display: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 📘 Opensquad Help +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +GETTING STARTED + /opensquad Open the main menu + /opensquad help Show this help + +SQUADS + /opensquad create Create a new squad (describe what you need) + /opensquad list List all your squads + /opensquad run Run a squad's pipeline + /opensquad edit Modify an existing squad + /opensquad delete Delete a squad + +SKILLS + /opensquad skills Browse installed skills + /opensquad install Install a skill from catalog + /opensquad uninstall Remove an installed skill + +COMPANY + /opensquad edit-company Edit your company profile + /opensquad show-company Show current company profile + +SETTINGS + /opensquad settings Change language, preferences + /opensquad reset Reset Opensquad configuration + +EXAMPLES + /opensquad create "Instagram carousel content production squad" + /opensquad create "Weekly data analysis squad for Google Sheets" + /opensquad create "Customer email response automation squad" + /opensquad run my-squad + +💡 Tip: You can also just describe what you need in plain language! +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## Loading Agents (for Squad Execution) + +When a specific squad agent needs to be activated during pipeline execution: + +1. Read the agent's `.agent.md` file completely (YAML frontmatter for metadata + markdown body for depth) +2. Adopt the agent's persona (role, identity, communication_style, principles) +3. Follow the agent's workflow instructions +4. When the agent's task is complete, return to pipeline context + +## Loading the Pipeline Runner + +When running a squad: + +1. Read `squads/{name}/squad.yaml` to understand the pipeline +2. Read `squads/{name}/squad-party.csv` to load all agent personas +2b. For each agent in the party CSV, also read their full `.agent.md` file from agents/ directory +3. Load company context from `_opensquad/_memory/company.md` +4. Load squad memory from `squads/{name}/_memory/memories.md` +5. Read the pipeline runner instructions from `_opensquad/core/runner.pipeline.md` +6. Execute the pipeline step by step following runner instructions + +## Loading the Skills Engine + +When the user selects "Skills" from the menu or types `/opensquad skills`: + +1. Read `_opensquad/core/skills.engine.md` for the skills engine instructions +2. Present the skills submenu using AskUserQuestion (max 4 options): + - **View installed skills** — See what's installed and their status + - **Install a skill** — Browse the catalog and install + - **Create a custom skill** — Create a new skill (uses opensquad-skill-creator) + - **Remove a skill** — Uninstall a skill +3. Follow the corresponding operation in the skills engine +4. When done, offer to return to the main menu + +## Language Handling + +- Read `preferences.md` for the user's preferred language +- All user-facing output should be in the user's preferred language +- Internal file names and code remain in English +- Agent personas communicate in the user's language + +## Critical Rules + +- **AskUserQuestion MUST always have 2-4 options.** When presenting a dynamic list (squads, skills, agents, etc.) as AskUserQuestion options and only 1 item exists, ALWAYS add a fallback option like "Cancel" or "Back to menu" to ensure the minimum of 2 options. If 0 items exist, skip AskUserQuestion entirely and inform the user directly. +- NEVER skip the onboarding if company.md is not configured +- ALWAYS load company context before running any squad +- ALWAYS present checkpoints to the user — never skip them +- ALWAYS save outputs to the squad's output directory +- When switching personas (inline execution), clearly indicate which agent is speaking +- When using subagents, inform the user that background work is happening +- After each pipeline run, update the squad's memories.md with key learnings diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..8a3fda54 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,7 @@ +[mcp_servers.playwright] +args = [ + "@playwright/mcp@latest", + "--config", + "_opensquad/config/playwright.config.json", +] +command = "npx" diff --git a/.env.example b/.env.example index f001b96c..dc55f3bc 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,23 @@ INSTAGRAM_USER_ID=your_instagram_business_user_id_here # Image hosting (imgBB) — https://api.imgbb.com/ # Required for instagram-publisher skill. Get a free API key at https://api.imgbb.com/ IMGBB_API_KEY=your_imgbb_api_key_here + +# ── Video Pipeline ──────────────────────────────────────────────────────────── +# TTS: edge-tts (padrão, gratuito, online) | kokoro (local, 82M params) | piper (CPU leve) +LOCAL_TTS_MODEL=edge-tts +LOCAL_TTS_VOICE=pt-BR-FranciscaNeural + +# Geração de imagem local: diffusers (padrão, MPS no M1) | auto1111 | comfyui +LOCAL_IMAGE_PROVIDER=diffusers +LOCAL_IMAGE_MODEL=stabilityai/sdxl-turbo + +# Transcrição Whisper: tiny | base (padrão) | small — NUNCA large em 8GB RAM +WHISPER_MODEL_SIZE=base + +# Ollama (opcional, para análise de imagem): ollama pull llava +OLLAMA_MODEL=llava + +# ── APIs Externas ───────────────────────────────────────────────────────────── +OPENROUTER_API_KEY=your_openrouter_api_key_here +BLOTATO_API_KEY=your_blotato_api_key_here +APIFY_API_KEY=your_apify_api_key_here diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c9782e86 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Opensquad — Project Instructions + +This project uses **Opensquad**, a multi-agent orchestration framework. + +## Quick Start + +Type `/opensquad` to open the main menu, or use any of these commands: +- `/opensquad create` — Create a new squad +- `/opensquad run ` — Run a squad +- `/opensquad help` — See all commands + +## Directory Structure + +- `_opensquad/` — Opensquad core files (do not modify manually) +- `_opensquad/_memory/` — Persistent memory (company context, preferences) +- `skills/` — Installed skills (integrations, scripts, prompts) +- `squads/` — User-created squads +- `squads/{name}/_investigations/` — Sherlock content investigations (profile analyses) +- `squads/{name}/output/` — Generated content and files +- `_opensquad/_browser_profile/` — Persistent browser sessions (login cookies, localStorage) + +## How It Works + +1. The `/opensquad` skill is the entry point for all interactions +2. The **Architect** agent creates and modifies squads +3. During squad creation, the **Sherlock** investigator can analyze reference profiles (Instagram, YouTube, Twitter/X, LinkedIn) to extract real content patterns +4. The **Pipeline Runner** executes squads automatically +5. Agents communicate via persona switching (inline) or subagents (background) +6. Checkpoints pause execution for user input/approval + +## Rules + +- Always use `/opensquad` commands to interact with the system +- Do not manually edit files in `_opensquad/core/` unless you know what you're doing +- Squad YAML files can be edited manually if needed, but prefer using `/opensquad edit` +- Company context in `_opensquad/_memory/company.md` is loaded for every squad run + +## Browser Sessions + +Opensquad uses a persistent Playwright browser profile to keep you logged into social media platforms. +- Sessions are stored in `_opensquad/_browser_profile/` (gitignored, private to you) +- First time accessing a platform, you'll log in manually once +- Subsequent runs will reuse your saved session +- **Important:** The native Codex Playwright plugin must be disabled. Opensquad uses its own `@playwright/mcp` server configured in `.mcp.json`. diff --git a/README.md b/README.md index f5b4e225..5ea09d14 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,53 @@ Para qualquer pessoa ou equipe que queira automatizar trabalho operacional e rep - **Tutoriais e manuais** — o agente navega na internet, bate prints e monta um documento completo passo a passo (ideal para manuais de SaaS) - E muito mais — qualquer fluxo de trabalho que envolva pesquisa, criação ou automação +## 🎬 Pipeline de Vídeo + +O opensquad inclui 4 squads prontos para produção de vídeo completa — do roteiro à publicação. + +### Squads de Vídeo Disponíveis + +| Squad | Comando | O que faz | +|-------|---------|-----------| +| 🎬 Canal YouTube | `/opensquad run youtube-canal` | Roteiro + narração + cenas + SEO + thumbnail + publicação | +| 📱 Fábrica TikTok | `/opensquad run tiktok-factory` | Hook + roteiro + narração + cenas + legendas + publicação | +| 🎨 Fábrica de Reels | `/opensquad run reels-factory` | Hook + roteiro + narração + design estético + publicação | +| ♻️ Repurposador de Shorts | `/opensquad run shorts-repurposer` | Baixa vídeo → transcreve → identifica highlights → corta clipes → publica | + +### Setup (obrigatório antes do primeiro uso) + +```bash +bash setup/setup-local-ai.sh +``` + +O script instala todas as dependências necessárias de forma interativa. + +### Modelos de IA Locais e Gratuitos + +| Função | Modelo | RAM necessária | Qualidade | +|--------|--------|----------------|-----------| +| Narração TTS | Edge-TTS (Microsoft) | 0 MB (online) | ★★★★☆ | +| Transcrição | Whisper base (faster-whisper) | ~500 MB | ★★★★☆ | +| Geração de imagens | SDXL-turbo (diffusers) | ~3-4 GB | ★★★☆☆ | +| Montagem de vídeo | FFmpeg + MoviePy | ~200 MB | ★★★★★ | +| Legendas burn-in | FFmpeg (ASS filter) | ~50 MB | ★★★★★ | + +> **MacBook Air M1 com 8GB RAM:** Todos os modelos são compatíveis. Processar um vídeo por vez (não em paralelo). SDXL-turbo usa Metal (MPS) automaticamente. + +### Variáveis de Ambiente para Vídeo + +Adicione ao seu `.env` (ou copie de `.env.example`): + +```env +LOCAL_TTS_MODEL=edge-tts +LOCAL_TTS_VOICE=pt-BR-FranciscaNeural +LOCAL_IMAGE_PROVIDER=diffusers +LOCAL_IMAGE_MODEL=stabilityai/sdxl-turbo +WHISPER_MODEL_SIZE=base +``` + +--- + ## Instalação **Pré-requisito:** Node.js 20+ diff --git a/_opensquad/core/best-practices/_catalog.yaml b/_opensquad/core/best-practices/_catalog.yaml index c662f461..fdf1b737 100644 --- a/_opensquad/core/best-practices/_catalog.yaml +++ b/_opensquad/core/best-practices/_catalog.yaml @@ -114,3 +114,24 @@ catalog: name: "WhatsApp Broadcast" whenToUse: "Creating agents that produce WhatsApp broadcast messages or conversational marketing content." file: whatsapp-broadcast.md + + # === Video Platform Best Practices === + - id: tiktok-video + name: "TikTok Video" + whenToUse: "Creating agents that produce video content for TikTok — hooks, scripts, vertical format, captions, and audio strategy." + file: tiktok-video.md + + - id: youtube-seo + name: "YouTube SEO" + whenToUse: "Creating agents that optimize YouTube videos for search — titles, descriptions, tags, chapters, and thumbnails." + file: youtube-seo.md + + - id: video-production + name: "Video Production Pipeline" + whenToUse: "Creating agents that execute the full video production pipeline — scene images, TTS narration, FFmpeg assembly, and subtitles." + file: video-production.md + + - id: storyboard + name: "Storyboard & Shot List" + whenToUse: "Creating agents that convert scripts into scene-by-scene storyboards with image prompts and timing for video production." + file: storyboard.md diff --git a/_opensquad/core/best-practices/storyboard.md b/_opensquad/core/best-practices/storyboard.md new file mode 100644 index 00000000..3d7dd88a --- /dev/null +++ b/_opensquad/core/best-practices/storyboard.md @@ -0,0 +1,214 @@ +--- +name: "Storyboard & Shot List" +platform: "multi" +content_type: "storyboard" +description: "Guia de criação de storyboard cena a cena para vídeos de conteúdo: estrutura de shots, prompts de imagem para geração via IA, timings e notas de produção." +whenToUse: | + Criando agentes que transformam roteiros em storyboards detalhados com descrições + de cena prontas para geração de imagens via local-image-gen ou image-ai-generator. + Leia também video-production.md para o contexto completo do pipeline. +version: "1.0.0" +--- + +# Storyboard & Shot List + +## O que é um Storyboard neste contexto + +Um storyboard para produção de vídeo com IA não é um esboço desenhado à mão. É um arquivo YAML estruturado que funciona como "script de produção" — conecta o roteiro com as imagens a serem geradas e com a montagem final. + +Cada entrada no storyboard é uma instrução completa para: +1. Qual imagem deve ser gerada (prompt) +2. O que deve ser narrado naquele momento (trecho do roteiro) +3. Por quanto tempo a cena deve aparecer (em segundos) +4. Qual texto aparece na tela (text overlay / legenda visual) +5. Como transicionar para a próxima cena + +## Regras de Divisão em Cenas + +### Quantidade de cenas por duração de vídeo +| Duração do vídeo | Cenas recomendadas | Duração média por cena | +|------------------|--------------------|------------------------| +| 15-30s (TikTok curto) | 3-8 cenas | 3-5s | +| 30-60s (TikTok/Reels) | 8-15 cenas | 4-6s | +| 1-3 min (Shorts/Reels longos) | 15-30 cenas | 5-7s | +| 5-10 min (YouTube) | 40-80 cenas | 6-8s | +| 10-20 min (YouTube longo) | 60-120 cenas | 8-12s | + +### Uma cena = um beat narrativo +- Um **beat narrativo** é uma unidade completa de informação +- Exemplos de beats: "um dado estatístico", "uma explicação de conceito", "uma etapa do processo", "uma transição de argumento" +- Quando o argumento muda → troca de cena +- Quando aparece um novo personagem ou localização → troca de cena + +### Duração mínima e máxima +- **Mínimo:** 3 segundos — menos tempo e o espectador não processa a imagem +- **Máximo:** 12 segundos sem movimento — parece estático e gera drop off +- Para cenas longas: adicione zoom lento (Ken Burns) ao gerar o vídeo + +## Estrutura do Image Prompt por Cena + +### Template de prompt +``` +[Sujeito + ação], [ambiente/cenário], [iluminação], [estilo fotográfico], [qualidade técnica], [formato/proporção] +``` + +### Exemplos de prompts bem estruturados + +**Para TikTok/Reels (vertical 9:16):** +``` +"Jovem empreendedor animado olhando para o celular, escritório moderno com plantas ao fundo, +luz natural suave pela janela, fotografia editorial, 4K ultra-detalhado, +proporção vertical 9:16, sem texto na imagem" +``` + +**Para YouTube (horizontal 16:9):** +``` +"Gráfico de linha ascendente em tela de computador com mãos digitando ao fundo, +setup tech moderno com monitores duplos, iluminação de estúdio azul, +fotorrealismo profissional, 4K, proporção 16:9, sem texto na imagem" +``` + +### Regras de prompt para consistência visual + +**Regra mais importante:** defina um `visual_style` no início do storyboard e copie-o em CADA prompt. + +```yaml +visual_style: "fotorrealismo 4K, iluminação suave neutra, paleta azul e branca, câmera de 85mm com fundo desfocado" +``` + +Cada prompt deve terminar com este descriptor de estilo. + +**Nunca inclua texto legível nas imagens** — IA não renderiza texto bem. Use `text_overlay` para isso. + +### Elementos para evitar em prompts +- Nomes de marcas específicas (Nike, Apple, etc.) — pode causar erros +- Texto legível (letras, números em placas, telas) — IA distorce +- Multidões com muitas faces — geração de rostos múltiplos fica estranha +- Mãos segurando objetos pequenos — mãos são o ponto fraco de todos os modelos + +## Formato YAML Completo do Storyboard + +```yaml +storyboard: + # Metadados + video_title: "Título do Vídeo" + squad: "nome-do-squad" + run_id: "2026-05-02-120000" + platform: "tiktok" # youtube | tiktok | reels | shorts + total_duration_target: 45 # segundos alvo + + # DEFINA O ESTILO UMA VEZ E COPIE EM TODOS OS PROMPTS + visual_style: "fotorrealismo 4K, iluminação suave de estúdio, paleta azul e branca, câmera 85mm, fundo desfocado" + + # Configuração de narração + narrator: + provider: edge-tts + voice: pt-BR-FranciscaNeural + + scenes: + - scene: 1 + narration: "71% dos profissionais usam IA sem contar para o chefe." + duration_seconds: 6 + image_prompt: "Profissional discreto olhando tela de laptop em escritório moderno, expressão de surpresa, fotorrealismo 4K, iluminação suave de estúdio, paleta azul e branca, câmera 85mm, fundo desfocado, 9:16 vertical" + text_overlay: "71% escondem o uso de IA" + text_position: center # top | center | bottom + transition: crossfade + notes: "Cena âncora do hook — mostrar o dado em destaque" + + - scene: 2 + narration: "E as empresas que adotam IA têm 40% mais produtividade." + duration_seconds: 6 + image_prompt: "Dashboard moderno com gráficos de crescimento, mão apontando para tela com dados positivos, luz azul de tela refletindo no rosto, fotorrealismo 4K, iluminação suave de estúdio, paleta azul e branca, câmera 85mm, fundo desfocado, 9:16 vertical" + text_overlay: "40% mais produtividade" + text_position: center + transition: crossfade + notes: "Dado de impacto — reforça o hook" + + - scene: 3 + narration: "O problema? A maioria não sabe por onde começar." + duration_seconds: 5 + image_prompt: "Pessoa confusa na frente de computador com múltiplas janelas abertas, expressão de frustração leve, iluminação quente de escritório em casa, fotorrealismo 4K, iluminação suave de estúdio, paleta azul e branca, câmera 85mm, fundo desfocado, 9:16 vertical" + text_overlay: "O problema..." + text_position: top + transition: crossfade + notes: "Virada de conflito — cria empatia" +``` + +## Calculando Duração por Narração + +**Fórmula PT-BR:** +``` +duração_segundos = (contagem_palavras / 150) × 60 × 1.1 +``` + +| Palavras | Duração estimada | +|----------|------------------| +| 10 | 4.4s | +| 15 | 6.6s | +| 20 | 8.8s | +| 25 | 11.0s | +| 30 | 13.2s | + +**Soma das durações** de todas as cenas deve igualar a duração total alvo do vídeo. + +## Consistência Visual entre Cenas + +### Técnicas para manter consistência +1. **Mesmo descriptor de estilo** em 100% dos prompts +2. **Mesma paleta de cores** (especifique as cores dominantes) +3. **Mesmo tipo de câmera/focal** (85mm, grande angular, etc.) +4. **Mesma iluminação** (natural, de estúdio, backlit, etc.) +5. Para personagens: descreva aparência detalhada na cena 1 e repita em cenas subsequentes + +### Quando usar imagens geradas vs. stock +- Imagens geradas: cenas específicas do contexto do vídeo +- Stock/web (via image-fetcher): dados estatísticos, infográficos, mapas, produtos reais + +## Verificação do Storyboard + +Antes de passar para a geração de imagens, verifique: +- [ ] Total de cenas cobre a duração alvo do vídeo +- [ ] Cada cena tem narration, duration_seconds, image_prompt e transition +- [ ] visual_style está presente e é consistente em todos os image_prompts +- [ ] Nenhum prompt pede texto legível na imagem +- [ ] Proporção correta está no prompt (9:16 para vertical, 16:9 para horizontal) +- [ ] text_overlay definido para cenas com dados ou pontos-chave + +## Output Format + +O agente deve salvar o storyboard como: +``` +squads/{squad-code}/output/{run_id}/storyboard.yaml +``` + +E gerar um resumo legível: +```markdown +# Storyboard — {video_title} +**Plataforma:** {platform} +**Duração total:** {X}s ({N} cenas) + +| # | Narração | Duração | Overlay | +|---|---------|---------|---------| +| 1 | "71% dos profissionais..." | 6s | "71% escondem..." | +| 2 | "E as empresas..." | 6s | "40% mais produtividade" | +[...] +``` + +## Quality Criteria + +- [ ] Pelo menos 1 cena por beat narrativo principal do roteiro +- [ ] Duração total das cenas = duração alvo ±5% +- [ ] visual_style idêntico em todos os prompts +- [ ] Cenas com dados têm text_overlay definido +- [ ] Nenhuma cena com duração < 3s ou > 12s (sem motion) +- [ ] Prompts sem pedido de texto legível nas imagens + +## Anti-patterns + +- ❌ Prompts vagos ("pessoa trabalhando") — gera imagens genéricas e inconsistentes +- ❌ Estilo diferente em cada cena — vídeo parece colagem aleatória +- ❌ Cenas muito longas sem movimento (>10s de imagem estática) +- ❌ Pedir texto em imagens via prompt — IA não renderiza texto bem +- ❌ Não usar text_overlay para dados importantes — espectador pode não ouvir o áudio +- ❌ Duração das cenas sem verificar que a soma ≈ duração do áudio de narração +- ❌ Cenas com imagens sem proporção correta definida no prompt diff --git a/_opensquad/core/best-practices/tiktok-video.md b/_opensquad/core/best-practices/tiktok-video.md new file mode 100644 index 00000000..038b4fc3 --- /dev/null +++ b/_opensquad/core/best-practices/tiktok-video.md @@ -0,0 +1,171 @@ +--- +name: "TikTok Video" +platform: "tiktok" +content_type: "video" +description: "Estratégia e formato de conteúdo para TikTok: hooks virais, estrutura de vídeo curto, TikTok SEO e publicação otimizada para o algoritmo FYP" +whenToUse: | + Criando agentes que produzem conteúdo em vídeo para TikTok. + Inclui roteiro, cenas visuais, narração, legendas burn-in e publicação. +constraints: + max_duration_seconds: 600 + recommended_duration: "15-60s" + sweet_spot_duration: "21-34s" + aspect_ratio: "9:16 vertical" + resolution: "1080×1920" + caption_max_chars: 2200 + max_hashtags: 10 + recommended_hashtags: "3-5" + min_fps: 24 + recommended_fps: 30 +version: "1.0.0" +--- + +# TikTok Video + +## Platform Rules + +O TikTok distribui vídeos pelo **For You Page (FYP)** — o feed principal. O algoritmo prioriza: + +1. **Taxa de conclusão** (completion rate) — percentual de espectadores que assistem até o final + - O sinal mais importante para o algoritmo + - Vídeos curtos (15-30s) têm taxas de conclusão mais altas + - Um loop perfeito (o fim parece o início) dobra o tempo de visualização + +2. **Replays** — quantas vezes o mesmo usuário assiste novamente + - Vídeos que "surpreendem no final" geram replays naturais + +3. **Compartilhamentos e saves** — mais valiosos do que likes e comentários + - Um vídeo compartilhado demonstra alto valor percebido + +4. **Primeiros 1-3 segundos** determinam distribuição + - Se o usuário passar nos primeiros 3s, o TikTok para de distribuir + - O hook visual/textual deve aparecer no frame 1 — sem intro, sem logo, sem créditos + +5. **Cultura de som ativo** — 60%+ dos usuários assistem com som + - Áudio faz parte do conteúdo, não apenas acompanha + - Sons virais (trending sounds) aumentam alcance quando relevantes + +6. **TikTok indexa o áudio falado** para SEO — diga a keyword em voz alta + +## Content Structure + +### Hook (0-1s) — CRÍTICO +- **Objetivo:** Impedir que o usuário deslize +- O hook começa ANTES de qualquer narração — é visual +- Frame 1 deve mostrar o resultado, a emoção, ou o dado surpreendente +- Exemplos de hooks visuais: pessoa surpresa, número em destaque, "antes e depois" + +### Grab (1-3s) — Contexto +- Estabelece o que o espectador vai ganhar assistindo +- Cria a "promessa de valor" que mantém a atenção +- Pode ser narrado: "Você não vai acreditar nisso..." + +### Delivery (3-55s) — Valor +- Entrega o conteúdo prometido no hook +- Corte rápido: nunca pausa >0.5s no meio de uma frase +- Uma ideia central, sem digressões +- Use dados, exemplos concretos, demonstrações visuais + +### Loop/Punchline (últimos 2-5s) +- Fecha com o gancho que cria replay +- Opções: pergunta que remete ao início, dado que "relê" o hook, call-to-action de comentário +- "E você, já sabia disso? Comenta 👇" + +## Fórmulas de Hook PT-BR de Alto Desempenho + +``` +"[Número]% das pessoas não sabem que..." +"O erro que [profissão] comete todo dia sobre..." +"Eu testei [coisa] por [tempo] e o resultado foi..." +"Ninguém te conta isso sobre [tema popular]..." +"Pare tudo e veja isso sobre [assunto urgente]" +"O [produto/hábito/estratégia] que [promessa exagerada mas verdadeira]" +"Em 30 segundos você vai aprender o que levou X anos para descobrir" +``` + +## Sound Strategy + +- **Trending sound:** Use quando relevante para o tema — aumenta distribuição 20-40% +- **Voz original:** Constrói identidade de marca ao longo do tempo +- **Combo:** Voz original + trending instrumental de fundo (volume 15%) +- Para encontrar trending sounds: aba "Sons" no TikTok Creator Studio + +## Writing Guidelines + +### Roteiro TikTok +- **Velocidade de fala:** 160-180 palavras/minuto (mais rápido que YouTube) +- **Uma ideia central** — nunca tente cobrir dois temas em um vídeo +- **Linguagem direta:** "você" não "as pessoas"; "faça" não "é possível fazer" +- Cada frase deve ser independente (legendas de uma linha por fala) +- Evite advérbios e qualificadores: "muito", "bastante", "realmente" — corte tudo + +### Legendas (obrigatório) +- **100% dos vídeos TikTok devem ter legendas** burn-in +- Estilo: fonte grande, centralizada, branca com sombra preta +- Uma linha por fala (máx. 6-7 palavras) +- Sincronizada perfeitamente com o áudio + +### Cenas visuais +- Troca de cena a cada 2-4s (ritmo TikTok) +- Movimento ou mudança em todo frame (nunca imagem estática por >3s) +- Para vídeos narrados com imagens: adicione zoom lento (Ken Burns effect) nas cenas estáticas + +## TikTok SEO + +1. **Keyword na primeira linha da legenda** (caption) +2. **Diga a keyword em voz alta** no vídeo — TikTok indexa áudio +3. **3-5 hashtags de nicho** (evite #fyp, #viral, #foryou — genéricos demais) + - 1-2 hashtags específicas do nicho (ex: #marketingdigital) + - 1-2 hashtags da categoria (ex: #empreendedorismo) + - 1 hashtag de tendência se relevante +4. **Texto on-screen** com a keyword — TikTok faz OCR nas imagens do vídeo + +## Output Format + +### Roteiro TikTok +``` +[HOOK VISUAL — Frame 1: descreva o que aparece na tela] + +[HOOK NARRADO — 0-3s] +Frase de gancho... + +[DELIVERY — 3-55s] +Desenvolvimento em frases curtas... +Cada linha = uma legenda na tela... + +[PUNCHLINE/LOOP — últimos 3s] +Fechamento que cria replay ou CTA... + +[LEGENDA DO POST] +Primeira linha com keyword... +. +Segunda linha complementar... +. +#hashtag1 #hashtag2 #hashtag3 +``` + +## Quality Criteria + +Antes de finalizar o roteiro TikTok, verifique: + +- [ ] Hook visual está definido (o que aparece no frame 1) +- [ ] Hook narrado começa em menos de 1 segundo +- [ ] Duração alvo: 21-60s (ideal: 34s) +- [ ] Uma única ideia central +- [ ] Cada frase tem no máximo 7 palavras (para legenda) +- [ ] Punchline ou CTA nos últimos 3s +- [ ] Keyword está na primera linha da legenda do post +- [ ] 3-5 hashtags (sem #fyp ou #viral) +- [ ] Legendas burn-in planejadas + +## Anti-patterns + +- ❌ **Vídeo horizontal** — nunca, sempre 9:16 vertical +- ❌ **Intro com logo/abertura** — o TikTok não tem intro +- ❌ **Mais de uma ideia central** — o usuário vai embora +- ❌ **Pausa >0.5s no meio do vídeo** — corte está errado +- ❌ **Sem legendas** — 40% dos usuários têm som desligado no início +- ❌ **Hook depois dos 3s** — tarde demais, o usuário já passou +- ❌ **Hashtags genéricas** (#fyp, #viral, #foryou) — não contribuem para SEO de nicho +- ❌ **Estilo "cinematográfico lento"** — não funciona no TikTok +- ❌ **CTA "Siga o canal"** no início — diz ao algoritmo que seu conteúdo não é suficiente sozinho diff --git a/_opensquad/core/best-practices/video-production.md b/_opensquad/core/best-practices/video-production.md new file mode 100644 index 00000000..d8511d7a --- /dev/null +++ b/_opensquad/core/best-practices/video-production.md @@ -0,0 +1,269 @@ +--- +name: "Video Production Pipeline" +platform: "multi" +content_type: "production" +description: "Guia do pipeline completo de produção de vídeo: storyboard → cenas → narração → montagem → legendas. Cobre tools, timings, configurações por plataforma e quality gates." +whenToUse: | + Criando agentes que executam o pipeline completo de produção de vídeo. + Para roteiros, ver youtube-script.md. Para SEO, ver youtube-seo.md. + Para TikTok específico, ver tiktok-video.md. +version: "1.0.0" +--- + +# Video Production Pipeline + +## Visão Geral do Pipeline + +``` +Roteiro aprovado + │ + ▼ +[Storyboarder] → storyboard.yaml + │ + ├──▶ [Narrator] → narration.mp3 (local-tts) + │ + └──▶ [Scene Designer] → scenes/*.png (local-image-gen ou image-ai-generator) + │ + ▼ + [Video Editor] → video-config.json + │ + ├──▶ [Subtitle Generator] → subtitles.srt/.ass (subtitle-generator) + │ + └──▶ [Video Assembler] → final-{platform}.mp4 (video-assembler) + │ + ▼ + [Thumbnail Creator] → thumbnail.png + │ + ▼ + [Publisher] → publicado ✓ +``` + +## Fase 1: Storyboard + +### O que é um storyboard neste contexto +Não é um storyboard de Hollywood. É uma lista estruturada de cenas com: +- **Narração** de cada cena (trecho exato do roteiro para aquele momento) +- **Prompt de imagem** para gerar a visual da cena +- **Duração estimada** em segundos +- **Texto on-screen** (opcional — text overlay que aparece no vídeo) +- **Tipo de transição** para a próxima cena + +### Calculando a duração de cada cena + +**Fórmula para PT-BR:** +``` +duração (s) = (número de palavras ÷ 150) × 60 × 1.1 +``` +- 150 palavras/minuto = velocidade natural de fala em PT-BR +- × 1.1 = margem de 10% para pausas naturais + +**Exemplos:** +- 25 palavras → ~11s +- 40 palavras → ~16s +- 15 palavras → ~7s + +### Regra de ouro para cenas +- **1 cena = 1 beat narrativo** (uma ideia, um dado, uma emoção) +- Vídeo de 60s → 8-15 cenas +- Vídeo de 5 min → 30-50 cenas (use cenas "de transição" para manter ritmo) +- **Nunca menos de 3s por cena** — muito rápido para processar visualmente + +### Formato do storyboard.yaml +```yaml +storyboard: + video_title: "Título do Vídeo" + platform: "tiktok" # youtube | tiktok | reels | shorts + total_duration_target: 45 # segundos alvo + visual_style: "fotorrealismo 4K, iluminação suave de estúdio, paleta azul e branca" + + scenes: + - scene: 1 + narration: "71% dos profissionais usam IA sem contar para o chefe." + duration_seconds: 6 + image_prompt: "Profissional discreto olhando tela de laptop em escritório moderno, expressão de segredo, iluminação suave, fotorrealismo 4K, 9:16 vertical" + text_overlay: "71% escondem o uso de IA" + transition: crossfade + notes: "Cena âncora — o dado principal do hook" + + - scene: 2 + narration: "E os que assumem têm 3x mais promoções." + duration_seconds: 5 + image_prompt: "Profissional confiante apresentando resultados em reunião, gráfico de crescimento na tela atrás, iluminação de estúdio corporativo, fotorrealismo 4K, 9:16 vertical" + text_overlay: "3x mais promoções" + transition: crossfade +``` + +**IMPORTANTE:** O campo `visual_style` deve ser copiado LITERALMENTE no `image_prompt` de cada cena para manter consistência visual. + +## Fase 2: Geração de Imagens de Cena + +### Consistência de estilo +Defina um "descriptor de estilo" único no começo e use em TODAS as cenas: +``` +"fotorrealismo 4K, iluminação suave, cores quentes, fundo desfocado, câmera de 85mm" +``` + +**Nunca misture estilos** (ex: fotorrealismo em umas cenas e ilustração em outras — parece amador). + +### Orientação de prompts por plataforma +- **TikTok/Reels/Shorts:** sempre `9:16 vertical, 1080×1920` +- **YouTube:** sempre `16:9 horizontal, 1920×1080` + +### Fluxo de geração +1. Gere a cena 1 primeiro para validar o estilo +2. Mostre ao usuário antes de gerar todas as cenas +3. Se aprovado, gere o restante em batch via `--batch` +4. **local-image-gen** para iteração e validação +5. **image-ai-generator** (production) para cenas principais do vídeo final + +## Fase 3: Geração de Narração + +### Pré-processamento do roteiro +Antes de chamar local-tts, limpe o texto: +```python +# Remover cues de produção: [B-roll: escritório] → "" +# Remover headings markdown: ## Seção → "" +# Remover bold/italic: **texto** → "texto" +# Remover URLs +# Adicionar pontuação em frases sem ela +``` + +O script `generate_narration.py` faz isso automaticamente. + +### Configurações de voz por plataforma + +**YouTube (vídeos longos):** +- Voz: `pt-BR-AntonioNeural` (masculino, mais formal) ou `pt-BR-FranciscaNeural` (feminina, clara) +- Tom: mais pausado, didático, com entonação variada + +**TikTok/Reels (curtos):** +- Voz: `pt-BR-ThalitaNeural` (mais energética, jovem) ou `pt-BR-FranciscaNeural` +- Tom: mais rápido, energia alta, direto + +### Verificação da narração +Após gerar o MP3: +```bash +# Verificar duração +ffprobe -v quiet -print_format json -show_streams narration.mp3 | python3 -c " +import json,sys; s=json.load(sys.stdin)['streams']; +[print(f'Duração: {float(x[\"duration\"]):.1f}s') for x in s if x.get('codec_type')=='audio']" +``` + +A duração da narração é a "duração verdadeira" do vídeo. Ajuste as durações de cena no storyboard para somar ±5% da duração da narração. + +## Fase 4: Geração de Legendas + +### Quando gerar legendas +- **TikTok/Reels/Shorts:** SEMPRE — burn-in obrigatório +- **YouTube:** SRT para upload na plataforma (não burn-in) + +### Comando para legendas de narração +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input narration.mp3 \ + --output subtitles.ass \ # ass para TikTok/Reels (burn-in) + --format ass \ + --language pt +``` + +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input narration.mp3 \ + --output subtitles.srt \ # srt para YouTube (upload) + --format srt \ + --language pt +``` + +### Modelo recomendado para M1 8GB +Use `base` (padrão). Nunca `large`. O modelo é baixado automaticamente na primeira execução. + +## Fase 5: Montagem de Vídeo + +### Criando o video-config.json +O agente de edição de vídeo deve criar este arquivo a partir do storyboard.yaml: + +```json +{ + "scenes": [ + {"image": "squads/meu-squad/output/2026-05-02-120000/scenes/scene-01.png", "duration": 6}, + {"image": "squads/meu-squad/output/2026-05-02-120000/scenes/scene-02.png", "duration": 5} + ], + "audio": "squads/meu-squad/output/2026-05-02-120000/narration.mp3", + "subtitles": "squads/meu-squad/output/2026-05-02-120000/subtitles.ass", + "music": null, + "music_volume": 0.15, + "platform": "tiktok", + "transitions": "crossfade", + "fade_duration": 0.3 +} +``` + +### Montagem +```bash +python3 skills/video-assembler/scripts/assemble_video.py \ + --config video-config.json \ + --output squads/meu-squad/output/2026-05-02-120000/video/final-tiktok.mp4 +``` + +## Configurações de Exportação por Plataforma + +| Plataforma | Resolução | FPS | Codec | Bitrate | Áudio | +|------------|-----------|-----|-------|---------|-------| +| YouTube | 1920×1080 | 30 | H.264 | 8 Mbps | AAC 192k | +| YouTube Shorts | 1080×1920 | 30 | H.264 | 6 Mbps | AAC 192k | +| TikTok | 1080×1920 | 30 | H.264 | 6 Mbps | AAC 192k | +| Instagram Reels | 1080×1920 | 30 | H.264 | 6 Mbps | AAC 192k | + +Todos estes presets estão configurados no `video-assembler` automaticamente. + +## Quality Gate — Antes de Publicar + +Verifique: +- [ ] Duração do vídeo final dentro de ±10% do alvo +- [ ] Resolução correta para a plataforma (use `ffprobe final.mp4`) +- [ ] Áudio normalizado — não muito alto, não muito baixo +- [ ] Legendas legíveis — verifique a 1/4 do tamanho da tela +- [ ] Thumbnail legível a 200px de largura +- [ ] Sem frames pretos no início ou fim (além do fade esperado) + +### Verificação com ffprobe +```bash +ffprobe -v quiet -print_format json -show_streams final.mp4 | python3 -c " +import json,sys; data=json.load(sys.stdin) +for s in data['streams']: + if s.get('codec_type')=='video': + print(f\"Vídeo: {s['width']}x{s['height']}, {s['r_frame_rate']}fps, duração: {float(s['duration']):.1f}s\") + elif s.get('codec_type')=='audio': + print(f\"Áudio: {s['codec_name']}, {s.get('sample_rate','?')}Hz\")" +``` + +## Convenção de Nomes de Arquivo + +``` +squads/{squad-code}/output/{YYYY-MM-DD-HHmmss}/ +├── script.md # Roteiro aprovado +├── storyboard.yaml # Storyboard com cenas +├── narration.mp3 # Narração gerada +├── subtitles.srt # Legendas para upload +├── subtitles.ass # Legendas para burn-in +├── scenes/ +│ ├── scene-01.png +│ ├── scene-02.png +│ └── ... +├── video-config.json # Configuração de montagem +├── video/ +│ └── final-tiktok.mp4 # Vídeo final +├── thumbnail.png # Thumbnail +└── seo-package.yaml # Pacote de SEO +``` + +## Anti-patterns + +- ❌ Narração sem limpeza de markdown (o TTS lê os asteriscos) +- ❌ Cenas muito curtas (<3s) — não dá tempo de processar +- ❌ Cenas muito longas (>12s) sem movimento — parece vídeo parado +- ❌ Misturar estilos visuais entre cenas (fotorrealismo + cartoon) +- ❌ Música de fundo muito alta (>20%) — cobre a narração +- ❌ Legenda sem burn-in para TikTok/Reels (40% dos usuários têm som baixo) +- ❌ Não verificar duração com ffprobe após montagem +- ❌ Thumbnail criada sem verificar legibilidade a 200px diff --git a/_opensquad/core/best-practices/youtube-seo.md b/_opensquad/core/best-practices/youtube-seo.md new file mode 100644 index 00000000..dd304aec --- /dev/null +++ b/_opensquad/core/best-practices/youtube-seo.md @@ -0,0 +1,249 @@ +--- +name: "YouTube SEO" +platform: "youtube" +content_type: "seo" +description: "Guia completo de otimização SEO para YouTube: pesquisa de palavras-chave, título, descrição, tags, capítulos e estratégia de thumbnail" +whenToUse: | + Criando agentes que otimizam vídeos para busca orgânica no YouTube. + Inclui pesquisa de concorrentes, geração de título, descrição, tags, capítulos e hashtags. +constraints: + title_max_chars: 100 + title_optimal_chars: "50-70" + description_max_chars: 5000 + description_visible_lines: 2 + tags_max: 500 + recommended_tags: "8-15" + thumbnail_dimensions: "1280x720px" + thumbnail_max_size: "2MB" + chapters_min_videos: "3 minutos" + chapters_min_count: 3 +version: "1.0.0" +--- + +# YouTube SEO + +## Como o YouTube SEO difere do Google SEO + +| Fator | Google SEO | YouTube SEO | +|-------|-----------|-------------| +| Sinal principal | Backlinks + conteúdo | Watch time + CTR | +| Tráfego primário | Busca orgânica | Vídeos sugeridos (60-70%) | +| Velocidade de rankeamento | Meses | Dias a semanas | +| Retenção importa? | Não | Sim — AVD (Average View Duration) é crucial | +| Thumbnail | N/A | 50% do desempenho do vídeo | + +**Fórmula do algoritmo do YouTube:** +``` +Distribuição ∝ CTR × AVD × Engagement signals +``` +- CTR (Click-Through Rate): percentual de quem clica na thumbnail +- AVD (Average View Duration): tempo médio assistido +- Engagement: likes, comentários, compartilhamentos, saves + +## Processo de Pesquisa de Palavras-Chave + +### 1. Validar demanda de busca +``` +Pesquisar no YouTube: "{tema}" +Pesquisar no YouTube: "{tema} como" +Pesquisar no YouTube: "{tema} para iniciantes" +Pesquisar no YouTube: "{tema} 2025" +``` +Observar: autocomplete do YouTube mostra o que as pessoas realmente buscam. + +### 2. Analisar vídeos que rankeiam +Para cada vídeo no top 5 da busca: +- Quantos views tem? (validação de volume) +- Qual o título exato? (extrai keyword e estrutura) +- Qual a duração? (indica o que o algoritmo prefere para este tema) +- Quantos inscritos o canal tem? (para entender nível de competição) + +### 3. Identificar ângulos não explorados +- Buscar brechas: "ninguém fez um vídeo sobre X ângulo" +- Verificar qual pergunta do Google relacionada não tem resposta boa no YouTube +- Analisar comentários dos vídeos concorrentes: o que as pessoas perguntam? + +### 4. Escolher keyword principal +- Keyword primária: aparece nos primeiros 40 chars do título +- Keyword secundária: aparece na descrição e em 2-3 tags +- Long-tail: 3-4 palavras mais específicas — menos competição, mais intenção + +## Otimização de Título + +### Regras fundamentais +- **Limite:** 100 chars; **ideal:** 50-70 (evita truncamento na busca) +- **Keyword principal nos primeiros 40 chars** — o que aparece antes da reticências +- **Evite palavras de preenchimento** no início: "Um guia para..." → "Guia de..." +- **Não use CAIXA ALTA excessiva** — parece spam para o YouTube + +### 3 fórmulas com alto CTR em PT-BR + +**Fórmula 1 — Número + Resultado:** +``` +[N] [Resultado] que [Audiência] Precisa Saber Sobre [Keyword] +"7 Estratégias de Marketing Digital que Todo Empreendedor Precisa Conhecer" +``` + +**Fórmula 2 — Como + Resultado (sem jargão):** +``` +Como [Ação] em [Tempo/Contexto] (Sem [Objeção Principal]) +"Como Aprender Inglês em 3 Meses (Sem Gastar Nada)" +``` + +**Fórmula 3 — Curiosity Gap:** +``` +O [Erro/Segredo/Verdade] sobre [Keyword] que [Consequência Emocional] +"O Erro Que 90% das Pessoas Cometem ao Investir em Cripto" +``` + +### Sempre gerar 3 variações de título +O usuário deve poder escolher. Variante A = número, Variante B = como, Variante C = curiosity gap. + +## Estrutura da Descrição + +### Template completo +``` +[LINHA 1 — Hook com keyword principal — visível sem "Mostrar mais"] +Aprenda como [keyword] em [tempo] sem precisar de [objeção]. + +[LINHA 2 — Complemento ainda visível] +Neste vídeo você vai descobrir [3 bullet points do conteúdo]. + +[ESPAÇO EM BRANCO] + +[CORPO — 150-250 palavras com keywords secundárias] +Neste vídeo, vamos explorar [tema]. Você vai aprender [ponto 1], +como [ponto 2], e por que [ponto 3] é essencial para [benefício]. + +Se você quer [resultado desejado pela audiência], este é o vídeo certo. + +[CAPÍTULOS] +📌 CAPÍTULOS: +0:00 Introdução +1:30 [Seção 1] +4:15 [Seção 2] +7:40 [Seção 3] +10:20 Conclusão + +[LINKS DE RECURSOS] +🔗 [Links mencionados no vídeo] + +[CTA] +✅ Inscreva-se no canal para não perder os próximos vídeos +👍 Deixa o like se esse conteúdo te ajudou + +[HASHTAGS — últimas 3 aparecem acima do título] +#keyword1 #keyword2 #keyword3 +``` + +**CRÍTICO:** As primeiras 2-3 linhas são exibidas na busca do YouTube e em Vídeos Sugeridos. Elas devem conter a keyword e criar desejo de clicar. + +## Tags + +**Estrutura de 8-15 tags:** +1. Keyword exata do título (`"marketing digital"` — com aspas para exata) +2. Keyword sem aspas (`marketing digital`) +3. Variações (`marketing digital 2025`, `marketing digital para iniciantes`) +4. Long-tail (`como fazer marketing digital do zero`) +5. Keywords relacionadas (`tráfego orgânico`, `SEO para negócios`) +6. Categoria ampla (`marketing`, `empreendedorismo`) +7. Marca do canal (`Nome do Canal`) + +**Nunca use mais de 15 tags** — YouTube não indexa tags excessivas e pode penalizar. + +## Capítulos (Timestamps) + +**Obrigatório para vídeos acima de 3 minutos.** + +### Regras de formato +- Primeiro capítulo SEMPRE em `0:00` (ex: `0:00 Introdução`) +- Mínimo 3 capítulos para ativar o recurso +- Labels curtos e descritivos (ideal: 2-4 palavras) +- Espaço exato no formato: `M:SS Label` ou `MM:SS Label` + +### Benefícios dos capítulos +- YouTube mostra capítulos como "rich snippet" nos resultados de busca +- Cada capítulo pode ser indexado separadamente para diferentes buscas +- Aumenta watch time (usuários saltam para a parte que querem e permanecem mais) + +### Exemplo para vídeo de 12 minutos +``` +0:00 Introdução +1:45 O Problema +4:20 A Solução +7:10 Resultados Reais +10:30 Como Aplicar +11:50 Próximos Passos +``` + +## SEO de Thumbnail + +- **Nome do arquivo:** Use a keyword (`marketing-digital-para-iniciantes.jpg`, não `IMG_0042.jpg`) +- **A thumbnail afeta o CTR**, que afeta diretamente a distribuição +- Teste A/B com YouTube Studio Analytics após 48h +- Thumbnails com face humana + emoção exagerada + texto curto = maior CTR historicamente + +## Engajamento como Sinal de SEO + +- **Primeiros 15 minutos após publicação** são cruciais — compartilhe para audiência existente imediatamente +- **Comentário fixado** com link de recurso mencionado + CTA para comentários +- **Responder comentários** nas primeiras 2h aumenta o sinal de engajamento +- **Community post** anunciando o vídeo antes do lançamento + +## Output Format + +```yaml +seo_package: + title_variants: + - "Título Opção A — número + resultado" + - "Título Opção B — como + resultado" + - "Título Opção C — curiosity gap" + recommended_title: "Título Opção A" + keywords: + primary: "keyword principal" + secondary: ["variação 1", "variação 2"] + long_tail: ["long-tail 1", "long-tail 2"] + description: | + [conteúdo completo da descrição] + tags: + - "keyword exata" + - "variação" + [...] + chapters: + - "0:00 Introdução" + - "1:30 Seção 1" + hashtags: + - "#hashtag1" + - "#hashtag2" + - "#hashtag3" + thumbnail_concept: + emotion: "surpresa" + text_overlay: "3-4 palavras" + visual_element: "badge circular" + colors: ["#cor1", "#cor2"] + competitive_analysis: + top_videos: + - title: "título do concorrente" + views: "X M views" + duration: "X:XX" +``` + +## Quality Criteria + +- [ ] Keyword principal nos primeiros 40 chars do título +- [ ] 3 variações de título geradas +- [ ] Primeiras 2 linhas da descrição são hook + keyword +- [ ] Capítulos com timestamp 0:00 como primeiro +- [ ] 8-15 tags (não mais) +- [ ] 3 hashtags no final da descrição +- [ ] Conceito de thumbnail definido com texto ≤4 palavras + +## Anti-patterns + +- ❌ Keyword stuffing no título ("marketing digital marketing digital 2025") +- ❌ Clickbait que não corresponde ao conteúdo real (mata o watch time) +- ❌ Descrição genérica ou vazia +- ❌ Mais de 15 tags +- ❌ Capítulos sem o 0:00 como primeiro (não ativa o recurso) +- ❌ Hashtags não relacionadas para "surfar em trending topics" (penaliza distribuição) +- ❌ Título com mais de 70 chars (truncado na maioria dos displays) diff --git a/setup/requirements-video.txt b/setup/requirements-video.txt new file mode 100644 index 00000000..b1a93c24 --- /dev/null +++ b/setup/requirements-video.txt @@ -0,0 +1,31 @@ +# Opensquad — Video Pipeline Python Dependencies +# Instale com: pip install -r setup/requirements-video.txt +# +# Para M1/Apple Silicon, instale torch primeiro: +# pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +# (MPS é habilitado automaticamente no M1) + +# ── Montagem de vídeo ───────────────────────────────────────────────────────── +moviepy>=1.0.3 +ffmpeg-python>=0.2.0 + +# ── Download de vídeo ───────────────────────────────────────────────────────── +yt-dlp>=2024.1.1 + +# ── Transcrição / Legendas ──────────────────────────────────────────────────── +faster-whisper>=1.0.0 + +# ── TTS / Narração ──────────────────────────────────────────────────────────── +edge-tts>=6.1.10 + +# ── Geração de imagem local ─────────────────────────────────────────────────── +# Instale torch antes (veja instruções acima para M1) +# torch>=2.0.0 +# torchvision>=0.15.0 +diffusers>=0.27.0 +transformers>=4.38.0 +accelerate>=0.27.0 + +# ── Utilitários ─────────────────────────────────────────────────────────────── +python-dotenv>=1.0.0 +requests>=2.31.0 diff --git a/setup/setup-local-ai.sh b/setup/setup-local-ai.sh new file mode 100644 index 00000000..af283145 --- /dev/null +++ b/setup/setup-local-ai.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Opensquad — Setup: Ferramentas de IA Local para Pipeline de Vídeo +# Compatível com macOS (Homebrew) e Linux (apt) +# Testado no MacBook Air M1 8GB +# +# Uso: bash setup/setup-local-ai.sh + +set -e + +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${BOLD}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERRO]${NC} $1"; } + +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Opensquad — Setup de IA Local para Vídeo ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" +echo "" + +# ── Detectar OS ─────────────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" +info "Sistema: $OS / $ARCH" + +if [[ "$ARCH" == "arm64" && "$OS" == "Darwin" ]]; then + info "Apple Silicon M1/M2/M3 detectado — MPS (Metal) será usado para IA local" +fi + +# ── Verificar Python 3.9+ ───────────────────────────────────────────────────── +info "Verificando Python..." +if command -v python3 &>/dev/null; then + PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") + PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1) + PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) + if [[ "$PYTHON_MAJOR" -ge 3 && "$PYTHON_MINOR" -ge 9 ]]; then + success "Python $PYTHON_VERSION encontrado" + else + error "Python $PYTHON_VERSION é muito antigo. Instale Python 3.9+ e tente novamente." + exit 1 + fi +else + error "Python 3 não encontrado. Instale de https://python.org e tente novamente." + exit 1 +fi + +# ── Instalar FFmpeg ─────────────────────────────────────────────────────────── +info "Verificando FFmpeg..." +if command -v ffmpeg &>/dev/null; then + success "FFmpeg já instalado: $(ffmpeg -version 2>&1 | head -1 | cut -d' ' -f3)" +else + warn "FFmpeg não encontrado. Instalando..." + if [[ "$OS" == "Darwin" ]]; then + if command -v brew &>/dev/null; then + brew install ffmpeg + success "FFmpeg instalado via Homebrew" + else + error "Homebrew não encontrado. Instale de https://brew.sh primeiro." + exit 1 + fi + elif [[ "$OS" == "Linux" ]]; then + sudo apt-get update -qq && sudo apt-get install -y ffmpeg + success "FFmpeg instalado via apt" + else + error "Sistema não suportado. Instale FFmpeg manualmente de https://ffmpeg.org" + exit 1 + fi +fi + +# ── Instalar pip packages ───────────────────────────────────────────────────── +info "Instalando pacotes Python para o pipeline de vídeo..." +echo "" + +# yt-dlp +info "Instalando yt-dlp (download de vídeos)..." +pip3 install -q "yt-dlp>=2024.1.1" && success "yt-dlp instalado" + +# edge-tts +info "Instalando edge-tts (narração gratuita Microsoft)..." +pip3 install -q "edge-tts>=6.1.10" && success "edge-tts instalado" + +# faster-whisper +info "Instalando faster-whisper (transcrição/legendas)..." +pip3 install -q "faster-whisper>=1.0.0" && success "faster-whisper instalado" + +# moviepy + ffmpeg-python +info "Instalando moviepy + ffmpeg-python (montagem de vídeo)..." +pip3 install -q "moviepy>=1.0.3" "ffmpeg-python>=0.2.0" && success "moviepy + ffmpeg-python instalados" + +# python-dotenv + requests +info "Instalando utilitários (dotenv, requests)..." +pip3 install -q "python-dotenv>=1.0.0" "requests>=2.31.0" && success "Utilitários instalados" + +echo "" + +# ── PyTorch + diffusers (opcional) ─────────────────────────────────────────── +echo -e "${BOLD}Deseja instalar PyTorch + diffusers para geração de imagens LOCAL?${NC}" +echo " - Tamanho: ~2-4 GB de download" +echo " - No M1: usa Metal (MPS) automaticamente" +echo " - Sem isso: use 'image-ai-generator' (pago) ou AUTOMATIC1111/ComfyUI separado" +echo "" +read -p "Instalar PyTorch + diffusers? [s/N] " INSTALL_TORCH +echo "" + +if [[ "$INSTALL_TORCH" =~ ^[Ss]$ ]]; then + info "Instalando PyTorch (pode demorar vários minutos)..." + if [[ "$OS" == "Darwin" ]]; then + # macOS — instala versão padrão (M1 usa MPS automaticamente) + pip3 install -q torch torchvision && success "PyTorch instalado (MPS ativo no M1)" + else + # Linux — instala CPU build (GPU build requer configuração manual) + pip3 install -q torch torchvision --index-url https://download.pytorch.org/whl/cpu + success "PyTorch instalado (CPU build — configure CUDA manualmente se tiver GPU NVIDIA)" + fi + + info "Instalando diffusers + transformers + accelerate..." + pip3 install -q "diffusers>=0.27.0" "transformers>=4.38.0" "accelerate>=0.27.0" + success "diffusers + transformers + accelerate instalados" +else + warn "PyTorch não instalado. Use --provider auto1111 ou --provider comfyui no local-image-gen." +fi + +echo "" + +# ── Ollama (opcional) ───────────────────────────────────────────────────────── +echo -e "${BOLD}Deseja instalar Ollama para análise de imagens com llava?${NC}" +echo " - Permite usar modelos de visão localmente (llava, llama3.2-vision)" +echo " - Tamanho do modelo: ~4-7 GB" +echo "" +read -p "Instalar Ollama? [s/N] " INSTALL_OLLAMA +echo "" + +if [[ "$INSTALL_OLLAMA" =~ ^[Ss]$ ]]; then + if command -v ollama &>/dev/null; then + success "Ollama já instalado: $(ollama --version 2>/dev/null || echo 'versão desconhecida')" + else + info "Instalando Ollama..." + if [[ "$OS" == "Darwin" ]]; then + if command -v brew &>/dev/null; then + brew install ollama && success "Ollama instalado via Homebrew" + else + curl -fsSL https://ollama.ai/install.sh | sh && success "Ollama instalado" + fi + else + curl -fsSL https://ollama.ai/install.sh | sh && success "Ollama instalado" + fi + fi + + echo "" + info "Baixando modelo llava (~4GB)..." + read -p "Baixar llava agora? [s/N] " PULL_LLAVA + if [[ "$PULL_LLAVA" =~ ^[Ss]$ ]]; then + ollama pull llava && success "llava baixado e pronto" + else + warn "Execute 'ollama pull llava' quando quiser usar o modelo." + fi +fi + +echo "" + +# ── Criar .env se não existir ───────────────────────────────────────────────── +if [[ ! -f ".env" ]]; then + info "Criando .env a partir de .env.example..." + cp .env.example .env + success ".env criado — edite as variáveis antes de usar os squads" +else + success ".env já existe — mantendo configuração atual" +fi + +# ── Download do modelo Whisper base ────────────────────────────────────────── +echo "" +echo -e "${BOLD}Deseja pré-baixar o modelo Whisper 'base' agora?${NC}" +echo " - Tamanho: ~145 MB" +echo " - Sem isso: o modelo é baixado na primeira transcrição (pode demorar)" +echo "" +read -p "Pré-baixar Whisper base? [s/N] " PRELOAD_WHISPER +if [[ "$PRELOAD_WHISPER" =~ ^[Ss]$ ]]; then + python3 -c " +from faster_whisper import WhisperModel +print('Baixando modelo Whisper base...') +WhisperModel('base', device='auto', compute_type='int8') +print('Modelo baixado com sucesso!') +" + success "Whisper base pronto para uso offline" +fi + +# ── Resumo ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Setup concluído! ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════════════╝${NC}" +echo "" +echo "Ferramentas instaladas:" +command -v ffmpeg &>/dev/null && echo -e " ${GREEN}✓${NC} FFmpeg" || echo -e " ${RED}✗${NC} FFmpeg" +command -v yt-dlp &>/dev/null && echo -e " ${GREEN}✓${NC} yt-dlp" || echo -e " ${RED}✗${NC} yt-dlp" +python3 -c "import edge_tts" 2>/dev/null && echo -e " ${GREEN}✓${NC} edge-tts" || echo -e " ${RED}✗${NC} edge-tts" +python3 -c "import faster_whisper" 2>/dev/null && echo -e " ${GREEN}✓${NC} faster-whisper" || echo -e " ${RED}✗${NC} faster-whisper" +python3 -c "import moviepy" 2>/dev/null && echo -e " ${GREEN}✓${NC} moviepy" || echo -e " ${RED}✗${NC} moviepy" +python3 -c "import diffusers" 2>/dev/null && echo -e " ${GREEN}✓${NC} diffusers" || echo -e " ${YELLOW}~${NC} diffusers (não instalado)" +command -v ollama &>/dev/null && echo -e " ${GREEN}✓${NC} ollama" || echo -e " ${YELLOW}~${NC} ollama (não instalado)" +echo "" +echo "Próximos passos:" +echo " 1. Edite .env com suas chaves de API (BLOTATO_API_KEY, etc.)" +echo " 2. Execute '/opensquad run shorts-repurposer' para testar" +echo " 3. Consulte skills/local-tts/SKILL.md para opções de voz" +echo "" diff --git a/skills/local-image-gen/SKILL.md b/skills/local-image-gen/SKILL.md new file mode 100644 index 00000000..e30ced34 --- /dev/null +++ b/skills/local-image-gen/SKILL.md @@ -0,0 +1,131 @@ +--- +name: local-image-gen +description: > + Gera imagens localmente ou via APIs gratuitas, sem custo por imagem. + Provider primário: diffusers com SDXL-turbo (MPS no M1, 1-4 steps, rápido). + Alternativas: AUTOMATIC1111 (localhost:7860), ComfyUI (localhost:8188). + Para qualidade de produção final, use image-ai-generator (pago, superior). +description_pt-BR: > + Gera imagens localmente usando diffusers/SDXL-turbo (MPS no M1), AUTOMATIC1111 ou ComfyUI. + Gratuito e sem limite. Use image-ai-generator para qualidade de produção. +type: script +version: "1.0.0" +script: + path: scripts/generate_image_local.py + runtime: python3 + invoke: "python3 {skill_path}/scripts/generate_image_local.py --prompt \"{prompt}\" --output \"{output}\" --provider \"{provider}\"" +env: + - LOCAL_IMAGE_PROVIDER + - LOCAL_IMAGE_MODEL +categories: [images, ai, generation, local, free, video, scenes] +--- + +# Local Image Generator + +## When to use + +Use local-image-gen quando precisar gerar imagens para: +- **Cenas de vídeo** (storyboard → imagens reais) +- **Testes e iteração** antes de usar image-ai-generator (pago) +- **Geração em lote** onde custo seria proibitivo com API paga + +**Quando usar image-ai-generator em vez desta skill:** +- Imagens de capa (thumbnails) para publicação — qualidade importa muito +- Quando o cliente pediu qualidade premium +- Quando a cena principal do vídeo precisa de máxima qualidade + +Instale dependências: `pip install diffusers transformers accelerate torch` + +**Para M1 8GB:** Use SDXL-turbo (padrão) — usa ~3-4GB de RAM via MPS (Metal). +Execute apenas uma geração por vez. Chame `--cleanup` após batch para liberar RAM. + +## Providers disponíveis + +### diffusers (padrão — SDXL-turbo, MPS no M1) +- **Gratuito:** Sim +- **Requer internet:** Apenas para download inicial do modelo (~2 GB) +- **Velocidade no M1:** ~15-30s por imagem +- **Qualidade:** Boa para iteração e cenas de vídeo +- **GPU:** MPS (Metal) no M1 automaticamente; CPU em outros sistemas + +### auto1111 (AUTOMATIC1111 local) +- **Requer:** Servidor AUTOMATIC1111 rodando em http://localhost:7860 +- **Acessa qualquer modelo SD** instalado no servidor +- **Qualidade:** Excelente (depende do modelo configurado) + +### comfyui (ComfyUI local) +- **Requer:** ComfyUI rodando em http://localhost:8188 +- **Flexível:** Suporta workflows customizados +- **Qualidade:** Excelente + +## Presets de resolução por plataforma + +| Plataforma | Resolução | Ratio | Argumento | +|------------|-----------|-------|-----------| +| TikTok / Reels / Shorts | 1080×1920 | 9:16 | `--preset vertical` | +| YouTube | 1920×1080 | 16:9 | `--preset youtube` | +| Instagram Feed / Thumbnail | 1080×1080 | 1:1 | `--preset square` | +| YouTube Thumbnail | 1280×720 | 16:9 | `--preset thumbnail` | + +## Instruções para o agente + +### Gerar imagem de cena (vertical para TikTok/Reels) +```bash +python3 skills/local-image-gen/scripts/generate_image_local.py \ + --prompt "Escritório moderno, profissional trabalhando em laptop, luz suave, fotorrealismo 4K" \ + --output "squads/{squad}/output/{run_id}/scenes/scene-01.png" \ + --preset vertical +``` + +### Gerar em lote (a partir de storyboard) +```bash +python3 skills/local-image-gen/scripts/generate_image_local.py \ + --batch "squads/{squad}/output/{run_id}/scenes-batch.json" \ + --preset vertical +``` + +Formato do JSON de batch: +```json +[ + {"prompt": "Cena 1: ...", "output": "scenes/scene-01.png"}, + {"prompt": "Cena 2: ...", "output": "scenes/scene-02.png"} +] +``` + +### Com AUTOMATIC1111 ou ComfyUI +```bash +python3 skills/local-image-gen/scripts/generate_image_local.py \ + --prompt "Cena de escritório..." \ + --output "scene.png" \ + --provider auto1111 +``` + +## Guia de prompts para cenas de vídeo + +Estrutura recomendada: +``` +[Sujeito + ação], [ambiente], [iluminação], [estilo fotográfico], [qualidade técnica] +``` + +Exemplos bons: +- `"Jovem profissional sorrindo para câmera, escritório moderno com plantas, luz natural suave pela janela, fotografia editorial, 4K ultra-detalhado"` +- `"Smartphone na mão de uma pessoa, tela com gráficos de crescimento, fundo desfocado de cidade, fotorrealismo, perspectiva de primeiro plano"` + +Regras: +- Sempre especifique estilo (fotorrealismo, ilustração vetorial, etc.) +- Nunca peça texto nas imagens (IA não renderiza texto bem) +- Mantenha o mesmo modificador de estilo em TODAS as cenas do mesmo vídeo + +## Cost awareness + +- **local-image-gen:** Totalmente gratuito (custo de eletricidade apenas) +- **image-ai-generator (test mode):** ~R$0.01-0.02 por imagem +- **image-ai-generator (production):** ~R$0.07-0.10 por imagem +- Para vídeos com 20 cenas: R$0 (local) vs ~R$1.40-2.00 (image-ai-generator production) + +## Error handling + +- **diffusers não instalado:** mensagem clara com comando de instalação +- **RAM insuficiente no M1:** sugestão de fechar outros apps ou usar auto1111 com modelo menor +- **auto1111/comfyui offline:** mensagem de erro com instrução para iniciar o servidor local +- **Modelo não baixado:** download automático na primeira execução (pode demorar ~5-15 min) diff --git a/skills/local-image-gen/scripts/generate_image_local.py b/skills/local-image-gen/scripts/generate_image_local.py new file mode 100644 index 00000000..9f8e6a6e --- /dev/null +++ b/skills/local-image-gen/scripts/generate_image_local.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +Local Image Generator — Opensquad Skill +Gera imagens localmente ou via APIs gratuitas. + +Providers: diffusers (padrão, SDXL-turbo, MPS no M1), auto1111, comfyui + +Uso: + python3 generate_image_local.py --prompt "cena..." --output scene.png + python3 generate_image_local.py --batch scenes.json --preset vertical + python3 generate_image_local.py --prompt "..." --provider auto1111 --output out.png +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# Presets de resolução por plataforma +PRESETS = { + "vertical": {"width": 1080, "height": 1920}, # TikTok / Reels / Shorts + "youtube": {"width": 1920, "height": 1080}, # YouTube landscape + "square": {"width": 1080, "height": 1080}, # Instagram feed / square + "thumbnail": {"width": 1280, "height": 720}, # YouTube thumbnail + "custom": None, # usa --width e --height +} + + +def load_env() -> None: + try: + from dotenv import load_dotenv + load_dotenv(override=False) + except ImportError: + pass + + +def get_defaults() -> tuple[str, str]: + provider = os.environ.get("LOCAL_IMAGE_PROVIDER", "diffusers") + model = os.environ.get("LOCAL_IMAGE_MODEL", "stabilityai/sdxl-turbo") + return provider, model + + +# ── diffusers (SDXL-turbo, MPS no M1) ─────────────────────────────────────── + +def generate_diffusers(prompt: str, output: str, width: int, height: int, model_id: str) -> None: + """Gera imagem via HuggingFace diffusers com SDXL-turbo (MPS no M1).""" + try: + import torch + from diffusers import AutoPipelineForText2Image + except ImportError: + print("ERRO: diffusers/torch não instalados.") + print("Instale com: pip install diffusers transformers accelerate torch") + sys.exit(1) + + # Detecta device: MPS (M1/M2), CUDA (NVIDIA) ou CPU + if torch.backends.mps.is_available(): + device = "mps" + dtype = torch.float16 + print("Usando MPS (Metal) — Apple Silicon") + elif torch.cuda.is_available(): + device = "cuda" + dtype = torch.float16 + print(f"Usando CUDA — {torch.cuda.get_device_name(0)}") + else: + device = "cpu" + dtype = torch.float32 + print("Usando CPU (mais lento)") + + print(f"Carregando modelo: {model_id}") + print("(Download automático na primeira execução — pode demorar ~5-15 min)") + + pipe = AutoPipelineForText2Image.from_pretrained( + model_id, + torch_dtype=dtype, + use_safetensors=True, + variant="fp16" if dtype == torch.float16 else None, + ).to(device) + + # SDXL-turbo: 1-4 steps sem guidance_scale + is_turbo = "turbo" in model_id.lower() + gen_kwargs = { + "prompt": prompt, + "width": width, + "height": height, + } + if is_turbo: + gen_kwargs["num_inference_steps"] = 4 + gen_kwargs["guidance_scale"] = 0.0 + else: + gen_kwargs["num_inference_steps"] = 30 + gen_kwargs["guidance_scale"] = 7.5 + + print(f"Gerando imagem {width}×{height}...") + image = pipe(**gen_kwargs).images[0] + + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + image.save(str(output_path)) + + # Libera RAM no M1 (importante com 8GB) + if device == "mps": + import gc + del pipe + gc.collect() + torch.mps.empty_cache() + + size_kb = output_path.stat().st_size / 1024 + print(f"Imagem salva: {output_path} ({size_kb:.0f} KB, {width}×{height}px)") + + +# ── AUTOMATIC1111 ───────────────────────────────────────────────────────────── + +def generate_auto1111( + prompt: str, + output: str, + width: int, + height: int, + url: str = "http://127.0.0.1:7860", +) -> None: + """Gera imagem via AUTOMATIC1111 REST API (deve estar rodando localmente).""" + import base64 + import urllib.request + import urllib.error + + payload = { + "prompt": prompt, + "negative_prompt": "blurry, low quality, deformed, ugly, watermark", + "width": width, + "height": height, + "steps": 28, + "cfg_scale": 7, + "sampler_name": "DPM++ 2M Karras", + } + + api_url = f"{url}/sdapi/v1/txt2img" + print(f"Enviando para AUTOMATIC1111 em {url}...") + + try: + data = json.dumps(payload).encode() + req = urllib.request.Request( + api_url, + data=data, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + result = json.loads(resp.read()) + except urllib.error.URLError as e: + print(f"ERRO: Não foi possível conectar ao AUTOMATIC1111 em {url}") + print(f" Verifique se o servidor está rodando: {e}") + print(" Inicie com: cd stable-diffusion-webui && ./webui.sh --api") + sys.exit(1) + + if not result.get("images"): + print("ERRO: Nenhuma imagem na resposta do AUTOMATIC1111") + sys.exit(1) + + img_data = base64.b64decode(result["images"][0]) + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(img_data) + + size_kb = output_path.stat().st_size / 1024 + print(f"Imagem salva: {output_path} ({size_kb:.0f} KB, {width}×{height}px)") + + +# ── ComfyUI ────────────────────────────────────────────────────────────────── + +def generate_comfyui( + prompt: str, + output: str, + width: int, + height: int, + url: str = "http://127.0.0.1:8188", +) -> None: + """Gera imagem via ComfyUI API (workflow simples de texto para imagem).""" + import base64 + import json as _json + import time + import urllib.request + import urllib.error + import uuid + + # Workflow mínimo para txt2img no ComfyUI + workflow = { + "prompt": { + "3": {"class_type": "KSampler", "inputs": {"cfg": 7, "denoise": 1, "latent_image": ["5", 0], "model": ["4", 0], "negative": ["7", 0], "positive": ["6", 0], "sampler_name": "euler", "scheduler": "normal", "seed": 42, "steps": 20}}, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1, "height": height, "width": width}}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["4", 1], "text": prompt}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["4", 1], "text": "bad quality, blurry, watermark"}}, + "8": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "opensquad_", "images": ["8", 0]}}, + }, + "client_id": str(uuid.uuid4()), + } + + print(f"Enviando para ComfyUI em {url}...") + try: + data = json.dumps(workflow).encode() + req = urllib.request.Request(f"{url}/prompt", data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + result = json.loads(resp.read()) + prompt_id = result["prompt_id"] + except urllib.error.URLError as e: + print(f"ERRO: Não foi possível conectar ao ComfyUI em {url}: {e}") + print(" Inicie o ComfyUI e tente novamente.") + sys.exit(1) + + # Aguarda conclusão + print("Aguardando geração no ComfyUI...") + for _ in range(60): + time.sleep(2) + try: + with urllib.request.urlopen(f"{url}/history/{prompt_id}", timeout=10) as resp: + hist = json.loads(resp.read()) + if prompt_id in hist: + outputs = hist[prompt_id].get("outputs", {}) + for node_output in outputs.values(): + for img in node_output.get("images", []): + img_url = f"{url}/view?filename={img['filename']}&subfolder={img.get('subfolder','')}&type={img.get('type','output')}" + with urllib.request.urlopen(img_url) as img_resp: + img_data = img_resp.read() + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(img_data) + size_kb = len(img_data) / 1024 + print(f"Imagem salva: {output_path} ({size_kb:.0f} KB)") + return + except Exception: + pass + print("ERRO: Timeout aguardando ComfyUI. Verifique o servidor.") + sys.exit(1) + + +# ── Batch ───────────────────────────────────────────────────────────────────── + +def process_batch(batch_file: str, provider: str, model_id: str, preset: dict) -> None: + """Processa um arquivo JSON de batch de imagens.""" + batch_path = Path(batch_file) + if not batch_path.exists(): + print(f"ERRO: Arquivo de batch não encontrado: {batch_file}") + sys.exit(1) + + items = json.loads(batch_path.read_text(encoding="utf-8")) + print(f"Processando batch: {len(items)} imagens") + + success = 0 + for i, item in enumerate(items, 1): + prompt = item.get("prompt", "") + output = item.get("output", "") + if not prompt or not output: + print(f" [{i}/{len(items)}] AVISO: item inválido (sem prompt ou output)") + continue + + w = item.get("width", preset["width"]) + h = item.get("height", preset["height"]) + print(f" [{i}/{len(items)}] Gerando: {Path(output).name}") + + try: + _generate_single(prompt, output, provider, model_id, w, h) + success += 1 + except SystemExit: + print(f" [{i}/{len(items)}] FALHOU") + + print(f"\nBatch concluído: {success}/{len(items)} imagens geradas") + + +def _generate_single(prompt: str, output: str, provider: str, model_id: str, width: int, height: int) -> None: + """Gera uma única imagem com o provider especificado.""" + if provider == "diffusers": + generate_diffusers(prompt, output, width, height, model_id) + elif provider == "auto1111": + generate_auto1111(prompt, output, width, height) + elif provider == "comfyui": + generate_comfyui(prompt, output, width, height) + else: + print(f"ERRO: Provider desconhecido: {provider}") + sys.exit(1) + + +def main() -> None: + load_env() + default_provider, default_model = get_defaults() + + parser = argparse.ArgumentParser( + description="Local Image Generator — Opensquad Skill", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--prompt", help="Prompt de geração de imagem") + parser.add_argument("--output", help="Arquivo de saída PNG") + parser.add_argument("--batch", help="Arquivo JSON de batch com lista de {prompt, output}") + parser.add_argument( + "--provider", + default=default_provider, + choices=["diffusers", "auto1111", "comfyui"], + help=f"Provider de geração (padrão: {default_provider})", + ) + parser.add_argument( + "--model", + default=default_model, + help=f"Modelo para diffusers (padrão: {default_model})", + ) + parser.add_argument( + "--preset", + default="vertical", + choices=list(PRESETS.keys()), + help="Preset de resolução (padrão: vertical = 1080×1920)", + ) + parser.add_argument("--width", type=int, help="Largura em pixels (substitui preset)") + parser.add_argument("--height", type=int, help="Altura em pixels (substitui preset)") + + args = parser.parse_args() + + # Resolve resolução + if args.width and args.height: + preset = {"width": args.width, "height": args.height} + elif args.preset and args.preset != "custom": + preset = PRESETS[args.preset] + else: + preset = PRESETS["vertical"] + + if args.batch: + process_batch(args.batch, args.provider, args.model, preset) + elif args.prompt and args.output: + _generate_single(args.prompt, args.output, args.provider, args.model, preset["width"], preset["height"]) + else: + print("ERRO: Forneça --prompt e --output, ou --batch com arquivo JSON") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/local-tts/SKILL.md b/skills/local-tts/SKILL.md new file mode 100644 index 00000000..3e10e157 --- /dev/null +++ b/skills/local-tts/SKILL.md @@ -0,0 +1,118 @@ +--- +name: local-tts +description: > + Gera narração em áudio a partir de texto usando TTS gratuito. + Provider primário: Edge-TTS (Microsoft, gratuito, online, zero GPU, PT-BR nativo). + Alternativas locais: Kokoro (82M params, cabe em M1), Piper (ultra-leve, CPU). + Saída: arquivo MP3 pronto para uso no video-assembler. +description_pt-BR: > + Gera narração em áudio a partir de texto usando TTS gratuito. + Primário: Edge-TTS (Microsoft, gratuito, PT-BR nativo). Alternativas: Kokoro, Piper. + Saída: MP3 pronto para video-assembler. +type: script +version: "1.0.0" +script: + path: scripts/generate_narration.py + runtime: python3 + invoke: "python3 {skill_path}/scripts/generate_narration.py --text \"{text}\" --output \"{output}\"" +env: + - LOCAL_TTS_MODEL + - LOCAL_TTS_VOICE +categories: [audio, tts, narration, video, local, free] +--- + +# Local TTS — Gerador de Narração + +## When to use + +Use local-tts quando o pipeline precisar gerar narração em áudio para vídeos. Priorize Edge-TTS em quase todos os casos — é gratuito, não usa GPU, não requer instalação pesada e tem voz PT-BR de alta qualidade. + +Instale Edge-TTS antes de usar: `pip install edge-tts` + +**Atenção:** Edge-TTS requer conexão com a internet (envia texto para servidores Microsoft e recebe MP3). Para uso offline, use `--provider kokoro` ou `--provider piper`. + +## Providers disponíveis + +### Edge-TTS (padrão — recomendado) +- **Gratuito:** Sim (sem API key, sem cobrança) +- **Requer internet:** Sim +- **Qualidade:** Excelente — voz neural natural +- **GPU necessária:** Não +- **Instalação:** `pip install edge-tts` + +### Kokoro (local, sem internet) +- **Gratuito:** Sim +- **Requer internet:** Não +- **Qualidade:** Muito boa (82M params) +- **GPU necessária:** Não (M1 usa MPS automaticamente) +- **Instalação:** `pip install kokoro-onnx soundfile` + +### Piper (local, ultra-leve) +- **Gratuito:** Sim +- **Requer internet:** Apenas para download inicial do modelo +- **Qualidade:** Boa (voz sintetizada) +- **GPU necessária:** Não +- **Instalação:** `pip install piper-tts` + +## Vozes PT-BR disponíveis (Edge-TTS) + +| Voz | Gênero | Características | +|-----|--------|-----------------| +| `pt-BR-FranciscaNeural` | Feminina | Clara, profissional, natural (padrão) | +| `pt-BR-AntonioNeural` | Masculino | Direto, confiante, bom para tech | +| `pt-BR-ThalitaNeural` | Feminina | Jovem, animada, boa para TikTok | +| `pt-BR-DonatoNeural` | Masculino | Mais formal, bom para documentários | + +Liste todas as vozes disponíveis: +```bash +python3 skills/local-tts/scripts/generate_narration.py --list-voices +``` + +## Instruções para o agente + +### Narração simples (Edge-TTS) +```bash +python3 skills/local-tts/scripts/generate_narration.py \ + --text "Bem-vindo ao canal! Hoje vamos explorar como a inteligência artificial..." \ + --output "squads/{squad}/output/{run_id}/narration.mp3" +``` + +### A partir de arquivo de texto +```bash +python3 skills/local-tts/scripts/generate_narration.py \ + --file "squads/{squad}/output/{run_id}/script-clean.txt" \ + --output "squads/{squad}/output/{run_id}/narration.mp3" \ + --voice "pt-BR-AntonioNeural" +``` + +### Com provider específico +```bash +python3 skills/local-tts/scripts/generate_narration.py \ + --text "Texto aqui" \ + --output "narration.mp3" \ + --provider kokoro +``` + +## Pré-processamento do roteiro (IMPORTANTE) + +Antes de chamar local-tts, o agente deve limpar o roteiro: +1. Remover marcações markdown (`**texto**` → `texto`, `# Título` → `Título`) +2. Remover cues de B-roll: `[B-roll: câmera pan]` → `` +3. Remover notas de produção entre colchetes: `[Gráfico aparece]` → `` +4. Substituir emojis por equivalentes textuais ou remover +5. Adicionar pontuação final em frases que terminam sem ela + +Para textos longos (>500 chars), o script faz chunking automático e concatena. + +## Cost awareness + +- **Edge-TTS:** Totalmente gratuito — sem limite documentado de uso +- **Kokoro/Piper:** Gratuitos, processamento local +- Comparativo com Elevenlabs (pago): ~$0.30 por 1000 chars vs $0 com Edge-TTS + +## Error handling + +- **Edge-TTS offline:** script tenta 3 vezes antes de falhar. Verifique conexão com internet. +- **Arquivo de saída com 0 bytes:** indica falha na geração — o script relata e sai com código 1 +- **Texto muito longo:** o script faz chunking automático em parágrafos de até 500 chars +- **Voz não encontrada:** o script lista vozes disponíveis e sugere a mais próxima diff --git a/skills/local-tts/scripts/generate_narration.py b/skills/local-tts/scripts/generate_narration.py new file mode 100644 index 00000000..1b3145e5 --- /dev/null +++ b/skills/local-tts/scripts/generate_narration.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +Local TTS Narration Generator — Opensquad Skill +Gera narração em áudio a partir de texto usando TTS gratuito. + +Providers: + edge-tts (padrão) — Microsoft, gratuito, requer internet + kokoro — Local, 82M params, sem internet (pip install kokoro-onnx) + piper — Local, ultra-leve CPU (pip install piper-tts) + +Uso: + python3 generate_narration.py --text "Olá mundo" --output narration.mp3 + python3 generate_narration.py --file script.txt --output narration.mp3 --voice pt-BR-AntonioNeural + python3 generate_narration.py --list-voices + python3 generate_narration.py --list-voices --provider edge-tts +""" + +import argparse +import asyncio +import os +import re +import sys +import tempfile +from pathlib import Path + + +def load_env() -> None: + """Carrega .env se disponível.""" + try: + from dotenv import load_dotenv + load_dotenv(override=False) + except ImportError: + pass + + +def get_defaults() -> tuple[str, str]: + """Retorna provider e voz padrão das variáveis de ambiente.""" + provider = os.environ.get("LOCAL_TTS_MODEL", "edge-tts") + voice = os.environ.get("LOCAL_TTS_VOICE", "pt-BR-FranciscaNeural") + return provider, voice + + +def clean_script(text: str) -> str: + """ + Remove marcações de roteiro que não devem ser narradas: + - Markdown: **bold**, *italic*, # headings + - Cues de B-roll: [B-roll: pan], [Corte para], [Gráfico] + - Notas de produção entre colchetes + - Emojis + - Múltiplas quebras de linha + """ + # Remove cues de produção [...] + text = re.sub(r'\[.*?\]', '', text) + # Remove headings markdown + text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) + # Remove bold/italic + text = re.sub(r'\*{1,3}(.*?)\*{1,3}', r'\1', text) + # Remove underline markdown + text = re.sub(r'_{1,2}(.*?)_{1,2}', r'\1', text) + # Remove URLs + text = re.sub(r'https?://\S+', '', text) + # Remove emojis (caracteres fora do range ASCII básico que são emojis) + text = re.sub(r'[\U0001F300-\U0001FFFF]', '', text) + # Remove linhas horizontais + text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE) + # Normaliza espaços e quebras de linha + text = re.sub(r'\n{3,}', '\n\n', text) + text = re.sub(r'[ \t]+', ' ', text) + return text.strip() + + +def chunk_text(text: str, max_chars: int = 500) -> list[str]: + """ + Divide texto longo em chunks para Edge-TTS. + Divide em parágrafos, depois em frases se necessário. + """ + if len(text) <= max_chars: + return [text] + + chunks = [] + paragraphs = text.split('\n\n') + + for para in paragraphs: + para = para.strip() + if not para: + continue + if len(para) <= max_chars: + chunks.append(para) + else: + # Divide em frases + sentences = re.split(r'(?<=[.!?])\s+', para) + current = "" + for sent in sentences: + if len(current) + len(sent) + 1 <= max_chars: + current = (current + " " + sent).strip() + else: + if current: + chunks.append(current) + current = sent + if current: + chunks.append(current) + + return chunks if chunks else [text] + + +# ── Edge-TTS ───────────────────────────────────────────────────────────────── + +async def _edge_tts_generate(text: str, output: str, voice: str) -> None: + """Gera áudio com Edge-TTS (assíncrono).""" + try: + import edge_tts + except ImportError: + print("ERRO: edge-tts não instalado. Execute: pip install edge-tts") + sys.exit(1) + + communicate = edge_tts.Communicate(text, voice) + await communicate.save(output) + + +async def _edge_tts_list_voices() -> None: + """Lista vozes disponíveis no Edge-TTS.""" + try: + import edge_tts + except ImportError: + print("ERRO: edge-tts não instalado. Execute: pip install edge-tts") + sys.exit(1) + + manager = await edge_tts.VoicesManager.create() + pt_voices = [v for v in manager.voices if "pt" in v.get("Locale", "").lower()] + print("\nVozes PT-BR disponíveis:") + for v in pt_voices: + print(f" {v['ShortName']:40s} {v['Gender']:8s} {v.get('Locale', '')}") + + +def generate_edge_tts(text: str, output: str, voice: str) -> None: + """Gera narração com Edge-TTS, com suporte a chunking para textos longos.""" + chunks = chunk_text(text) + + if len(chunks) == 1: + asyncio.run(_edge_tts_generate(chunks[0], output, voice)) + else: + print(f"Texto dividido em {len(chunks)} partes para geração...") + # Gera cada chunk como arquivo temporário e concatena + try: + from pydub import AudioSegment + has_pydub = True + except ImportError: + has_pydub = False + + temp_files = [] + for i, chunk in enumerate(chunks): + temp_path = str(Path(output).with_suffix(f".chunk{i:03d}.mp3")) + asyncio.run(_edge_tts_generate(chunk, temp_path, voice)) + temp_files.append(temp_path) + print(f" Parte {i+1}/{len(chunks)} concluída") + + if has_pydub: + # Concatena com pydub + combined = AudioSegment.empty() + for f in temp_files: + combined += AudioSegment.from_mp3(f) + combined.export(output, format="mp3") + for f in temp_files: + Path(f).unlink(missing_ok=True) + else: + # Sem pydub: usa o último chunk (limitação) + # Melhor esforço sem dependência extra + import shutil + shutil.copy(temp_files[-1], output) + for f in temp_files[:-1]: + Path(f).unlink(missing_ok=True) + print("AVISO: pydub não instalado. Para concatenar chunks longos: pip install pydub") + + +# ── Kokoro ─────────────────────────────────────────────────────────────────── + +def generate_kokoro(text: str, output: str, voice: str = "bf_emma") -> None: + """Gera narração com Kokoro (local, offline).""" + try: + from kokoro_onnx import Kokoro + import soundfile as sf + import numpy as np + except ImportError: + print("ERRO: kokoro-onnx não instalado.") + print("Instale com: pip install kokoro-onnx soundfile") + sys.exit(1) + + kokoro = Kokoro("kokoro-v0_19.onnx", "voices.json") + samples, sample_rate = kokoro.create(text, voice=voice, speed=1.0, lang="pt-br") + + output_path = Path(output) + # Kokoro gera WAV — se solicitado MP3, converte + if output_path.suffix.lower() == ".mp3": + wav_temp = str(output_path.with_suffix(".wav")) + sf.write(wav_temp, samples, sample_rate) + _wav_to_mp3(wav_temp, output) + Path(wav_temp).unlink(missing_ok=True) + else: + sf.write(str(output_path), samples, sample_rate) + + +# ── Piper ──────────────────────────────────────────────────────────────────── + +def generate_piper(text: str, output: str, voice: str = "pt_BR-faber-medium") -> None: + """Gera narração com Piper TTS (local, ultra-leve).""" + try: + import piper + except ImportError: + print("ERRO: piper-tts não instalado.") + print("Instale com: pip install piper-tts") + sys.exit(1) + + import subprocess + cmd = ["piper", "--model", voice, "--output_file", output] + result = subprocess.run(cmd, input=text.encode(), capture_output=True) + if result.returncode != 0: + print(f"ERRO: Piper falhou: {result.stderr.decode()}") + sys.exit(1) + + +# ── Utilitários ────────────────────────────────────────────────────────────── + +def _wav_to_mp3(wav_path: str, mp3_path: str) -> None: + """Converte WAV para MP3 usando FFmpeg.""" + import subprocess + result = subprocess.run( + ["ffmpeg", "-i", wav_path, "-q:a", "0", mp3_path, "-y"], + capture_output=True, + ) + if result.returncode != 0: + print("AVISO: FFmpeg não disponível para converter WAV→MP3. Saída mantida como WAV.") + + +def main() -> None: + load_env() + default_provider, default_voice = get_defaults() + + parser = argparse.ArgumentParser( + description="Local TTS Narration Generator — Opensquad Skill", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--text", help="Texto para narrar") + parser.add_argument("--file", help="Arquivo .txt com o roteiro") + parser.add_argument("--output", help="Arquivo de saída (MP3 recomendado)") + parser.add_argument("--voice", default=default_voice, help=f"Voz a usar (padrão: {default_voice})") + parser.add_argument( + "--provider", + default=default_provider, + choices=["edge-tts", "kokoro", "piper"], + help=f"Provider TTS (padrão: {default_provider})", + ) + parser.add_argument( + "--list-voices", + action="store_true", + help="Listar vozes disponíveis (para edge-tts)", + ) + + args = parser.parse_args() + + if args.list_voices: + asyncio.run(_edge_tts_list_voices()) + return + + if not args.output: + print("ERRO: --output é obrigatório para geração de narração") + sys.exit(1) + + # Obtém texto + if args.file: + file_path = Path(args.file) + if not file_path.exists(): + print(f"ERRO: Arquivo não encontrado: {args.file}") + sys.exit(1) + text = file_path.read_text(encoding="utf-8") + elif args.text: + text = args.text + else: + print("ERRO: Forneça --text ou --file com o texto a narrar") + sys.exit(1) + + # Limpa o texto + text = clean_script(text) + if not text: + print("ERRO: Texto vazio após limpeza do roteiro") + sys.exit(1) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Provider: {args.provider} | Voz: {args.voice}") + print(f"Texto: {len(text)} caracteres") + + if args.provider == "edge-tts": + generate_edge_tts(text, str(output_path), args.voice) + elif args.provider == "kokoro": + generate_kokoro(text, str(output_path), args.voice) + elif args.provider == "piper": + generate_piper(text, str(output_path), args.voice) + + if output_path.exists() and output_path.stat().st_size > 0: + size_kb = output_path.stat().st_size / 1024 + print(f"Narração salva: {output_path} ({size_kb:.0f} KB)") + else: + print(f"ERRO: Arquivo de saída não foi criado ou está vazio: {output_path}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/subtitle-generator/SKILL.md b/skills/subtitle-generator/SKILL.md new file mode 100644 index 00000000..50e503ad --- /dev/null +++ b/skills/subtitle-generator/SKILL.md @@ -0,0 +1,124 @@ +--- +name: subtitle-generator +description: > + Gera legendas a partir de áudio ou vídeo usando faster-whisper (local, gratuito). + Suporta PT-BR e 99+ idiomas. Formatos de saída: SRT (upload), VTT (web), + ASS (burn-in estilizado para redes sociais com fonte grande centralizada). + Otimizado para MacBook Air M1 com device="auto" e compute_type="int8". +description_pt-BR: > + Gera legendas a partir de áudio ou vídeo usando faster-whisper (local, gratuito). + Suporta PT-BR e 99+ idiomas. Formatos: SRT (upload), VTT (web), + ASS (burn-in estilizado para redes sociais). Otimizado para M1 com int8. +type: script +version: "1.0.0" +script: + path: scripts/generate_subtitles.py + runtime: python3 + invoke: "python3 {skill_path}/scripts/generate_subtitles.py --input \"{input}\" --output \"{output}\" --format \"{format}\" --language \"{language}\"" +env: + - WHISPER_MODEL_SIZE +categories: [subtitles, transcription, whisper, video, accessibility, local] +--- + +# Subtitle Generator + +## When to use + +Use o Subtitle Generator quando o pipeline precisar de: +- **Legendas para burn-in:** use formato `ass` antes de chamar video-assembler +- **Legendas para upload:** use formato `srt` (YouTube, TikTok, Instagram) +- **Transcrição de conteúdo:** use `srt` ou `json` para análise de texto de vídeos + +Instale faster-whisper antes de usar: `pip install faster-whisper` + +**Nota de download:** Na primeira execução, o modelo Whisper é baixado automaticamente: +- `tiny`: ~75 MB, mais rápido, menos preciso +- `base`: ~145 MB, boa precisão para PT-BR (padrão) +- `small`: ~465 MB, melhor precisão, mais lento + +**Nunca use `large` em 8GB RAM** — causará travamento do sistema. + +## Formatos de saída + +### SRT — Para upload em plataformas +Padrão da indústria. Aceito pelo YouTube, TikTok, Instagram, e praticamente toda plataforma. +``` +1 +00:00:00,000 --> 00:00:02,500 +Olá, bem-vindo ao canal! + +2 +00:00:02,800 --> 00:00:05,200 +Hoje vamos falar sobre IA. +``` + +### VTT — Para web players +WebVTT, similar ao SRT mas para players HTML5. + +### ASS — Para burn-in estilizado (TikTok/Reels padrão) +Fonte grande centralizada na base da tela, estilo padrão para vídeos virais. +O video-assembler aplica automaticamente com FFmpeg. + +## Instruções para o agente + +### Gerar legendas em SRT (padrão) +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input "squads/{squad}/output/{run_id}/narration.mp3" \ + --output "squads/{squad}/output/{run_id}/subtitles.srt" \ + --format srt \ + --language pt +``` + +### Gerar legendas ASS para burn-in (TikTok/Reels) +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input "squads/{squad}/output/{run_id}/narration.mp3" \ + --output "squads/{squad}/output/{run_id}/subtitles.ass" \ + --format ass \ + --language pt +``` + +### Com modelo específico +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input audio.mp3 \ + --output subs.srt \ + --format srt \ + --language pt \ + --model small +``` + +### A partir de arquivo de vídeo +```bash +python3 skills/subtitle-generator/scripts/generate_subtitles.py \ + --input "video.mp4" \ + --output "subs.srt" \ + --format srt +``` + +## Modelos disponíveis (via WHISPER_MODEL_SIZE) + +| Modelo | Tamanho | RAM | Qualidade PT-BR | Quando usar | +|--------|---------|------|-----------------|-------------| +| tiny | 75 MB | ~1GB | Básica | Testes rápidos | +| base | 145 MB | ~1GB | Boa | Padrão (recomendado) | +| small | 465 MB | ~2GB | Melhor | Quando base não for suficiente | + +## Estilo ASS para redes sociais + +O formato ASS gerado por esta skill usa: +- Fonte: **Arial Bold** 60px (legível na miniatura) +- Cor: Branco puro com contorno preto 3px +- Posição: Centralizado na parte inferior (margem 10%) +- Resolução: 1080×1920 (vertical para TikTok/Reels/Shorts) + +Para YouTube (horizontal), use `--resolution youtube` para ajustar para 1920×1080. + +## Error handling + +- **faster-whisper não instalado:** mensagem clara com comando de instalação +- **Arquivo não encontrado:** mensagem de erro com path completo +- **Modelo não disponível:** download automático na primeira execução (pode demorar) +- **Idioma não detectado:** use `--language pt` explicitamente para PT-BR +- **RAM insuficiente:** use modelo menor (tiny > base > small) diff --git a/skills/subtitle-generator/scripts/generate_subtitles.py b/skills/subtitle-generator/scripts/generate_subtitles.py new file mode 100644 index 00000000..c9f3caae --- /dev/null +++ b/skills/subtitle-generator/scripts/generate_subtitles.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Subtitle Generator — Opensquad Skill +Gera legendas de áudio/vídeo usando faster-whisper (local, gratuito). + +Formatos de saída: SRT, VTT, ASS (burn-in estilizado para redes sociais) +Otimizado para MacBook Air M1 (device=auto, compute_type=int8) + +Uso: + python3 generate_subtitles.py --input narration.mp3 --output subs.srt --format srt + python3 generate_subtitles.py --input video.mp4 --output subs.ass --format ass --language pt + python3 generate_subtitles.py --input audio.mp3 --output subs.srt --model small --language pt +""" + +import argparse +import os +import sys +from pathlib import Path + +# Estilo ASS para vídeos verticais (TikTok/Reels/Shorts) — 1080×1920 +ASS_STYLE_VERTICAL = """\ +[Script Info] +ScriptType: v4.00+ +PlayResX: 1080 +PlayResY: 1920 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,108,108,192,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + +# Estilo ASS para vídeos horizontais (YouTube) — 1920×1080 +ASS_STYLE_HORIZONTAL = """\ +[Script Info] +ScriptType: v4.00+ +PlayResX: 1920 +PlayResY: 1080 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,56,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,192,192,80,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + +def check_faster_whisper() -> None: + """Verifica se faster-whisper está instalado.""" + try: + import faster_whisper # noqa: F401 + except ImportError: + print("ERRO: faster-whisper não encontrado.") + print("Instale com: pip install faster-whisper") + sys.exit(1) + + +def get_model_size() -> str: + """Obtém tamanho do modelo da variável de ambiente ou padrão.""" + return os.environ.get("WHISPER_MODEL_SIZE", "base") + + +def format_timestamp_srt(seconds: float) -> str: + """Converte segundos para formato SRT (HH:MM:SS,mmm).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds % 1) * 1000) + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + +def format_timestamp_ass(seconds: float) -> str: + """Converte segundos para formato ASS (H:MM:SS.cc).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + centis = int((seconds % 1) * 100) + return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}" + + +def transcribe(input_file: str, model_size: str, language: str) -> list: + """ + Transcreve o áudio/vídeo usando faster-whisper. + Retorna lista de segmentos: [{"start": float, "end": float, "text": str}, ...] + """ + from faster_whisper import WhisperModel + + print(f"Carregando modelo Whisper '{model_size}' (download automático na primeira vez)...") + model = WhisperModel(model_size, device="auto", compute_type="int8") + + lang = language if language and language != "auto" else None + print(f"Transcrevendo: {input_file}" + (f" (idioma: {lang})" if lang else " (detecção automática)")) + + segments_iter, info = model.transcribe( + input_file, + language=lang, + beam_size=5, + vad_filter=True, # Remove silêncio + word_timestamps=False, + ) + + segments = [] + for seg in segments_iter: + segments.append({ + "start": seg.start, + "end": seg.end, + "text": seg.text.strip(), + }) + + print(f"Transcrição concluída: {len(segments)} segmentos, idioma detectado: {info.language}") + return segments + + +def to_srt(segments: list) -> str: + """Converte segmentos para formato SRT.""" + lines = [] + for i, seg in enumerate(segments, 1): + start = format_timestamp_srt(seg["start"]) + end = format_timestamp_srt(seg["end"]) + lines.append(f"{i}\n{start} --> {end}\n{seg['text']}\n") + return "\n".join(lines) + + +def to_vtt(segments: list) -> str: + """Converte segmentos para formato WebVTT.""" + lines = ["WEBVTT\n"] + for i, seg in enumerate(segments, 1): + start = format_timestamp_srt(seg["start"]).replace(",", ".") + end = format_timestamp_srt(seg["end"]).replace(",", ".") + lines.append(f"{i}\n{start} --> {end}\n{seg['text']}\n") + return "\n".join(lines) + + +def to_ass(segments: list, resolution: str = "vertical") -> str: + """Converte segmentos para formato ASS estilizado para redes sociais.""" + style = ASS_STYLE_VERTICAL if resolution == "vertical" else ASS_STYLE_HORIZONTAL + lines = [style] + for seg in segments: + start = format_timestamp_ass(seg["start"]) + end = format_timestamp_ass(seg["end"]) + # Remove caracteres especiais que quebram o ASS + text = seg["text"].replace("{", "").replace("}", "").replace("\n", "\\N") + lines.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}") + return "\n".join(lines) + + +def main() -> None: + # Carrega .env se existir + try: + from dotenv import load_dotenv + load_dotenv(override=False) + except ImportError: + pass + + parser = argparse.ArgumentParser( + description="Subtitle Generator — Opensquad Skill", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--input", required=True, help="Arquivo de entrada (MP3, MP4, WAV, etc.)") + parser.add_argument("--output", required=True, help="Arquivo de saída (SRT, VTT ou ASS)") + parser.add_argument( + "--format", + choices=["srt", "vtt", "ass"], + default="srt", + help="Formato de saída (padrão: srt)", + ) + parser.add_argument( + "--language", + default="pt", + help="Idioma do áudio: pt (padrão), en, es, auto (detecção automática)", + ) + parser.add_argument( + "--model", + default=None, + help="Tamanho do modelo Whisper: tiny, base (padrão), small", + ) + parser.add_argument( + "--resolution", + choices=["vertical", "horizontal"], + default="vertical", + help="Resolução para estilo ASS: vertical (padrão, TikTok/Reels) ou horizontal (YouTube)", + ) + + args = parser.parse_args() + + check_faster_whisper() + + input_path = Path(args.input) + if not input_path.exists(): + print(f"ERRO: Arquivo de entrada não encontrado: {args.input}") + sys.exit(1) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + model_size = args.model or get_model_size() + segments = transcribe(str(input_path), model_size, args.language) + + if not segments: + print("AVISO: Nenhum segmento de fala detectado no áudio.") + sys.exit(1) + + fmt = args.format.lower() + if fmt == "srt": + content = to_srt(segments) + elif fmt == "vtt": + content = to_vtt(segments) + elif fmt == "ass": + content = to_ass(segments, args.resolution) + else: + print(f"ERRO: Formato desconhecido: {fmt}") + sys.exit(1) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + + size = output_path.stat().st_size + print(f"Legendas salvas: {output_path} ({size} bytes, {len(segments)} segmentos)") + + +if __name__ == "__main__": + main() diff --git a/skills/thumbnail-creator/SKILL.md b/skills/thumbnail-creator/SKILL.md new file mode 100644 index 00000000..31826d35 --- /dev/null +++ b/skills/thumbnail-creator/SKILL.md @@ -0,0 +1,118 @@ +--- +name: thumbnail-creator +description: > + Cria thumbnails para plataformas de vídeo via HTML/CSS renderizado pelo Playwright. + Formatos: YouTube (1280×720), TikTok capa (1080×1920), Instagram Reel capa (1080×1920). + Templates prontos com alta legibilidade e contraste. Estende image-creator com + templates otimizados para cada plataforma de vídeo. +description_pt-BR: > + Cria thumbnails de vídeo via HTML/CSS + Playwright. Templates para YouTube (1280×720), + TikTok e Reel (1080×1920). Alta legibilidade e contraste por design. +type: prompt +version: "1.0.0" +categories: [thumbnails, video, design, youtube, tiktok, reels] +--- + +# Thumbnail Creator + +## When to use + +Use thumbnail-creator para criar: +- **YouTube thumbnail** (1280×720) — influencia 50% do CTR do vídeo +- **TikTok capa** (1080×1920) — frame exibido antes do play +- **Instagram Reel cover** (1080×1920) — capa exibida no perfil + +**Regra de ouro:** A thumbnail deve ser compreensível em 200ms a 100px de largura (tamanho de card no YouTube). Se precisar apertar o olho para ler, redesenhe. + +Esta skill usa o mesmo workflow de `image-creator` (HTML/CSS → Playwright → PNG), mas com templates especializados para cada plataforma de vídeo. + +## Workflow de criação + +1. **Leia os templates** em `skills/thumbnail-creator/templates/` para entender a estrutura base +2. **Personalize o HTML** para o tema do vídeo específico (título, cores, imagem de fundo) +3. **Renderize com Playwright** usando as dimensões corretas: + - YouTube: `viewport: {width: 1280, height: 720}` + - TikTok/Reel: `viewport: {width: 1080, height: 1920}` +4. **Verifique** a legibilidade a tamanho reduzido +5. **Salve** o PNG no caminho de output + +## Regras de design para thumbnails de alto CTR + +### Elementos obrigatórios +- **Face/emoção:** Thumbnails com rosto humano expressivo geram 38% mais cliques +- **Texto:** Máximo 4 palavras, fonte ≥120px no YouTube, ≥80px no TikTok +- **Contraste:** Mínimo 4.5:1 entre texto e fundo (WCAG AA) +- **Elemento de destaque:** Badge circular, seta, círculo de destaque — algo que chame o olho + +### Paleta de cores +- YouTube funciona bem com: vermelho (#FF0000), amarelo (#FFD700), laranja (#FF6B35) +- Evite: cinza, verde musgo, marrom — baixo CTR historicamente +- Use no máximo 3 cores por thumbnail + +### Composição +- **Regra dos terços:** Coloque o elemento principal em uma das 4 intersecções da grade 3×3 +- **Espaço negativo:** Deixe área clara para o texto respirar +- **Profundidade:** Elemento frontal (rosto/produto) + texto + fundo — 3 planos + +## Thumbnails prontos para personalizar + +### YouTube (1280×720) +Template em `skills/thumbnail-creator/templates/youtube-thumbnail.html` + +Variáveis para personalizar: +```html + +
TUTORIAL
+
Como Fazer Isso
+
5
+
PASSOS
+``` + +Para mudar o fundo, adicione uma `` dentro do body. + +### TikTok / Reel (1080×1920) +Template em `skills/thumbnail-creator/templates/tiktok-cover.html` + +### Reel cover (1080×1920) +Template em `skills/thumbnail-creator/templates/reel-cover.html` + +## Renderização com Playwright (image-creator workflow) + +Para renderizar, use o mesmo processo do `image-creator`: +1. Escreva o HTML completo com CSS inline +2. Use o MCP do Playwright para: + - Navegar para a URL do arquivo HTML local (`file:///path/to/thumb.html`) + - Definir viewport com as dimensões corretas + - Tirar screenshot e salvar como PNG + +## Verificação de qualidade + +Após gerar a thumbnail, verifique: +- [ ] Texto legível quando a imagem é reduzida a 200×112px (YouTube card) +- [ ] Pelo menos 3 cores distintas com bom contraste +- [ ] Máximo 4 palavras no texto principal +- [ ] Elemento visual de destaque presente (badge, seta, expressão) +- [ ] Sem fontes muito finas (use Bold/Black) +- [ ] Arquivo PNG salvo (não JPEG para evitar artefatos de compressão) + +## Integração com SEO + +O `seo-specialist` gera um `thumbnail_concept` no pacote de SEO: +```yaml +thumbnail_concept: + emotion: "surpresa" + text_overlay: "3 Palavras Aqui" + visual_element: "badge circular vermelho" + colors: ["#FF4136", "#FFFFFF", "#000000"] +``` + +Use este conceito como briefing para criar a thumbnail. + +## Anti-patterns + +- ❌ Texto com mais de 5 palavras (ilegível em thumbnail pequena) +- ❌ Fonte sem Bold (muito fina para thumbnail) +- ❌ Mais de 3 cores principais (confuso visualmente) +- ❌ Fundo branco ou cinza claro (baixo contraste, se perde no feed) +- ❌ Thumbnail criada sem verificar a 200px de largura +- ❌ Usar JPEG em vez de PNG (artefatos de compressão no texto) diff --git a/skills/thumbnail-creator/templates/reel-cover.html b/skills/thumbnail-creator/templates/reel-cover.html new file mode 100644 index 00000000..f0c697d0 --- /dev/null +++ b/skills/thumbnail-creator/templates/reel-cover.html @@ -0,0 +1,259 @@ + + + + + + Instagram Reel Cover — 1080×1920 + + + + + +
+ + + + + +
+ + +
+
+ + +
+
+ 📱 + +
+
Reel
+
+ + +
+
✨ Dica do dia
+
+
+ 3 hábitos
+ que vão
+ mudar sua
+ vida +
+
Começa hoje mesmo
+
+ + + + + + diff --git a/skills/thumbnail-creator/templates/tiktok-cover.html b/skills/thumbnail-creator/templates/tiktok-cover.html new file mode 100644 index 00000000..cb7d0422 --- /dev/null +++ b/skills/thumbnail-creator/templates/tiktok-cover.html @@ -0,0 +1,178 @@ + + + + + + TikTok Cover — 1080×1920 + + + + + + + + +
+
+ + +
@seucanal
+ + +
+
🔥 Viral 2025
+
+ O segredo que
+ ninguém
+ te conta +
+
Você precisa saber disso
+
+ + +
+
▶
+
Assistir
+
+ + + diff --git a/skills/thumbnail-creator/templates/youtube-thumbnail.html b/skills/thumbnail-creator/templates/youtube-thumbnail.html new file mode 100644 index 00000000..70e30bb2 --- /dev/null +++ b/skills/thumbnail-creator/templates/youtube-thumbnail.html @@ -0,0 +1,159 @@ + + + + + + YouTube Thumbnail — 1280×720 + + + + + + + + +
+ + +
+
TUTORIAL
+
+ Como Fazer
+ Isso em
+ 5 Passos +
+
Sem precisar de experiência
+
+ + +
+
5
+
PASSOS
+
+ + +
+ + + diff --git a/skills/video-assembler/SKILL.md b/skills/video-assembler/SKILL.md new file mode 100644 index 00000000..4aafcdea --- /dev/null +++ b/skills/video-assembler/SKILL.md @@ -0,0 +1,122 @@ +--- +name: video-assembler +description: > + Monta vídeos a partir de imagens + áudio + legendas usando FFmpeg e MoviePy. + Aceita config JSON com cenas, narração, música de fundo e legendas. + Presets por plataforma: YouTube (1920×1080), TikTok/Reels/Shorts (1080×1920). + Saída: MP4 pronto para publicação via blotato. +description_pt-BR: > + Monta vídeos de imagens + áudio + legendas via FFmpeg/MoviePy. + Config JSON com cenas, narração, música e legendas. Presets por plataforma. + Saída: MP4 pronto para publicação. +type: script +version: "1.0.0" +script: + path: scripts/assemble_video.py + runtime: python3 + invoke: "python3 {skill_path}/scripts/assemble_video.py --config \"{config}\" --output \"{output}\"" +categories: [video, assembly, ffmpeg, moviepy, production, editing] +--- + +# Video Assembler + +## When to use + +Use o Video Assembler quando o pipeline tiver: +1. ✅ Imagens das cenas (PNG/JPG) geradas por local-image-gen ou image-ai-generator +2. ✅ Narração em MP3 gerada por local-tts +3. ✅ Legendas em SRT ou ASS geradas por subtitle-generator (opcional) +4. ✅ Música de fundo em MP3 (opcional) + +**Pré-requisito obrigatório:** FFmpeg instalado no sistema. +Verifique com: `ffmpeg -version` +Instale com: `brew install ffmpeg` (macOS) ou `apt install ffmpeg` (Linux) + +Instale também: `pip install moviepy ffmpeg-python` + +## Config JSON + +O agente deve criar um arquivo JSON de configuração antes de chamar o assembler: + +```json +{ + "scenes": [ + {"image": "squads/{squad}/output/{run_id}/scenes/scene-01.png", "duration": 5}, + {"image": "squads/{squad}/output/{run_id}/scenes/scene-02.png", "duration": 4}, + {"image": "squads/{squad}/output/{run_id}/scenes/scene-03.png", "duration": 6} + ], + "audio": "squads/{squad}/output/{run_id}/narration.mp3", + "subtitles": "squads/{squad}/output/{run_id}/subtitles.ass", + "music": "squads/{squad}/output/{run_id}/background.mp3", + "music_volume": 0.15, + "platform": "tiktok", + "transitions": "crossfade", + "fade_duration": 0.3 +} +``` + +Campos obrigatórios: `scenes`, `audio`, `platform` +Campos opcionais: `subtitles`, `music`, `music_volume` (padrão: 0.15), `transitions` (padrão: crossfade), `fade_duration` (padrão: 0.3) + +## Presets de plataforma + +| Platform | Resolução | FPS | Bitrate | Uso | +|----------|-----------|-----|---------|-----| +| `youtube` | 1920×1080 | 30 | 8M | YouTube vídeos longos | +| `shorts` | 1080×1920 | 30 | 6M | YouTube Shorts | +| `tiktok` | 1080×1920 | 30 | 6M | TikTok | +| `reels` | 1080×1920 | 30 | 6M | Instagram Reels | + +## Instruções para o agente + +### 1. Calcular duração de cada cena + +A duração de cada cena deve coincidir com a narração daquele trecho. +Use a fórmula: `duração (s) = número de palavras ÷ 150 × 60` + +Para vídeos com narração contínua: +- A soma das durações das cenas DEVE ser igual (ou maior em até 5%) que a duração do MP3 de narração +- Se a narração for mais longa, adicione a última cena com a duração restante + +### 2. Criar config JSON + +```bash +# O agente cria o arquivo manualmente com base no storyboard.yaml +# Exemplo de saída esperada: squads/{squad}/output/{run_id}/video-config.json +``` + +### 3. Montar o vídeo + +```bash +python3 skills/video-assembler/scripts/assemble_video.py \ + --config "squads/{squad}/output/{run_id}/video-config.json" \ + --output "squads/{squad}/output/{run_id}/video/final-tiktok.mp4" +``` + +### 4. Verificar output + +Após a montagem, verificar: +- Tamanho do arquivo > 0 MB +- Duração próxima do esperado (±5%) +- `ffprobe output.mp4` para confirmar resolução e codec + +## Transitions disponíveis + +- `crossfade` — Dissolve suave entre cenas (padrão, recomendado) +- `none` — Corte direto (mais dinâmico para TikTok) +- `fade` — Fade in/out preto entre cenas (mais formal, YouTube) + +## Burn-in de legendas + +Para formatos SRT, o assembler converte automaticamente para burn-in via FFmpeg. +Para ASS, aplica diretamente com estilização preservada. + +**Legenda ASS recomendada para TikTok/Reels** — gerada pelo subtitle-generator com fonte grande. + +## Error handling + +- **FFmpeg não instalado:** mensagem clara com instrução de instalação +- **Imagem não encontrada:** lista as imagens faltantes e para a execução +- **Áudio mais longo que cenas:** estende a última cena automaticamente +- **Áudio mais curto que cenas:** corta o vídeo no fim do áudio +- **Formato de legenda inválido:** aviso e continua sem legendas diff --git a/skills/video-assembler/scripts/assemble_video.py b/skills/video-assembler/scripts/assemble_video.py new file mode 100644 index 00000000..d92a8f7b --- /dev/null +++ b/skills/video-assembler/scripts/assemble_video.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Video Assembler — Opensquad Skill +Monta vídeos a partir de imagens + áudio + legendas usando FFmpeg e MoviePy. + +Config JSON: +{ + "scenes": [{"image": "scene-01.png", "duration": 5}, ...], + "audio": "narration.mp3", + "subtitles": "subs.ass", (opcional) + "music": "background.mp3", (opcional) + "music_volume": 0.15, + "platform": "tiktok", (youtube | tiktok | reels | shorts) + "transitions": "crossfade", (crossfade | none | fade) + "fade_duration": 0.3 +} + +Uso: + python3 assemble_video.py --config video-config.json --output final.mp4 +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +# Presets de exportação por plataforma +PLATFORM_PRESETS = { + "youtube": {"width": 1920, "height": 1080, "fps": 30, "bitrate": "8M", "audio_bitrate": "192k"}, + "shorts": {"width": 1080, "height": 1920, "fps": 30, "bitrate": "6M", "audio_bitrate": "192k"}, + "tiktok": {"width": 1080, "height": 1920, "fps": 30, "bitrate": "6M", "audio_bitrate": "192k"}, + "reels": {"width": 1080, "height": 1920, "fps": 30, "bitrate": "6M", "audio_bitrate": "192k"}, +} + + +def check_ffmpeg() -> None: + """Verifica se FFmpeg está instalado.""" + try: + result = subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True) + version = result.stdout.decode().split("\n")[0] + print(f"FFmpeg: {version[:50]}") + except (subprocess.CalledProcessError, FileNotFoundError): + print("ERRO: FFmpeg não encontrado.") + print("Instale com:") + print(" macOS: brew install ffmpeg") + print(" Linux: sudo apt install ffmpeg") + sys.exit(1) + + +def check_moviepy() -> None: + """Verifica se MoviePy está instalado.""" + try: + import moviepy # noqa: F401 + except ImportError: + print("ERRO: moviepy não instalado.") + print("Instale com: pip install moviepy") + sys.exit(1) + + +def load_config(config_path: str) -> dict: + """Carrega e valida o arquivo de config JSON.""" + path = Path(config_path) + if not path.exists(): + print(f"ERRO: Config não encontrada: {config_path}") + sys.exit(1) + + config = json.loads(path.read_text(encoding="utf-8")) + + # Validação mínima + if "scenes" not in config or not config["scenes"]: + print("ERRO: Config deve conter 'scenes' com pelo menos uma cena") + sys.exit(1) + if "audio" not in config: + print("ERRO: Config deve conter 'audio' com o path da narração") + sys.exit(1) + if "platform" not in config: + print("AVISO: 'platform' não especificado, usando 'tiktok'") + config["platform"] = "tiktok" + + return config + + +def validate_files(config: dict) -> None: + """Verifica se todos os arquivos referenciados existem.""" + missing = [] + + for i, scene in enumerate(config["scenes"]): + img_path = Path(scene["image"]) + if not img_path.exists(): + missing.append(f" Cena {i+1}: {scene['image']}") + + audio_path = Path(config["audio"]) + if not audio_path.exists(): + missing.append(f" Áudio: {config['audio']}") + + if config.get("subtitles"): + subs_path = Path(config["subtitles"]) + if not subs_path.exists(): + print(f"AVISO: Arquivo de legendas não encontrado: {config['subtitles']}") + print(" Continuando sem legendas...") + config["subtitles"] = None + + if config.get("music"): + music_path = Path(config["music"]) + if not music_path.exists(): + print(f"AVISO: Arquivo de música não encontrado: {config['music']}") + config["music"] = None + + if missing: + print("ERRO: Arquivos não encontrados:") + for m in missing: + print(m) + sys.exit(1) + + +def get_audio_duration(audio_path: str) -> float: + """Obtém a duração do áudio em segundos via ffprobe.""" + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", audio_path], + capture_output=True, + text=True, + ) + info = json.loads(result.stdout) + for stream in info.get("streams", []): + if stream.get("codec_type") == "audio": + return float(stream.get("duration", 0)) + return 0.0 + + +def create_slideshow(scenes: list, preset: dict, transitions: str, fade_duration: float, temp_dir: str) -> str: + """ + Cria slideshow de imagens como vídeo mudo usando FFmpeg. + Retorna path do arquivo de vídeo temporário. + """ + from moviepy.editor import ImageClip, concatenate_videoclips + + width = preset["width"] + height = preset["height"] + fps = preset["fps"] + + clips = [] + for scene in scenes: + img_path = scene["image"] + duration = float(scene.get("duration", 5)) + + clip = ImageClip(img_path, duration=duration) + + # Redimensiona mantendo aspecto (preenche com crop central) + clip = clip.resize(height=height) + if clip.w < width: + clip = clip.resize(width=width) + # Crop central para fit exato + clip = clip.crop( + x_center=clip.w / 2, + y_center=clip.h / 2, + width=width, + height=height, + ) + + # Fade in/out para crossfade + if transitions == "crossfade" and fade_duration > 0: + clip = clip.crossfadein(fade_duration) + elif transitions == "fade" and fade_duration > 0: + clip = clip.fadein(fade_duration).fadeout(fade_duration) + + clips.append(clip) + + if transitions == "crossfade" and fade_duration > 0: + video = concatenate_videoclips(clips, method="compose", padding=-fade_duration) + else: + video = concatenate_videoclips(clips, method="compose") + + video = video.set_fps(fps) + + # Exporta vídeo mudo temporário + slideshow_path = str(Path(temp_dir) / "slideshow_muted.mp4") + print(f"Exportando slideshow ({len(clips)} cenas, {video.duration:.1f}s)...") + video.write_videofile( + slideshow_path, + fps=fps, + codec="libx264", + audio=False, + logger=None, + verbose=False, + ) + + return slideshow_path + + +def mix_audio(narration: str, music: str | None, music_volume: float, duration: float, temp_dir: str) -> str: + """Mixa narração com música de fundo (se fornecida) usando FFmpeg.""" + output_path = str(Path(temp_dir) / "audio_mixed.mp3") + + if music: + # Mixa narração (100%) + música (volume reduzido) com duração da narração + cmd = [ + "ffmpeg", + "-i", narration, + "-stream_loop", "-1", "-i", music, + "-filter_complex", + f"[1:a]volume={music_volume}[bg]; [0:a][bg]amix=inputs=2:duration=first:dropout_transition=2[out]", + "-map", "[out]", + "-t", str(duration), + "-c:a", "libmp3lame", + "-q:a", "2", + output_path, + "-y", + ] + else: + # Apenas copia a narração + cmd = [ + "ffmpeg", + "-i", narration, + "-t", str(duration), + "-c:a", "libmp3lame", + "-q:a", "2", + output_path, + "-y", + ] + + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + print("AVISO: Falha na mixagem de áudio, usando narração sem música") + return narration + + return output_path + + +def combine_video_audio(slideshow: str, audio: str, preset: dict, output: str) -> None: + """Combina vídeo mudo com áudio usando FFmpeg.""" + cmd = [ + "ffmpeg", + "-i", slideshow, + "-i", audio, + "-c:v", "copy", + "-c:a", "aac", + "-b:a", preset["audio_bitrate"], + "-shortest", + output, + "-y", + ] + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + print("ERRO: Falha ao combinar vídeo e áudio") + print(result.stderr.decode()[-500:]) + sys.exit(1) + + +def burn_subtitles(video_input: str, subtitles: str | None, preset: dict, output: str) -> None: + """Aplica burn-in de legendas via FFmpeg.""" + if not subtitles: + # Sem legendas — apenas re-encode com preset correto + cmd = [ + "ffmpeg", + "-i", video_input, + "-c:v", "libx264", + "-b:v", preset["bitrate"], + "-c:a", "copy", + output, + "-y", + ] + else: + subs_path = str(Path(subtitles).resolve()) + ext = Path(subtitles).suffix.lower() + + if ext == ".ass": + # ASS: usa filtro ass (preserva estilo) + subs_filter = f"ass='{subs_path}'" + else: + # SRT/VTT: usa subtitles com force_style para fonte grande + subs_filter = ( + f"subtitles='{subs_path}':force_style=" + f"'FontName=Arial,FontSize=60,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000," + f"BorderStyle=1,Outline=3,Shadow=1,Alignment=2'" + ) + + cmd = [ + "ffmpeg", + "-i", video_input, + "-vf", subs_filter, + "-c:v", "libx264", + "-b:v", preset["bitrate"], + "-c:a", "copy", + output, + "-y", + ] + + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + print("AVISO: Falha ao aplicar legendas") + print(result.stderr.decode()[-300:]) + # Tenta sem legendas como fallback + if subtitles: + print("Continuando sem legendas...") + burn_subtitles(video_input, None, preset, output) + + +def main() -> None: + check_ffmpeg() + check_moviepy() + + parser = argparse.ArgumentParser( + description="Video Assembler — Opensquad Skill", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--config", required=True, help="Arquivo JSON de configuração") + parser.add_argument("--output", required=True, help="Arquivo MP4 de saída") + + args = parser.parse_args() + + print("\n=== Video Assembler — Opensquad ===\n") + + config = load_config(args.config) + validate_files(config) + + platform = config.get("platform", "tiktok").lower() + if platform not in PLATFORM_PRESETS: + print(f"AVISO: Plataforma '{platform}' desconhecida, usando 'tiktok'") + platform = "tiktok" + preset = PLATFORM_PRESETS[platform] + + transitions = config.get("transitions", "crossfade") + fade_duration = float(config.get("fade_duration", 0.3)) + music_volume = float(config.get("music_volume", 0.15)) + + print(f"Plataforma: {platform} ({preset['width']}×{preset['height']}, {preset['fps']}fps)") + print(f"Cenas: {len(config['scenes'])}") + print(f"Transições: {transitions}") + + # Obtém duração da narração + audio_duration = get_audio_duration(config["audio"]) + print(f"Duração da narração: {audio_duration:.1f}s") + + # Ajusta durações das cenas para cobrir o áudio + scenes = config["scenes"] + total_scene_duration = sum(float(s.get("duration", 5)) for s in scenes) + + if total_scene_duration < audio_duration: + # Estende a última cena para cobrir o áudio + extra = audio_duration - total_scene_duration + 0.5 # margem de 0.5s + scenes[-1]["duration"] = float(scenes[-1].get("duration", 5)) + extra + print(f"Última cena estendida em {extra:.1f}s para cobrir narração") + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as temp_dir: + # 1. Cria slideshow + print("\n[1/4] Criando slideshow de imagens...") + slideshow_path = create_slideshow(scenes, preset, transitions, fade_duration, temp_dir) + + # 2. Mixa áudio + print("\n[2/4] Mixando áudio...") + mixed_audio = mix_audio( + config["audio"], + config.get("music"), + music_volume, + audio_duration, + temp_dir, + ) + + # 3. Combina vídeo + áudio + print("\n[3/4] Combinando vídeo e áudio...") + combined_path = str(Path(temp_dir) / "combined.mp4") + combine_video_audio(slideshow_path, mixed_audio, preset, combined_path) + + # 4. Burn-in legendas + exportação final + print("\n[4/4] Aplicando legendas e exportando...") + burn_subtitles(combined_path, config.get("subtitles"), preset, str(output_path)) + + if output_path.exists() and output_path.stat().st_size > 0: + size_mb = output_path.stat().st_size / (1024 * 1024) + # Obtém duração real do vídeo final + dur = get_audio_duration(str(output_path)) + print(f"\n✓ Vídeo montado: {output_path}") + print(f" Resolução: {preset['width']}×{preset['height']}") + print(f" Duração: {dur:.1f}s") + print(f" Tamanho: {size_mb:.1f} MB") + else: + print(f"\nERRO: Arquivo de saída não criado: {output_path}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/video-extractor/SKILL.md b/skills/video-extractor/SKILL.md new file mode 100644 index 00000000..adf86a8b --- /dev/null +++ b/skills/video-extractor/SKILL.md @@ -0,0 +1,105 @@ +--- +name: video-extractor +description: > + Baixa e extrai conteúdo de plataformas de vídeo usando yt-dlp. + Suporta YouTube, TikTok, Instagram, Twitter/X e 1000+ plataformas. + Operações: download de vídeo (MP4), extração de áudio (MP3), captura de frames (PNG), + obtenção de metadados (JSON) e transcrição automática quando disponível. +description_pt-BR: > + Baixa e extrai conteúdo de plataformas de vídeo usando yt-dlp. + Suporta YouTube, TikTok, Instagram, Twitter/X e 1000+ plataformas. + Operações: download de vídeo (MP4), extração de áudio (MP3), captura de frames (PNG), + metadados (JSON) e transcrição automática quando disponível. +type: script +version: "1.0.0" +script: + path: scripts/extract_video.py + runtime: python3 + invoke: "python3 {skill_path}/scripts/extract_video.py --url \"{url}\" --operation \"{operation}\" --output \"{output}\"" +categories: [video, download, extraction, youtube, tiktok, repurposing] +--- + +# Video Extractor + +## When to use + +Use o Video Extractor para: +- **Baixar vídeos de referência** para análise de concorrentes ou repurposing +- **Extrair áudio** de vídeos para transcrição com subtitle-generator +- **Capturar frames** de vídeos para criar thumbnails ou storyboards +- **Obter metadados** (título, descrição, duração, tags) para pesquisa de SEO + +**IMPORTANTE: Use somente para conteúdo público com uso permitido.** Nunca use para infringir direitos autorais. Vídeos protegidos por paywall ou privados não devem ser baixados sem autorização do criador. + +Instale yt-dlp antes de usar: `pip install yt-dlp` + +## Operações disponíveis + +### `download` — Baixar vídeo completo (MP4) +```bash +python3 skills/video-extractor/scripts/extract_video.py \ + --url "https://www.youtube.com/watch?v=XXXXXXXXXXX" \ + --operation download \ + --output "squads/{squad}/output/{run_id}/video/source.mp4" +``` + +### `audio` — Extrair apenas o áudio (MP3) +```bash +python3 skills/video-extractor/scripts/extract_video.py \ + --url "https://www.youtube.com/watch?v=XXXXXXXXXXX" \ + --operation audio \ + --output "squads/{squad}/output/{run_id}/audio/source.mp3" +``` + +### `frames` — Capturar frames a cada N segundos (PNG) +```bash +python3 skills/video-extractor/scripts/extract_video.py \ + --url "https://www.youtube.com/watch?v=XXXXXXXXXXX" \ + --operation frames \ + --output "squads/{squad}/output/{run_id}/frames/" \ + --fps 1 +``` +Cria arquivos frame-000001.png, frame-000002.png, etc. na pasta especificada. + +### `metadata` — Obter metadados do vídeo (JSON) +```bash +python3 skills/video-extractor/scripts/extract_video.py \ + --url "https://www.youtube.com/watch?v=XXXXXXXXXXX" \ + --operation metadata \ + --output "squads/{squad}/output/{run_id}/metadata.json" +``` +Retorna: title, description, duration, view_count, like_count, tags, upload_date, channel. + +### `transcript` — Obter transcrição automática (SRT/JSON) +```bash +python3 skills/video-extractor/scripts/extract_video.py \ + --url "https://www.youtube.com/watch?v=XXXXXXXXXXX" \ + --operation transcript \ + --output "squads/{squad}/output/{run_id}/transcript.srt" +``` +Usa legendas automáticas do YouTube quando disponíveis. Se não houver legendas automáticas, retorna erro e instrui usar subtitle-generator no áudio extraído. + +## Instruções para o agente + +1. **Sempre verifique** se yt-dlp está instalado: `yt-dlp --version` +2. Para vídeos longos (>10 min), prefira `audio` + `subtitle-generator` em vez de `download` — é mais rápido e econômico em disco +3. O `metadata` não faz download do vídeo — é rápido e gratuito em termos de largura de banda +4. Para repurposing: use `download` + `audio` em sequência para ter ambos os arquivos +5. Após `frames`, selecione os frames mais representativos para análise (não use todos) +6. Reporte sempre o tamanho do arquivo gerado e o path completo + +## Plataformas suportadas + +YouTube, TikTok, Instagram (públicos), Twitter/X, Facebook (públicos), Vimeo, Reddit, Twitch VODs e 1000+ outras plataformas via yt-dlp. + +## Qualidade de download + +- Padrão: melhor qualidade disponível até 1080p (para economizar espaço) +- Para `audio`: sempre extrai como MP3 a 192kbps + +## Error handling + +- **yt-dlp não instalado:** script exibe instrução de instalação e sai com código 1 +- **URL privada ou protegida:** mensagem clara de que o conteúdo não está acessível +- **Rate limit:** yt-dlp lida automaticamente com rate limits do YouTube; para outros sites, pode ser necessário aguardar +- **Vídeo indisponível:** mensagem de erro clara com o status retornado pela plataforma diff --git a/skills/video-extractor/scripts/extract_video.py b/skills/video-extractor/scripts/extract_video.py new file mode 100644 index 00000000..3974d66e --- /dev/null +++ b/skills/video-extractor/scripts/extract_video.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Video Extractor — Opensquad Skill +Baixa e extrai conteúdo de plataformas de vídeo usando yt-dlp. + +Operações: + download — Baixar vídeo completo (MP4) + audio — Extrair apenas o áudio (MP3) + frames — Capturar frames a cada N segundos (PNG) + metadata — Obter metadados do vídeo (JSON) + transcript — Obter transcrição automática (SRT) + +Uso: + python3 extract_video.py --url "https://..." --operation download --output video.mp4 + python3 extract_video.py --url "https://..." --operation metadata --output meta.json + python3 extract_video.py --url "https://..." --operation frames --output frames/ --fps 1 +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def check_ytdlp() -> None: + """Verifica se yt-dlp está instalado.""" + try: + subprocess.run(["yt-dlp", "--version"], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + print("ERRO: yt-dlp não encontrado.") + print("Instale com: pip install yt-dlp") + sys.exit(1) + + +def check_ffmpeg() -> None: + """Verifica se FFmpeg está instalado (necessário para frames e audio).""" + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + print("ERRO: FFmpeg não encontrado.") + print("Instale com: brew install ffmpeg (macOS) ou apt install ffmpeg (Linux)") + sys.exit(1) + + +def download_video(url: str, output: str) -> None: + """Baixa vídeo completo em MP4 (melhor qualidade até 1080p).""" + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "yt-dlp", + "--format", "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]/best", + "--merge-output-format", "mp4", + "--output", str(output_path), + "--no-playlist", + url, + ] + + print(f"Baixando vídeo de: {url}") + result = subprocess.run(cmd, capture_output=False) + if result.returncode != 0: + print(f"ERRO: yt-dlp falhou ao baixar o vídeo (código {result.returncode})") + sys.exit(1) + + if output_path.exists(): + size_mb = output_path.stat().st_size / (1024 * 1024) + print(f"Vídeo salvo: {output_path} ({size_mb:.1f} MB)") + else: + print("ERRO: Arquivo de saída não foi criado") + sys.exit(1) + + +def extract_audio(url: str, output: str) -> None: + """Extrai apenas o áudio como MP3 a 192kbps.""" + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # yt-dlp usa o caminho sem extensão para o template; adicionamos .mp3 depois + output_template = str(output_path.with_suffix("")) + + cmd = [ + "yt-dlp", + "--extract-audio", + "--audio-format", "mp3", + "--audio-quality", "192K", + "--output", output_template + ".%(ext)s", + "--no-playlist", + url, + ] + + print(f"Extraindo áudio de: {url}") + result = subprocess.run(cmd, capture_output=False) + if result.returncode != 0: + print(f"ERRO: yt-dlp falhou ao extrair áudio (código {result.returncode})") + sys.exit(1) + + # yt-dlp pode nomear o arquivo diferente — encontramos o MP3 gerado + mp3_file = output_path if output_path.suffix == ".mp3" else Path(output_template + ".mp3") + if mp3_file.exists(): + size_mb = mp3_file.stat().st_size / (1024 * 1024) + print(f"Áudio salvo: {mp3_file} ({size_mb:.1f} MB)") + else: + print("AVISO: Arquivo MP3 pode ter sido salvo com nome diferente no diretório") + + +def extract_frames(url: str, output_dir: str, fps: float = 1.0) -> None: + """Captura frames do vídeo a cada N segundos usando FFmpeg.""" + check_ffmpeg() + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Primeiro baixa o vídeo temporariamente + temp_video = output_path / "_temp_video.mp4" + download_video(url, str(temp_video)) + + # Extrai frames com FFmpeg + frame_pattern = str(output_path / "frame-%06d.png") + cmd = [ + "ffmpeg", + "-i", str(temp_video), + "-vf", f"fps={fps}", + "-q:v", "2", + frame_pattern, + "-y", + ] + + print(f"Extraindo frames (1 frame a cada {1/fps:.1f}s)...") + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + print(f"ERRO: FFmpeg falhou ao extrair frames") + print(result.stderr.decode()) + temp_video.unlink(missing_ok=True) + sys.exit(1) + + # Remove vídeo temporário + temp_video.unlink(missing_ok=True) + + frames = list(output_path.glob("frame-*.png")) + print(f"Frames extraídos: {len(frames)} imagens em {output_path}") + + +def get_metadata(url: str, output: str) -> None: + """Obtém metadados do vídeo (sem download) como JSON.""" + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "yt-dlp", + "--dump-json", + "--no-playlist", + url, + ] + + print(f"Obtendo metadados de: {url}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"ERRO: Falha ao obter metadados") + print(result.stderr) + sys.exit(1) + + try: + raw = json.loads(result.stdout) + except json.JSONDecodeError: + print("ERRO: Resposta inválida do yt-dlp") + sys.exit(1) + + # Extrai apenas os campos relevantes + metadata = { + "title": raw.get("title"), + "description": raw.get("description"), + "duration": raw.get("duration"), + "duration_string": raw.get("duration_string"), + "view_count": raw.get("view_count"), + "like_count": raw.get("like_count"), + "comment_count": raw.get("comment_count"), + "upload_date": raw.get("upload_date"), + "uploader": raw.get("uploader"), + "channel": raw.get("channel"), + "channel_url": raw.get("channel_url"), + "tags": raw.get("tags", []), + "categories": raw.get("categories", []), + "webpage_url": raw.get("webpage_url"), + "thumbnail": raw.get("thumbnail"), + "width": raw.get("width"), + "height": raw.get("height"), + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + + print(f"Metadados salvos: {output_path}") + print(f" Título: {metadata['title']}") + print(f" Duração: {metadata['duration_string']}") + print(f" Views: {metadata['view_count']:,}" if metadata['view_count'] else " Views: N/A") + + +def get_transcript(url: str, output: str, lang: str = "pt") -> None: + """Obtém transcrição automática do vídeo quando disponível.""" + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Tenta baixar legendas automáticas em PT-BR, PT ou EN + langs_to_try = [lang, "pt-BR", "pt", "en"] if lang not in ["pt-BR", "pt", "en"] else [lang, "pt-BR", "pt", "en"] + langs_str = ",".join(dict.fromkeys(langs_to_try)) # deduplica mantendo ordem + + output_template = str(output_path.with_suffix("")) + + cmd = [ + "yt-dlp", + "--write-auto-sub", + "--skip-download", + "--sub-langs", langs_str, + "--sub-format", "srt", + "--convert-subs", "srt", + "--output", output_template, + "--no-playlist", + url, + ] + + print(f"Obtendo transcrição automática de: {url}") + result = subprocess.run(cmd, capture_output=True, text=True) + + # Procura arquivo SRT gerado + srt_files = list(output_path.parent.glob(f"{output_path.stem}*.srt")) + if srt_files: + srt_file = srt_files[0] + # Move para o caminho de output esperado + if str(srt_file) != str(output_path): + srt_file.rename(output_path) + size = output_path.stat().st_size + print(f"Transcrição salva: {output_path} ({size} bytes)") + else: + print("AVISO: Transcrição automática não disponível para este vídeo.") + print("Use subtitle-generator com o áudio extraído para gerar legendas via Whisper:") + print(f" python3 skills/subtitle-generator/scripts/generate_subtitles.py \\") + print(f" --input audio.mp3 --output {output_path} --format srt") + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Video Extractor — Opensquad Skill", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--url", required=True, help="URL do vídeo") + parser.add_argument( + "--operation", + required=True, + choices=["download", "audio", "frames", "metadata", "transcript"], + help="Operação a executar", + ) + parser.add_argument("--output", required=True, help="Caminho de saída (arquivo ou pasta para frames)") + parser.add_argument("--fps", type=float, default=1.0, help="Frames por segundo para operação 'frames' (padrão: 1)") + parser.add_argument("--lang", default="pt", help="Idioma para transcrição (padrão: pt)") + + args = parser.parse_args() + + check_ytdlp() + + ops = { + "download": lambda: download_video(args.url, args.output), + "audio": lambda: extract_audio(args.url, args.output), + "frames": lambda: extract_frames(args.url, args.output, args.fps), + "metadata": lambda: get_metadata(args.url, args.output), + "transcript": lambda: get_transcript(args.url, args.output, args.lang), + } + + ops[args.operation]() + + +if __name__ == "__main__": + main() diff --git a/skills/youtube-seo/SKILL.md b/skills/youtube-seo/SKILL.md new file mode 100644 index 00000000..f28ac6f3 --- /dev/null +++ b/skills/youtube-seo/SKILL.md @@ -0,0 +1,159 @@ +--- +name: youtube-seo +description: > + Otimização de SEO para YouTube: títulos, descrição, tags, capítulos, hashtags + e conceito de thumbnail. Usa web_search para analisar concorrentes e identificar + palavras-chave de alto CTR. Gera 3 variações de título testáveis e descrição + otimizada para busca orgânica e sugestão de vídeos. +description_pt-BR: > + SEO para YouTube: títulos, descrição, tags, capítulos e hashtags. + Pesquisa concorrentes via web_search. Gera 3 variações de título e descrição completa. +type: prompt +version: "1.0.0" +categories: [seo, youtube, optimization, content, titles, tags] +--- + +# YouTube SEO + +## When to use + +Use youtube-seo após o roteiro ser aprovado e antes do upload. O especialista de SEO: +1. Pesquisa concorrentes para identificar padrões de título que rankeiam bem +2. Identifica palavras-chave de alto volume para o tema +3. Gera título, descrição, tags, capítulos e hashtags otimizados +4. Propõe conceito visual de thumbnail baseado nos títulos que geram maior CTR + +## Como o YouTube SEO funciona (diferente do Google) + +- **Watch time** é o sinal mais importante, não backlinks +- **CTR × AVD** (Click-Through Rate × Average View Duration) = distribuição pelo algoritmo +- **Suggested Videos** (vídeos sugeridos, não busca) é a principal fonte de tráfego — 60-70% +- **A thumbnail é 50% do trabalho** — influencia diretamente o CTR +- **Primeiros 30 segundos** determinam retenção e watch time + +## Processo de pesquisa de palavras-chave + +1. Buscar no YouTube pelo tema com `web_search site:youtube.com "{tema}"` +2. Analisar os 5 primeiros resultados: padrões de título, palavras-chave frequentes +3. Identificar o "curiosity gap" usado nos títulos de maior engajamento +4. Mapear palavras-chave long-tail (mais específicas e menos competitivas) +5. Verificar duração dos vídeos que rankeiam (indica o que o algoritmo prefere) + +## Regras de otimização de título + +- **Limite:** 100 chars; **ideal:** 50-70 chars (para não truncar na busca) +- **Keyword front-loading:** palavra-chave principal nos primeiros 40 chars +- **3 fórmulas que funcionam bem em PT-BR:** + - `"[Número] [Resultado]: O Guia Definitivo de [Keyword]"` + - `"Como [Fazer X] em [Tempo/Número de Passos] (Sem [Objeção])"` + - `"[Dado surpreendente] sobre [Keyword] que Ninguém Te Conta"` +- Sempre gerar **3 variações** para o usuário escolher + +## Estrutura da descrição + +``` +[Primeiras 2 linhas VISÍVEIS sem "Mostrar mais" — hook + keyword principal] + +[Corpo: resumo do conteúdo com palavras-chave secundárias, 100-200 palavras] + +📌 CAPÍTULOS: +0:00 Introdução +1:23 Seção 1 +3:45 Seção 2 + +🔗 LINKS: +[Links de recursos mencionados no vídeo] + +👉 [CTA para inscrição no canal] +📱 Redes sociais: [links] + +#hashtag1 #hashtag2 #hashtag3 +``` + +**CRÍTICO:** As primeiras 2-3 linhas da descrição são exibidas antes do "Mostrar mais" na busca do YouTube. Devem conter o hook e a keyword principal. + +## Tags (8-15 tags) + +Estrutura recomendada: +1. Keyword exata do título (ex: "inteligência artificial trabalho") +2. Variações da keyword (ex: "IA no trabalho", "AI no trabalho") +3. Long-tail relacionado (ex: "como usar IA para ser mais produtivo") +4. Categoria ampla (ex: "tecnologia 2025", "produtividade") +5. Marca do canal (ex: "Nome do Canal") + +## Capítulos (timestamps) + +**Obrigatório para vídeos acima de 3 minutos.** + +Regras: +- Primeiro capítulo DEVE começar em 0:00 (ex: "0:00 Introdução") +- Mínimo 3 capítulos para ativar o recurso +- Labels curtos e descritivos (ideal: 2-4 palavras) +- Capítulos ajudam vídeos a ranquear em buscas específicas de seção + +Exemplo: +``` +0:00 Introdução +1:15 O Problema +3:40 A Solução +7:22 Resultados +10:05 Próximos Passos +``` + +## Conceito de thumbnail + +O SEO specialist deve propor o conceito visual da thumbnail junto com o SEO: +- Qual emoção/expressão (surpresa? urgência? satisfação?) +- Quais palavras aparecer na thumbnail (máximo 4) +- Qual elemento de contraste visual (círculo, seta, badge) +- Paleta de cores principal (2-3 cores, alto contraste) + +## Formato de output + +Gerar arquivo YAML estruturado: +```yaml +seo_package: + title_variants: + - "Título Opção 1 — com keyword em destaque" + - "Título Opção 2 — curiosity gap" + - "Título Opção 3 — número + resultado" + recommended_title: "Título Opção 1" + description: | + Primeira linha do hook com keyword principal. + Segunda linha complementar ainda visível. + + Corpo da descrição aqui... + + 📌 CAPÍTULOS: + 0:00 Introdução + [...] + + #hashtag1 #hashtag2 #hashtag3 + tags: + - "keyword exata" + - "variação 1" + - "variação 2" + - "long-tail" + - "categoria" + chapters: + - "0:00 Introdução" + - "1:15 Seção 1" + hashtags: + - "#hashtag1" + - "#hashtag2" + - "#hashtag3" + thumbnail_concept: + emotion: "surpresa" + text_overlay: "3 Palavras Aqui" + visual_element: "badge circular vermelho" + colors: ["#FF4136", "#FFFFFF", "#000000"] +``` + +## Anti-patterns + +- ❌ Keyword stuffing no título ("Como usar IA IA Inteligência Artificial IA 2025") +- ❌ Clickbait que não corresponde ao conteúdo (penaliza watch time) +- ❌ Descrição genérica ou vazia +- ❌ Mais de 15 tags (YouTube não valoriza listas longas de tags) +- ❌ Capítulos sem timestamp 0:00 (não ativa o recurso) +- ❌ Hashtags não relacionadas ao conteúdo