Blob Blame Raw


// TnzCore includes
#include "tsystem.h"
#include "tstopwatch.h"
#include "tthreadmessage.h"
#include "timagecache.h"
#include "tlevel_io.h"
#include "trasterimage.h"
#include "timageinfo.h"
#include "trop.h"
#include "tsop.h"

// TnzLib includes
#include "toonz/toonzscene.h"
#include "toonz/sceneproperties.h"
#include "toonz/txsheet.h"
#include "toonz/tcamera.h"
#include "toonz/preferences.h"
#include "toonz/trasterimageutils.h"
#include "toonz/levelupdater.h"
#include "toutputproperties.h"
#include "toonz/boardsettings.h"

// tcg includes
#include "tcg/tcg_macros.h"

// Qt includes
#include <QCoreApplication>
#include <QTimer>

#include "toonz/movierenderer.h"

//**************************************************************************
//    Local Namespace  stuff
//**************************************************************************

namespace {

int RenderSessionId = 0;

//---------------------------------------------------------

void addMark(const TRasterP &mark, TRasterImageP img) {
  TRasterP raster = img->getRaster();

  if (raster->getLx() >= mark->getLx() && raster->getLy() >= mark->getLy()) {
    TRasterP ras = raster->clone();

    int borderx = troundp(0.035 * (ras->getLx() - mark->getLx()));
    int bordery = troundp(0.035 * (ras->getLy() - mark->getLy()));

    TRect rect = TRect(borderx, bordery, borderx + mark->getLx() - 1,
                       bordery + mark->getLy() - 1);
    TRop::over(ras->extract(rect), mark);

    img->setRaster(ras);
  }
}

//---------------------------------------------------------

void getRange(ToonzScene *scene, bool isPreview, int &from, int &to) {
  TSceneProperties *sprop = scene->getProperties();

  int step;
  if (isPreview)
    sprop->getPreviewProperties()->getRange(from, to, step);
  else
    sprop->getOutputProperties()->getRange(from, to, step);

  if (to < 0) {
    TXsheet *xs = scene->getXsheet();

    // NOTE: Use of numeric_limits::min is justified since the type is
    // *INTERGRAL*.
    from = (std::numeric_limits<int>::max)(),
    to   = (std::numeric_limits<int>::min)();

    for (int k = 0; k < xs->getColumnCount(); ++k) {
      int r0, r1;
      xs->getCellRange(k, r0, r1);

       TXshColumn *col         = xs->getColumn(k);
      TXshSoundColumn *sndCol = col ? col->getSoundColumn() : 0;

      if (sndCol) r0  = 0;
      from = std::min(from, r0), to = std::max(to, r1);
    }
  }
}

//---------------------------------------------------------

QString getPreviewName(unsigned long renderSessionId) {
  return "previewed" + QString::number(renderSessionId) + ".noext";
}

}  // namespace

//**************************************************************************
//    MovieRenderer::Imp  definition
//**************************************************************************

class MovieRenderer::Imp final : public TRenderPort, public TSmartObject {
public:
  ToonzScene *m_scene;
  TRenderer m_renderer;
  TFilePath m_fp;

  TRenderSettings m_renderSettings;
  TDimension m_frameSize;
  double m_xDpi, m_yDpi;

  std::set<MovieRenderer::Listener *> m_listeners;
  std::unique_ptr<LevelUpdater> m_levelUpdaterA, m_levelUpdaterB;
  TSoundTrackP m_st;

  std::map<double, std::pair<TRasterP, TRasterP>> m_toBeSaved;
  std::vector<std::pair<double, TFxPair>> m_framesToBeRendered;
  std::string m_renderCacheId;
  /*--- 同じラスタのキャッシュを使いまわすとき、
          最初のものだけガンマをかけ、以降はそれを使いまわすようにする。
  ---*/
  std::map<double, bool> m_toBeAppliedGamma;

  TThread::Mutex m_mutex;

  int m_renderSessionId;
  long m_whiteSample;

  int m_nextFrameIdxToSave;
  int m_savingThreadsCount;
  bool m_firstCompletedRaster;
  bool m_failure;
  bool m_cacheResults;
  bool m_preview;
  bool m_movieType;
  bool m_seqRequired;
  bool m_waitAfterFinish;

public:
  Imp(ToonzScene *scene, const TFilePath &moviePath, int threadCount,
      bool cacheResults);
  ~Imp();

  // TRenderPort methods

  void onRenderRasterCompleted(const RenderData &renderData) override;
  void onRenderFailure(const RenderData &renderData, TException &e) override;

  /*-- キャンセル時にはm_overallRenderedRegionを更新しない --*/
  void onRenderFinished(bool isCanceled = false) override;

  void doRenderRasterCompleted(const RenderData &renderData);
  void doPreviewRasterCompleted(const RenderData &renderData);

  // Helper methods

  void prepareForStart();
  void addSoundtrack(int r0, int r1, double fps, int boardDuration = 0);
  void postProcessImage(const TRasterImageP &img, bool has64bitOutputSupport,
                        const TRasterP &mark, int frame);

  //! Saves the specified rasters at the specified time; returns whether the
  //! frames were successfully saved, and
  //! the associated time-adjusted level frame.
  std::pair<bool, int> saveFrame(double frame,
                                 const std::pair<TRasterP, TRasterP> &rasters);
  std::string getRenderCacheId();

  // returns board duration in frame
  int addBoard();
};

//---------------------------------------------------------

MovieRenderer::Imp::Imp(ToonzScene *scene, const TFilePath &moviePath,
                        int threadCount, bool cacheResults)
    : m_scene(scene)
    , m_renderer(threadCount)
    , m_fp(moviePath)
    , m_frameSize(scene->getCurrentCamera()->getRes())
    , m_xDpi(72)
    , m_yDpi(72)
    , m_renderSessionId(RenderSessionId++)
    , m_nextFrameIdxToSave(0)
    , m_savingThreadsCount(0)
    , m_whiteSample(0)
    , m_firstCompletedRaster(
          true)         //< I know, sounds weird - it's just set to false
    , m_failure(false)  //  AFTER the first completed raster gets processed
    , m_cacheResults(cacheResults)
    , m_preview(moviePath.isEmpty())
    , m_movieType(isMovieType(moviePath))
    , m_seqRequired(isSequencialRequired(moviePath)) {
  m_renderCacheId =
      m_fp.withName(m_fp.getName() + "#RENDERID" +
                    QString::number(m_renderSessionId).toStdString())
          .getLevelName();

  m_renderer.addPort(this);
  m_waitAfterFinish = m_movieType && !m_seqRequired && threadCount > 1;
}

//---------------------------------------------------------

MovieRenderer::Imp::~Imp() {
  m_renderer.removePort(this);  // Please, note: a TRenderer instance is
                                // currently a shared-pointer-like
}  // object to a private worker. *That* object may outlive the TRenderer
   // instance.

//---------------------------------------------------------

void MovieRenderer::Imp::prepareForStart() {
  struct locals {
    static void eraseUncompatibleExistingLevel(
        const TFilePath &fp, const TDimension &imageSize)  // nothrow
    {
      assert(!fp.isEmpty());

      if (TSystem::doesExistFileOrLevel(fp)) {
        bool remove = false;
        // In case the raster specifics are different from those of a currently
        // existing movie, erase it
        try {
          if (fp.isFfmpegType()) {
            TSystem::removeFileOrLevel(fp);
          } else {
            TLevelReaderP lr(fp);
            lr->loadInfo();

            const TImageInfo *info = lr->getImageInfo();
            if (!info || info->m_lx != imageSize.lx ||
                info->m_ly != imageSize.ly)
              TSystem::removeFileOrLevel(fp);  // nothrow
          }
        } catch (...) {
          // Same if the level could not be read/opened
          TSystem::removeFileOrLevel(fp);  // nothrow
        }

        // NOTE: The level removal procedure could still fail.
        // In this case, no signaling takes place. The level readers will throw
        // when the time to write on the file comes, leading to a render
        // failure.
      }
    }
  };

  TOutputProperties *oprop = m_scene->getProperties()->getOutputProperties();
  double frameRate         = (double)oprop->getFrameRate();

  /*-- Frame rate の stretch --*/
  double stretchFactor = oprop->getRenderSettings().m_timeStretchTo /
                         oprop->getRenderSettings().m_timeStretchFrom;
  frameRate *= stretchFactor;

  // Get the shrink
  int shrinkX = m_renderSettings.m_shrinkX,
      shrinkY = m_renderSettings.m_shrinkY;

  // Build the render area
  TPointD cameraPos(-0.5 * m_frameSize.lx, -0.5 * m_frameSize.ly);
  TDimensionD cameraRes(double(m_frameSize.lx) / shrinkX,
                        double(m_frameSize.ly) / shrinkY);
  TDimension cameraResI(cameraRes.lx, cameraRes.ly);

  TRectD renderArea(cameraPos.x, cameraPos.y, cameraPos.x + cameraRes.lx,
                    cameraPos.y + cameraRes.ly);
  setRenderArea(renderArea);

  if (!m_fp.isEmpty()) {
    try  // Construction of a LevelUpdater may throw (well, almost ANY operation
         // on a LevelUpdater
    {    // could throw). But due to backward compatibility this function is
         // assumed to be non-throwing.
      if (!m_renderSettings.m_stereoscopic) {
        locals::eraseUncompatibleExistingLevel(m_fp, cameraResI);

        m_levelUpdaterA.reset(new LevelUpdater(
            m_fp, oprop->getFileFormatProperties(m_fp.getType()),
            oprop->formatTemplateFId()));
        m_levelUpdaterA->getLevelWriter()->setFrameRate(frameRate);
        m_fp = m_levelUpdaterA->getLevelWriter()->getFilePath();
      } else {
        TFilePath leftFp  = m_fp.withName(m_fp.getName() + "_l");
        TFilePath rightFp = m_fp.withName(m_fp.getName() + "_r");

        locals::eraseUncompatibleExistingLevel(leftFp, cameraResI);
        locals::eraseUncompatibleExistingLevel(rightFp, cameraResI);

        m_levelUpdaterA.reset(new LevelUpdater(
            leftFp, oprop->getFileFormatProperties(leftFp.getType()),
            oprop->formatTemplateFId()));
        m_levelUpdaterA->getLevelWriter()->setFrameRate(frameRate);
        leftFp = m_levelUpdaterA->getLevelWriter()->getFilePath();

        m_levelUpdaterB.reset(new LevelUpdater(
            rightFp, oprop->getFileFormatProperties(rightFp.getType()),
            oprop->formatTemplateFId()));
        m_levelUpdaterB->getLevelWriter()->setFrameRate(frameRate);
        rightFp = m_levelUpdaterB->getLevelWriter()->getFilePath();
      }
    } catch (...) {
      // If we get here, it's because one of the LevelUpdaters could not be
      // created. So, let's say
      // that if one could not be created, then ALL OF THEM couldn't (ie saving
      // is not possible at all).
      m_levelUpdaterA.reset();
      m_levelUpdaterB.reset();
    }
  }
}

//---------------------------------------------------------

void MovieRenderer::Imp::addSoundtrack(int r0, int r1, double fps,
                                       int boardDuration) {
  TCG_ASSERT(r0 <= r1, return );

  TXsheet::SoundProperties *prop =
      new TXsheet::SoundProperties();  // Ownership will be surrendered ...
  prop->m_frameRate = fps;

  TSoundTrack *snd = m_scene->getXsheet()->makeSound(prop);  // ... here
  if (!snd) {
    // No soundtrack
    m_whiteSample = (r1 - r0 + 1) * 918;  // 918?? wtf... I don't think it has
    return;  // any arcane meaning - but i'm not touching it.
  }          // My impression would be that... no sound implies
             // no access to m_whiteSample, no?

  double samplePerFrame = snd->getSampleRate() / fps;

  // Extract the useful part of scene soundtrack
  TSoundTrackP snd1 = snd->extract((TINT32)(r0 * samplePerFrame),
                                   (TINT32)(r1 * samplePerFrame));
  assert(!m_st);
  if (!m_st) {
    // First, add white sound before the 'from' instant
    m_st          = TSoundTrack::create(snd1->getFormat(), m_whiteSample);
    m_whiteSample = 0;  // Why?  Probably being pedantic here... I guess
  }

  // Then, add the rest
  TINT32 fromSample = m_st->getSampleCount();
  TINT32 numSample =
      std::max(TINT32((r1 - r0 + 1) * samplePerFrame), snd1->getSampleCount());

  m_st = TSop::insertBlank(m_st, fromSample, numSample + m_whiteSample);
  m_st->copy(snd1, TINT32(fromSample + m_whiteSample));

  // insert blank sound for clapperboard
  if (boardDuration > 0)
    m_st = TSop::insertBlank(m_st, 0, TINT32(boardDuration * samplePerFrame));

  m_whiteSample = 0;
}

//---------------------------------------------------------

void MovieRenderer::Imp::onRenderRasterCompleted(const RenderData &renderData) {
  if (m_preview)
    doPreviewRasterCompleted(renderData);
  else
    doRenderRasterCompleted(renderData);
}

//---------------------------------------------------------

void MovieRenderer::Imp::postProcessImage(const TRasterImageP &img,
                                          bool has64bitOutputSupport,
                                          const TRasterP &mark, int frame) {
  img->setDpi(m_xDpi, m_yDpi);

  if (img->getRaster()->getPixelSize() == 8 && !has64bitOutputSupport) {
    TRaster32P aux(img->getRaster()->getLx(), img->getRaster()->getLy());
    TRop::convert(aux, img->getRaster());
    img->setRaster(aux);
  }

  if (mark) addMark(mark, img);

  if (Preferences::instance()->isSceneNumberingEnabled())
    TRasterImageUtils::addGlobalNumbering(img, m_scene->getSceneName(), frame);
}

//---------------------------------------------------------------------

std::pair<bool, int> MovieRenderer::Imp::saveFrame(
    double frame, const std::pair<TRasterP, TRasterP> &rasters) {
  bool success = false;

  // Build the frame number to write to
  double stretchFac = double(m_renderSettings.m_timeStretchTo) /
                      m_renderSettings.m_timeStretchFrom;

  int fr = (stretchFac != 1) ? tround(frame * stretchFac) : int(frame);

  int boardDuration = 0;
  if (m_movieType) {
    BoardSettings *bs =
        m_scene->getProperties()->getOutputProperties()->getBoardSettings();
    boardDuration = (bs->isActive()) ? bs->getDuration() : 0;
  }

  TFrameId fid(fr + 1 + boardDuration);

  if (m_levelUpdaterA.get()) {
    assert(m_levelUpdaterB.get() || !rasters.second);

    // Analyze writer
    bool has64bitOutputSupport = false;
    {
      if (TImageWriterP writerA =
              m_levelUpdaterA->getLevelWriter()->getFrameWriter(fid))
        has64bitOutputSupport = writerA->is64bitOutputSupported();

      // NOTE: If the writer could not be retrieved, the updater will throw.
      // Failure will be caught then.
    }

    // Prepare the images to be flushed
    TRasterP rasterA = rasters.first, rasterB = rasters.second;
    assert(rasterA);

    /*--- 同じラスタのキャッシュを使いまわすとき、
    最初のものだけガンマをかけ、以降はそれを使いまわすようにする。
---*/
    if (m_renderSettings.m_gamma != 1.0 && m_toBeAppliedGamma[frame]) {
      TRop::gammaCorrect(rasterA, m_renderSettings.m_gamma);
      if (rasterB) TRop::gammaCorrect(rasterB, m_renderSettings.m_gamma);
    }

    // Flush images
    try {
      TRasterImageP imgA(rasterA);
      postProcessImage(imgA, has64bitOutputSupport, m_renderSettings.m_mark,
                       fid.getNumber());

      m_levelUpdaterA->update(fid, imgA);

      if (rasterB) {
        TRasterImageP imgB(rasterB);
        postProcessImage(imgB, has64bitOutputSupport, m_renderSettings.m_mark,
                         fid.getNumber());

        m_levelUpdaterB->update(fid, imgB);
      }

      // Should no more throw from here on

      if (m_cacheResults) {
        if (imgA->getRaster()->getPixelSize() == 8) {
          // Convert 64-bit images to 32 - cached images are supposed to be
          // 32-bit
          TRaster32P aux(imgA->getRaster()->getLx(),
                         imgA->getRaster()->getLy());

          TRop::convert(aux, imgA->getRaster());
          imgA->setRaster(aux);
        }

        TImageCache::instance()->add(
            m_renderCacheId + std::to_string(fid.getNumber()), imgA);
      }

      success = true;
    } catch (...) {
      // Nothing. The images could not be saved for whatever reason.
      // Failure is reported.
    }
  }

  return std::make_pair(success, fr);
}

//---------------------------------------------------------------------

void MovieRenderer::Imp::doRenderRasterCompleted(const RenderData &renderData) {
  assert(!(m_cacheResults &&
           m_levelUpdaterB.get()));  // Cannot cache results on stereoscopy

  QMutexLocker locker(&m_mutex);

  bool allowMT    = Preferences::instance()->getFfmpegMultiThread();
  bool requireSeq = allowMT ? m_seqRequired : m_movieType;

  // Build soundtrack at the first time a frame is completed - and the filetype
  // is that of a movie.
  if (m_firstCompletedRaster && m_movieType && !m_st) {
    int boardDuration = addBoard();

    int from, to;
    getRange(m_scene, false, from, to);

    TLevelP oldLevel(m_levelUpdaterA->getInputLevel());
    if (oldLevel) {
      from = std::min(from, oldLevel->begin()->first.getNumber() - 1);
      to   = std::max(to, (--oldLevel->end())->first.getNumber() - 1);
    }

    addSoundtrack(
        from, to,
        m_scene->getProperties()->getOutputProperties()->getFrameRate(),
        boardDuration);

    if (m_st) {
      m_levelUpdaterA->getLevelWriter()->saveSoundTrack(m_st.getPointer());
      if (m_levelUpdaterB.get())
        m_levelUpdaterB->getLevelWriter()->saveSoundTrack(m_st.getPointer());
    }
  }

  // Output frames must be *cloned*, since the supplied rasters will be
  // overwritten by m_renderer
  TRasterP toBeSavedRasA = renderData.m_rasA->clone();
  TRasterP toBeSavedRasB =
      renderData.m_rasB ? renderData.m_rasB->clone() : TRasterP();

  m_toBeSaved[renderData.m_frames[0]] =
      std::make_pair(toBeSavedRasA, toBeSavedRasB);

  m_toBeAppliedGamma[renderData.m_frames[0]] = true;

  // Prepare the cluster's frames to be saved (possibly in the future)
  std::vector<double>::const_iterator jt;
  for (jt = renderData.m_frames.begin(), ++jt; jt != renderData.m_frames.end();
       ++jt) {
    m_toBeSaved[*jt]        = std::make_pair(toBeSavedRasA, toBeSavedRasB);
    m_toBeAppliedGamma[*jt] = false;
  }

  // Attempt flushing as many frames as possible to the level updater(s)
  while (!m_toBeSaved.empty()) {
    std::map<double, std::pair<TRasterP, TRasterP>>::iterator ft =
        m_toBeSaved.begin();

    // In the *movie type* case, frames must be saved sequentially.
    // If the frame is not the next one in the sequence, wait until *that* frame
    // is available.
    if (requireSeq &&
        (ft->first != m_framesToBeRendered[m_nextFrameIdxToSave].first))
      break;

    // This thread will be the one processing ft - remove it from the map to
    // prevent another
    // thread from interfering
    double frame                          = ft->first;
    std::pair<TRasterP, TRasterP> rasters = ft->second;

    ++m_nextFrameIdxToSave;
    m_toBeSaved.erase(ft);

    // Save current frame
    std::pair<bool, int> savedFrame;
    {
      // Time the saving procedure
      struct SaveTimer {
        int &m_count;
        SaveTimer(int &count) : m_count(count) {
          if (m_count++ == 0) TStopWatch::global(0).start();
        }
        ~SaveTimer() {
          if (--m_count == 0) TStopWatch::global(0).stop();
        }
      } saveTimer(m_savingThreadsCount);

      // Unlock the mutex only in case this is NOT a movie type. Single images
      // can be saved concurrently.
      struct MutexUnlocker {
        QMutexLocker *m_locker;
        ~MutexUnlocker() {
          if (m_locker) m_locker->relock();
        }
      } unlocker = {requireSeq ? (QMutexLocker *)0
                               : (locker.unlock(), &locker)};

      savedFrame = saveFrame(frame, rasters);
    }

    // Report status and deal with responses
    bool okToContinue = true;

    std::set<MovieRenderer::Listener *>::iterator lt = m_listeners.begin();

    if (savedFrame.first) {
      for (; lt != m_listeners.end(); ++lt)
        okToContinue &= (*lt)->onFrameCompleted(savedFrame.second);
    } else {
      for (; lt != m_listeners.end(); ++lt) {
        TException e;
        okToContinue &= (*lt)->onFrameFailed(savedFrame.second, e);
      }
    }

    if (!okToContinue) {
      // Some listener invoked termination of the render procedure. It seems
      // it's their right
      // to do so. I wonder what happens if two listeners would disagree on the
      // matter...
      // BTW stop the rendering, alright.

      {
        int from, to;
        getRange(m_scene, false, from,
                 to);  // It's ok since cancels can only happen from Toonz...

        for (int i = from; i < to; i++)
          TImageCache::instance()->remove(m_renderCacheId +
                                          std::to_string(i + 1));
      }

      m_renderer.stopRendering();

      m_levelUpdaterA
          .reset();  // No more saving. Further attempts to save images
      m_levelUpdaterB.reset();  // will be rejected and treated as failures.
    }
  }

  m_firstCompletedRaster = false;
}

//---------------------------------------------------------

void MovieRenderer::Imp::doPreviewRasterCompleted(
    const RenderData &renderData) {
  // Most probably unused now. I'm not reviewing this.

  assert(!m_levelUpdaterA.get());

  QMutexLocker sl(&m_mutex);

  QString name = getPreviewName(m_renderSessionId);

  TRasterP ras = renderData.m_rasA->clone();
  if (renderData.m_rasB) {
    assert(m_renderSettings.m_stereoscopic);
    TRop::makeStereoRaster(ras, renderData.m_rasB);
  }

  TRasterImageP img(ras);
  img->setDpi(m_xDpi, m_yDpi);

  try {
    if (renderData.m_info.m_mark != TRasterP())
      addMark(renderData.m_info.m_mark, img);

    if (img->getRaster()->getPixelSize() == 8) {
      TRaster32P aux(img->getRaster()->getLx(), img->getRaster()->getLy());
      TRop::convert(aux, img->getRaster());
      img->setRaster(aux);
    }

    QString frameName = name + QString::number(renderData.m_frames[0] + 1);

    TImageCache::instance()->add(frameName.toStdString(), img);

    // controlla se ci sono frame(uguali ad altri) da mettere in cache

    std::vector<double>::const_iterator jt;
    for (jt = renderData.m_frames.begin(), ++jt;
         jt != renderData.m_frames.end(); ++jt) {
      frameName = name + QString::number(*jt + 1);
      TImageCache::instance()->add(frameName.toStdString(), img);
    }
  } catch (...) {
  }

  std::set<MovieRenderer::Listener *>::iterator listenerIt =
      m_listeners.begin();

  bool okToContinue = true;

  for (; listenerIt != m_listeners.end(); ++listenerIt)
    okToContinue &= (*listenerIt)->onFrameCompleted(renderData.m_frames[0]);

  if (!okToContinue) {
    // Svuoto la cache nel caso in cui si esce dal render prima della fine
    int from, to;
    getRange(m_scene, true, from, to);
    for (int i = from; i < to; i++) {
      QString frameName = name + QString::number(i + 1);
      TImageCache::instance()->remove(frameName.toStdString());
    }

    m_renderer.stopRendering();
  }

  m_firstCompletedRaster = false;
}

//---------------------------------------------------------

void MovieRenderer::Imp::onRenderFailure(const RenderData &renderData,
                                         TException &e) {
  QMutexLocker sl(&m_mutex);  // Lock as soon as possible.
                              // No sense making it later in this case!
  m_failure = true;

  bool allowMT    = Preferences::instance()->getFfmpegMultiThread();
  bool requireSeq = allowMT ? m_seqRequired : m_movieType;

  // If the saver object has already been destroyed - or it was never
  // created to begin with, nothing to be done
  if (!m_levelUpdaterA.get()) return;  // The preview case would fall here

  // Flush out as much as we can of the frames that were already rendered
  m_toBeSaved[0.0] =
      std::make_pair(TRasterP(), TRasterP());  // ?? Why is this ??

  std::map<double, std::pair<TRasterP, TRasterP>>::iterator it =
      m_toBeSaved.begin();
  while (it != m_toBeSaved.end()) {
    if (requireSeq &&
        (it->first != m_framesToBeRendered[m_nextFrameIdxToSave].first))
      break;

    // o_o!
    // I would have expected that at least those frames that were computed could
    // attempt saving! Why is this not addressed? They're even marked as
    // 'failed'!

    double stretchFac = (double)renderData.m_info.m_timeStretchTo /
                        renderData.m_info.m_timeStretchFrom;

    int fr;
    if (stretchFac != 1)
      fr = tround(it->first * stretchFac);
    else
      fr = (int)it->first;

    // No saving? Really?

    std::set<MovieRenderer::Listener *>::iterator lt = m_listeners.begin();
    bool okToContinue                                = true;

    for (; lt != m_listeners.end(); ++lt)
      okToContinue &= (*lt)->onFrameFailed((int)it->first, e);

    if (!okToContinue) m_renderer.stopRendering();

    ++m_nextFrameIdxToSave;
    m_toBeSaved.erase(it++);
  }
}

//---------------------------------------------------------

void MovieRenderer::Imp::onRenderFinished(bool isCanceled) {
  TFilePath levelName(
      m_levelUpdaterA.get()
          ? m_fp
          : TFilePath(getPreviewName(m_renderSessionId).toStdWString()));

  if (m_waitAfterFinish) {
    // Wait half a second to add some stability before finalizing
    QEventLoop eloop;
    QTimer timer;
    timer.connect(&timer, &QTimer::timeout, &eloop, &QEventLoop::quit);
    timer.start(500);
    eloop.exec();
  }

  // Close updaters. After this, the output levels should be finalized on disk.
  m_levelUpdaterA.reset();
  m_levelUpdaterB.reset();

  if (!m_failure) {
    // Inform listeners of the render completion
    std::set<MovieRenderer::Listener *>::iterator it;
    for (it = m_listeners.begin(); it != m_listeners.end(); ++it)
      (*it)->onSequenceCompleted(levelName);

    // I wonder why listeners are not informed of a failed sequence, btw...
  }

  release();  // The movieRenderer is released by the render process. It could
              // eventually be deleted.
}

//---------------------------------------------------------

int MovieRenderer::Imp::addBoard() {
  BoardSettings *boardSettings =
      m_scene->getProperties()->getOutputProperties()->getBoardSettings();
  if (!boardSettings->isActive()) return 0;
  int duration = boardSettings->getDuration();
  if (duration == 0) return 0;
  // Get the image size
  int shrinkX = m_renderSettings.m_shrinkX,
      shrinkY = m_renderSettings.m_shrinkY;
  TDimensionD cameraRes(double(m_frameSize.lx) / shrinkX,
                        double(m_frameSize.ly) / shrinkY);
  TDimension cameraResI(cameraRes.lx, cameraRes.ly);

  TRaster32P boardRas =
      boardSettings->getBoardRaster(cameraResI, shrinkX, m_scene);

  if (m_levelUpdaterA.get()) {
    // Flush images
    try {
      TRasterImageP img(boardRas);
      for (int f = 0; f < duration; f++) {
        m_levelUpdaterA->update(TFrameId(f + 1), img);
        if (m_levelUpdaterB.get())
          m_levelUpdaterB->update(TFrameId(f + 1), img);
      }
    } catch (...) {
      // Nothing. The images could not be saved for whatever reason.
      // Failure is reported.
    }
  }
  return duration;
}

//======================================================================================

//======================
//    MovieRenderer
//----------------------

MovieRenderer::MovieRenderer(ToonzScene *scene, const TFilePath &moviePath,
                             int threadCount, bool cacheResults)
    : m_imp(new Imp(scene, moviePath, threadCount, cacheResults)) {
  m_imp->addRef();  // See MovieRenderer::start(). Can't just delete it in the
                    // dtor.
}

//---------------------------------------------------------

MovieRenderer::~MovieRenderer() { m_imp->release(); }

//---------------------------------------------------------

void MovieRenderer::setRenderSettings(const TRenderSettings &renderSettings) {
  m_imp->m_renderSettings = renderSettings;
}

//---------------------------------------------------------

void MovieRenderer::setDpi(double xDpi, double yDpi) {
  m_imp->m_xDpi = xDpi;
  m_imp->m_yDpi = yDpi;
}

//---------------------------------------------------------

void MovieRenderer::addListener(Listener *listener) {
  m_imp->m_listeners.insert(listener);
}

//---------------------------------------------------------

void MovieRenderer::addFrame(double frame, const TFxPair &fxPair) {
  m_imp->m_framesToBeRendered.push_back(std::make_pair(frame, fxPair));
}

//---------------------------------------------------------

void MovieRenderer::enablePrecomputing(bool on) {
  m_imp->m_renderer.enablePrecomputing(on);
}

//---------------------------------------------------------

bool MovieRenderer::isPrecomputingEnabled() const {
  return m_imp->m_renderer.isPrecomputingEnabled();
}

//---------------------------------------------------------

void MovieRenderer::start() {
  m_imp->prepareForStart();

  // Add a reference to MovieRenderer's Imp. The reference is 'owned' by
  // TRenderer's render process - when it
  // ends (that is, when notifies onRenderFinished), the reference is released.
  // As to TRenderer's specifics,
  // this is ensured to happen only after all the other port notifications for
  // each frame have been invoked.
  m_imp->addRef();

  // Prepare the TRenderer::RenderDatas to render
  RenderDataVector *datasToBeRendered = new RenderDataVector;
  size_t i, size = m_imp->m_framesToBeRendered.size();
  for (i = 0; i < size; ++i)
    datasToBeRendered->push_back(TRenderer::RenderData(
        m_imp->m_framesToBeRendered[i].first, m_imp->m_renderSettings,
        m_imp->m_framesToBeRendered[i].second));

  m_imp->m_renderer.startRendering(datasToBeRendered);
}

//---------------------------------------------------------

void MovieRenderer::onCanceled() { m_imp->m_renderer.stopRendering(true); }

//---------------------------------------------------------

TRenderer *MovieRenderer::getTRenderer() {
  // Again, this is somewhat BAD. The pointed-to object dies together with the
  // MovieRenderer instance.
  // Since a TRenderer is already smart-pointer-like, we could just return a
  // copy - however, it really
  // shouldn't be that way. Maybe one day we'll revert that and actually use a
  // smart pointer class.

  // For now, no use of this function seems to access the returned pointer
  // beyond the lifespan of the
  // associated MovieRenderer instance - so I'm not gonna touch the class
  // interface.
  return &m_imp->m_renderer;
}