nlve/src/uSubtitles.pas
4aiman 62ba75ff2f Сборка ролика с текстом: вжиганием или отдельной дорожкой
Окно сборки теперь спрашивает не только имя файла: куда собирать, в каком
контейнере и что делать с текстом — это один вопрос, ответ на любую часть
меняет допустимое в остальных.

При MP4 вжигание становится обязательным, и галка запирается. Причина
измерена, а не выдумана: отдельной дорожкой субтитров со всем оформлением
умеет только Matroska, в MP4 их кладут как mov_text, а он не хранит ни
цвета, ни обводки, ни положения. Переключение на MKV возвращает прежний
ответ человека — тот, что он давал до того, как MP4 решил за него.

Вжигание. Куски, которые задевает надпись, помечаются к пересборке, и
каждому подкладывается своя разметка: времена сдвинуты к началу куска и
обрезаны по его длине. Иначе никак — фильтр subtitles рисует по времени
того файла, который ему дали, а кусок начинается со своего нуля. Текст
дорисовывается последним, уже по кадру проекта: раньше он попал бы под
масштабирование и уехал бы вместе с картинкой.

Отдельная дорожка. Разметка вкладывается в готовый файл кодеком ass при
сшивании, ничего не перекодируется.

Предупреждение о пересборке теперь считается после подготовки текста:
раньше оно показывалось до неё и не знало о вжигаемых участках.

Проверено сборкой двух роликов из одного монтажа с надписью на 2..6
секунде. С вжиганием: кадры на 1 и 7 секундах чистые, на 3 и 5 — с
надписью, предупреждение назвало 8 секунд из 15.5. Отдельной дорожкой: в
файле три потока, третий ass с названием «Надписи», картинка чистая.

Своя ошибка, найденная по дороге: установка галки кодом вызывает тот же
обработчик, что и щелчок, и навязанное при MP4 значение записывалось как
осознанный выбор — при возврате на MKV галка оставалась стоять.

Окно хода сборки переименовано в «Ход сборки»: два окна с заголовком
«Сборка ролика» путали и меня, и человека.
2026-08-30 16:34:59 +03:00

532 lines
20 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
uSubtitles — текст поверх картинки.
Рисует не эта программа, а libass: он есть и внутри mpv, и внутри
ffmpeg, поэтому предпросмотр и готовый ролик показывают одно и то же.
Наше дело — собрать для него файл ASS.
Почему переносы считаем сами. libass умеет переносить, но тогда
неизвестно, сколько выйдет строк, и подобрать размер под отведённую
полосу нечем. Поэтому меряем и переносим здесь, а в файл отдаём готовые
переводы строк: что померили, то и нарисуется.
Чем меряем. Тем же шрифтом, что уйдёт в libass, через холст LCL — за
обоими стоит FreeType, и ширины сходятся. Небольшой запас всё равно
берём: настройки сглаживания у них разные.
Замер, на котором держится подбор размера (DejaVu Sans, кадр 1280x720):
шаг строк почти равен размеру шрифта, высота блока из N строк ≈
размер × (N − 0.16). В полосу 20% высоты влезает три строки размером до
50, четыре до 37, пять до 30.
}
unit uSubtitles;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, uAppTypes, uProject, uFlatten;
type
{ Как померить ширину строки. Возвращает ширину в точках кадра. }
TTextMeasure = function(const AText: string; const AStyle: TTextStyle;
ASize: Integer): Integer;
var
{ Замерщик ширины текста.
Подключается снаружи: точный замер требует холста, а холст —
работающего оконного набора. Служебной программе без экрана он
недоступен, и она обходится оценкой (см. EstimateWidth) — для
проверки самого файла ASS этого хватает.
Окно ставит сюда замер тем же шрифтом, каким libass будет рисовать. }
TextMeasure: TTextMeasure = nil;
{ Собирает ASS из всех текстовых клипов проекта.
Возвращает пустую строку, если текста нет вовсе — тогда и файл писать
незачем. }
function BuildAss(AProject: TProject): string;
{ Пишет ASS в файл. False — писать было нечего или не вышло. }
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
{ Есть ли в проекте хоть один текстовый клип. }
function HasText(AProject: TProject): Boolean;
{ Подбор размера и переносов — для окна редактора: оно показывает, во что
превратится надпись, ещё до сборки разметки. Тот же код, что и в самой
сборке, поэтому расхождения быть не может. }
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
{ Занят ли текстом этот отрезок монтажа. Нужно сборке: вжигать текст
приходится только в те куски, которые он покрывает. }
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
{ Разметка для одного куска монтажа: события сдвинуты к его началу и
обрезаны по его длине.
Нужно вжиганию. Фильтр subtitles рисует по времени того файла, который
ему дали, а кусок начинается со своего нуля — общая разметка легла бы
не туда. }
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
const APath: string): Boolean;
{ Готовит куски к вжиганию: тем, которые задевает текст, ставит признак
пересборки и подкладывает свою разметку.
Возвращает, сколько секунд придётся пересобрать сверх того, что и так
пересобиралось: человеку это говорят до начала. }
function BurnTextIntoSegments(AProject: TProject;
var ASegments: TFlatSegmentArray; const ACacheDir: string): Double;
implementation
uses
Math, LazUTF8;
const
{ Запас по ширине: наши мерки и мерки libass расходятся на проценты. }
WidthSafety = 0.95;
{ Поля от краёв кадра в долях ширины и высоты. }
MarginShare = 0.05;
{ Ниже этого размер не опускаем: дальше текст всё равно не прочесть,
и лучше показать его крупным с выходом за полосу, чем нечитаемым. }
MinFontSize = 14;
{ Время в записи ASS: часы:минуты:секунды.сотые. }
function AssTime(const ASeconds: Double): string;
var
T: Double;
H, M: Integer;
S: Double;
begin
T := Max(0, ASeconds);
H := Trunc(T) div 3600;
M := (Trunc(T) mod 3600) div 60;
S := T - H * 3600 - M * 60;
{ Дробную часть добиваем нулём вручную: %05.2f у Паскаля дополняет
пробелом, а ASS с пробелом внутри времени не читается. }
Result := Format('%d:%.2d:%.2d.%.2d',
[H, M, Trunc(S), Round(Frac(S) * 100)]);
end;
{ Цвет в записи ASS: &HAABBGGRR. Порядок обратный привычному, а первым
идёт не прозрачность, а её отсутствие — ноль значит «непрозрачно». }
function AssColor(AColor: TRgb; ATransparent: Boolean = False): string;
var
Alpha: Integer;
begin
if ATransparent then
Alpha := $FF
else
Alpha := 0;
Result := Format('&H%.2X%.2X%.2X%.2X',
[Alpha, (AColor shr 16) and $FF, (AColor shr 8) and $FF, AColor and $FF]);
end;
{ Внутри строки ASS перевод строки записывается как \N, а фигурные скобки
начинают команду — их надо обезвредить. }
function AssEscape(const AText: string): string;
begin
Result := StringReplace(AText, '\', '\\', [rfReplaceAll]);
Result := StringReplace(Result, '{', '(', [rfReplaceAll]);
Result := StringReplace(Result, '}', ')', [rfReplaceAll]);
Result := StringReplace(Result, LineEnding, '\N', [rfReplaceAll]);
Result := StringReplace(Result, #10, '\N', [rfReplaceAll]);
Result := StringReplace(Result, #13, '\N', [rfReplaceAll]);
end;
{ Оценка ширины, когда точного замера нет.
Множитель взят из замера: DejaVu Sans, размер 54, строка из двадцати
знаков кириллицы заняла 530 точек — это 0.49 размера на знак. Оценка
грубая, но для проверки самой разметки годится, а окно всегда ставит
точный замер. }
function EstimateWidth(const AText: string; ASize: Integer): Integer;
begin
Result := Round(UTF8Length(AText) * ASize * 0.49);
end;
function MeasureWidth(const AText: string; const AStyle: TTextStyle;
ASize: Integer): Integer;
begin
if Assigned(TextMeasure) then
Result := TextMeasure(AText, AStyle, ASize)
else
Result := EstimateWidth(AText, ASize);
end;
{ Перенос по словам под заданную ширину. Возвращает число строк, сами
строки складывает в ALines. }
function WrapText(const AText: string; const AStyle: TTextStyle;
ASize, AMaxWidth: Integer; ALines: TStringList): Integer;
var
Src: TStringList;
I: Integer;
Rest, Word, Line: string;
Space: Integer;
begin
ALines.Clear;
Src := TStringList.Create;
try
{ Свои переводы строк человек ставил не зря — они остаются. }
Src.Text := AText;
for I := 0 to Src.Count - 1 do
begin
Rest := Src[I];
if Trim(Rest) = '' then
begin
ALines.Add('');
Continue;
end;
Line := '';
while Rest <> '' do
begin
Space := Pos(' ', Rest);
if Space = 0 then
begin
Word := Rest;
Rest := '';
end
else
begin
Word := Copy(Rest, 1, Space - 1);
Rest := Copy(Rest, Space + 1, Length(Rest));
end;
if Line = '' then
Line := Word
else if MeasureWidth(Line + ' ' + Word, AStyle, ASize) <= AMaxWidth then
Line := Line + ' ' + Word
else
begin
ALines.Add(Line);
Line := Word;
end;
end;
if Line <> '' then
ALines.Add(Line);
end;
finally
Src.Free;
end;
Result := ALines.Count;
end;
{ Подбор размера и переносов под отведённую полосу.
Идём от заданного размера вниз: он же и верхняя граница. Уменьшаем,
пока блок не влезет по высоте — по ширине перенос справляется сам. }
function FitText(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
var
MaxWidth, MaxHeight, Size, BlockHeight: Integer;
Lo, Hi, Mid, Best, Count: Integer;
begin
MaxWidth := Round(AFrameWidth * (1 - MarginShare * 2) * WidthSafety);
MaxHeight := Round(AFrameHeight * AStyle.MaxHeight);
Size := AStyle.FontSize;
if Size < MinFontSize then
Size := MinFontSize;
{ Ищем наибольший размер, при котором блок влезает, делением отрезка
пополам.
Прикидка «уменьшить во столько же раз, во сколько не влезли» даёт
слишком мелкий шрифт: после переноса строк становится меньше, и
места остаётся вдоволь. Проверено на длинной надписи — вместо 29
помещался 40. Десятка кругов хватает на любой разумный размер. }
Lo := MinFontSize;
Hi := Size;
Best := MinFontSize;
while Lo <= Hi do
begin
Mid := (Lo + Hi) div 2;
Count := WrapText(AText, AStyle, Mid, MaxWidth, ALines);
if Count < 1 then
Count := 1;
{ Высота блока по замеру: шаг строк равен размеру, последняя строка
занимает чуть меньше. }
BlockHeight := Round(Mid * (Count - 0.16));
if BlockHeight <= MaxHeight then
begin
Best := Mid;
Lo := Mid + 1;
end
else
Hi := Mid - 1;
end;
{ Последний перенос должен соответствовать выбранному размеру: во время
поиска он мог остаться от неподошедшей примерки. }
WrapText(AText, AStyle, Best, MaxWidth, ALines);
Result := Best;
end;
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
begin
Result := FitText(AText, AStyle, AFrameWidth, AFrameHeight, ALines);
end;
function HasText(AProject: TProject): Boolean;
var
T, C: Integer;
begin
Result := False;
if AProject = nil then
Exit;
for T := 0 to AProject.TrackCount - 1 do
begin
if AProject.Tracks[T].Kind <> tkText then
Continue;
if not AProject.TrackAudible(AProject.Tracks[T]) then
Continue;
for C := 0 to AProject.Tracks[T].Count - 1 do
if Trim(AProject.Tracks[T][C].Text) <> '' then
Exit(True);
end;
end;
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
var
T, C: Integer;
Clip: TClip;
begin
Result := False;
if AProject = nil then
Exit;
for T := 0 to AProject.TrackCount - 1 do
begin
if AProject.Tracks[T].Kind <> tkText then
Continue;
if not AProject.TrackAudible(AProject.Tracks[T]) then
Continue;
for C := 0 to AProject.Tracks[T].Count - 1 do
begin
Clip := AProject.Tracks[T][C];
if Trim(Clip.Text) = '' then
Continue;
{ Пересечение отрезков, а не попадание точки: кусок может быть
длиннее надписи и наоборот. }
if (Clip.TlStart < ATo - 0.0005) and (Clip.TlEnd > AFrom + 0.0005) then
Exit(True);
end;
end;
end;
{ Общая сборка. AFrom и ATo ограничивают выборку; ATo меньше нуля значит
«до конца монтажа». При ненулевом AFrom времена сдвигаются к нулю — так
разметка годится для отдельного куска. }
function BuildAssRange(AProject: TProject; const AFrom, ATo: Double): string;
var
Lines: TStringList;
Styles, Events: TStringList;
T, C, I, Size: Integer;
Clip: TClip;
Track: TTrack;
Name, Body: string;
W, H, MarginH, MarginV: Integer;
StartAt, EndAt: Double;
begin
Result := '';
if (AProject = nil) or (not HasText(AProject)) then
Exit;
W := AProject.Master.Width;
H := AProject.Master.Height;
if (W < 2) or (H < 2) then
begin
W := 1920;
H := 1080;
end;
MarginH := Round(W * MarginShare);
MarginV := Round(H * MarginShare);
Lines := TStringList.Create;
Styles := TStringList.Create;
Events := TStringList.Create;
try
for T := 0 to AProject.TrackCount - 1 do
begin
Track := AProject.Tracks[T];
if Track.Kind <> tkText then
Continue;
if not AProject.TrackAudible(Track) then
Continue;
for C := 0 to Track.Count - 1 do
begin
Clip := Track[C];
if Trim(Clip.Text) = '' then
Continue;
{ Надписи, не попадающие в нужный отрезок, разбирать незачем. }
if (ATo >= 0) and (Clip.TlStart >= ATo - 0.0005) then
Continue;
if Clip.TlEnd <= AFrom + 0.0005 then
Continue;
Size := FitText(Clip.Text, Clip.TextStyle, W, H, Lines);
{ У каждого клипа своё оформление, поэтому и свой стиль. Имя
выводим из номера дорожки и клипа — оно должно быть
неповторимым и не содержать запятых. }
Name := Format('s%d_%d', [T, C]);
Styles.Add(Format(
'Style: %s,%s,%d,%s,%s,%s,%s,%d,%d,0,0,100,100,0,0,1,%d,%d,%d,' +
'%d,%d,%d,1',
[Name, Clip.TextStyle.FontName, Size,
AssColor(Clip.TextStyle.Color),
AssColor(Clip.TextStyle.Color),
AssColor(Clip.TextStyle.OutlineColor),
AssColor(Clip.TextStyle.ShadowColor,
Clip.TextStyle.Shadow <= 0),
Ord(Clip.TextStyle.Bold) * -1, Ord(Clip.TextStyle.Italic) * -1,
Clip.TextStyle.Outline, Clip.TextStyle.Shadow,
Ord(Clip.TextStyle.Place),
MarginH, MarginH, MarginV]));
Body := '';
for I := 0 to Lines.Count - 1 do
begin
if Body <> '' then
Body := Body + '\N';
Body := Body + AssEscape(Lines[I]);
end;
{ Обрезаем по границам куска и сдвигаем к его началу. }
StartAt := Max(Clip.TlStart, AFrom) - AFrom;
EndAt := Clip.TlEnd - AFrom;
if ATo >= 0 then
EndAt := Min(EndAt, ATo - AFrom);
if EndAt <= StartAt then
Continue;
Events.Add(Format('Dialogue: 0,%s,%s,%s,,0,0,0,,%s',
[AssTime(StartAt), AssTime(EndAt), Name, Body]));
end;
end;
if Events.Count = 0 then
Exit;
Result :=
'[Script Info]' + LineEnding +
'ScriptType: v4.00+' + LineEnding +
Format('PlayResX: %d', [W]) + LineEnding +
Format('PlayResY: %d', [H]) + LineEnding +
{ Переносы мы уже расставили, libass переносить нечего. }
'WrapStyle: 2' + LineEnding +
'ScaledBorderAndShadow: yes' + LineEnding +
'YCbCr Matrix: None' + LineEnding +
LineEnding +
'[V4+ Styles]' + LineEnding +
'Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,' +
'OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,' +
'ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,' +
'MarginL,MarginR,MarginV,Encoding' + LineEnding +
Styles.Text +
LineEnding +
'[Events]' + LineEnding +
'Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,' +
'Effect,Text' + LineEnding +
Events.Text;
finally
Events.Free;
Styles.Free;
Lines.Free;
end;
end;
function BuildAss(AProject: TProject): string;
begin
Result := BuildAssRange(AProject, 0, -1);
end;
function WriteText(const AText, APath: string): Boolean;
var
Stream: TFileStream;
begin
Result := False;
try
ForceDirectories(ExtractFilePath(APath));
Stream := TFileStream.Create(APath, fmCreate);
try
if Length(AText) > 0 then
Stream.WriteBuffer(AText[1], Length(AText));
finally
Stream.Free;
end;
Result := True;
except
Result := False;
end;
end;
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
const APath: string): Boolean;
var
Text: string;
begin
Text := BuildAssRange(AProject, AFrom, ATo);
Result := (Text <> '') and WriteText(Text, APath);
end;
function BurnTextIntoSegments(AProject: TProject;
var ASegments: TFlatSegmentArray; const ACacheDir: string): Double;
var
I: Integer;
At, SegEnd: Double;
Path: string;
begin
Result := 0;
if (AProject = nil) or (not HasText(AProject)) then
Exit;
At := 0;
for I := 0 to High(ASegments) do
begin
SegEnd := At + ASegments[I].Duration;
if TextCovers(AProject, At, SegEnd) then
begin
Path := IncludeTrailingPathDelimiter(ACacheDir) +
Format('burn_%.3d.ass', [I]);
if WriteAssForRange(AProject, At, SegEnd, Path) then
begin
{ Кусок, который и так пересобирался, дороже не становится —
считаем только те, что раньше копировались. }
if not (ASegments[I].Transcode or (Length(ASegments[I].Layers) > 0)) then
Result := Result + ASegments[I].Duration;
ASegments[I].SubFile := Path;
ASegments[I].Transcode := True;
end;
end;
At := SegEnd;
end;
end;
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
var
Text: string;
begin
Result := False;
Text := BuildAss(AProject);
if Text = '' then
begin
{ Текста не стало — старый файл убираем, иначе плеер покажет то,
чего на монтаже уже нет. }
if FileExists(APath) then
SysUtils.DeleteFile(APath);
Exit;
end;
Result := WriteText(Text, APath);
end;
end.