From 18e55d466ef2cd3e5c8bf0bb85af598b5d8ed200 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 14 Sep 2026 16:06:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F:=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сохранение теперь отвечает в строке состояния: «Проект сохранён: имя, время». Раньше об этом знал только журнал, а он по умолчанию закрыт — нажал Ctrl+S и не знаешь, дошло ли до файла. Проект пишется во временный файл рядом и подставляется целиком; прежний остаётся как .bak. Оборванная запись больше не может оставить огрызок вместо проекта. Автосохранение: пока есть несохранённое, раз в полминуты рядом с проектом пишется копия .autosave. При открытии программа сравнивает её время с временем проекта и, если копия новее, показывает оба времени и предлагает её открыть. После сохранения проекта копия убирается. Журнал рядом с программой больше не переписывается при каждом запуске: запуски дописываются с заголовком, файл урезается при переросте. Прошлый запуск — как раз тот, в котором обычно и надо разбираться. Клавиша \ подтягивает окно к курсору и ставит его посередине: её жмут после реза или выделения. Переключатель «курсор посередине» остался кнопкой с целью. --- README.md | 24 ++++- src/uMainForm.pas | 243 +++++++++++++++++++++++++++++++++++++++++++--- src/uProject.pas | 72 ++++++++++++-- 3 files changed, 319 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 88b5d69..ceb0769 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ xdg-mime query default application/x-nlve-project |---|---| | Пуск и пауза | Пробел | | В начало / конец монтажа | Home / End | +| Показать, где курсор (окно едет к нему) | \ | | Разрезать по курсору (все дорожки) | S | | Разрезать точно (перекодирует стык) | Shift+S | | Удалить выделенное | Delete | @@ -144,7 +145,7 @@ xdg-mime query default application/x-nlve-project липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без другого. -**Курсор посередине** (кнопка с целью, клавиша `\`). Включено — при +**Курсор посередине** (кнопка с целью). Включено — при воспроизведении монтаж едет под курсором, и всегда видно, что будет дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за край: отмотал Shift с колесом — там и останешься. Раньше в выключенном @@ -251,6 +252,27 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро неправильно. Усиление меняет только рисунок, самого звука не касается, и запоминается между запусками. +## Сохранение и автосохранение + +**Сохранение видно.** После Ctrl+S внизу появляется «Проект сохранён: +имя, время». Раньше об этом говорила только строчка в журнале, а журнал +по умолчанию закрыт: нажал — и непонятно, дошло до файла или нет. + +**Запись не оставляет огрызков.** Проект пишется во временный файл рядом +и подставляется на место настоящего, только когда записан целиком. +Прежний файл при этом становится запасным — `имя.nlve.bak`. Падение или +кончившееся место посреди записи больше не оставляют половину проекта. + +**Копия пишется сама.** Пока в проекте есть несохранённое, раз в полминуты +рядом появляется `имя.nlve.autosave`. Это не сохранение: файл проекта не +трогается, признак «изменён» остаётся. + +При открытии проекта программа смотрит, нет ли рядом копии новее его +самого. Есть — значит прошлая работа до файла не дошла, и программа +показывает оба времени и предлагает открыть копию. Отказ копию не +удаляет: она лежит, пока проект не сохранят заново. После сохранения +копия не нужна и убирается. + ## Файл не совпадает с проектом Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 33952f8..df6ba55 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -95,6 +95,10 @@ type FStarted: Boolean; FPreviewTimer: TTimer; + { Автосохранение: копия проекта пишется сама собой, пока человек + работает. Нужна ровно на один случай — работа пропала, а сохранить + её забыли или сохранение не дошло до файла. } + FAutoSaveTimer: TTimer; FPreviewDirty: Boolean; FUpdatingFromPlayer: Boolean; FLastLoadedEdl: string; @@ -217,6 +221,8 @@ type FSeekWaitTicks: Integer; { До какого времени ответ на команду держится в строке состояния. } FStatusHoldUntil: TDateTime; + { Автосохранение не удалось — жалуемся один раз, а не каждые полминуты. } + FAutoSaveBroken: Boolean; procedure ApplyVolumeAt(const ATime: Double); { Переписывает файл субтитров и отдаёт его плееру. } @@ -292,9 +298,16 @@ type procedure RemindAboutDeps; procedure Log(const AText: string); procedure LogToFile(const AText: string); + { Урезает файл журнала, когда он перерос предел. } + procedure TrimLogFile; { AAnswer — это ответ на команду человека, а не проходное сообщение: такой ответ держится в строке несколько секунд. } procedure SetStatus(const AText: string; AAnswer: Boolean = False); + { Автосохранение: копия рядом с проектом. } + function AutoSavePath: string; + procedure DropAutoSave; + procedure AutoSaveTick(Sender: TObject); + procedure OfferAutoSave; procedure UpdateCaption; procedure UpdateTimeLabel; procedure RefreshPool; @@ -415,6 +428,18 @@ const затереть проходным сообщением. Четыре секунды — успеть прочесть. } StatusHoldTime = 4 / (24 * 60 * 60); + { Как часто пишется копия проекта для восстановления. Полминуты — это + потолок потерь: больше получаса работы человек в неё не вложит, а + запись проекта стоит миллисекунды. } + AutoSaveIntervalMs = 30 * 1000; + + { Приставка к имени файла проекта у автосохранённой копии. } + AutoSaveSuffix = '.autosave'; + + { Предел для файла журнала. Больше двух мегабайт читать всё равно + никто не станет, а место жалко. } + MaxLogFileSize = 2 * 1024 * 1024; + function FormatTimecode(const Seconds: Double): string; var Total, H, M, S, Ms: Integer; @@ -460,6 +485,11 @@ begin FPreviewTimer.Interval := PreviewDebounceMs; FPreviewTimer.OnTimer := @PreviewTimerTick; + FAutoSaveTimer := TTimer.Create(Self); + FAutoSaveTimer.Interval := AutoSaveIntervalMs; + FAutoSaveTimer.OnTimer := @AutoSaveTick; + FAutoSaveTimer.Enabled := True; + { Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg делает их ровно такими: список изображений в LCL требует одинакового размера у всех элементов. } @@ -1245,11 +1275,14 @@ begin lstPool.ItemIndex := 0; end; - { Спрашивать про клипы на чужих дорожках можно только теперь: окно - показано, и вопрос не окажется за кадром. } + { Спрашивать можно только теперь: окно показано, и вопрос не окажется + за кадром. Автосохранённая копия — первым делом: если прошлая работа + не дошла до файла, человек должен узнать об этом до того, как начнёт + правку поверх старого. } if FCheckMisplacedOnShow then begin FCheckMisplacedOnShow := False; + OfferAutoSave; CheckMisplacedClips; end; end; @@ -1334,12 +1367,57 @@ end; { --- Мелочи интерфейса --- } +{ Урезание журнала до последних строк, когда файл перерос предел. + + Читается целиком: предел небольшой, и возиться с чтением с конца ради + пары мегабайт незачем. } +procedure TMainForm.TrimLogFile; +var + L: TStringList; + Keep: Integer; + Found: TSearchRec; + Size: Int64; +begin + if not FileExists(FLogFilePath) then + Exit; + Size := 0; + if FindFirst(FLogFilePath, faAnyFile, Found) = 0 then + begin + Size := Found.Size; + FindClose(Found); + end; + if Size <= MaxLogFileSize then + Exit; + L := TStringList.Create; + try + try + L.LoadFromFile(FLogFilePath); + Keep := L.Count div 2; + while L.Count > Keep do + L.Delete(0); + L.Insert(0, '=== начало журнала урезано ==='); + L.SaveToFile(FLogFilePath); + except + { Не вышло урезать — не беда: журнал не работа, ради которой сюда + пришли. } + end; + finally + L.Free; + end; +end; + { Дублирование журнала в файл рядом с программой. Нужно ровно для одного случая: программа падает до того, как окно успело появиться. Тогда всё, что она о себе рассказала, пропадает - вместе с окном, и причина остаётся неизвестной. Файл переписывается - при каждом запуске: интересен последний. + вместе с окном, и причина остаётся неизвестной. + + Файл не переписывается при запуске, а дописывается: разбираться обычно + приходится не с тем запуском, который идёт сейчас, а с прошлым, и + затирать его нельзя — второй запуск программы уносил бы единственное + свидетельство. Каждый запуск отделён строкой с датой и временем. Файл + урезается, когда перерастёт предел: бесконечно растущий журнал никому + не нужен. Неудача записи ничего не значит — каталог может быть закрыт на запись, и работать это мешать не должно. } @@ -1350,14 +1428,19 @@ begin if FLogFileBroken then Exit; try + if not FLogFileStarted then + begin + TrimLogFile; + FLogFileStarted := True; + LogToFile(''); + LogToFile('=== запуск ' + FormatDateTime('dd.mm.yyyy hh:nn:ss', Now) + + ' ==='); + end; AssignFile(F, FLogFilePath); - if FLogFileStarted then + if FileExists(FLogFilePath) then Append(F) else - begin Rewrite(F); - FLogFileStarted := True; - end; try WriteLn(F, AText); finally @@ -2363,6 +2446,9 @@ begin FSettings.NoteRecentProject(FProject.FileName); RefreshRecentMenu; Log('Открыт проект: ' + FProject.FileName); + { Если рядом лежит копия новее проекта — значит прошлая работа не дошла + до файла, и об этом надо сказать сразу при открытии. } + OfferAutoSave; CheckMisplacedClips; end; @@ -3322,12 +3408,21 @@ begin Exit; end; - { Обратная косая: пункта меню для неё нет намеренно — сочетание из - одной такой клавиши в меню выглядит как опечатка, а работать должно. } + { Обратная косая — «покажи, где курсор»: окно едет к нему и ставит его + посередине. Жмут её чаще всего после реза или выделения, когда курсор + стоит на границе и надо к ней вернуться. + + Раньше она переключала режим «курсор посередине». Переключатель + остался кнопкой с целью внизу: включают его раз и надолго, а + подтягивать окно к курсору нужно постоянно. + + Пункта меню для неё нет намеренно — сочетание из одной такой клавиши + в меню выглядит как опечатка, а работать должно. } if (Key = VK_OEM_5) and (Shift = []) then begin Key := 0; - ToggleCenterPlayhead; + FTimeline.ScrollToPlayhead(True); + SetStatus('Окно у курсора: ' + FormatTimecode(FTimeline.Playhead), True); end; end; @@ -3391,10 +3486,134 @@ begin else begin UpdateCaption; - Log('Проект сохранён.'); + { Сохранение должно быть видно. Раньше о нём говорила только строчка + в журнале, а журнал по умолчанию закрыт: человек нажимал Ctrl+S, + ничего не происходило, и понять, дошло сохранение до файла или нет, + было не по чему. } + SetStatus('Проект сохранён: ' + ExtractFileName(FProject.FileName) + + ', ' + FormatDateTime('hh:nn:ss', Now), True); + Log('Проект сохранён: ' + FProject.FileName); + { Копия для восстановления больше не нужна: в файле то же самое. } + DropAutoSave; end; end; +{ Где лежит автосохранённая копия проекта. Рядом с самим проектом, чтобы + она нашлась там же, где его ищут. У безымянного проекта её нет: некуда + положить, и восстанавливать потом нечего. } +function TMainForm.AutoSavePath: string; +begin + if FProject.FileName = '' then + Result := '' + else + Result := FProject.FileName + AutoSaveSuffix; +end; + +procedure TMainForm.DropAutoSave; +var + Path: string; +begin + Path := AutoSavePath; + if (Path <> '') and FileExists(Path) then + DeleteFile(Path); +end; + +{ Копия проекта пишется, пока в нём есть несохранённое. + + Это не замена сохранению: файл проекта не трогается, имя не меняется, + признак «изменён» остаётся. Копия нужна на случай, когда работа всё же + пропала — программу закрыли, она упала, сохранение не дошло до файла. } +procedure TMainForm.AutoSaveTick(Sender: TObject); +var + Path, Err: string; +begin + if FProject = nil then + Exit; + Path := AutoSavePath; + if Path = '' then + Exit; + { Несохранённого нет — писать нечего. Копию при этом не трогаем: она + может быть единственным следом прошлой работы, а спросить о ней ещё + не успели. Убирается она там, где точно больше не нужна: после + сохранения проекта и после ответа на вопрос при открытии. } + if not FProject.Modified then + Exit; + if not FProject.SaveCopyTo(Path, Err) then + begin + { Жалуемся один раз и больше не мешаем: автосохранение — подстраховка, + а не работа, ради которой человек сюда пришёл. } + if not FAutoSaveBroken then + begin + FAutoSaveBroken := True; + Log('Автосохранение не работает: ' + Err); + end; + end + else + FAutoSaveBroken := False; +end; + +{ Проверка при открытии: не осталась ли копия новее самого проекта. + + Так бывает ровно в одном случае — работа не дошла до файла проекта. + Молча подменять файл нельзя, поэтому спрашиваем и показываем время. } +procedure TMainForm.OfferAutoSave; +var + Path, Err: string; + CopyStamp, ProjStamp: LongInt; + CopyTime, ProjTime: TDateTime; +begin + Path := AutoSavePath; + if (Path = '') or (not FileExists(Path)) then + Exit; + CopyStamp := FileAge(Path); + ProjStamp := FileAge(FProject.FileName); + if (CopyStamp < 0) or (ProjStamp < 0) then + Exit; + CopyTime := FileDateToDateTime(CopyStamp); + ProjTime := FileDateToDateTime(ProjStamp); + { Копия старше проекта — значит проект сохранён позже неё, и она уже + ни о чём не говорит. } + if CopyTime <= ProjTime + 1 / (24 * 60 * 60) then + begin + DeleteFile(Path); + Exit; + end; + + Log(Format('Найдена автосохранённая копия от %s, проект сохранён %s.', + [FormatDateTime('dd.mm hh:nn:ss', CopyTime), + FormatDateTime('dd.mm hh:nn:ss', ProjTime)])); + + if ThemedMessageDlg('nlve', + Format('Рядом с проектом лежит автосохранённая копия от %s, ' + + 'а сам проект сохранён %s.' + LineEnding + LineEnding + + 'Похоже, последние правки не дошли до файла проекта. ' + + 'Открыть копию?' + LineEnding + LineEnding + + 'Проект при этом не перезаписывается: копия просто откроется, и ' + + 'сохранить её поверх проекта можно будет самому. Откажетесь — ' + + 'копия останется лежать рядом, пока проект не сохранят заново.', + [FormatDateTime('dd.mm.yyyy hh:nn:ss', CopyTime), + FormatDateTime('dd.mm.yyyy hh:nn:ss', ProjTime)]), + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then + Exit; + + if not FProject.LoadFromFile(Path, Err) then + begin + ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0); + Exit; + end; + + { Имя возвращаем проектное: копия открыта, но работать человек + продолжает с проектом, и Ctrl+S должен писать в него. } + FProject.FileName := Copy(Path, 1, Length(Path) - Length(AutoSaveSuffix)); + FProject.Modified := True; + RefreshPool; + UpdateCaption; + FLastLoadedEdl := ''; + SchedulePreview; + SetStatus('Открыта автосохранённая копия. Проверьте и сохраните: Ctrl+S.', + True); +end; + procedure TMainForm.MenuSaveProject(Sender: TObject); begin DoSaveProject; diff --git a/src/uProject.pas b/src/uProject.pas index 34d4a70..ebcdad0 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -377,6 +377,9 @@ type { --- Файл проекта --- } function SaveToFile(const APath: string; out AError: string): Boolean; + { Копия проекта в другой файл: имя проекта и признак «изменён» не + трогаются. Для автосохранения. } + function SaveCopyTo(const APath: string; out AError: string): Boolean; function LoadFromFile(const APath: string; out AError: string): Boolean; property Pool[AIndex: Integer]: TMediaItem read GetPoolItem; @@ -1843,7 +1846,10 @@ begin end; end; -function TProject.SaveToFile(const APath: string; out AError: string): Boolean; +{ Запись проекта в произвольный файл, не трогая ни имя проекта, ни признак + «изменён». Нужна для автосохранения: копия пишется сама собой, и проект + от этого не должен считаться сохранённым. } +function TProject.SaveCopyTo(const APath: string; out AError: string): Boolean; var Text: TStringList; begin @@ -1851,23 +1857,75 @@ begin Result := False; Text := TStringList.Create; try - { Имя файла назначаем до записи: от него зависит путь к кэшу, который - попадает внутрь проекта косвенно — через сгенерированные клипы. } - FFileName := ExpandFileName(APath); Text.Text := Serialize; try - Text.SaveToFile(FFileName); - FModified := False; + Text.SaveToFile(APath); Result := True; except on E: Exception do - AError := 'Не удалось сохранить проект: ' + E.Message; + AError := 'Не удалось записать ' + APath + ': ' + E.Message; end; finally Text.Free; end; end; +{ Сохранение проекта. + + Пишем сперва во временный файл рядом, и только когда он записан целиком, + подставляем его на место настоящего. Прежний файл при этом становится + запасным (.bak). Так падение или полный диск посреди записи не оставляют + от проекта огрызок, а испорченное сохранение всегда можно откатить. + + Признак «изменён» снимается только после того, как файл на месте: если + запись не удалась, проект остаётся несохранённым и программа спросит + о нём при выходе. } +function TProject.SaveToFile(const APath: string; out AError: string): Boolean; +var + Full, Temp, Backup: string; +begin + AError := ''; + Result := False; + { Имя файла назначаем до записи: от него зависит путь к кэшу, который + попадает внутрь проекта косвенно — через сгенерированные клипы. } + Full := ExpandFileName(APath); + FFileName := Full; + Temp := Full + '.tmp'; + Backup := Full + '.bak'; + + if not SaveCopyTo(Temp, AError) then + Exit; + + try + if FileExists(Full) then + begin + if FileExists(Backup) then + DeleteFile(Backup); + if not RenameFile(Full, Backup) then + begin + AError := 'Не удалось отложить прежний файл проекта: ' + Backup; + Exit; + end; + end; + + if not RenameFile(Temp, Full) then + begin + { Подстановка не удалась — возвращаем прежний файл на место, чтобы + не остаться вообще без проекта. } + if FileExists(Backup) and (not FileExists(Full)) then + RenameFile(Backup, Full); + AError := 'Не удалось записать проект: ' + Full; + Exit; + end; + + FModified := False; + Result := True; + except + on E: Exception do + AError := 'Не удалось сохранить проект: ' + E.Message; + end; +end; + function TProject.LoadFromFile(const APath: string; out AError: string): Boolean; var Text: TStringList;