Как я написал программу шортсогенератор
На днях мне позвонил давний друг. На тот момент я спал, и его звонок застал меня врасплох, особенно учитывая, что он начал пытаться объяснить мне, как заработать на создании шортсов на Youtube.
Он увидел такое видео и вбил его в Socialblade. У автора приблизительный доход — 1.5 - 24 (к$/мес).

Я давно разочаровался в методах заработка, которые вращаются вокруг того, что надо стать знаменитым в интернете. Но я сказал, что посмотрю и поразбираюсь в том, как можно было бы сделать такое видео, но Youtube каналом заниматься не буду. Если честно, то я не очень-то и рассчитывал всерьез этим заниматься, но я уже проснулся, спать было неохота, и я уселся думать над тем, как можно повторить такую симуляцию.
Друг ко мне обратился, потому что знал, что я когда-то делал игры. Чтобы не терять время на изучение нового игрового движка, я решил освежить свои навыки Lua.
Музыкальных навыков у меня нет, но я знаю про формат Midi и догадываюсь, что получить нужную аудиодорожку можно, если воспроизводить ноты синхронно с ударами мяча.
Возвращаясь к графической части, инструмент, на который в младенчестве пал мой выбор — LÖVE. Фреймворк с очень низким порогом вхождения, позволяющий создавать 2D игры на Lua.
Единственная его проблема — сообщество очень любит «велосипеды». Библиотек по пальцам пересчитать, и пакета способного воспроизводить Midi нет и в помине. Но я решил разбираться с проблемами по порядку и взялся за то, что точно могу сделать — графику.
Графика
Создадим файл main.lua
и начнем с перечисления констант, которые будут задавать поведение симуляции:
local windowW = 720
local windowH = 720
local centerX = windowW / 2
local centerY = windowH / 2
local circleCount = 6 -- Количество окружностей
local radiusIncrement = 50 -- Расстояние между окружностями
local circleResolution = 36 -- Сколько градусов приходится на один сегмент
local gravity = 180
local circles = {}
local ball = {
x = centerX,
y = centerY,
radius = 7,
speedX = 100 * love.math.random() - 50, -- Дадим случайную начальную скорость мячику
speedY = 100 * love.math.random() - 50,
color = {1, 1, 1} -- Белый
}
Любая игра на LÖVE разделена на 3 основные функции:
love.load()
— выполняется единожды при запуске игры,love.update()
— выполняется каждый кадр,love.draw()
— выполняется каждый кадр, отвечает за отрисовку графики.
love.load()
В функции love.load()
создадим нужные нам окружности.
Они состоят из арок, и, чтобы арки на внешних окружностях не были слишком большими,
умножаем разрешение окружностей на их номер по порядку от центра.
function love.load()
love.window.setMode(720, 720, {highdpi = true})
-- Создаем несколько окружностей
for i = 1, circleCount do
local radius = radiusIncrement * i
local angleStart = 0
local angleEnd = 360
local segments = {}
local boostedResolution = circleResolution / i
-- Заполняем окружности сегментами, в соответствии с разрешением
for angle = angleStart, angleEnd - boostedResolution, boostedResolution do
local angleRadStart = math.rad(angle)
local angleRadEnd = math.rad(angle + boostedResolution)
local startX = centerX + radius * math.cos(angleRadStart)
local startY = centerY + radius * math.sin(angleRadStart)
local endX = centerX + radius * math.cos(angleRadEnd)
local endY = centerY + radius * math.sin(angleRadEnd)
segments[#segments + 1] = {
startX = startX,
startY = startY,
endX = endX,
endY = endY,
angleStart = angle,
angleEnd = angle + boostedResolution,
isAlive = true
}
end
circles[i] = {
-- У каждой окружности будет случайный цвет
color = {
love.math.random(),
love.math.random(),
love.math.random()
},
segments = segments
}
end
end
Получаем структуру:
circles = {
{
color = rgba,
segments = {
{
startX = number,
startY = number,
endX = number,
endY = number,
angleStart = number,
angleEnd = number,
isAlive = boolean
},
...
}
},
...
}
-- Я понятия не имею как описывать типы в Lua
love.update()
Здесь мы опишем то, как мяч изменяет свою скорость с течением времени, как от этого меняются его координаты и опишем логику столкновения с окружностями.
Для этого сначала опишем функцию поиска пересечения окружности и линии. В роли окружности будет наш мячик, а в роли линии сегмент круга. Конечно, сегмент, на самом деле, не прямая линия, а арка, но никто не заметит, что при столкновении он ведет себя как линия.
function isCollidingWithLine(ball, arc)
local function pointToLineDistance(px, py, ax, ay, bx, by)
local abx = bx - ax
local aby = by - ay
local apx = px - ax
local apy = py - ay
local ab2 = abx * abx + aby * aby
local ap_ab = apx * abx + apy * aby
local t = ap_ab / ab2
t = math.max(0, math.min(t, 1))
local nearestX = ax + t * abx
local nearestY = ay + t * aby
local distX = px - nearestX
local distY = py - nearestY
return math.sqrt(distX * distX + distY * distY), nearestX, nearestY
end
local distToSegment, nearestX, nearestY = pointToLineDistance(
ball.x, ball.y,
arc.startX, arc.startY,
arc.endX, arc.endY
)
if distToSegment < ball.radius then
return true, nearestX, nearestY
end
return false
end
Может произойти так, что наш шарик одновременно пересечет 2 сегмента. Если никак от этого не защититься, то получится, что он 2 раза за кадр поменяет свое направление на 180º и как будто пролетит сквозь препятствие.
Поэтому храним информацию о том, сталкивался ли уже мячик с чем-то и один раз в кадр даем ему это сделать.
function love.update(dt)
ball.collided = false
-- Гравитация
ball.speedY = ball.speedY + gravity * dt
ball.speedY = math.min(ball.speedY, maxSpeed)
ball.speedX = math.min(ball.speedX, maxSpeed)
ball.x = ball.x + ball.speedX * dt
ball.y = ball.y + ball.speedY * dt
-- Проверяем столкновения мячика и окружностей
for i, circle in ipairs(circles) do
for j, arc in ipairs(circle.segments) do
if arc.isAlive and not ball.collided then
local collided, impactX, impactY = isCollidingWithLine(ball, arc)
if collided then
arc.isAlive = false
ball.collided = true
-- Рассчитываем нормаль к точке столкновения
local normalX = ball.x - impactX
local normalY = ball.y - impactY
local normalLength = math.sqrt(normalX * normalX + normalY * normalY)
normalX = normalX / normalLength
normalY = normalY / normalLength
-- Отражаем скорость относительно нормали
local dotProduct = ball.speedX * normalX + ball.speedY * normalY
ball.speedX = ball.speedX - 2 * dotProduct * normalX
ball.speedY = ball.speedY - 2 * dotProduct * normalY
-- Увеличиваем скорость после столкновения, так веселее
ball.speedX = ball.speedX * 1.1
ball.speedY = ball.speedY * 1.1
end
end
end
end
end
love.draw()
Ну и, наконец, отрисовка
function love.draw()
-- Окружности
for i, circle in ipairs(circles) do
for _, arc in ipairs(circle.segments) do
if arc.isAlive then
love.graphics.setColor(circle.color)
love.graphics.arc(
"line",
"open",
centerX,
centerY,
radiusIncrement * i,
math.rad(arc.angleStart),
math.rad(arc.angleEnd)
)
end
end
end
-- Мячик
love.graphics.setColor(ball.color)
love.graphics.circle("fill", ball.x, ball.y, ball.radius)
end
Добавим немного эффектов и получаем такой результат:
Клик по окну перезапускает «игру».
Звук
Раз возможности воспроизводить Midi файлы из самой программы у нас нет, напишем отдельный скрипт на Python, который будет за это отвечать. Благо, в экосистеме Питона нет недостатка готовых библиотек.
Принцип в том, что графическая часть будет писать в файл время каждого удара, а скрипт на питоне можно будет потом запустить и получить звук, который будет синхронизирован с видеорядом, если мы таковой записали с экрана.
Midi файл представляет собой очень плотно упакованный бинарный файл, который просто так, как JSON не откроешь, но если его расшифровать, то он состоит из событий. Они делятся на несколько типов:
- Note Off,
- Note On,
- Poly Key Pressure,
- Controller Change,
- Program Change,
- Channel Pressure,
- Pitch Bend.
Нас интересует «Note On». Их мы будем поочередно задерживать до следующего таймстампа, а остальные события будем просто пропускать без задержки.
import time
import mido
timestamps = [
# Вставляем сюда список таймстампов
]
midi_file = mido.MidiFile('midis/tetris.mid')
print(mido.get_output_names()) # Выводим список Midi устройств
port = mido.open_output('IAC Driver Bus 1') # Вводим сюда имя нужного нам устройства
def play_midi_file(midi_file, timestamps):
current_time = 0
index = 0
for msg in midi_file.play():
print(msg)
if (msg.type == 'note_on'):
timestamp = timestamps[index]
time.sleep(timestamp - current_time)
current_time = timestamp
index += 1
port.send(msg) # Воспроизводим звук
play_midi_file(midi_file, timestamps)
Этот скрипт можно воспринимать, как будто мы подключили к компьютеру клавиатуру, и скрипт играет на ней выбранную нами песню в нужном нам темпе. Проблема в том, что если вы подключите физическую клавиатуру к компьютеру и начнете нажимать по клавишам, то вы ничего не услышите. Компьютеру неоткуда знать, что делать с этим вводом.
Чтобы услышать что-то при запуске скрипта, надо чтобы на компьютере был запущен виртуальный синтезатор. В моем случае это Garage Band.
Результат
Теперь можно взять запись экрана первой программы и совместить ее с записью звука в вашем любимом видеоредакторе. На выходе получаем такой довольно залипательный шортс/рилс/тикток: