feat: rename Product Strategist to Product Lead, add lead coordination + dual-mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniil
2026-03-22 22:42:35 +03:00
parent 6430ab3eff
commit 27e03cc56c
20 changed files with 6305 additions and 14 deletions
@@ -0,0 +1,918 @@
# Advanced Remotion Templates Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend the caption animation system with 4 new highlight styles, 2 new segment transitions, 3 new animation fields, and ship 2 system presets ("Шортс" and "Подкаст").
**Architecture:** Additive changes only — extend existing `CaptionStyleSchema` with new enum values and fields, implement new animation rendering in `Captions.tsx` using Remotion primitives, seed 2 new system presets via Alembic migration, and add new form controls to the frontend StyleEditor.
**Tech Stack:** Remotion (`interpolate()`, `spring()`), ElysiaJS (Elysia `t.*` validation), FastAPI + Pydantic + Alembic, Next.js + react-hook-form
**Spec:** `docs/superpowers/specs/2026-03-21-advanced-remotion-templates-design.md`
---
## File Map
### Remotion Service (`remotion_service/`)
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `server/types/CaptionStyleSchema.ts` | Add new enum values + 3 new fields to Elysia validation schema |
| Modify | `src/types/caption_style.d.ts` | Mirror TypeScript type changes |
| Modify | `src/components/Captions.tsx` | Implement 4 new highlight renderers, 2 new transitions, word entrance logic, rotation, text-transform |
### Backend (`cofee_backend/`)
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `cpv3/modules/captions/schemas.py` | Extend Pydantic `CaptionAnimationStyle` with new Literal values + 3 fields |
| Create | `alembic/versions/e6f7a8b9c0d1_seed_shorts_podcast_presets.py` | Seed 2 new system presets |
### Frontend (`cofee_frontend/`)
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/features/project/CaptionSettingsStep/StyleEditor.tsx` | Add new select options + 3 new form fields to AnimationFields |
---
## Task 1: Extend Remotion Schema & Types
**Files:**
- Modify: `remotion_service/server/types/CaptionStyleSchema.ts` (lines 30-47)
- Modify: `remotion_service/src/types/caption_style.d.ts` (lines 20-26)
- [ ] **Step 1: Update Elysia validation schema**
In `remotion_service/server/types/CaptionStyleSchema.ts`, replace the `CaptionAnimationStyle` object (lines 30-47) with:
```typescript
export const CaptionAnimationStyle = t.Object({
highlight_style: t.Union(
[
t.Literal("color"),
t.Literal("scale"),
t.Literal("underline"),
t.Literal("color_scale"),
t.Literal("pop_in"),
t.Literal("karaoke"),
t.Literal("bounce"),
t.Literal("glow_pulse"),
],
{ default: "color" },
),
highlight_scale: t.Number({ default: 1.1 }),
segment_transition: t.Union(
[
t.Literal("fade"),
t.Literal("slide"),
t.Literal("none"),
t.Literal("zoom_in"),
t.Literal("drop_in"),
],
{ default: "fade" },
),
fade_duration_frames: t.Number({ default: 3 }),
animation_speed: t.Number({ default: 1.0 }),
word_entrance: t.Union(
[t.Literal("none"), t.Literal("pop"), t.Literal("typewriter")],
{ default: "none" },
),
highlight_rotation_deg: t.Number({ default: 0 }),
text_transform: t.Union(
[t.Literal("none"), t.Literal("uppercase"), t.Literal("lowercase")],
{ default: "none" },
),
});
```
- [ ] **Step 2: Update TypeScript type definitions**
In `remotion_service/src/types/caption_style.d.ts`, replace the `CaptionAnimationStyle` type (lines 20-26) with:
```typescript
export type CaptionAnimationStyle = {
highlight_style:
| "color"
| "scale"
| "underline"
| "color_scale"
| "pop_in"
| "karaoke"
| "bounce"
| "glow_pulse";
highlight_scale: number;
segment_transition: "fade" | "slide" | "none" | "zoom_in" | "drop_in";
fade_duration_frames: number;
animation_speed: number;
word_entrance: "none" | "pop" | "typewriter";
highlight_rotation_deg: number;
text_transform: "none" | "uppercase" | "lowercase";
};
```
- [ ] **Step 3: Type-check Remotion service**
Run: `cd remotion_service && bunx tsc --noEmit`
Expected: PASS — no type errors (existing code uses only the old enum values, which are still present)
- [ ] **Step 4: Commit**
```bash
cd remotion_service
git add server/types/CaptionStyleSchema.ts src/types/caption_style.d.ts
git commit -m "feat(remotion): extend CaptionAnimationStyle schema with new highlight styles, transitions, and fields"
```
---
## Task 2: Implement New Highlight Styles in Captions.tsx
**Files:**
- Modify: `remotion_service/src/components/Captions.tsx` (lines 57-136 — `StyledWord` component)
This is the largest task. We modify the `StyledWord` component to handle 4 new highlight styles. The current `isCurrent` block (lines 86-133) handles `color`, `scale`, `underline`, `color_scale`. We add `pop_in`, `karaoke`, `bounce`, `glow_pulse`.
- [ ] **Step 1: Add spring import**
At the top of `remotion_service/src/components/Captions.tsx`, add `spring` to the Remotion import (line 2):
```typescript
import { interpolate, spring, useVideoConfig } from "remotion";
```
- [ ] **Step 2: Add `useVideoConfig` to StyledWord**
Inside `StyledWord` (line 69), add `fps` extraction right after the destructure:
```typescript
const { fps } = useVideoConfig();
```
This is needed for `spring()` calls which require fps.
- [ ] **Step 3: Implement `pop_in` highlight style**
In `StyledWord`, inside the `if (isCurrent)` block (after the existing `underline` branch at line 132), add:
```typescript
if (animation.highlight_style === "pop_in") {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const scale =
wordDuration > MIN_INTERPOLATE_SPAN * 2
? spring({
fps,
frame: currentFrame - wordFrameTime.start,
config: { damping: 12, stiffness: 200 },
durationInFrames: Math.min(Math.ceil(wordDuration / 2), 15),
})
: 1;
const finalScale = interpolate(scale, [0, 1], [0, animation.highlight_scale]);
baseStyle.transform = `scale(${finalScale})`;
}
```
- [ ] **Step 4: Implement `karaoke` highlight style**
After the `pop_in` block, add:
```typescript
if (animation.highlight_style === "karaoke") {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const progress =
wordDuration > MIN_INTERPOLATE_SPAN
? interpolate(
currentFrame,
[wordFrameTime.start, wordFrameTime.end],
[0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 100;
baseStyle.background = `linear-gradient(to right, ${textStyle.highlight_color} ${progress}%, ${textStyle.text_color} ${progress}%)`;
baseStyle.WebkitBackgroundClip = "text";
baseStyle.WebkitTextFillColor = "transparent";
baseStyle.backgroundClip = "text";
// Override the color set above — karaoke uses gradient instead
baseStyle.color = undefined;
}
```
- [ ] **Step 5: Implement `bounce` highlight style**
After the `karaoke` block, add:
```typescript
if (animation.highlight_style === "bounce") {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const scale =
wordDuration > MIN_INTERPOLATE_SPAN * 2
? spring({
fps,
frame: currentFrame - wordFrameTime.start,
config: { damping: 8, stiffness: 180 },
durationInFrames: Math.min(Math.ceil(wordDuration), 20),
})
: 1;
const finalScale = interpolate(scale, [0, 1], [1, animation.highlight_scale]);
baseStyle.transform = `scale(${finalScale})`;
}
```
- [ ] **Step 6: Implement `glow_pulse` highlight style**
After the `bounce` block, add:
```typescript
if (animation.highlight_style === "glow_pulse") {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const pulse =
wordDuration > MIN_INTERPOLATE_SPAN * 2
? interpolate(
currentFrame,
[
wordFrameTime.start,
wordFrameTime.start + wordDuration * 0.25,
wordFrameTime.start + wordDuration * 0.5,
wordFrameTime.start + wordDuration * 0.75,
wordFrameTime.end,
],
[4, 12, 4, 12, 4],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 8;
baseStyle.textShadow = `0 0 ${pulse}px ${textStyle.highlight_color}, 0 0 ${pulse * 2}px ${textStyle.highlight_color}`;
}
```
- [ ] **Step 7: Add `highlight_rotation_deg` support**
After all highlight style branches (still inside `if (isCurrent)`), add rotation support:
```typescript
if (animation.highlight_rotation_deg > 0) {
const existingTransform = baseStyle.transform || "";
baseStyle.transform = `${existingTransform} rotate(${animation.highlight_rotation_deg}deg)`.trim();
}
```
- [ ] **Step 8: Type-check**
Run: `cd remotion_service && bunx tsc --noEmit`
Expected: PASS
- [ ] **Step 9: Commit**
```bash
cd remotion_service
git add src/components/Captions.tsx
git commit -m "feat(remotion): implement pop_in, karaoke, bounce, glow_pulse highlight styles + rotation"
```
---
## Task 3: Implement Word Entrance, Text Transform, and New Transitions
**Files:**
- Modify: `remotion_service/src/components/Captions.tsx`
- [ ] **Step 1: Add `useVideoConfig` to the Captions component**
At the top of the `Captions` component (around line 186), add fps extraction for `spring()` calls in transitions:
```typescript
const { fps: videoFps } = useVideoConfig();
```
This is needed by `drop_in` transition (Step 4) and must be declared before use.
- [ ] **Step 2: Implement `word_entrance` in StyledWord**
In `StyledWord`, BEFORE the `if (isCurrent)` block (around line 86), add word entrance logic. This controls how words appear before they're spoken. The spring also applies when the word is `isCurrent` (just started being spoken) so the pop-in is smooth regardless of highlight style:
```typescript
// Word entrance: controls visibility/scale of words before their start frame
const entrance = style.animation.word_entrance ?? "none";
if (entrance !== "none" && currentFrame < wordFrameTime.start) {
// Word hasn't been spoken yet — hide it
if (entrance === "pop") {
baseStyle.transform = "scale(0)";
baseStyle.opacity = 0;
} else if (entrance === "typewriter") {
baseStyle.opacity = 0;
}
} else if (entrance === "pop" && currentFrame >= wordFrameTime.start) {
// Word has been spoken (or is being spoken right now) — spring it in
const framesSinceStart = currentFrame - wordFrameTime.start;
const popScale = spring({
fps,
frame: framesSinceStart,
config: { damping: 12, stiffness: 200 },
durationInFrames: 10,
});
// Set as base transform — highlight styles will append to it if needed
baseStyle.transform = `scale(${popScale})`;
}
```
Note: The pop spring applies to BOTH `isCurrent` and past words. When `isCurrent`, the highlight style block below may overwrite `baseStyle.transform` (e.g., bounce sets its own scale). This is intentional — the highlight animation takes precedence once the word is active. For highlight styles that don't set transform (like `color` or `karaoke`), the pop spring provides the entrance animation.
- [ ] **Step 3: Implement `zoom_in` and `drop_in` segment transitions**
In the `Captions` component, after the existing `slide` transition block (around line 247), add both new transitions and a `scale` variable:
```typescript
let scale = 1;
if (transition === "zoom_in" && !hasShortSegment) {
opacity = interpolate(
currentFrame,
hasFadePlateau
? [start, fadeIn, fadeOut, end]
: [start, middleFrame, end],
hasFadePlateau ? [0, 1, 1, 0] : [0, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
scale = interpolate(
currentFrame,
hasFadePlateau
? [start, fadeIn, fadeOut, end]
: [start, middleFrame, end],
hasFadePlateau ? [0.8, 1, 1, 1.2] : [0.8, 1, 1.2],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
}
if (transition === "drop_in" && !hasShortSegment) {
opacity = interpolate(
currentFrame,
hasFadePlateau
? [start, fadeIn, fadeOut, end]
: [start, middleFrame, end],
hasFadePlateau ? [0, 1, 1, 0] : [0, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const dropSpring = spring({
fps: videoFps,
frame: currentFrame - start,
config: { damping: 10, stiffness: 150 },
durationInFrames: Math.min(fadeDuration * 3, 20),
});
// Spring goes 0→1, we want -50→0
translateY = interpolate(dropSpring, [0, 1], [-50, 0]);
}
```
Note: `videoFps` comes from `useVideoConfig()` added in Step 1.
- [ ] **Step 4: Implement `text_transform` and update `segmentStyle.transform`**
In the `Captions` component (line 250, inside the inline style mode block), rebuild `segmentStyle` to include `textTransform` and the composite `transform` (accounting for translateY + scale):
```typescript
const textTransformValue = styleConfig.animation.text_transform ?? "none";
const segmentStyle: React.CSSProperties = {
opacity,
transform: [
translateY !== 0 ? `translateY(${translateY}px)` : "",
scale !== 1 ? `scale(${scale})` : "",
].filter(Boolean).join(" ") || undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
width: "100%",
padding: background.bg_padding_px,
background: background.bg_color,
borderRadius: background.bg_border_radius_px,
textTransform: textTransformValue !== "none" ? textTransformValue : undefined,
};
```
- [ ] **Step 5: Update the CSS theme mode segment div to include scale**
In the CSS theme mode return (line 291), update the style to use the same composite transform:
```typescript
return (
<div
className="segment"
style={{
opacity,
transform: [
translateY !== 0 ? `translateY(${translateY}px)` : "",
scale !== 1 ? `scale(${scale})` : "",
].filter(Boolean).join(" ") || undefined,
}}
>
```
- [ ] **Step 6: Type-check**
Run: `cd remotion_service && bunx tsc --noEmit`
Expected: PASS
- [ ] **Step 7: Visually verify in Remotion Studio**
Run: `cd remotion_service && bun run dev`
Open Remotion Studio in browser. Test each new animation by modifying the composition props in the studio UI:
- Set `highlight_style` to each of: `pop_in`, `karaoke`, `bounce`, `glow_pulse`
- Set `segment_transition` to each of: `zoom_in`, `drop_in`
- Set `word_entrance` to `pop` and `typewriter`
- Set `text_transform` to `uppercase`
- Set `highlight_rotation_deg` to `3`
Verify each renders without errors and the visual effect matches the spec description.
- [ ] **Step 8: Commit**
```bash
cd remotion_service
git add src/components/Captions.tsx
git commit -m "feat(remotion): implement word entrance, text transform, zoom_in and drop_in transitions"
```
---
## Task 4: Dynamic Font Loading
**Files:**
- Modify: `remotion_service/src/components/Captions.tsx` (lines 1-12 — imports and font loading)
Currently, only Lobster is loaded (line 3 + line 12). The new presets use Montserrat and Inter. We need to dynamically load the correct font based on `styleConfig.text.font_family`.
- [ ] **Step 1: Replace static font loading with dynamic loading**
Replace lines 1-12 of `Captions.tsx`:
```typescript
import React from "react";
import { interpolate, spring, useVideoConfig } from "remotion";
import { loadFont as loadLobster } from "@remotion/google-fonts/Lobster";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
import { loadFont as loadRoboto } from "@remotion/google-fonts/Roboto";
import { loadFont as loadOpenSans } from "@remotion/google-fonts/OpenSans";
import {
LineWithFrames,
SegmentWithFrames,
WordWithFrames,
} from "@/types/transcription";
import { CaptionStyleConfig } from "@/types/caption_style";
import { useTheme } from "@/hooks/useTheme";
// Load all supported fonts — Remotion deduplicates, only loads what's used
loadLobster();
loadInter();
loadMontserrat();
loadRoboto();
loadOpenSans();
```
Note: Remotion's `loadFont()` is idempotent and only triggers one network request per font. Loading all 5 is safe and keeps the component simple. The alternative (dynamic loading based on styleConfig) adds complexity for no real benefit since all 5 fonts are small.
- [ ] **Step 2: Install missing font packages (if needed)**
Run: `cd remotion_service && bunx tsc --noEmit`
If any `@remotion/google-fonts/*` imports fail, the packages are already included in `@remotion/google-fonts` — they're subpath exports, not separate packages. If there's an error, check:
Run: `cd remotion_service && bun run dev`
The fonts should load in the Remotion Studio preview.
- [ ] **Step 3: Commit**
```bash
cd remotion_service
git add src/components/Captions.tsx
git commit -m "feat(remotion): load Inter, Montserrat, Roboto, OpenSans fonts alongside Lobster"
```
---
## Task 5: Extend Backend Schema
**Files:**
- Modify: `cofee_backend/cpv3/modules/captions/schemas.py` (lines 37-42)
- [ ] **Step 1: Update CaptionAnimationStyle Pydantic model**
Replace lines 37-42 in `cofee_backend/cpv3/modules/captions/schemas.py`:
```python
class CaptionAnimationStyle(Schema):
highlight_style: Literal[
"color", "scale", "underline", "color_scale",
"pop_in", "karaoke", "bounce", "glow_pulse",
] = "color"
highlight_scale: float = 1.1
segment_transition: Literal["fade", "slide", "none", "zoom_in", "drop_in"] = "fade"
fade_duration_frames: int = 3
animation_speed: float = 1.0
word_entrance: Literal["none", "pop", "typewriter"] = "none"
highlight_rotation_deg: float = 0
text_transform: Literal["none", "uppercase", "lowercase"] = "none"
```
- [ ] **Step 2: Lint and type-check**
Run: `cd cofee_backend && uv run ruff check cpv3/modules/captions/schemas.py && uv run ruff format cpv3/modules/captions/schemas.py`
Expected: PASS or auto-formatted
- [ ] **Step 3: Verify existing tests still pass**
Run: `cd cofee_backend && uv run pytest tests/integration/ -x -q 2>&1 | tail -5`
Expected: All existing tests pass (new fields have defaults, so backward compatible)
- [ ] **Step 4: Commit**
```bash
cd cofee_backend
git add cpv3/modules/captions/schemas.py
git commit -m "feat(backend): extend CaptionAnimationStyle with new highlight styles, transitions, and fields"
```
---
## Task 6: Seed New System Presets (Alembic Migration)
**Files:**
- Create: `cofee_backend/alembic/versions/e6f7a8b9c0d1_seed_shorts_podcast_presets.py`
- [ ] **Step 1: Create migration file**
Create `cofee_backend/alembic/versions/e6f7a8b9c0d1_seed_shorts_podcast_presets.py`:
```python
"""seed shorts and podcast system presets
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-03-21 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e6f7a8b9c0d1"
down_revision: Union[str, None] = "d5e6f7a8b9c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SHORTS_PRESET = {
"id": "00000000-0000-4000-a000-000000000004",
"user_id": None,
"name": "Шортс",
"description": "Жирные субтитры для вертикальных видео — TikTok, Reels, Shorts",
"is_system": True,
"style_config": {
"text": {
"font_family": "Montserrat",
"font_size": 72,
"font_weight": 700,
"text_color": "#FFFFFF",
"highlight_color": "#FFE500",
"text_shadow": "3px 3px 0px #000000",
"text_stroke_width": 3,
"text_stroke_color": "#000000",
},
"layout": {
"vertical_position": "bottom",
"horizontal_alignment": "center",
"padding_px": 20,
"max_width_pct": 85,
"lines_per_screen": 1,
},
"animation": {
"highlight_style": "bounce",
"highlight_scale": 1.15,
"highlight_rotation_deg": 3,
"word_entrance": "pop",
"segment_transition": "zoom_in",
"fade_duration_frames": 3,
"animation_speed": 1.0,
"text_transform": "uppercase",
},
"background": {
"bg_color": "transparent",
"bg_blur_px": 0,
"bg_glow_color": None,
"bg_border_radius_px": 0,
"bg_padding_px": 0,
},
},
"preview_url": None,
"is_active": True,
}
PODCAST_PRESET = {
"id": "00000000-0000-4000-a000-000000000005",
"user_id": None,
"name": "Подкаст",
"description": "Чистые субтитры для подкастов и интервью — караоке-подсветка, фон с размытием",
"is_system": True,
"style_config": {
"text": {
"font_family": "Inter",
"font_size": 44,
"font_weight": 400,
"text_color": "#E0E0E0",
"highlight_color": "#FFFFFF",
"text_shadow": "1px 1px 3px rgba(0,0,0,0.7)",
"text_stroke_width": 0,
"text_stroke_color": "#000000",
},
"layout": {
"vertical_position": "bottom",
"horizontal_alignment": "center",
"padding_px": 20,
"max_width_pct": 90,
"lines_per_screen": 2,
},
"animation": {
"highlight_style": "karaoke",
"highlight_scale": 1.0,
"highlight_rotation_deg": 0,
"word_entrance": "none",
"segment_transition": "fade",
"fade_duration_frames": 5,
"animation_speed": 1.0,
"text_transform": "none",
},
"background": {
"bg_color": "rgba(0,0,0,0.5)",
"bg_blur_px": 8,
"bg_glow_color": None,
"bg_border_radius_px": 12,
"bg_padding_px": 16,
},
},
"preview_url": None,
"is_active": True,
}
def upgrade() -> None:
# Idempotent: check if presets with these names already exist before inserting
conn = op.get_bind()
caption_presets = sa.table(
"caption_presets",
sa.column("id", sa.UUID()),
sa.column("user_id", sa.UUID()),
sa.column("name", sa.String()),
sa.column("description", sa.Text()),
sa.column("is_system", sa.Boolean()),
sa.column("style_config", sa.JSON()),
sa.column("preview_url", sa.String()),
sa.column("is_active", sa.Boolean()),
)
for preset in [SHORTS_PRESET, PODCAST_PRESET]:
exists = conn.execute(
sa.select(sa.func.count())
.select_from(caption_presets)
.where(caption_presets.c.name == preset["name"])
.where(caption_presets.c.is_system == True) # noqa: E712
).scalar()
if not exists:
op.bulk_insert(caption_presets, [preset])
def downgrade() -> None:
conn = op.get_bind()
caption_presets = sa.table(
"caption_presets",
sa.column("id", sa.UUID()),
sa.column("is_active", sa.Boolean()),
)
for preset_id in [SHORTS_PRESET["id"], PODCAST_PRESET["id"]]:
conn.execute(
caption_presets.update()
.where(caption_presets.c.id == preset_id)
.values(is_active=False)
)
```
- [ ] **Step 2: Apply migration**
Run: `cd cofee_backend && uv run alembic upgrade head`
Expected: Migration applies successfully, 2 new rows in `caption_presets`
- [ ] **Step 3: Verify presets exist via API**
Run: `curl -s http://localhost:8000/api/captions/presets/ -H "Authorization: Bearer <token>" | python3 -m json.tool | grep -E '"name"'`
Expected: Should list "Классические", "Неон", "Минимализм", "Шортс", "Подкаст"
(If backend isn't running, verify via direct DB query instead)
- [ ] **Step 4: Commit**
```bash
cd cofee_backend
git add alembic/versions/e6f7a8b9c0d1_seed_shorts_podcast_presets.py
git commit -m "feat(backend): seed Шортс and Подкаст system presets"
```
---
## Task 7: Extend Frontend StyleEditor
**Files:**
- Modify: `cofee_frontend/src/features/project/CaptionSettingsStep/StyleEditor.tsx` (lines 38-71 — FormValues type, lines 73-106 — DEFAULT_VALUES, lines 360-452 — AnimationFields)
- [ ] **Step 1: Regenerate API types from updated backend schema**
Run: `cd cofee_frontend && bun run gen:api-types`
Expected: `src/shared/api/__generated__/openapi.types.ts` updated with new animation fields
(Backend must be running with the schema changes applied for this to work. If not available, proceed — the local `FormValues` type is what the form uses.)
- [ ] **Step 2: Update FormValues type**
In `StyleEditor.tsx`, replace lines 57-63 (the `animation` section of `FormValues`):
```typescript
animation: {
highlight_style:
| "color"
| "scale"
| "underline"
| "color_scale"
| "pop_in"
| "karaoke"
| "bounce"
| "glow_pulse";
highlight_scale: number;
segment_transition: "fade" | "slide" | "none" | "zoom_in" | "drop_in";
fade_duration_frames: number;
animation_speed: number;
word_entrance: "none" | "pop" | "typewriter";
highlight_rotation_deg: number;
text_transform: "none" | "uppercase" | "lowercase";
}
```
- [ ] **Step 3: Update DEFAULT_VALUES**
In `StyleEditor.tsx`, replace lines 92-98 (the `animation` section of `DEFAULT_VALUES`):
```typescript
animation: {
highlight_style: "color" as const,
highlight_scale: 1.2,
segment_transition: "fade" as const,
fade_duration_frames: 5,
animation_speed: 1.0,
word_entrance: "none" as const,
highlight_rotation_deg: 0,
text_transform: "none" as const,
},
```
- [ ] **Step 4: Add new options to AnimationFields**
In the `AnimationFields` component, add the 4 new highlight style options to the existing `<Select>` (after line 381, the `color_scale` SelectItem):
```tsx
<SelectItem value="pop_in">Появление</SelectItem>
<SelectItem value="karaoke">Караоке</SelectItem>
<SelectItem value="bounce">Отскок</SelectItem>
<SelectItem value="glow_pulse">Свечение</SelectItem>
```
Add the 2 new segment transition options to the existing `<Select>` (after line 414, the `none` SelectItem):
```tsx
<SelectItem value="zoom_in">Приближение</SelectItem>
<SelectItem value="drop_in">Выпадание</SelectItem>
```
- [ ] **Step 5: Add 3 new form fields to AnimationFields**
After the last `<Controller>` in `AnimationFields` (the `animation_speed` slider, ending around line 451), add:
```tsx
<Controller
name="animation.word_entrance"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Появление слов</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Появление"
>
<SelectItem value="none">Все сразу</SelectItem>
<SelectItem value="pop">Выскакивание</SelectItem>
<SelectItem value="typewriter">Печатная машинка</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="animation.highlight_rotation_deg"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Поворот выделения"
unit="°"
min={0}
max={15}
step={1}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="animation.text_transform"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Регистр текста</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Регистр"
>
<SelectItem value="none">Без изменений</SelectItem>
<SelectItem value="uppercase">ЗАГЛАВНЫЕ</SelectItem>
<SelectItem value="lowercase">строчные</SelectItem>
</Select>
</div>
)}
/>
```
- [ ] **Step 6: Type-check frontend**
Run: `cd cofee_frontend && bunx tsc --noEmit`
Expected: PASS (or pre-existing type errors only — see memory for known issues)
- [ ] **Step 7: Commit**
```bash
cd cofee_frontend
git add src/features/project/CaptionSettingsStep/StyleEditor.tsx
git commit -m "feat(frontend): add new animation options and fields to StyleEditor"
```
---
## Task 8: Final Integration Verification
- [ ] **Step 1: Type-check all three projects**
Run these in parallel:
```bash
cd remotion_service && bunx tsc --noEmit
cd cofee_frontend && bunx tsc --noEmit
cd cofee_backend && uv run ruff check cpv3/
```
Expected: All pass
- [ ] **Step 2: Visual verification in Remotion Studio**
Run: `cd remotion_service && bun run dev`
In the Remotion Studio, test the two preset configs by pasting their `styleConfig` JSON into composition props:
1. **Шортс preset**: Verify uppercase text, words pop in one by one, active word bounces with yellow color + rotation, zoom_in transition between segments
2. **Подкаст preset**: Verify normal case, karaoke wipe on active word, frosted glass background, fade transition
- [ ] **Step 3: Verify frontend editor shows new options**
Run: `cd cofee_frontend && bun dev`
Open http://localhost:3000, navigate to a project → Caption Settings → create or edit a preset:
- Verify "Анимация" tab shows all 8 highlight styles, 5 transitions
- Verify new fields appear: "Появление слов", "Поворот выделения", "Регистр текста"
- Verify the 2 new system presets ("Шортс", "Подкаст") appear in the preset grid
- [ ] **Step 4: End-to-end render test**
If the full stack is running (backend + remotion service + S3):
1. Select the "Шортс" preset
2. Generate captions on a test video
3. Verify the output video has uppercase text with bounce animation
4. Repeat with "Подкаст" preset — verify karaoke wipe + frosted glass
- [ ] **Step 5: Final commit (if any fixes were needed)**
```bash
git add -A
git commit -m "fix: integration fixes for advanced remotion templates"
```
@@ -0,0 +1,229 @@
# Advanced Remotion Templates — Design Spec
## Summary
Extend the Remotion caption animation system with new highlight styles, segment transitions, and per-word entrance effects. Create two polished system presets ("Шортс" and "Подкаст") using the new capabilities. No new Remotion compositions — presets are style configurations within the existing `CaptionedVideo` composition.
## Context
### Current State
- Remotion service renders captions via a single `CaptionedVideo` composition
- `CaptionStyleSchema` controls all styling: text, layout, animation, background
- 4 highlight styles: `color`, `scale`, `underline`, `color_scale`
- 2 segment transitions: `fade`, `slide`, `none`
- 3 system presets seeded in DB: "Классические", "Неон", "Минимализм"
- Frontend has preset grid browser + full style editor with live preview
- Backend preset CRUD is complete with system/user preset separation
### What This Changes
- Adds 4 new highlight styles, 2 new segment transitions, 3 new animation fields
- Adds 2 new system presets targeting Shorts/Clips and Podcast content creators
- All changes are additive — existing presets and rendering continue to work unchanged
## Approach
**Extend existing schema (Approach A)** — add new enum values and fields to `CaptionAnimationStyle`. All rendering stays in the single `Captions.tsx` component. Chosen over separate compositions (too much duplication) and plugin architecture (over-engineered for 4-6 new animation types).
## Animation System Extensions
### New `highlight_style` Values
| Style | Visual Effect | Implementation |
|-------|--------------|----------------|
| `pop_in` | Each word springs from scale 0→1 when spoken | `spring()` on `transform: scale()` keyed to word start frame |
| `karaoke` | Color fills word left→right over its duration | CSS `linear-gradient` with `interpolate()` shifting stop from 0%→100% |
| `bounce` | Active word overshoots scale (1→1.15→1.0) with elastic ease | `spring({ damping: 8 })` on scale, triggers at word start |
| `glow_pulse` | Active word's text-shadow glow intensity oscillates | `interpolate()` cycling shadow blur/spread over word duration |
### New `segment_transition` Values
| Transition | Visual Effect |
|-----------|--------------|
| `zoom_in` | Old segment scales up + fades out, new segment scales 0.8→1 + fades in |
| `drop_in` | New segment drops from above with spring bounce |
### New Fields on `CaptionAnimationStyle`
| Field | Type | Default | Purpose |
|-------|------|---------|---------|
| `word_entrance` | `"none" \| "pop" \| "typewriter"` | `"none"` | How unspoken words appear. `pop`: spring from scale 0→1 at word start. `typewriter`: words become visible sequentially (no scale animation). `none`: all words in segment visible immediately. |
| `highlight_rotation_deg` | `float` (015) | `0` | Rotation in degrees applied to active word via `transform: rotate()` |
| `text_transform` | `"none" \| "uppercase" \| "lowercase"` | `"none"` | CSS `text-transform` applied to entire caption container |
### Backward Compatibility
All new fields have defaults that match current behavior (`word_entrance: "none"`, `highlight_rotation_deg: 0`, `text_transform: "none"`). Existing presets and inline configs continue to work without changes.
## System Presets
### Preset: "Шортс" (Shorts/Clips)
Target: Bold, high-energy captions for TikTok/Reels/Shorts vertical content.
```json
{
"text": {
"font_family": "Montserrat",
"font_size": 72,
"font_weight": 700,
"text_color": "#FFFFFF",
"highlight_color": "#FFE500",
"text_stroke_width": 3,
"text_stroke_color": "#000000",
"text_shadow": "3px 3px 0px #000000"
},
"layout": {
"vertical_position": "bottom",
"horizontal_alignment": "center",
"max_width_pct": 85,
"lines_per_screen": 1,
"padding_px": 20
},
"animation": {
"highlight_style": "bounce",
"highlight_scale": 1.15,
"highlight_rotation_deg": 3,
"word_entrance": "pop",
"segment_transition": "zoom_in",
"fade_duration_frames": 3,
"animation_speed": 1.0,
"text_transform": "uppercase"
},
"background": {
"bg_color": "transparent",
"bg_blur_px": 0,
"bg_glow_color": null,
"bg_border_radius_px": 0,
"bg_padding_px": 0
}
}
```
Key characteristics:
- All caps, 1 line at a time, no background box
- Words pop in at full size via spring animation
- Active word: yellow + 1.15x bounce + 3° rotation + subtle glow
- Heavy text stroke provides contrast without background
- Zoom transition between segments
### Preset: "Подкаст" (Podcast)
Target: Clean, professional captions for long-form podcast/interview content.
```json
{
"text": {
"font_family": "Inter",
"font_size": 44,
"font_weight": 400,
"text_color": "#E0E0E0",
"highlight_color": "#FFFFFF",
"text_stroke_width": 0,
"text_stroke_color": null,
"text_shadow": "1px 1px 3px rgba(0,0,0,0.7)"
},
"layout": {
"vertical_position": "bottom",
"horizontal_alignment": "center",
"max_width_pct": 90,
"lines_per_screen": 2,
"padding_px": 20
},
"animation": {
"highlight_style": "karaoke",
"highlight_scale": 1.0,
"highlight_rotation_deg": 0,
"word_entrance": "none",
"segment_transition": "fade",
"fade_duration_frames": 5,
"animation_speed": 1.0,
"text_transform": "none"
},
"background": {
"bg_color": "rgba(0,0,0,0.5)",
"bg_blur_px": 8,
"bg_glow_color": null,
"bg_border_radius_px": 12,
"bg_padding_px": 16
}
}
```
Key characteristics:
- Normal case, 2 lines, frosted glass background
- Karaoke wipe fills active word left→right with white
- All words visible — no entrance animation
- Subtle fade between segments
- Inter font, soft white for readability
## Changes Per Layer
### Remotion Service (`remotion_service/`)
**`server/types/CaptionStyleSchema.ts`**
- Extend `highlight_style` union: add `"pop_in" | "karaoke" | "bounce" | "glow_pulse"`
- Extend `segment_transition` union: add `"zoom_in" | "drop_in"`
- Add fields: `word_entrance`, `highlight_rotation_deg`, `text_transform` with defaults
**`src/components/Captions.tsx`** (~150 lines added)
- New rendering branches for each highlight style using `interpolate()` and `spring()`
- `word_entrance` logic: controls opacity/scale of words before their `wordStartFrame`
- `highlight_rotation_deg`: applies `transform: rotate()` on active word
- `text_transform`: CSS `text-transform` on caption container (lives in animation schema because it's applied at render time alongside animation logic)
- All animations must use Remotion primitives only — no CSS transitions, no Framer Motion
- Load `Montserrat` and `Inter` via `@remotion/google-fonts` alongside existing `Lobster` — dynamically load based on `styleConfig.text.font_family`
**No changes to:** `Root.tsx`, `Composition.tsx`, `useCaptions.ts`, server endpoints, queue, S3 logic
### Backend (`cofee_backend/`)
**`cpv3/modules/captions/schemas.py`**
- Extend `CaptionAnimationStyle` Literal types to include new values
- Add 3 new Optional fields with defaults matching current behavior
**Alembic migration**
- Seed 2 new system presets ("Шортс", "Подкаст") into `caption_presets` table with `is_system=True`, `user_id=NULL`
- Seed must be idempotent — check for existing name before inserting to avoid duplicates on re-run
**No changes to:** router, service, repository, task system, webhooks, notifications
### Frontend (`cofee_frontend/`)
**`features/project/CaptionSettingsStep/StyleEditor.tsx`**
- Add 4 new options to highlight style `<select>`
- Add 2 new options to segment transition `<select>`
- Add 3 new form fields: word_entrance `<select>`, rotation slider, text_transform `<select>`
- Update local `FormValues` type to include new literal values (it duplicates backend types)
**`features/project/CaptionSettingsStep/StylePreview.tsx`** (optional enhancement)
- Hint at karaoke effect with gradient in static preview
- Not critical — real preview is the rendered video
**No new components, no new files, no new API endpoints.**
## Data Flow
Unchanged. The existing flow handles this entirely:
1. User picks preset or edits style → `style_config` JSON
2. Submit → `POST /api/tasks/captions-generate/` with `preset_id` or inline config
3. Backend resolves config → sends to Remotion service
4. Remotion reads new fields from `styleConfig`, renders with new animation logic
5. Output → S3 → webhook → notification → frontend
## Testing
- **Remotion**: Visual testing via `bun run dev` (Remotion Studio) — verify each new animation style renders correctly with sample transcription data
- **Backend**: Existing integration tests cover preset CRUD — add test cases with new fields to verify persistence and retrieval
- **Frontend**: Existing E2E covers preset selection flow — verify new select options appear and are selectable
- **Type-check**: `bunx tsc --noEmit` in both `remotion_service/` and `cofee_frontend/`
## Out of Scope
- New Remotion compositions (only extending existing `CaptionedVideo`)
- Layout templates (split-screen, PiP, speaker labels)
- Social media overlays (progress bars, CTAs)
- Video cropping/resizing
- Preview rendering in the style editor (static CSS preview is sufficient)