{
  "version": "1.0",
  "fps_reference": 30,
  "doctrine": [
    "Переход не создаёт смысл, а обслуживает уже найденный стык.",
    "Прямая склейка — вариант по умолчанию. Эффект нужно обосновать.",
    "Звук ставится ДО визуального события: пик SFX совпадает с точкой склейки.",
    "Один и тот же переход не идёт дважды подряд.",
    "Бюджет энергии на минуту ограничен: см. max_per_minute у каждого рецепта.",
    "Переход длиннее 20 кадров в вертикальном ролике читается как ошибка рендера.",
    "Переход, который гасит или подменяет картинку (в чёрное, растворение, расфокус), ставится только в паузу речи: поверх слова он глушит смысл."
  ],
  "sfx_timing": "offset_frames отсчитывается от точки склейки: отрицательное значение — звук начинается раньше картинки. Пик SFX должен приходиться на сам стык.",
  "families": {
    "cut": "Без эффекта. Смысл несёт монтаж и звук.",
    "motion": "Сдвиг кадра. Направление читается как смысл.",
    "zoom": "Изменение масштаба. Вход внутрь или выход наружу.",
    "light": "Экспозиция и растворение. Время и настроение.",
    "mask": "Перекрытие плоскостью. Карточки и темы.",
    "dimensional": "Псевдо-3D. Геройские переходы.",
    "mechanical": "Затвор, ирис, радиальная развёртка. Техника.",
    "digital": "Глитч, пиксель, полосы. Ошибка и данные.",
    "organic": "Плёнка, расфокус. Память и мягкость."
  },
  "transitions": [
    {
      "id": "cut",
      "label": "Прямая склейка",
      "family": "cut",
      "energy": 0,
      "frames": {
        "min": 0,
        "max": 0,
        "default": 0
      },
      "when": "По умолчанию. Речь продолжается, смысл не меняется. Стоит 0 очков энергии — это нейтральная база, от которой считается всё остальное.",
      "avoid": "Никогда не заменяй склейку эффектом только чтобы «было динамичнее».",
      "ffmpeg": {
        "kind": "cut"
      },
      "sfx": [],
      "hyperframes": {
        "out": {},
        "in": {},
        "ease": "none"
      },
      "max_per_minute": 999,
      "needs_speech_pause": false
    },
    {
      "id": "cut_sfx",
      "label": "Склейка с акцентом",
      "family": "cut",
      "energy": 2,
      "frames": {
        "min": 0,
        "max": 0,
        "default": 0
      },
      "when": "Смена мысли на прямой склейке. Звук делает всю работу.",
      "avoid": "Не ставь на каждую склейку — акцент перестанет быть акцентом.",
      "ffmpeg": {
        "kind": "cut"
      },
      "sfx": [
        {
          "id": "impact_tight",
          "offset_frames": 0,
          "gain_db": -19
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {},
        "ease": "none"
      },
      "max_per_minute": 8,
      "needs_speech_pause": false
    },
    {
      "id": "dissolve_soft",
      "label": "Мягкое растворение",
      "family": "light",
      "energy": 1,
      "frames": {
        "min": 8,
        "max": 14,
        "default": 11
      },
      "when": "Смена времени или места в спокойном темпе. Документальный тон.",
      "avoid": "Не используй между двумя динамичными планами — гасит энергию.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "dissolve"
      },
      "sfx": [
        {
          "id": "air_tail",
          "offset_frames": -2,
          "gain_db": -24
        }
      ],
      "hyperframes": {
        "out": {
          "opacity": [
            1,
            0
          ]
        },
        "in": {
          "opacity": [
            0,
            1
          ]
        },
        "ease": "power1.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": true
    },
    {
      "id": "fade_soft",
      "label": "Плавный кроссфейд",
      "family": "light",
      "energy": 1,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Пауза в речи, вдох, смена главы.",
      "avoid": "Не ставь поверх произносимого слова.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadeslow"
      },
      "sfx": [],
      "hyperframes": {
        "out": {
          "opacity": [
            1,
            0
          ]
        },
        "in": {
          "opacity": [
            0,
            1
          ]
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": true
    },
    {
      "id": "push_soft",
      "label": "Мягкий push влево",
      "family": "motion",
      "energy": 2,
      "frames": {
        "min": 10,
        "max": 14,
        "default": 12
      },
      "when": "Смена смыслового блока без скачка энергии. Рабочая лошадка.",
      "avoid": "Не чередуй направление случайно — зритель считывает направление как смысл.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "smoothleft"
      },
      "sfx": [
        {
          "id": "whoosh_soft",
          "offset_frames": -3,
          "gain_db": -17,
          "pan": "l2r"
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "-18%"
          ],
          "scale": [
            1,
            1.03
          ]
        },
        "in": {
          "x": [
            "18%",
            0
          ],
          "scale": [
            1.02,
            1
          ]
        },
        "ease": "power3.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": false
    },
    {
      "id": "push_soft_right",
      "label": "Мягкий push вправо",
      "family": "motion",
      "energy": 2,
      "frames": {
        "min": 10,
        "max": 14,
        "default": 12
      },
      "when": "Возврат назад по смыслу, к предыдущей мысли.",
      "avoid": "То же правило направления.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "smoothright"
      },
      "sfx": [
        {
          "id": "whoosh_soft",
          "offset_frames": -3,
          "gain_db": -17,
          "pan": "r2l"
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "18%"
          ],
          "scale": [
            1,
            1.03
          ]
        },
        "in": {
          "x": [
            "-18%",
            0
          ],
          "scale": [
            1.02,
            1
          ]
        },
        "ease": "power3.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": false
    },
    {
      "id": "push_up",
      "label": "Push вверх",
      "family": "motion",
      "energy": 2,
      "frames": {
        "min": 9,
        "max": 13,
        "default": 11
      },
      "when": "Список, шаги, перечисление. Вертикальный ритм.",
      "avoid": "Не мешай с горизонтальными push в одном перечислении.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "smoothup"
      },
      "sfx": [
        {
          "id": "whoosh_soft",
          "offset_frames": -3,
          "gain_db": -18
        }
      ],
      "hyperframes": {
        "out": {
          "y": [
            0,
            "-16%"
          ]
        },
        "in": {
          "y": [
            "16%",
            0
          ]
        },
        "ease": "power3.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": false
    },
    {
      "id": "slide_hard",
      "label": "Жёсткий слайд",
      "family": "motion",
      "energy": 3,
      "frames": {
        "min": 7,
        "max": 11,
        "default": 9
      },
      "when": "Резкая смена темы, контраст «до/после».",
      "avoid": "Слишком часто — получается презентация PowerPoint.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "slideleft"
      },
      "sfx": [
        {
          "id": "whoosh_fast",
          "offset_frames": -2,
          "gain_db": -15,
          "pan": "l2r"
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "-100%"
          ]
        },
        "in": {
          "x": [
            "100%",
            0
          ]
        },
        "ease": "power4.out"
      },
      "max_per_minute": 4,
      "needs_speech_pause": false
    },
    {
      "id": "whip_pan",
      "label": "Whip pan",
      "family": "motion",
      "energy": 4,
      "frames": {
        "min": 6,
        "max": 10,
        "default": 8
      },
      "when": "Сильный поворот, смена места или эпохи, резкая мысль.",
      "avoid": "Не чаще 2–3 раз в минуту, иначе укачивает.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,0.038*W*(1-abs(2*Q-1)));st(2,X+W*Q);st(3,X-W*(1-Q));if(gt(Q,0.5),(B@(ld(3)-ld(1),Y)+B@(ld(3)-ld(1)/2,Y)+B@(ld(3),Y)+B@(ld(3)+ld(1)/2,Y)+B@(ld(3)+ld(1),Y))/5,(A@(ld(2)-ld(1),Y)+A@(ld(2)-ld(1)/2,Y)+A@(ld(2),Y)+A@(ld(2)+ld(1)/2,Y)+A@(ld(2)+ld(1),Y))/5)",
        "note": "Пятитаповый горизонтальный смаз плюс сдвиг кадра: настоящее моушен-блюр, а не размытие фильтром."
      },
      "sfx": [
        {
          "id": "whip_pan",
          "offset_frames": -2,
          "gain_db": -13,
          "pan": "l2r"
        },
        {
          "id": "impact_tight",
          "offset_frames": 0,
          "gain_db": -22
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "-45%"
          ],
          "blur": [
            0,
            24
          ],
          "rotate": [
            0,
            1.5
          ]
        },
        "in": {
          "x": [
            "45%",
            0
          ],
          "blur": [
            24,
            0
          ],
          "rotate": [
            -1.5,
            0
          ]
        },
        "ease": "power4.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "whip_pan_wind",
      "label": "Whip со смазом",
      "family": "motion",
      "energy": 4,
      "frames": {
        "min": 6,
        "max": 9,
        "default": 7
      },
      "when": "Тот же whip, но со следом ветра. Спорт, мото, экшн.",
      "avoid": "Не поверх читаемого титра — след смазывает текст.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "hrwind"
      },
      "sfx": [
        {
          "id": "whoosh_fast",
          "offset_frames": -2,
          "gain_db": -13,
          "pan": "l2r"
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "-50%"
          ],
          "blur": [
            0,
            30
          ]
        },
        "in": {
          "x": [
            "50%",
            0
          ],
          "blur": [
            30,
            0
          ]
        },
        "ease": "expo.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "zoom_through",
      "label": "Zoom-through",
      "family": "zoom",
      "energy": 4,
      "frames": {
        "min": 8,
        "max": 14,
        "default": 11
      },
      "when": "От общего к деталям, от объекта к данным, вход внутрь процесса.",
      "avoid": "Не увеличивай лицо до пиксельной каши.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1+0.55*Q);st(2,0.70+0.30*Q);st(3,Q*Q*(3-2*Q));A@((X-W/2)/ld(1)+W/2,(Y-H/2)/ld(1)+H/2)*(1-ld(3))+B@((X-W/2)/ld(2)+W/2,(Y-H/2)/ld(2)+H/2)*ld(3)",
        "note": "Исходящий кадр наезжает, входящий выходит из уменьшения. Настоящее изменение масштаба, а не кроссфейд."
      },
      "sfx": [
        {
          "id": "riser_short",
          "offset_frames": -6,
          "gain_db": -18
        },
        {
          "id": "whoosh_soft",
          "offset_frames": 0,
          "gain_db": -17
        }
      ],
      "hyperframes": {
        "out": {
          "scale": [
            1,
            1.35
          ]
        },
        "in": {
          "scale": [
            0.84,
            1
          ],
          "blur": [
            8,
            0
          ]
        },
        "ease": "power3.inOut"
      },
      "max_per_minute": 4,
      "needs_speech_pause": false
    },
    {
      "id": "zoom_punch",
      "label": "Zoom-удар",
      "family": "zoom",
      "energy": 5,
      "frames": {
        "min": 4,
        "max": 7,
        "default": 5
      },
      "when": "Один сильный акцент на цифре или выводе.",
      "avoid": "Максимум один-два раза за ролик.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1+1.05*Q);st(2,0.52+0.48*Q);st(3,gte(Q,0.58));A@((X-W/2)/ld(1)+W/2,(Y-H/2)/ld(1)+H/2)*(1-ld(3))+B@((X-W/2)/ld(2)+W/2,(Y-H/2)/ld(2)+H/2)*ld(3)",
        "note": "Резкий наезд и щелчок на новый кадр на 58% перехода."
      },
      "sfx": [
        {
          "id": "impact_low",
          "offset_frames": 0,
          "gain_db": -14
        },
        {
          "id": "sub_drop",
          "offset_frames": 0,
          "gain_db": -22
        }
      ],
      "hyperframes": {
        "out": {
          "scale": [
            1,
            1.6
          ]
        },
        "in": {
          "scale": [
            0.7,
            1
          ]
        },
        "ease": "expo.out"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "match_cut",
      "label": "Match cut",
      "family": "cut",
      "energy": 2,
      "frames": {
        "min": 3,
        "max": 6,
        "default": 4
      },
      "when": "Два объекта совпадают по форме, направлению или положению.",
      "avoid": "Работает только если центры интереса действительно совпадают.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadefast"
      },
      "sfx": [
        {
          "id": "air_tail",
          "offset_frames": -1,
          "gain_db": -26
        }
      ],
      "hyperframes": {
        "out": {
          "opacity": [
            1,
            0
          ]
        },
        "in": {
          "opacity": [
            0,
            1
          ]
        },
        "ease": "power1.inOut"
      },
      "max_per_minute": 4,
      "needs_speech_pause": false
    },
    {
      "id": "flash_white",
      "label": "Вспышка через белое",
      "family": "light",
      "energy": 4,
      "frames": {
        "min": 4,
        "max": 6,
        "default": 5
      },
      "when": "Единичный мощный акцент, «внезапно».",
      "avoid": "Регулярное использование превращает ролик в мигающую лампу.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadewhite"
      },
      "sfx": [
        {
          "id": "flash_white",
          "offset_frames": -1,
          "gain_db": -16
        },
        {
          "id": "impact_tight",
          "offset_frames": 0,
          "gain_db": -21
        }
      ],
      "hyperframes": {
        "out": {
          "exposure": [
            0,
            1.6
          ]
        },
        "in": {
          "exposure": [
            1.6,
            0
          ]
        },
        "ease": "expo.inOut"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "flash_black",
      "label": "Провал в чёрное",
      "family": "light",
      "energy": 3,
      "frames": {
        "min": 6,
        "max": 10,
        "default": 8
      },
      "when": "Конец главы, пауза, смена настроения вниз.",
      "avoid": "Не в первые 5 секунд: зритель решит, что ролик кончился.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadeblack"
      },
      "sfx": [
        {
          "id": "boom_cinematic",
          "offset_frames": 0,
          "gain_db": -19
        }
      ],
      "hyperframes": {
        "out": {
          "brightness": [
            0,
            -1
          ]
        },
        "in": {
          "brightness": [
            -1,
            0
          ]
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": true
    },
    {
      "id": "cover_left",
      "label": "Cover-перекрытие",
      "family": "mask",
      "energy": 3,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Карточка, интерфейс, новая тема заезжает поверх.",
      "avoid": "Край маски без тени выглядит дешёво.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "coverleft"
      },
      "sfx": [
        {
          "id": "cloth_sweep",
          "offset_frames": -3,
          "gain_db": -17,
          "pan": "l2r"
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {
          "x": [
            "100%",
            0
          ],
          "shadow": true
        },
        "ease": "power3.out"
      },
      "max_per_minute": 6,
      "needs_speech_pause": false
    },
    {
      "id": "reveal_right",
      "label": "Reveal-открытие",
      "family": "mask",
      "energy": 3,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Уход карточки, возврат к спикеру.",
      "avoid": "Обратная пара к cover_left — используй их симметрично.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "revealright"
      },
      "sfx": [
        {
          "id": "paper_swipe",
          "offset_frames": -3,
          "gain_db": -18,
          "pan": "r2l"
        }
      ],
      "hyperframes": {
        "out": {
          "x": [
            0,
            "100%"
          ]
        },
        "in": {},
        "ease": "power3.in"
      },
      "max_per_minute": 6,
      "needs_speech_pause": false
    },
    {
      "id": "wipe_diagonal",
      "label": "Диагональная протяжка",
      "family": "mask",
      "energy": 3,
      "frames": {
        "min": 9,
        "max": 14,
        "default": 11
      },
      "when": "Смена подтемы внутри блока. Динамика без поворота камеры.",
      "avoid": "Диагональ спорит с вертикальным кадром — не злоупотребляй.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "diagtl"
      },
      "sfx": [
        {
          "id": "digital_sweep",
          "offset_frames": -3,
          "gain_db": -19
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {
          "clipPath": "diagonal"
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 4,
      "needs_speech_pause": false
    },
    {
      "id": "squeeze_card",
      "label": "Сжатие по горизонтали (псевдо-3D)",
      "family": "dimensional",
      "energy": 4,
      "frames": {
        "min": 12,
        "max": 20,
        "default": 16
      },
      "when": "Геройский переход, показ продукта, смена главы. Это сжатие кадра, а не настоящий поворот в 3D — для истинного rotateY используй слой HyperFrames.",
      "avoid": "Цифры и лица не должны быть искажены в момент чтения.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "squeezeh"
      },
      "sfx": [
        {
          "id": "digital_sweep",
          "offset_frames": -4,
          "gain_db": -17
        },
        {
          "id": "impact_tight",
          "offset_frames": 0,
          "gain_db": -22
        }
      ],
      "hyperframes": {
        "out": {
          "rotateY": [
            0,
            -75
          ],
          "perspective": 1100
        },
        "in": {
          "rotateY": [
            75,
            0
          ]
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "cube_vertical",
      "label": "Сжатие по вертикали (псевдо-3D)",
      "family": "dimensional",
      "energy": 4,
      "frames": {
        "min": 12,
        "max": 18,
        "default": 15
      },
      "when": "Перелистывание шагов, «дальше». Вертикальная логика. Псевдо-3D через сжатие.",
      "avoid": "Не мешай с горизонтальной карточкой в одной сцене.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "squeezev"
      },
      "sfx": [
        {
          "id": "cloth_sweep",
          "offset_frames": -4,
          "gain_db": -18
        },
        {
          "id": "click_tick",
          "offset_frames": 0,
          "gain_db": -24
        }
      ],
      "hyperframes": {
        "out": {
          "rotateX": [
            0,
            75
          ],
          "perspective": 1100
        },
        "in": {
          "rotateX": [
            -75,
            0
          ]
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "iris_open",
      "label": "Радиальное раскрытие",
      "family": "mechanical",
      "energy": 3,
      "frames": {
        "min": 12,
        "max": 18,
        "default": 15
      },
      "when": "Техника, камера, вычисления, машинный процесс. Мягкий радиальный край.",
      "avoid": "Не поверх важной речи без паузы.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "circleopen"
      },
      "sfx": [
        {
          "id": "shutter_mech",
          "offset_frames": -3,
          "gain_db": -17
        },
        {
          "id": "air_tail",
          "offset_frames": 2,
          "gain_db": -25
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {
          "clipPath": "circle",
          "radius": [
            0,
            1
          ]
        },
        "ease": "power2.out"
      },
      "max_per_minute": 3,
      "needs_speech_pause": true
    },
    {
      "id": "iris_close",
      "label": "Радиальное закрытие",
      "family": "mechanical",
      "energy": 3,
      "frames": {
        "min": 12,
        "max": 18,
        "default": 15
      },
      "when": "Завершение технического блока. Парный к iris_open.",
      "avoid": "Парный к iris_open.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "circleclose"
      },
      "sfx": [
        {
          "id": "shutter_mech",
          "offset_frames": -3,
          "gain_db": -18
        }
      ],
      "hyperframes": {
        "out": {
          "clipPath": "circle",
          "radius": [
            1,
            0
          ]
        },
        "in": {},
        "ease": "power2.in"
      },
      "max_per_minute": 3,
      "needs_speech_pause": true
    },
    {
      "id": "radial_sweep",
      "label": "Радиальная развёртка",
      "family": "mechanical",
      "energy": 3,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Циферблат, таймер, круговой процесс.",
      "avoid": "Читается только если в кадре есть центр.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "radial"
      },
      "sfx": [
        {
          "id": "digital_sweep",
          "offset_frames": -3,
          "gain_db": -18
        },
        {
          "id": "click_tick",
          "offset_frames": 0,
          "gain_db": -25
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {
          "clipPath": "conic"
        },
        "ease": "none"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "glitch_digital",
      "label": "Цифровой глитч",
      "family": "digital",
      "energy": 5,
      "frames": {
        "min": 3,
        "max": 7,
        "default": 5
      },
      "when": "Ошибка, конфликт, цифровой разрыв, взлом.",
      "avoid": "Не держи глитч секунду — это уже поломка, а не стиль.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,floor(Y/(H/36)));st(2,(mod(ld(1)*7919,23)-11)*0.0045*W*(1-abs(2*Q-1)));st(3,X+ld(2));if(gt(Q,0.5),B@(ld(3),Y),A@(ld(3),Y))",
        "note": "Горизонтальный разрыв по 36 полосам, детерминированный от номера полосы. Смещение максимально в середине."
      },
      "sfx": [
        {
          "id": "digital_glitch",
          "offset_frames": -1,
          "gain_db": -14
        }
      ],
      "hyperframes": {
        "out": {
          "rgbSplit": [
            0,
            8
          ]
        },
        "in": {
          "rgbSplit": [
            8,
            0
          ]
        },
        "ease": "steps"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "pixelize",
      "label": "Пикселизация",
      "family": "digital",
      "energy": 3,
      "frames": {
        "min": 8,
        "max": 14,
        "default": 11
      },
      "when": "Ретро-гейминг, анонимизация, «низкое разрешение».",
      "avoid": "Скрывает детали — не на важном кадре.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "pixelize"
      },
      "sfx": [
        {
          "id": "digital_sweep",
          "offset_frames": -3,
          "gain_db": -18
        },
        {
          "id": "click_tick",
          "offset_frames": 0,
          "gain_db": -24
        }
      ],
      "hyperframes": {
        "out": {
          "pixelate": [
            1,
            40
          ]
        },
        "in": {
          "pixelate": [
            40,
            1
          ]
        },
        "ease": "steps"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "tape_stop",
      "label": "Останов плёнки",
      "family": "organic",
      "energy": 4,
      "frames": {
        "min": 8,
        "max": 14,
        "default": 11
      },
      "when": "Обрыв мысли, «стоп», неожиданная пауза.",
      "avoid": "Требует настоящей паузы в речи.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadegrays"
      },
      "sfx": [
        {
          "id": "tape_stop",
          "offset_frames": -6,
          "gain_db": -15
        }
      ],
      "hyperframes": {
        "out": {
          "saturation": [
            1,
            0
          ],
          "scaleY": [
            1,
            0.96
          ]
        },
        "in": {
          "saturation": [
            0,
            1
          ]
        },
        "ease": "power4.out"
      },
      "max_per_minute": 2,
      "needs_speech_pause": true
    },
    {
      "id": "blur_dissolve",
      "label": "Расфокус",
      "family": "organic",
      "energy": 2,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Воспоминание, размышление, смена фокуса внимания.",
      "avoid": "Гасит энергию — не в первые секунды.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "hblur"
      },
      "sfx": [
        {
          "id": "air_tail",
          "offset_frames": -3,
          "gain_db": -23
        }
      ],
      "hyperframes": {
        "out": {
          "blur": [
            0,
            18
          ],
          "opacity": [
            1,
            0
          ]
        },
        "in": {
          "blur": [
            18,
            0
          ],
          "opacity": [
            0,
            1
          ]
        },
        "ease": "power2.inOut"
      },
      "max_per_minute": 6,
      "needs_speech_pause": true
    },
    {
      "id": "slice_horizontal",
      "label": "Вертикальные полосы",
      "family": "digital",
      "energy": 3,
      "frames": {
        "min": 9,
        "max": 14,
        "default": 11
      },
      "when": "Данные, таблицы, сравнение. Полосы съезжают по горизонтали.",
      "avoid": "Полосы конфликтуют с горизонтальными линиями в кадре.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "hlslice"
      },
      "sfx": [
        {
          "id": "digital_sweep",
          "offset_frames": -3,
          "gain_db": -19
        }
      ],
      "hyperframes": {
        "out": {},
        "in": {
          "slices": 8
        },
        "ease": "power2.out"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "wind_vertical",
      "label": "Вертикальный ветер",
      "family": "motion",
      "energy": 3,
      "frames": {
        "min": 8,
        "max": 13,
        "default": 10
      },
      "when": "Падение вниз, «глубже», уход в детали.",
      "avoid": "Не в паре с push_up в одном блоке.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "vdwind"
      },
      "sfx": [
        {
          "id": "whoosh_fast",
          "offset_frames": -2,
          "gain_db": -16
        }
      ],
      "hyperframes": {
        "out": {
          "y": [
            0,
            "40%"
          ],
          "blur": [
            0,
            20
          ]
        },
        "in": {
          "y": [
            "-40%",
            0
          ],
          "blur": [
            20,
            0
          ]
        },
        "ease": "expo.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "sparkle_reveal",
      "label": "Искристое появление",
      "family": "light",
      "energy": 3,
      "frames": {
        "min": 10,
        "max": 16,
        "default": 13
      },
      "when": "Результат, «вот оно», приятный итог.",
      "avoid": "Легко переборщить — это самый «инстаграмный» переход в наборе.",
      "ffmpeg": {
        "kind": "xfade",
        "transition": "fadewhite"
      },
      "sfx": [
        {
          "id": "sparkle",
          "offset_frames": -4,
          "gain_db": -19
        },
        {
          "id": "reverse_cymbal",
          "offset_frames": -10,
          "gain_db": -22
        }
      ],
      "hyperframes": {
        "out": {
          "exposure": [
            0,
            1.2
          ]
        },
        "in": {
          "exposure": [
            1.2,
            0
          ],
          "scale": [
            1.04,
            1
          ]
        },
        "ease": "power3.out"
      },
      "max_per_minute": 2,
      "needs_speech_pause": true
    },
    {
      "id": "flash_punch",
      "label": "Вспышка с наездом",
      "family": "light",
      "energy": 5,
      "frames": {"min": 4, "max": 7, "default": 5},
      "when": "Самый сильный акцент в наборе: кадр наезжает, выбивается в белое и открывается уже новым планом. Под вывод, цифру, поворот мысли.",
      "avoid": "Один-два раза за ролик. Третий превращает монтаж в стробоскоп.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,1+0.22*ld(1));st(3,gte(Q,0.5));st(4,if(ld(3),B@((X-W/2)/ld(2)+W/2,(Y-H/2)/ld(2)+H/2),A@((X-W/2)/ld(2)+W/2,(Y-H/2)/ld(2)+H/2)));st(5,if(eq(PLANE,0),255,128));st(6,pow(ld(1),0.7));ld(4)*(1-ld(6))+ld(5)*ld(6)",
        "note": "Белая точка задаётся по плоскости: 255 в яркости и 128 в цветности. Постоянная 255 во всех трёх планах даёт не вспышку, а зелёно-пурпурный кадр."
      },
      "sfx": [
        {"id": "flash_pop", "offset_frames": -1, "gain_db": -13},
        {"id": "bass_drop", "offset_frames": 0, "gain_db": -19}
      ],
      "hyperframes": {
        "out": {"scale": [1, 1.22], "exposure": [0, 2.0]},
        "in": {"scale": [1.22, 1], "exposure": [2.0, 0]},
        "ease": "expo.inOut"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "rgb_glitch",
      "label": "RGB-глитч с разрывом",
      "family": "digital",
      "energy": 5,
      "frames": {"min": 4, "max": 8, "default": 6},
      "when": "Сбой, подмена, «а на самом деле». Цветовые каналы разъезжаются и строки рвутся.",
      "avoid": "Не поверх читаемого титра: цветной развод по буквам читается как брак рендера.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,if(eq(PLANE,0),0,if(eq(PLANE,1),-1,1))*0.03*W*ld(1));st(3,floor(Y/(H/24)));st(4,(mod(ld(3)*7919,17)-8)*0.004*W*ld(1));st(5,X+ld(2)+ld(4));if(gt(Q,0.5),B@(ld(5),Y),A@(ld(5),Y))",
        "note": "Цветовое расслоение делается сдвигом плоскостей U и V в разные стороны — это и есть настоящий RGB-split, а не размытие. Яркость не сдвигается, поэтому картинка не двоится."
      },
      "sfx": [
        {"id": "glitch_stutter", "offset_frames": -2, "gain_db": -13},
        {"id": "data_burst", "offset_frames": 0, "gain_db": -19}
      ],
      "hyperframes": {
        "out": {"rgbSplit": [0, 14]},
        "in": {"rgbSplit": [14, 0]},
        "ease": "steps"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "strobe_cut",
      "label": "Строб-склейка",
      "family": "light",
      "energy": 5,
      "frames": {"min": 3, "max": 6, "default": 4},
      "when": "Перечисление ударов, смена ритма, вход в клиповый блок.",
      "avoid": "Никогда дважды подряд и никогда длиннее 6 кадров: это уже мигание, а не монтаж.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,floor(Q*6));st(2,if(eq(PLANE,0),255,128));if(eq(mod(ld(1),2),1),ld(2),if(gt(Q,0.5),B@(X,Y),A@(X,Y)))",
        "note": "Шесть шагов на длительность перехода: при 4 кадрах это три белых поля, ровно на грани восприятия как вспышка, а не как выпавшие кадры."
      },
      "sfx": [
        {"id": "flash_pop", "offset_frames": -1, "gain_db": -15},
        {"id": "impact_tight", "offset_frames": 0, "gain_db": -18}
      ],
      "hyperframes": {
        "out": {"exposure": [0, 2.2]},
        "in": {"exposure": [2.2, 0]},
        "ease": "steps"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "shake_impact",
      "label": "Тряска на ударе",
      "family": "motion",
      "energy": 5,
      "frames": {"min": 4, "max": 8, "default": 6},
      "when": "Удар, отрицание, резкое «нет». Кадр вздрагивает по обе стороны стыка.",
      "avoid": "На вертикальном видео легко укачать — не чаще двух раз в минуту.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,floor(Q*10));st(3,(mod(ld(2)*9781,13)-6)*0.006*W*ld(1));st(4,(mod(ld(2)*6151,11)-5)*0.006*H*ld(1));if(gt(Q,0.5),B@(X+ld(3),Y+ld(4)),A@(X+ld(3),Y+ld(4)))",
        "note": "Смещение детерминировано от номера шага, а не случайно: один и тот же рендер даёт один и тот же результат, иначе чанковый рендер стыкуется с разной тряской."
      },
      "sfx": [
        {"id": "impact_low", "offset_frames": 0, "gain_db": -14},
        {"id": "metal_hit", "offset_frames": 0, "gain_db": -22}
      ],
      "hyperframes": {
        "out": {"shake": [0, 12]},
        "in": {"shake": [12, 0]},
        "ease": "expo.out"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "zoom_blur_punch",
      "label": "Наезд с радиальным смазом",
      "family": "zoom",
      "energy": 5,
      "frames": {"min": 5, "max": 9, "default": 7},
      "when": "Вход внутрь мысли на скорости. Геройский переход между блоками.",
      "avoid": "Смаз съедает мелкий текст — не на кадре с интерфейсом или цифрами.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1+0.5*Q);st(2,0.72+0.28*Q);st(3,gte(Q,0.55));st(4,if(ld(3),ld(2),ld(1)));st(5,ld(4)*(1+0.07*(1-abs(2*Q-1))));st(6,ld(4)*(1-0.07*(1-abs(2*Q-1))));if(ld(3),(B@((X-W/2)/ld(4)+W/2,(Y-H/2)/ld(4)+H/2)+B@((X-W/2)/ld(5)+W/2,(Y-H/2)/ld(5)+H/2)+B@((X-W/2)/ld(6)+W/2,(Y-H/2)/ld(6)+H/2))/3,(A@((X-W/2)/ld(4)+W/2,(Y-H/2)/ld(4)+H/2)+A@((X-W/2)/ld(5)+W/2,(Y-H/2)/ld(5)+H/2)+A@((X-W/2)/ld(6)+W/2,(Y-H/2)/ld(6)+H/2))/3)",
        "note": "Три выборки на разных масштабах вокруг текущего — это настоящий радиальный смаз от движения, а не гауссово размытие поверх."
      },
      "sfx": [
        {"id": "reverse_whoosh", "offset_frames": -8, "gain_db": -17},
        {"id": "impact_tight", "offset_frames": 0, "gain_db": -16}
      ],
      "hyperframes": {
        "out": {"scale": [1, 1.5], "blur": [0, 14]},
        "in": {"scale": [0.72, 1], "blur": [14, 0]},
        "ease": "expo.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "block_shuffle",
      "label": "Блочный датамош",
      "family": "digital",
      "energy": 5,
      "frames": {"min": 4, "max": 8, "default": 6},
      "when": "Разрыв повествования, «перемотка», технический сбой как приём.",
      "avoid": "Не подряд с rgb_glitch — два цифровых сбоя рядом читаются как поломка файла.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,floor(X/(W/12))+floor(Y/(H/20))*12);st(3,(mod(ld(2)*7717,21)-10)*0.02*W*ld(1));st(4,(mod(ld(2)*3391,15)-7)*0.012*H*ld(1));if(gt(Q,0.5),B@(X+ld(3),Y+ld(4)),A@(X+ld(3),Y+ld(4)))",
        "note": "Сетка 12×20 блоков, смещение детерминировано от индекса блока. Смещение по обеим осям — иначе это горизонтальный разрыв, а не датамош."
      },
      "sfx": [
        {"id": "glitch_stutter", "offset_frames": -2, "gain_db": -14},
        {"id": "sub_drop", "offset_frames": 0, "gain_db": -21}
      ],
      "hyperframes": {
        "out": {"blocks": [0, 20]},
        "in": {"blocks": [20, 0]},
        "ease": "steps"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "roll_glitch",
      "label": "Вертикальный срыв кадра",
      "family": "digital",
      "energy": 4,
      "frames": {"min": 5, "max": 10, "default": 7},
      "when": "Аналоговый срыв синхронизации: ретро, VHS, «старая запись».",
      "avoid": "Требует, чтобы в кадре была горизонталь — иначе срыв не читается.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,mod(Y+ld(1)*H*0.35,H));st(3,floor(ld(2)/(H/40)));st(4,if(gt(mod(ld(3)*4813,29),24),0.05*W*ld(1),0));if(gt(Q,0.5),B@(X+ld(4),ld(2)),A@(X+ld(4),ld(2)))",
        "note": "Кадр прокручивается по вертикали с заворотом через mod, и часть строк дополнительно уезжает вбок. Это поведение телевизора без вертикальной синхронизации."
      },
      "sfx": [
        {"id": "vinyl_scratch", "offset_frames": -3, "gain_db": -16},
        {"id": "data_burst", "offset_frames": 0, "gain_db": -22}
      ],
      "hyperframes": {
        "out": {"rollY": [0, "35%"]},
        "in": {"rollY": ["-35%", 0]},
        "ease": "steps"
      },
      "max_per_minute": 2,
      "needs_speech_pause": false
    },
    {
      "id": "light_leak",
      "label": "Засветка плёнки",
      "family": "organic",
      "energy": 3,
      "frames": {"min": 8, "max": 14, "default": 11},
      "when": "Тёплая смена сцены, воспоминание, лирический блок. Мягче вспышки.",
      "avoid": "Гасит холодный грейд — не в паре с teal_orange на обоих планах.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,1-abs(2*Q-1));st(2,exp(-pow((X/W-Q)*3.2,2))*ld(1));st(3,if(eq(PLANE,0),255,if(eq(PLANE,1),95,175)));st(4,if(gt(Q,0.5),B@(X,Y),A@(X,Y)));ld(4)*(1-ld(2)*0.85)+ld(3)*ld(2)*0.85",
        "note": "Тёплая точка задаётся прямо в YUV: U=95 и V=175 — это оранжевый. Полоса засветки едет по кадру вместе с прогрессом, поэтому она читается как свет, а не как фильтр."
      },
      "sfx": [
        {"id": "air_tail", "offset_frames": -4, "gain_db": -22},
        {"id": "sparkle", "offset_frames": -2, "gain_db": -25}
      ],
      "hyperframes": {
        "out": {"exposure": [0, 1.1], "warmth": [0, 1]},
        "in": {"exposure": [1.1, 0], "warmth": [1, 0]},
        "ease": "power2.inOut"
      },
      "max_per_minute": 3,
      "needs_speech_pause": false
    },
    {
      "id": "film_burn",
      "label": "Прожог плёнки",
      "family": "organic",
      "energy": 4,
      "frames": {"min": 8, "max": 14, "default": 11},
      "when": "Конец главы, смена эпохи, «и тут всё изменилось». Прожог идёт из центра наружу.",
      "avoid": "Слишком заметен для рядовой склейки — только на смысловой границе.",
      "ffmpeg": {
        "kind": "xfade_custom",
        "expr": "st(1,hypot((X-W/2)/W,(Y-H/2)/H)*2);st(2,max(0,min(1,(Q*2.4-ld(1))*1.6)));st(3,if(eq(PLANE,0),252,if(eq(PLANE,1),88,180)));st(4,if(gt(ld(2),0.5),B@(X,Y),A@(X,Y)));st(5,exp(-pow((ld(2)-0.5)*5,2)));ld(4)*(1-ld(5))+ld(3)*ld(5)",
        "note": "Фронт прожога — функция расстояния от центра, а горящая кромка — гауссиан вокруг самого фронта. Поэтому светится именно край, а не весь кадр."
      },
      "sfx": [
        {"id": "riser_tension", "offset_frames": -14, "gain_db": -21},
        {"id": "boom_cinematic", "offset_frames": 0, "gain_db": -18}
      ],
      "hyperframes": {
        "out": {"burn": [0, 1]},
        "in": {"burn": [1, 0]},
        "ease": "power2.inOut"
      },
      "max_per_minute": 2,
      "needs_speech_pause": true
    }
  ],
  "custom_expression_contract": {
    "progress": "Рецепты пишутся через Q — прямой прогресс 0→1. В самом ffmpeg переменная P идёт наоборот, от 1 к 0; подстановку делает scripts/transition_lib.py.",
    "sampling": "A@(x,y) и B@(x,y) — выборка из исходящего и входящего кадра. Раскрываются в доступ по PLANE, потому что a0()/b0() всегда читают только плоскость яркости.",
    "chroma": "Custom-переходы рендерятся в yuv444p и возвращаются в yuv420p: X/Y идут по плоскости, а W/H остаются размером кадра.",
    "threads": "Регистры st()/ld() общие для всех потоков фильтра. xfade режет кадр на срезы и считает одно и то же выражение в несколько потоков, поэтому попиксельное значение в st() затирается соседним срезом, и переход выходит цветным шумом. Рецепты, у которых st() зависит только от Q, переживают это незаметно — именно поэтому поломка выглядела как «часть переходов сломана». scripts/render_transitions.py ставит -filter_complex_threads 1, как только в плане есть хоть одно custom-выражение. Проверять переход вручную нужно с тем же флагом, иначе увидишь шум и решишь, что виновата формула.",
    "flat_colour": "Белая или цветная точка задаётся по плоскости: if(eq(PLANE,0),255,128) — белый, if(eq(PLANE,0),255,if(eq(PLANE,1),95,175)) — тёплый оранжевый. Одна и та же константа во всех трёх плоскостях даёт не вспышку, а зелёно-пурпурный кадр."
  }
}
