/* * Copyright (c) 2015-2024 Meltytech, LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "undohelper.h" #include "Logger.h" #include "mltcontroller.h" #include "models/audiolevelstask.h" #include "shotcut_mlt_properties.h" #include #include #ifdef UNDOHELPER_DEBUG #define UNDOLOG LOG_DEBUG() #else #define UNDOLOG \ if (false) \ LOG_DEBUG() #endif UndoHelper::UndoHelper(MultitrackModel &model) : m_model(model) , m_hints(NoHints) {} void UndoHelper::recordBeforeState() { #ifdef UNDOHELPER_DEBUG debugPrintState("Before state"); #endif m_state.clear(); m_clipsAdded.clear(); m_insertedOrder.clear(); for (int i = 0; i < m_model.trackList().count(); ++i) { int mltIndex = m_model.trackList()[i].mlt_index; QScopedPointer trackProducer(m_model.tractor()->track(mltIndex)); Mlt::Playlist playlist(*trackProducer); for (int j = 0; j < playlist.count(); ++j) { QScopedPointer clip(playlist.get_clip(j)); QUuid uid = MLT.ensureHasUuid(clip->parent()); if (clip->is_blank()) { uid = MLT.ensureHasUuid(*clip); } m_insertedOrder << uid; Info &info = m_state[uid]; if (!(m_hints & SkipXML)) info.xml = MLT.XML(&clip->parent()); Mlt::ClipInfo clipInfo; playlist.clip_info(j, &clipInfo); info.frame_in = clipInfo.frame_in; info.frame_out = clipInfo.frame_out; info.oldTrackIndex = i; info.oldClipIndex = j; info.isBlank = playlist.is_blank(j); if (clipInfo.cut && clipInfo.cut->property_exists(kShotcutGroupProperty)) { info.group = clipInfo.cut->get_int(kShotcutGroupProperty); } } } } void UndoHelper::recordAfterState() { #ifdef UNDOHELPER_DEBUG debugPrintState("After state"); #endif QList clipsRemoved = m_state.keys(); m_clipsAdded.clear(); for (int i = 0; i < m_model.trackList().count(); ++i) { int mltIndex = m_model.trackList()[i].mlt_index; QScopedPointer trackProducer(m_model.tractor()->track(mltIndex)); Mlt::Playlist playlist(*trackProducer); for (int j = 0; j < playlist.count(); ++j) { QScopedPointer clip(playlist.get_clip(j)); QUuid uid = MLT.ensureHasUuid(clip->parent()); if (clip->is_blank()) { uid = MLT.ensureHasUuid(*clip); } /* Clips not previously in m_state are new */ if (!m_state.contains(uid)) { UNDOLOG << "New clip at" << i << j; m_clipsAdded << uid; m_affectedTracks << i; } else { Info &info = m_state[uid]; info.changes = 0; info.newTrackIndex = i; info.newClipIndex = j; /* Indices have changed; these are moved */ if (info.oldTrackIndex != info.newTrackIndex || info.oldClipIndex != info.newClipIndex) { UNDOLOG << "Clip" << uid << "moved from" << info.oldTrackIndex << info.oldClipIndex << "to" << info.newTrackIndex << info.newClipIndex; info.changes |= Moved; m_affectedTracks << info.oldTrackIndex; m_affectedTracks << info.newTrackIndex; } if (!(m_hints & SkipXML) && !info.isBlank) { QString newXml = MLT.XML(&clip->parent()); if (info.xml != newXml) { UNDOLOG << "Modified xml:" << uid; info.changes |= XMLModified; m_affectedTracks << i; } } Mlt::ClipInfo newInfo; playlist.clip_info(j, &newInfo); /* Only in/out point changes are handled at this time. */ if (info.frame_in != newInfo.frame_in || info.frame_out != newInfo.frame_out) { UNDOLOG << "In/out changed:" << uid; info.changes |= ClipInfoModified; info.in_delta = info.frame_in - newInfo.frame_in; info.out_delta = newInfo.frame_out - info.frame_out; m_affectedTracks << i; } } clipsRemoved.removeOne(uid); } } /* Clips that did not show up are removed from the timeline */ foreach (QUuid uid, clipsRemoved) { UNDOLOG << "Clip removed:" << uid; auto &info = m_state[uid]; info.changes = Removed; m_affectedTracks << info.oldTrackIndex; } } void UndoHelper::undoChanges() { #ifdef UNDOHELPER_DEBUG debugPrintState("Before undo"); #endif if (m_hints & RestoreTracks) { restoreAffectedTracks(); emit m_model.modified(); #ifdef UNDOHELPER_DEBUG debugPrintState("After undo"); #endif return; } QMap indexAdjustment; /* We're walking through the list in the order of uids, which is the order in which the * clips were laid out originally. As we go through the clips we make sure the clips behind * the current index are as they were originally before we move on to the next one */ foreach (QUuid uid, m_insertedOrder) { const Info &info = m_state[uid]; UNDOLOG << "Handling uid" << uid << "on track" << info.oldTrackIndex << "index" << info.oldClipIndex; int trackIndex = m_model.trackList()[info.oldTrackIndex].mlt_index; QScopedPointer trackProducer(m_model.tractor()->track(trackIndex)); Mlt::Playlist playlist(*trackProducer); /* This is the index in the track we're currently restoring */ int currentIndex = qMin(info.oldClipIndex + indexAdjustment[trackIndex], playlist.count() - 1); /* Clips that were moved are simply searched for using the uid, and moved in place. We * do not use the indices directly because they become invalid once the playlist is * modified. */ if (info.changes & Moved) { Q_ASSERT(info.newTrackIndex == info.oldTrackIndex && "cross-track moves are unsupported so far"); int clipCurrentlyAt = -1; for (int i = 0; i < playlist.count(); ++i) { QScopedPointer clip(playlist.get_clip(i)); if (MLT.uuid(clip->parent()) == uid || MLT.uuid(*clip) == uid) { clipCurrentlyAt = i; break; } } Q_ASSERT(clipCurrentlyAt != -1 && "Moved clip could not be found"); UNDOLOG << "Found clip with uid" << uid << "at index" << clipCurrentlyAt; if (clipCurrentlyAt != info.oldClipIndex && (currentIndex < clipCurrentlyAt || currentIndex > clipCurrentlyAt + 1)) { UNDOLOG << "moving from" << clipCurrentlyAt << "to" << currentIndex; QModelIndex modelIndex = m_model.createIndex(clipCurrentlyAt, 0, info.oldTrackIndex); m_model.beginMoveRows(modelIndex.parent(), clipCurrentlyAt, clipCurrentlyAt, modelIndex.parent(), currentIndex); playlist.move(clipCurrentlyAt, currentIndex); m_model.endMoveRows(); } } /* Removed clips are reinserted using their stored XML */ if (info.changes & Removed) { QModelIndex modelIndex = m_model.createIndex(currentIndex, 0, info.oldTrackIndex); m_model.beginInsertRows(modelIndex.parent(), currentIndex, currentIndex); if (info.isBlank) { playlist.insert_blank(currentIndex, info.frame_out - info.frame_in); UNDOLOG << "inserting isBlank at " << currentIndex; } else { UNDOLOG << "inserting clip at " << currentIndex << uid; Q_ASSERT(!(m_hints & SkipXML) && "Cannot restore clip without stored XML"); Q_ASSERT(!info.xml.isEmpty()); Mlt::Producer restoredClip(MLT.profile(), "xml-string", info.xml.toUtf8().constData()); if (restoredClip.type() == mlt_service_tractor_type) { // transition restoredClip.set("mlt_type", "mlt_producer"); } else { fixTransitions(playlist, currentIndex, restoredClip); } playlist.insert(restoredClip, currentIndex, info.frame_in, info.frame_out); } m_model.endInsertRows(); QScopedPointer clip(playlist.get_clip(currentIndex)); Q_ASSERT(currentIndex < playlist.count()); Q_ASSERT(!clip.isNull()); if (info.isBlank) { MLT.setUuid(*clip, uid); } else { MLT.setUuid(clip->parent(), uid); } if (info.group >= 0) { clip->set(kShotcutGroupProperty, info.group); } AudioLevelsTask::start(clip->parent(), &m_model, modelIndex); indexAdjustment[trackIndex]++; } /* Only in/out points handled so far */ if (info.changes & ClipInfoModified) { int filterIn = MLT.filterIn(playlist, currentIndex); int filterOut = MLT.filterOut(playlist, currentIndex); QScopedPointer clip(playlist.get_clip(currentIndex)); if (clip && clip->is_valid()) { UNDOLOG << "resizing clip at" << currentIndex << "in" << info.frame_in << "out" << info.frame_out; if (clip->parent().get_data("mlt_mix")) clip->parent().set("mlt_mix", nullptr, 0); if (clip->get_data("mix_in")) clip->set("mix_in", nullptr, 0); if (clip->get_data("mix_out")) clip->set("mix_out", nullptr, 0); playlist.resize_clip(currentIndex, info.frame_in, info.frame_out); MLT.adjustClipFilters(clip->parent(), filterIn, filterOut, info.in_delta, info.out_delta, info.in_delta); } QModelIndex modelIndex = m_model.createIndex(currentIndex, 0, info.oldTrackIndex); QVector roles; roles << MultitrackModel::InPointRole; roles << MultitrackModel::OutPointRole; roles << MultitrackModel::DurationRole; emit m_model.dataChanged(modelIndex, modelIndex, roles); if (clip && clip->is_valid()) AudioLevelsTask::start(clip->parent(), &m_model, modelIndex); } } /* Finally we walk through the tracks once more, removing clips that * were added, and clearing the temporarily used uid property */ int trackIndex = 0; foreach (const Track &track, m_model.trackList()) { QScopedPointer trackProducer(m_model.tractor()->track(track.mlt_index)); Mlt::Playlist playlist(*trackProducer); for (int i = playlist.count() - 1; i >= 0; --i) { QScopedPointer clip(playlist.get_clip(i)); QUuid uid = MLT.uuid(clip->parent()); if (clip->is_blank()) { uid = MLT.uuid(*clip); } if (m_clipsAdded.removeOne(uid)) { UNDOLOG << "Removing clip at" << i; m_model.beginRemoveRows(m_model.index(trackIndex), i, i); if (clip->parent().get_data("mlt_mix")) clip->parent().set("mlt_mix", NULL, 0); if (clip->get_data("mix_in")) clip->set("mix_in", NULL, 0); if (clip->get_data("mix_out")) clip->set("mix_out", NULL, 0); playlist.remove(i); m_model.endRemoveRows(); } } trackIndex++; } emit m_model.modified(); #ifdef UNDOHELPER_DEBUG debugPrintState("After undo"); #endif } void UndoHelper::setHints(OptimizationHints hints) { m_hints = hints; } void UndoHelper::debugPrintState(const QString &title) { LOG_DEBUG() << "timeline state:" << title << "{"; for (int i = 0; i < m_model.trackList().count(); ++i) { int mltIndex = m_model.trackList()[i].mlt_index; QString trackStr = QStringLiteral(" track %1 (mlt-idx %2):").arg(i).arg(mltIndex); QScopedPointer trackProducer(m_model.tractor()->track(mltIndex)); Mlt::Playlist playlist(*trackProducer); for (int j = 0; j < playlist.count(); ++j) { Mlt::ClipInfo info; playlist.clip_info(j, &info); QUuid uid = MLT.uuid(*info.producer); if (info.producer->is_blank() && info.cut) { uid = MLT.uuid(*info.cut); } trackStr += QStringLiteral(" [ %5 %1 -> %2 (%3 frames) %4]") .arg(info.frame_in) .arg(info.frame_out) .arg(info.frame_count) .arg(info.cut->is_blank() ? "blank " : "clip") .arg(uid.toString()); } LOG_DEBUG() << qPrintable(trackStr); } LOG_DEBUG() << "}"; } void UndoHelper::restoreAffectedTracks() { // Remove everything in the affected tracks. for (const auto &trackIndex : std::as_const(m_affectedTracks)) { if (trackIndex >= 0 && trackIndex < m_model.trackList().size()) { auto mlt_index = m_model.trackList().at(trackIndex).mlt_index; QScopedPointer producer(m_model.tractor()->track(mlt_index)); if (producer->is_valid()) { Mlt::Playlist playlist(*producer.data()); m_model.beginRemoveRows(m_model.index(trackIndex), 0, playlist.count() - 1); UNDOLOG << "clearing track" << trackIndex; playlist.clear(); m_model.endRemoveRows(); } } } for (const auto &uid : std::as_const(m_insertedOrder)) { const Info &info = m_state[uid]; if (m_affectedTracks.contains(info.oldTrackIndex)) { UNDOLOG << "Handling uid" << uid << "on track" << info.oldTrackIndex << "index" << info.oldClipIndex; // Clips are restored using their stored XML. int mltIndex = m_model.trackList()[info.oldTrackIndex].mlt_index; QScopedPointer trackProducer(m_model.tractor()->track(mltIndex)); Mlt::Playlist playlist(*trackProducer); auto currentIndex = playlist.count(); QModelIndex modelIndex = m_model.createIndex(currentIndex, 0, info.oldTrackIndex); m_model.beginInsertRows(modelIndex.parent(), currentIndex, currentIndex); if (info.isBlank) { playlist.blank(info.frame_out - info.frame_in); UNDOLOG << "appending blank at" << currentIndex << info.frame_out << info.frame_in; } else { UNDOLOG << "appending clip at" << currentIndex; Q_ASSERT(!(m_hints & SkipXML) && "Cannot restore clip without stored XML"); Q_ASSERT(!info.xml.isEmpty()); Mlt::Producer restoredClip(MLT.profile(), "xml-string", info.xml.toUtf8().constData()); if (restoredClip.type() == mlt_service_tractor_type) { // transition restoredClip.set("mlt_type", "mlt_producer"); } playlist.append(restoredClip, info.frame_in, info.frame_out); if (info.group >= 0) { QScopedPointer clip(playlist.get_clip(currentIndex)); clip->set(kShotcutGroupProperty, info.group); } } m_model.endInsertRows(); QScopedPointer clip(playlist.get_clip(currentIndex)); Q_ASSERT(currentIndex < playlist.count()); Q_ASSERT(!clip.isNull()); if (info.isBlank) { MLT.setUuid(*clip, uid); } else { MLT.setUuid(clip->parent(), uid); } AudioLevelsTask::start(clip->parent(), &m_model, modelIndex); } } for (const auto &trackIndex : std::as_const(m_affectedTracks)) { if (trackIndex >= 0 && trackIndex < m_model.trackList().size()) { auto mlt_index = m_model.trackList().at(trackIndex).mlt_index; QScopedPointer producer(m_model.tractor()->track(mlt_index)); if (producer->is_valid()) { Mlt::Playlist playlist(*producer.data()); for (auto currentIndex = 0; currentIndex < playlist.count(); currentIndex++) { Mlt::Producer clip = playlist.get_clip(currentIndex); fixTransitions(playlist, currentIndex, clip); } } } } } void UndoHelper::fixTransitions(Mlt::Playlist playlist, int clipIndex, Mlt::Producer clip) { if (clip.is_blank()) { return; } int transitionIndex = 0; for (auto currentIndex : {clipIndex + 1, clipIndex - 1}) { // Connect a transition on the right/left to the new producer. Mlt::Producer producer(playlist.get_clip(currentIndex)); if (producer.is_valid() && producer.parent().get(kShotcutTransitionProperty)) { Mlt::Tractor transition(producer.parent()); if (transition.is_valid()) { QScopedPointer transitionClip(transition.track(transitionIndex)); if (transitionClip->is_valid() && transitionClip->parent().get_service() != clip.parent().get_service()) { UNDOLOG << "Fixing transition at clip index" << currentIndex << "transition index" << transitionIndex; transitionClip.reset( clip.cut(transitionClip->get_in(), transitionClip->get_out())); transition.set_track(*transitionClip.data(), transitionIndex); } } } transitionIndex++; } }