nlve/src/uProject.pas
chaiman 3acf79d174 Этап 5: прозрачность, переходы и затухания
Ползунок на клипе картинки теперь задаёт непрозрачность, на звуковом —
громкость. Уплощение выдаёт для таких мест не одного победителя, а слои.

Слои появляются в трёх случаях: клип полупрозрачен и сквозь него виден
нижний; два клипа на одной дорожке наползли друг на друга и должны
перетечь один в другой; у клипа задано плавное появление или уход.

Перекрытие на разных дорожках переходом не считается — там по-прежнему
побеждает верхний. Иначе в проекте, где дорожки перекрываются постоянно,
каждое такое место пришлось бы пересобирать.

Картинка собирается фильтрами ffmpeg: постоянная доля через overlay,
переход через xfade, затухание через fade. Звук сводится прямо в
отсчётах — доля каждого слоя считается на каждый отсчёт, и длина
результата получается ровно такой, какая нужна.

Перед сборкой программа сообщает, сколько секунд из скольких придётся
пересобрать, и даёт отказаться.

Проверено замерами:
- красный с непрозрачностью 50% поверх синего дал (127, 0, 126);
- переход на одной дорожке: синий плавно перешёл в красный за секунду;
- звуковой переход: -24 dB, -27.5, -35, -51, тишина;
- появление за 2 секунды: пересобрано 2 секунды из 6, остальное
  скопировано.

Найдено по дороге: участок с наложением склеивался с соседним куском и
терял слои — в условии склейки они не учитывались.
2026-08-20 10:11:20 +03:00

1405 lines
44 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.

{
uProject — модель монтажа: список файлов, дорожки, клипы.
Порядок дорожек и есть приоритет: дорожка с индексом 0 самая верхняя,
она перекрывает все нижние целиком — и картинкой, и звуком. Никакого
смешивания не предусмотрено, это осознанное ограничение ради того,
чтобы результат собирался копированием потоков.
Клип хранит, откуда берётся материал (SrcIn) и где он стоит на монтаже
(TlStart), а длительность одна на оба конца. Отдельно помечено, как
сделан каждый край: притянут к опорному кадру или должен быть точным.
Отмена сделана снимками: перед каждым изменением состояние сохраняется
целиком в виде JSON. Расточительно по памяти, но полностью надёжно —
нет ни одного изменения, которое можно случайно забыть откатить. При
тысячах клипов снимок весит сотни килобайт, что несопоставимо с
весом самого видео.
}
unit uProject;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, contnrs, fpjson, uAppTypes, uMediaProbe, uKeyframeIndex,
uTransform;
type
{ Как сделан край клипа.
edSnap — рез притянут к опорному кадру, перекодирования нет.
edPrecise — рез там, где просили; при сборке огрызок до опорного
кадра придётся перекодировать. }
TEdgeMode = (edSnap, edPrecise);
{ ckAudio — звуковой клип: картинку из него не берут.
Отдельный вид, а не признак дорожки, потому что решение принимается по
клипу: сборка спрашивает у куска, что из него доставать. }
TClipKind = (ckVideo, ckImage, ckBlack, ckAudio);
{ На какие дорожки действует схлопывание.
rmCurrentTrack — двигается только та дорожка, где идёт правка.
rmAllTracks — двигаются все, чтобы дорожки не разъехались друг
относительно друга. }
TRippleMode = (rmCurrentTrack, rmAllTracks);
{ Файл, добавленный в проект. }
TMediaItem = class
private
FId: string;
FPath: string;
FInfo: TMediaInfo;
FNormalizedPath: string;
FIndex: TKeyframeIndex;
FIndexLoaded: Boolean;
FTransform: TMediaTransform;
public
constructor Create;
{ Откуда реально брать материал: из нормализованной копии, если она
сделана, иначе из исходника. }
function EffectivePath: string;
property Id: string read FId write FId;
property Path: string read FPath write FPath;
property Info: TMediaInfo read FInfo write FInfo;
property NormalizedPath: string read FNormalizedPath write FNormalizedPath;
property Index: TKeyframeIndex read FIndex write FIndex;
property IndexLoaded: Boolean read FIndexLoaded write FIndexLoaded;
{ Как кадр этого файла ложится в кадр проекта. }
property Transform: TMediaTransform read FTransform write FTransform;
end;
TClip = class
private
FId: string;
FMediaId: string;
FSrcIn: Double;
FTlStart: Double;
FDuration: Double;
FEdgeIn: TEdgeMode;
FEdgeOut: TEdgeMode;
FKind: TClipKind;
FSelected: Boolean;
FVolume: Double;
FTransform: TMediaTransform;
FGroupId: string;
FOpacity: Double;
FFadeIn: Double;
FFadeOut: Double;
public
constructor Create;
function TlEnd: Double;
function SrcOut: Double;
{ Попадает ли момент внутрь клипа. Правый край не включается: там уже
начинается следующий клип. }
function Covers(const ATime: Double): Boolean;
property Id: string read FId write FId;
property MediaId: string read FMediaId write FMediaId;
property SrcIn: Double read FSrcIn write FSrcIn;
property TlStart: Double read FTlStart write FTlStart;
property Duration: Double read FDuration write FDuration;
property EdgeIn: TEdgeMode read FEdgeIn write FEdgeIn;
property EdgeOut: TEdgeMode read FEdgeOut write FEdgeOut;
property Kind: TClipKind read FKind write FKind;
{ Громкость как множитель: 1 — как в исходнике, 0 — тишина, 2 — вдвое
громче. Хранится множителем, а не в децибелах, чтобы не переводить
туда-сюда при каждой отрисовке; в интерфейсе показывается процентами. }
property Volume: Double read FVolume write FVolume;
{ Как кадр этого клипа ложится в кадр проекта.
Настройка принадлежит клипу, а не файлу: два куска одного файла
бывает нужно кадрировать по-разному. У файла остаётся такая же
настройка, но она лишь заготовка — её берёт новый клип при
добавлении. }
property Transform: TMediaTransform read FTransform write FTransform;
{ Непрозрачность клипа картинки: 1 — обычный, 0.5 — сквозь него видно
то, что ниже.
Стоит понимать цену: как только клип перестаёт быть непрозрачным,
его нельзя скопировать — участок приходится пересобирать вместе со
всем, что под ним. Поэтому по умолчанию единица, и программа
предупреждает перед сборкой, сколько секунд это займёт. }
property Opacity: Double read FOpacity write FOpacity;
{ Плавное появление и уход, в секундах от краёв клипа. Для картинки
это затемнение, для звука — нарастание громкости. }
property FadeIn: Double read FFadeIn write FFadeIn;
property FadeOut: Double read FFadeOut write FFadeOut;
{ Связка. Клипы с одинаковым непустым значением двигаются, режутся и
удаляются вместе. По умолчанию так связаны картинка и её звук;
Ctrl+G связывает и развязывает что угодно. }
property GroupId: string read FGroupId write FGroupId;
{ Выделение живёт только в памяти и в файл проекта не пишется. }
property Selected: Boolean read FSelected write FSelected;
end;
{ Что живёт на дорожке. Картинка и звук разделены, потому что их можно
двигать порознь: звук из одного места монтажа под картинкой из
другого — обычное дело. }
TTrackKind = (tkVideo, tkAudio);
TTrack = class
private
FId: string;
FName: string;
FKind: TTrackKind;
FMuted: Boolean;
FSolo: Boolean;
FClips: TObjectList;
function GetClip(AIndex: Integer): TClip;
function GetCount: Integer;
public
constructor Create;
destructor Destroy; override;
procedure Add(AClip: TClip);
procedure Remove(AClip: TClip);
procedure Extract(AClip: TClip);
procedure Sort;
function ClipAt(const ATime: Double): TClip;
function IndexOf(AClip: TClip): Integer;
function EndTime: Double;
property Clips[AIndex: Integer]: TClip read GetClip; default;
property Count: Integer read GetCount;
property Id: string read FId write FId;
property Name: string read FName write FName;
property Kind: TTrackKind read FKind write FKind;
property Muted: Boolean read FMuted write FMuted;
{ Когда хоть у одной дорожки включено «соло», в результат идут только
такие дорожки. Удобно, чтобы послушать одну, не выключая остальные
по очереди. }
property Solo: Boolean read FSolo write FSolo;
end;
TProject = class
private
FPool: TObjectList;
FTracks: TObjectList;
FMaster: TMediaInfo;
FRippleMode: TRippleMode;
FRippleEnabled: Boolean;
FFileName: string;
FModified: Boolean;
FNextId: Integer;
FUndo: TStringList;
FRedo: TStringList;
function GetPoolItem(AIndex: Integer): TMediaItem;
function GetPoolCount: Integer;
function GetTrack(AIndex: Integer): TTrack;
function GetTrackCount: Integer;
function NewId(const APrefix: string): string;
function Serialize: string;
procedure Deserialize(const AText: string);
public
constructor Create;
destructor Destroy; override;
procedure Clear;
procedure NewProject;
function AddMedia(const APath: string; const AInfo: TMediaInfo): TMediaItem;
function FindMedia(const AId: string): TMediaItem;
function FindMediaByPath(const APath: string): TMediaItem;
procedure RemoveMedia(AItem: TMediaItem);
{ Номер новой связки. Отдельным именем, чтобы не пришлось помнить
префикс в каждом месте, где связка заводится. }
function NewGroupId: string;
function AddTrack(const AName: string): TTrack;
function AddTrackOfKind(const AName: string; AKind: TTrackKind): TTrack;
function FindTrack(const AId: string): TTrack;
procedure RemoveTrack(AIndex: Integer);
procedure MoveTrack(AFrom, ATo: Integer);
{ Сколько дорожек каждого вида и как найти парную.
Пара определяется порядковым номером: у первой дорожки картинки
первая дорожка звука. Так связка «картинка плюс её звук» ложится
напротив друг друга, и монтаж читается взглядом. }
function TrackCountOf(AKind: TTrackKind): Integer;
function TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
function IndexAmongKind(ATrack: TTrack): Integer;
{ Заводит недостающие звуковые дорожки, чтобы у каждой дорожки
картинки была парная. }
procedure EnsureAudioTracks;
{ Перенос проектов, записанных до разделения звука и картинки: там
звук шёл вместе с клипом картинки, и без пары он бы просто пропал. }
procedure MigrateAudio;
{ Кладёт клип и, если в файле есть звук, парный звуковой клип на
соответствующую звуковую дорожку. Возвращает клип картинки. }
function AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
function AddClip(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
function FindClipTrack(AClip: TClip): TTrack;
procedure DeleteClip(AClip: TClip);
procedure ClearSelection;
function SelectedCount: Integer;
{ Общая длина монтажа — до конца самого правого клипа. }
function EndTime: Double;
{ Есть ли хоть одна дорожка в режиме «соло». }
function AnySolo: Boolean;
{ Участвует ли дорожка в результате с учётом выключения и «соло». }
function TrackAudible(ATrack: TTrack): Boolean;
{ Параметры проекта берутся у первого добавленного видеофайла. }
procedure AdoptMasterFrom(const AInfo: TMediaInfo);
function MasterSet: Boolean;
{ Каталог для нарезки, сгенерированных клипов и кэша индексов. }
function CacheDir: string;
{ Индекс опорных кадров файла, с запоминанием в памяти и на диске. }
function KeyframeIndexOf(AItem: TMediaItem; out AIndex: TKeyframeIndex;
out AError: string): Boolean;
{ --- Отмена --- }
{ Вызывать ПЕРЕД изменением. }
procedure BeginChange;
{ Отменить заявку на изменение, если оно так и не состоялось.
Нужно там, где намерение изменить возникает раньше самого изменения:
нажали кнопку мыши на клипе, но отпустили не сдвинув. Без этого
каждое такое нажатие оставляло бы в истории пустой шаг, и «отменить»
приходилось бы жать по многу раз впустую. }
procedure CancelChange;
procedure Undo;
procedure Redo;
function CanUndo: Boolean;
function CanRedo: Boolean;
{ --- Файл проекта --- }
function SaveToFile(const APath: string; out AError: string): Boolean;
function LoadFromFile(const APath: string; out AError: string): Boolean;
property Pool[AIndex: Integer]: TMediaItem read GetPoolItem;
property PoolCount: Integer read GetPoolCount;
property Tracks[AIndex: Integer]: TTrack read GetTrack;
property TrackCount: Integer read GetTrackCount;
property Master: TMediaInfo read FMaster write FMaster;
{ Режим действует всегда, даже когда автосхлопывание выключено:
он определяет, что сделает Ctrl+Shift+R. }
property RippleMode: TRippleMode read FRippleMode write FRippleMode;
{ Автоматическое схлопывание при перетаскивании клипов. }
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
property FileName: string read FFileName write FFileName;
property Modified: Boolean read FModified write FModified;
end;
const
{ Сколько длится картинка, пока её не растянули. }
DefaultImageDuration = 5.0;
implementation
uses
jsonparser, jsonscanner, Math;
const
ProjectFormatVersion = 1;
MaxUndoDepth = 100;
{ --- TMediaItem --- }
constructor TMediaItem.Create;
begin
inherited Create;
FTransform := DefaultTransform;
end;
function TMediaItem.EffectivePath: string;
begin
if FNormalizedPath <> '' then
Result := FNormalizedPath
else
Result := FPath;
end;
{ --- TClip --- }
constructor TClip.Create;
begin
inherited Create;
{ Без явного значения множитель был бы нулём, то есть тишиной. }
FVolume := 1;
FOpacity := 1;
FTransform := DefaultTransform;
end;
function TClip.TlEnd: Double;
begin
Result := FTlStart + FDuration;
end;
function TClip.SrcOut: Double;
begin
Result := FSrcIn + FDuration;
end;
function TClip.Covers(const ATime: Double): Boolean;
begin
Result := (ATime >= FTlStart) and (ATime < TlEnd);
end;
{ --- TTrack --- }
constructor TTrack.Create;
begin
inherited Create;
FClips := TObjectList.Create(True);
end;
destructor TTrack.Destroy;
begin
FClips.Free;
inherited Destroy;
end;
function TTrack.GetClip(AIndex: Integer): TClip;
begin
Result := TClip(FClips[AIndex]);
end;
function TTrack.GetCount: Integer;
begin
Result := FClips.Count;
end;
procedure TTrack.Add(AClip: TClip);
begin
FClips.Add(AClip);
Sort;
end;
procedure TTrack.Remove(AClip: TClip);
begin
FClips.Remove(AClip);
end;
procedure TTrack.Extract(AClip: TClip);
begin
{ Именно Extract, а не Remove: список владеет объектами и Remove их
уничтожает, а при переносе клипа на другую дорожку он нужен живым. }
FClips.Extract(AClip);
end;
function CompareClips(Item1, Item2: Pointer): Integer;
var
A, B: TClip;
begin
A := TClip(Item1);
B := TClip(Item2);
if A.TlStart < B.TlStart then
Result := -1
else if A.TlStart > B.TlStart then
Result := 1
else
Result := 0;
end;
procedure TTrack.Sort;
begin
FClips.Sort(@CompareClips);
end;
function TTrack.ClipAt(const ATime: Double): TClip;
var
I: Integer;
begin
{ Клипы отсортированы и не перекрываются в пределах дорожки, так что
достаточно линейного прохода. Дорожка редко содержит столько клипов,
чтобы двоичный поиск дал заметную выгоду. }
for I := 0 to FClips.Count - 1 do
if TClip(FClips[I]).Covers(ATime) then
Exit(TClip(FClips[I]));
Result := nil;
end;
function TTrack.IndexOf(AClip: TClip): Integer;
begin
Result := FClips.IndexOf(AClip);
end;
function TTrack.EndTime: Double;
var
I: Integer;
begin
Result := 0;
for I := 0 to FClips.Count - 1 do
Result := Max(Result, TClip(FClips[I]).TlEnd);
end;
{ --- TProject --- }
constructor TProject.Create;
begin
inherited Create;
FPool := TObjectList.Create(True);
FTracks := TObjectList.Create(True);
FUndo := TStringList.Create;
FRedo := TStringList.Create;
FNextId := 1;
end;
destructor TProject.Destroy;
begin
FRedo.Free;
FUndo.Free;
FTracks.Free;
FPool.Free;
inherited Destroy;
end;
procedure TProject.Clear;
begin
FPool.Clear;
FTracks.Clear;
FUndo.Clear;
FRedo.Clear;
{ Именно Default, а не FillChar: в записи есть строки, и обнуление
их указателей побайтно оставило бы память занятой навсегда. }
FMaster := Default(TMediaInfo);
FRippleMode := rmCurrentTrack;
FRippleEnabled := False;
FFileName := '';
FModified := False;
FNextId := 1;
end;
procedure TProject.NewProject;
begin
Clear;
{ Две дорожки с самого начала: одна пустая дорожка не показывает главного —
что верхняя перекрывает нижнюю. }
{ Две дорожки картинки и две звука. Звук снизу: так принято везде, и
так он не мешает смотреть на картинку. }
AddTrack('V1');
AddTrack('V2');
AddTrackOfKind('A1', tkAudio);
AddTrackOfKind('A2', tkAudio);
FModified := False;
end;
function TProject.GetPoolItem(AIndex: Integer): TMediaItem;
begin
Result := TMediaItem(FPool[AIndex]);
end;
function TProject.GetPoolCount: Integer;
begin
Result := FPool.Count;
end;
function TProject.GetTrack(AIndex: Integer): TTrack;
begin
Result := TTrack(FTracks[AIndex]);
end;
function TProject.GetTrackCount: Integer;
begin
Result := FTracks.Count;
end;
function TProject.NewGroupId: string;
begin
Result := NewId('g');
end;
function TProject.NewId(const APrefix: string): string;
begin
Result := APrefix + IntToStr(FNextId);
Inc(FNextId);
end;
function TProject.AddMedia(const APath: string; const AInfo: TMediaInfo): TMediaItem;
begin
Result := TMediaItem.Create;
Result.Id := NewId('m');
Result.Path := APath;
Result.Info := AInfo;
FPool.Add(Result);
FModified := True;
{ Первый добавленный видеофайл задаёт параметры проекта: с ними будут
сравниваться все следующие и в них будут генерироваться картинки. }
if (not MasterSet) and (AInfo.Kind = mkVideo) then
AdoptMasterFrom(AInfo);
end;
function TProject.FindMedia(const AId: string): TMediaItem;
var
I: Integer;
begin
for I := 0 to FPool.Count - 1 do
if TMediaItem(FPool[I]).Id = AId then
Exit(TMediaItem(FPool[I]));
Result := nil;
end;
function TProject.FindMediaByPath(const APath: string): TMediaItem;
var
I: Integer;
begin
for I := 0 to FPool.Count - 1 do
if TMediaItem(FPool[I]).Path = APath then
Exit(TMediaItem(FPool[I]));
Result := nil;
end;
procedure TProject.RemoveMedia(AItem: TMediaItem);
var
T, C: Integer;
Track: TTrack;
begin
if AItem = nil then
Exit;
{ Клипы, которые ссылались на файл, теряют смысл вместе с ним. }
for T := 0 to FTracks.Count - 1 do
begin
Track := TTrack(FTracks[T]);
for C := Track.Count - 1 downto 0 do
if Track[C].MediaId = AItem.Id then
Track.Remove(Track[C]);
end;
FPool.Remove(AItem);
FModified := True;
end;
function TProject.AddTrack(const AName: string): TTrack;
begin
Result := AddTrackOfKind(AName, tkVideo);
end;
function TProject.AddTrackOfKind(const AName: string;
AKind: TTrackKind): TTrack;
var
I, Pos: Integer;
begin
Result := TTrack.Create;
Result.Id := NewId('t');
Result.Name := AName;
Result.Kind := AKind;
{ Порядок дорожек не только украшение: он задаёт, кто побеждает при
перекрытии. Картинка всегда выше звука, поэтому новая дорожка
картинки встаёт перед первой звуковой, а не в самый конец. }
if AKind = tkVideo then
begin
Pos := FTracks.Count;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = tkAudio then
begin
Pos := I;
Break;
end;
FTracks.Insert(Pos, Result);
end
else
FTracks.Add(Result);
FModified := True;
end;
function TProject.FindTrack(const AId: string): TTrack;
var
I: Integer;
begin
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Id = AId then
Exit(TTrack(FTracks[I]));
Result := nil;
end;
procedure TProject.RemoveTrack(AIndex: Integer);
begin
if (AIndex < 0) or (AIndex >= FTracks.Count) then
Exit;
FTracks.Delete(AIndex);
FModified := True;
end;
procedure TProject.MoveTrack(AFrom, ATo: Integer);
begin
if (AFrom < 0) or (AFrom >= FTracks.Count) then
Exit;
if (ATo < 0) or (ATo >= FTracks.Count) then
Exit;
FTracks.Move(AFrom, ATo);
FModified := True;
end;
function TProject.TrackCountOf(AKind: TTrackKind): Integer;
var
I: Integer;
begin
Result := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = AKind then
Inc(Result);
end;
function TProject.TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
var
I, N: Integer;
begin
Result := nil;
N := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = AKind then
begin
if N = AIndex then
Exit(TTrack(FTracks[I]));
Inc(N);
end;
end;
function TProject.IndexAmongKind(ATrack: TTrack): Integer;
var
I, N: Integer;
begin
Result := -1;
N := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = ATrack.Kind then
begin
if TTrack(FTracks[I]) = ATrack then
Exit(N);
Inc(N);
end;
end;
procedure TProject.MigrateAudio;
var
T, C: Integer;
Track, Dst: TTrack;
Clip, Snd: TClip;
Item: TMediaItem;
Group: string;
Sources: TFPList;
begin
{ Если звуковые дорожки уже есть, проект новый и переносить нечего. }
if TrackCountOf(tkAudio) > 0 then
Exit;
if TrackCountOf(tkVideo) = 0 then
Exit;
EnsureAudioTracks;
{ Клипы собираются заранее: добавление в дорожку меняет её список, и
перебирать его на ходу нельзя. }
Sources := TFPList.Create;
try
for T := 0 to FTracks.Count - 1 do
begin
Track := TTrack(FTracks[T]);
if Track.Kind <> tkVideo then
Continue;
for C := 0 to Track.Count - 1 do
Sources.Add(Track[C]);
end;
for C := 0 to Sources.Count - 1 do
begin
Clip := TClip(Sources[C]);
if Clip.Kind = ckBlack then
Continue;
Item := FindMedia(Clip.MediaId);
if (Item = nil) or (not Item.Info.HasAudio) then
Continue;
Dst := nil;
for T := 0 to FTracks.Count - 1 do
if TTrack(FTracks[T]).IndexOf(Clip) >= 0 then
begin
Dst := TrackOfKind(tkAudio, IndexAmongKind(TTrack(FTracks[T])));
Break;
end;
if Dst = nil then
Continue;
Group := NewId('g');
Clip.GroupId := Group;
Snd := AddClip(Dst, Item, Clip.SrcIn, Clip.Duration, Clip.TlStart);
Snd.GroupId := Group;
Snd.Kind := ckAudio;
Snd.Volume := Clip.Volume;
end;
finally
Sources.Free;
end;
for T := 0 to FTracks.Count - 1 do
TTrack(FTracks[T]).Sort;
end;
procedure TProject.EnsureAudioTracks;
var
Need, Have, I: Integer;
Track: TTrack;
begin
Need := TrackCountOf(tkVideo);
Have := TrackCountOf(tkAudio);
for I := Have to Need - 1 do
begin
Track := AddTrackOfKind('A' + IntToStr(I + 1), tkAudio);
end;
end;
function TProject.AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
var
ATrackAudio: TTrack;
Snd: TClip;
Group: string;
begin
Result := AddClip(ATrack, AMedia, ASrcIn, ADuration, ATlStart);
if (ATrack.Kind <> tkVideo) or (not AMedia.Info.HasAudio) then
Exit;
EnsureAudioTracks;
ATrackAudio := TrackOfKind(tkAudio, IndexAmongKind(ATrack));
if ATrackAudio = nil then
Exit;
Group := NewId('g');
Result.GroupId := Group;
Snd := AddClip(ATrackAudio, AMedia, ASrcIn, ADuration, ATlStart);
Snd.GroupId := Group;
{ Клип звука знает, что он звук: иначе сборка попыталась бы взять из
него картинку. }
Snd.Kind := ckAudio;
Snd.Transform := DefaultTransform;
end;
function TProject.AddClip(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
begin
Result := TClip.Create;
Result.Id := NewId('c');
Result.MediaId := AMedia.Id;
Result.SrcIn := ASrcIn;
Result.Duration := ADuration;
Result.TlStart := ATlStart;
Result.EdgeIn := edSnap;
Result.EdgeOut := edSnap;
if AMedia.Info.Kind = mkImage then
Result.Kind := ckImage
else
Result.Kind := ckVideo;
{ Настройка файла — заготовка: новый клип берёт её при добавлении и
дальше живёт своей. }
Result.Transform := AMedia.Transform;
ATrack.Add(Result);
FModified := True;
end;
function TProject.FindClipTrack(AClip: TClip): TTrack;
var
I: Integer;
begin
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).IndexOf(AClip) >= 0 then
Exit(TTrack(FTracks[I]));
Result := nil;
end;
procedure TProject.DeleteClip(AClip: TClip);
var
Track: TTrack;
begin
Track := FindClipTrack(AClip);
if Track <> nil then
begin
Track.Remove(AClip);
FModified := True;
end;
end;
procedure TProject.ClearSelection;
var
T, C: Integer;
begin
for T := 0 to FTracks.Count - 1 do
for C := 0 to TTrack(FTracks[T]).Count - 1 do
TTrack(FTracks[T])[C].Selected := False;
end;
function TProject.SelectedCount: Integer;
var
T, C: Integer;
begin
Result := 0;
for T := 0 to FTracks.Count - 1 do
for C := 0 to TTrack(FTracks[T]).Count - 1 do
if TTrack(FTracks[T])[C].Selected then
Inc(Result);
end;
function TProject.EndTime: Double;
var
I: Integer;
begin
Result := 0;
for I := 0 to FTracks.Count - 1 do
Result := Max(Result, TTrack(FTracks[I]).EndTime);
end;
function TProject.AnySolo: Boolean;
var
I: Integer;
begin
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Solo then
Exit(True);
Result := False;
end;
function TProject.TrackAudible(ATrack: TTrack): Boolean;
begin
Result := False;
if ATrack = nil then
Exit;
if ATrack.Muted then
Exit;
{ «Соло» сильнее выключения: раз его включили, всё остальное молчит. }
if AnySolo then
Exit(ATrack.Solo);
Result := True;
end;
procedure TProject.AdoptMasterFrom(const AInfo: TMediaInfo);
begin
FMaster := AInfo;
{ Длительность конкретного файла к параметрам проекта отношения не имеет. }
FMaster.Duration := 0;
FModified := True;
end;
function TProject.MasterSet: Boolean;
begin
Result := FMaster.Valid;
end;
function TProject.CacheDir: string;
begin
if FFileName = '' then
{ Проект ещё не сохранён — держим кэш рядом с программой, чтобы не
разбрасывать файлы по системным каталогам. }
Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) +
'unsaved.nlve-cache' + PathDelim
else
Result := IncludeTrailingPathDelimiter(ExtractFilePath(FFileName)) +
ChangeFileExt(ExtractFileName(FFileName), '') + '.nlve-cache' + PathDelim;
end;
function TProject.KeyframeIndexOf(AItem: TMediaItem; out AIndex: TKeyframeIndex;
out AError: string): Boolean;
begin
AError := '';
if AItem = nil then
begin
AError := 'Файл не задан.';
Exit(False);
end;
{ Запоминание в памяти: за один экспорт индекс спрашивают столько раз,
сколько кусков взято из файла. }
if AItem.IndexLoaded then
begin
AIndex := AItem.Index;
Exit(True);
end;
Result := CachedKeyframeIndex(CacheDir, AItem.EffectivePath, AIndex, AError);
if Result then
begin
AItem.Index := AIndex;
AItem.IndexLoaded := True;
end;
end;
{ --- Отмена --- }
procedure TProject.BeginChange;
begin
FUndo.Add(Serialize);
while FUndo.Count > MaxUndoDepth do
FUndo.Delete(0);
{ Новое действие обрывает ветку повторов: возвращаться уже некуда. }
FRedo.Clear;
FModified := True;
end;
procedure TProject.CancelChange;
begin
if FUndo.Count = 0 then
Exit;
{ Просто выбрасываем последний снимок. Именно выбрасываем, а не
применяем: состояние и так не менялось, а применение засорило бы
историю повторов. }
FUndo.Delete(FUndo.Count - 1);
end;
function TProject.CanUndo: Boolean;
begin
Result := FUndo.Count > 0;
end;
function TProject.CanRedo: Boolean;
begin
Result := FRedo.Count > 0;
end;
procedure TProject.Undo;
var
Snapshot: string;
begin
if FUndo.Count = 0 then
Exit;
FRedo.Add(Serialize);
Snapshot := FUndo[FUndo.Count - 1];
FUndo.Delete(FUndo.Count - 1);
Deserialize(Snapshot);
FModified := True;
end;
procedure TProject.Redo;
var
Snapshot: string;
begin
if FRedo.Count = 0 then
Exit;
FUndo.Add(Serialize);
Snapshot := FRedo[FRedo.Count - 1];
FRedo.Delete(FRedo.Count - 1);
Deserialize(Snapshot);
FModified := True;
end;
{ --- Чтение и запись --- }
function JsonToTransform(AObj: TJSONObject): TMediaTransform;
begin
Result := DefaultTransform;
if AObj = nil then
Exit;
Result.Fit := StrToFitMode(AObj.Get('fit', 'fit'));
Result.Rotation := AObj.Get('rotation', 0);
Result.FlipH := AObj.Get('flip_h', False);
Result.FlipV := AObj.Get('flip_v', False);
Result.Zoom := AObj.Get('zoom', Double(1));
Result.OffsetX := AObj.Get('offset_x', Double(0));
Result.OffsetY := AObj.Get('offset_y', Double(0));
if Result.Zoom <= 0 then
Result.Zoom := 1;
end;
function RippleModeToStr(AMode: TRippleMode): string;
begin
if AMode = rmAllTracks then
Result := 'all'
else
Result := 'current';
end;
function StrToRippleMode(const AValue: string): TRippleMode;
begin
if AValue = 'all' then
Result := rmAllTracks
else
Result := rmCurrentTrack;
end;
function EdgeToStr(AEdge: TEdgeMode): string;
begin
if AEdge = edPrecise then
Result := 'precise'
else
Result := 'snap';
end;
function StrToEdge(const AValue: string): TEdgeMode;
begin
if AValue = 'precise' then
Result := edPrecise
else
Result := edSnap;
end;
function TrackKindToStr(AKind: TTrackKind): string;
begin
if AKind = tkAudio then
Result := 'audio'
else
Result := 'video';
end;
function StrToTrackKind(const AValue: string): TTrackKind;
begin
if AValue = 'audio' then
Result := tkAudio
else
Result := tkVideo;
end;
function KindToStr(AKind: TClipKind): string;
begin
case AKind of
ckImage: Result := 'image';
ckBlack: Result := 'black';
else
Result := 'video';
end;
end;
function StrToKind(const AValue: string): TClipKind;
begin
if AValue = 'image' then
Result := ckImage
else if AValue = 'black' then
Result := ckBlack
else
Result := ckVideo;
end;
function MediaKindToStr(AKind: TMediaKind): string;
begin
case AKind of
mkImage: Result := 'image';
mkVideo: Result := 'video';
else
Result := 'unknown';
end;
end;
function StrToMediaKind(const AValue: string): TMediaKind;
begin
if AValue = 'image' then
Result := mkImage
else if AValue = 'video' then
Result := mkVideo
else
Result := mkUnknown;
end;
function InfoToJson(const AInfo: TMediaInfo): TJSONObject;
begin
Result := TJSONObject.Create;
Result.Add('valid', AInfo.Valid);
Result.Add('kind', MediaKindToStr(AInfo.Kind));
Result.Add('format_name', AInfo.FormatName);
Result.Add('duration', AInfo.Duration);
Result.Add('has_video', AInfo.HasVideo);
Result.Add('video_codec', AInfo.VideoCodec);
Result.Add('video_profile', AInfo.VideoProfile);
Result.Add('pix_fmt', AInfo.PixFmt);
Result.Add('width', AInfo.Width);
Result.Add('height', AInfo.Height);
Result.Add('fps_num', AInfo.FpsNum);
Result.Add('fps_den', AInfo.FpsDen);
Result.Add('video_bitrate', AInfo.VideoBitrate);
Result.Add('has_audio', AInfo.HasAudio);
Result.Add('audio_codec', AInfo.AudioCodec);
Result.Add('sample_rate', AInfo.SampleRate);
Result.Add('channels', AInfo.Channels);
Result.Add('channel_layout', AInfo.ChannelLayout);
Result.Add('audio_bitrate', AInfo.AudioBitrate);
Result.Add('extra_streams', AInfo.ExtraStreams);
end;
function JsonToInfo(AObj: TJSONObject): TMediaInfo;
begin
Result := Default(TMediaInfo);
if AObj = nil then
Exit;
Result.Valid := AObj.Get('valid', False);
Result.Kind := StrToMediaKind(AObj.Get('kind', 'unknown'));
Result.FormatName := AObj.Get('format_name', '');
Result.Duration := AObj.Get('duration', Double(0));
Result.HasVideo := AObj.Get('has_video', False);
Result.VideoCodec := AObj.Get('video_codec', '');
Result.VideoProfile := AObj.Get('video_profile', '');
Result.PixFmt := AObj.Get('pix_fmt', '');
Result.Width := AObj.Get('width', 0);
Result.Height := AObj.Get('height', 0);
Result.FpsNum := AObj.Get('fps_num', 0);
Result.FpsDen := AObj.Get('fps_den', 0);
Result.VideoBitrate := AObj.Get('video_bitrate', Int64(0));
Result.HasAudio := AObj.Get('has_audio', False);
Result.AudioCodec := AObj.Get('audio_codec', '');
Result.SampleRate := AObj.Get('sample_rate', 0);
Result.Channels := AObj.Get('channels', 0);
Result.ChannelLayout := AObj.Get('channel_layout', '');
Result.AudioBitrate := AObj.Get('audio_bitrate', Int64(0));
Result.ExtraStreams := AObj.Get('extra_streams', 0);
end;
function TProject.Serialize: string;
var
Root, Obj: TJSONObject;
PoolArr, TracksArr, ClipsArr: TJSONArray;
I, J: Integer;
Item: TMediaItem;
Track: TTrack;
Clip: TClip;
begin
Root := TJSONObject.Create;
try
Root.Add('version', ProjectFormatVersion);
Root.Add('next_id', FNextId);
Root.Add('master', InfoToJson(FMaster));
PoolArr := TJSONArray.Create;
for I := 0 to FPool.Count - 1 do
begin
Item := TMediaItem(FPool[I]);
Obj := TJSONObject.Create;
Obj.Add('id', Item.Id);
Obj.Add('path', Item.Path);
Obj.Add('normalized_path', Item.NormalizedPath);
Obj.Add('info', InfoToJson(Item.Info));
Obj.Add('transform', TJSONObject.Create([
'fit', FitModeToStr(Item.Transform.Fit),
'rotation', Item.Transform.Rotation,
'flip_h', Item.Transform.FlipH,
'flip_v', Item.Transform.FlipV,
'zoom', Item.Transform.Zoom,
'offset_x', Item.Transform.OffsetX,
'offset_y', Item.Transform.OffsetY
]));
PoolArr.Add(Obj);
end;
Root.Add('pool', PoolArr);
TracksArr := TJSONArray.Create;
for I := 0 to FTracks.Count - 1 do
begin
Track := TTrack(FTracks[I]);
Obj := TJSONObject.Create;
Obj.Add('id', Track.Id);
Obj.Add('name', Track.Name);
Obj.Add('kind', TrackKindToStr(Track.Kind));
Obj.Add('muted', Track.Muted);
Obj.Add('solo', Track.Solo);
ClipsArr := TJSONArray.Create;
for J := 0 to Track.Count - 1 do
begin
Clip := Track[J];
ClipsArr.Add(TJSONObject.Create([
'id', Clip.Id,
'media', Clip.MediaId,
'src_in', Clip.SrcIn,
'tl_start', Clip.TlStart,
'duration', Clip.Duration,
'edge_in', EdgeToStr(Clip.EdgeIn),
'edge_out', EdgeToStr(Clip.EdgeOut),
'kind', KindToStr(Clip.Kind),
'group', Clip.GroupId,
'volume', Clip.Volume,
'opacity', Clip.Opacity,
'fade_in', Clip.FadeIn,
'fade_out', Clip.FadeOut,
'transform', TJSONObject.Create([
'fit', FitModeToStr(Clip.Transform.Fit),
'rotation', Clip.Transform.Rotation,
'flip_h', Clip.Transform.FlipH,
'flip_v', Clip.Transform.FlipV,
'zoom', Clip.Transform.Zoom,
'offset_x', Clip.Transform.OffsetX,
'offset_y', Clip.Transform.OffsetY
])
]));
end;
Obj.Add('clips', ClipsArr);
TracksArr.Add(Obj);
end;
Root.Add('tracks', TracksArr);
Root.Add('settings', TJSONObject.Create([
'ripple_mode', RippleModeToStr(FRippleMode),
'ripple_enabled', FRippleEnabled
]));
Result := Root.FormatJSON;
finally
Root.Free;
end;
end;
procedure TProject.Deserialize(const AText: string);
var
Parser: TJSONParser;
Root: TJSONData;
Obj, ItemObj, TrackObj, ClipObj, Settings: TJSONObject;
Arr, ClipsArr: TJSONArray;
I, J: Integer;
Item, Media: TMediaItem;
Track: TTrack;
Clip: TClip;
begin
FPool.Clear;
FTracks.Clear;
Parser := TJSONParser.Create(AText, [joUTF8]);
try
Root := Parser.Parse;
finally
Parser.Free;
end;
if (Root = nil) or (not (Root is TJSONObject)) then
begin
Root.Free;
Exit;
end;
try
Obj := TJSONObject(Root);
FNextId := Obj.Get('next_id', 1);
FMaster := JsonToInfo(Obj.Find('master', jtObject) as TJSONObject);
Arr := Obj.Find('pool', jtArray) as TJSONArray;
if Arr <> nil then
for I := 0 to Arr.Count - 1 do
begin
if not (Arr.Items[I] is TJSONObject) then
Continue;
ItemObj := TJSONObject(Arr.Items[I]);
Item := TMediaItem.Create;
Item.Id := ItemObj.Get('id', '');
Item.Path := ItemObj.Get('path', '');
Item.NormalizedPath := ItemObj.Get('normalized_path', '');
Item.Info := JsonToInfo(ItemObj.Find('info', jtObject) as TJSONObject);
Item.Transform := JsonToTransform(
ItemObj.Find('transform', jtObject) as TJSONObject);
FPool.Add(Item);
end;
Arr := Obj.Find('tracks', jtArray) as TJSONArray;
if Arr <> nil then
for I := 0 to Arr.Count - 1 do
begin
if not (Arr.Items[I] is TJSONObject) then
Continue;
TrackObj := TJSONObject(Arr.Items[I]);
Track := TTrack.Create;
Track.Id := TrackObj.Get('id', '');
Track.Name := TrackObj.Get('name', '');
Track.Kind := StrToTrackKind(TrackObj.Get('kind', 'video'));
Track.Muted := TrackObj.Get('muted', False);
Track.Solo := TrackObj.Get('solo', False);
FTracks.Add(Track);
ClipsArr := TrackObj.Find('clips', jtArray) as TJSONArray;
if ClipsArr <> nil then
for J := 0 to ClipsArr.Count - 1 do
begin
if not (ClipsArr.Items[J] is TJSONObject) then
Continue;
ClipObj := TJSONObject(ClipsArr.Items[J]);
Clip := TClip.Create;
Clip.Id := ClipObj.Get('id', '');
Clip.MediaId := ClipObj.Get('media', '');
Clip.SrcIn := ClipObj.Get('src_in', Double(0));
Clip.TlStart := ClipObj.Get('tl_start', Double(0));
Clip.Duration := ClipObj.Get('duration', Double(0));
Clip.EdgeIn := StrToEdge(ClipObj.Get('edge_in', 'snap'));
Clip.EdgeOut := StrToEdge(ClipObj.Get('edge_out', 'snap'));
Clip.Kind := StrToKind(ClipObj.Get('kind', 'video'));
Clip.Volume := ClipObj.Get('volume', Double(1));
Clip.GroupId := ClipObj.Get('group', '');
Clip.Opacity := ClipObj.Get('opacity', Double(1));
Clip.FadeIn := ClipObj.Get('fade_in', Double(0));
Clip.FadeOut := ClipObj.Get('fade_out', Double(0));
{ Проекты, записанные до появления кадрирования у клипа,
берут его у файла — там оно и хранилось раньше. }
if ClipObj.Find('transform') <> nil then
Clip.Transform := JsonToTransform(
ClipObj.Get('transform', TJSONObject(nil)))
else
begin
Media := FindMedia(Clip.MediaId);
if Media <> nil then
Clip.Transform := Media.Transform;
end;
Track.Add(Clip);
end;
Track.Sort;
end;
MigrateAudio;
Settings := Obj.Find('settings', jtObject) as TJSONObject;
if Settings <> nil then
begin
FRippleMode := StrToRippleMode(Settings.Get('ripple_mode', 'current'));
FRippleEnabled := Settings.Get('ripple_enabled', False);
{ Старые проекты хранили один флаг «все дорожки». }
if Settings.Get('ripple_all_tracks', False) then
FRippleMode := rmAllTracks;
end;
finally
Root.Free;
end;
end;
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
var
Text: TStringList;
begin
AError := '';
Result := False;
Text := TStringList.Create;
try
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
FFileName := ExpandFileName(APath);
Text.Text := Serialize;
try
Text.SaveToFile(FFileName);
FModified := False;
Result := True;
except
on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message;
end;
finally
Text.Free;
end;
end;
function TProject.LoadFromFile(const APath: string; out AError: string): Boolean;
var
Text: TStringList;
begin
AError := '';
Result := False;
if not FileExists(APath) then
begin
AError := 'Файл проекта не найден: ' + APath;
Exit;
end;
Text := TStringList.Create;
try
try
Text.LoadFromFile(APath);
Clear;
Deserialize(Text.Text);
FFileName := ExpandFileName(APath);
FModified := False;
Result := True;
except
on E: Exception do
AError := 'Не удалось прочитать проект: ' + E.Message;
end;
finally
Text.Free;
end;
end;
end.