Files
remotion_service/docs/superpowers/plans/2026-03-21-advanced-remotion-templates.md
T
2026-03-22 22:42:35 +03:00

30 KiB

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:

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:

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
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):

import { interpolate, spring, useVideoConfig } from "remotion";
  • Step 2: Add useVideoConfig to StyledWord

Inside StyledWord (line 69), add fps extraction right after the destructure:

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:

    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:

    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:

    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:

    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:

    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
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:

  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:

  // 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:

  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):

    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:

  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
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:

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
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:

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
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:

"""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
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):

	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):

	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):

						<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):

						<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:

		<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
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:

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)
git add -A
git commit -m "fix: integration fixes for advanced remotion templates"