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:
@@ -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` (0–15) | `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)
|
||||
Reference in New Issue
Block a user