个人项目开发工具 BiliMerger 功能设计记录:需求判断与实现取舍

个人项目开发工具 BiliMerger 功能设计记录:需求判断与实现取舍 BiliMerger发布后偏向设计层面的问题——为什么要做三种模式、暂停恢复是怎么考虑的、自动关机是怎么设计的。这些问题涉及的不是代码本身而是开发过程中的需求判断和功能取舍。这篇文章记录BiliMerger开发过程中遇到的需求场景、功能设计思路以及具体的实现细节。功能背景B站App的缓存机制将视频和音频分开存储为独立的.m4s文件。每个缓存视频目录下通常包含一个.jpg封面文件、一个video.m4s视频流文件和一个audio.m4s音频流文件。用户若想将缓存视频导出为可播放的单一文件需要将音视频流合并。此过程涉及两个核心问题.m4s文件开头存在9字节的脏数据合并前需去除音视频流需通过外部工具ffmpeg进行合并。基于此需求BiliMerger的定位确定为自动扫描B站缓存目录配对音视频文件清理脏数据后调用ffmpeg合并为单一MP4或MKV文件。脏数据的处理刚开始做的时候我以为直接把两个.m4s文件丢给ffmpeg就能合并。结果ffmpeg报错提示无法识别格式。后来查了资料才发现B站缓存的.m4s文件开头有9个字节的脏数据。用十六进制编辑器打开一看开头9个字节全是30ASCII字符’0’第10个字节开始才是真正的文件头。这9个字节不删掉ffmpeg就没法正常读取。最开始的处理方案是用QFile读取文件从第9个字节开始往后复制到临时文件。这个方案在小文件上没问题但遇到几GB的大文件时额外读写一份数据对磁盘IO压力很大速度也慢。cleanDirtyData函数里做了这件事bool MergeWorker::cleanDirtyData(const QStringsrcPath, const QStringdstPath, qint64 totalSize, int startPercent, int endPercent, bool* cancelled){QFile src(srcPath);QFile dst(dstPath);if(!src.open(QIODevice::ReadOnly))returnfalse;if(!dst.open(QIODevice::WriteOnly)){src.close();returnfalse;}if(!src.seek(9)){src.close();dst.close();returnfalse;}const qint64 BUFFER_SIZE2*1024*1024;QByteArray buffer(BUFFER_SIZE, Qt::Uninitialized);qint64 processed0;while(!src.atEnd()){while((int)m_paused!(int)m_stopped){ QThread::msleep(100);QCoreApplication::processEvents();} if((int)m_stopped){*cancelledtrue;break;} qint64 readsrc.read(buffer.data(),BUFFER_SIZE);if(read0)break;if(dst.write(buffer.data(),read)!read){ src.close();dst.close();return false;} processedread;int percentstartPercent(int)((double)processed/totalSize*(endPercent-startPercent));emit progress(percent,);QCoreApplication::processEvents();}src.close();dst.close();return!(*cancelled);}后来查阅ffmpeg文档发现-skip_initial_bytes参数可以直接跳过输入文件开头的指定字节数省去了写临时文件的步骤。但要注意这个参数不是所有ffmpeg版本都支持。ffmpeg合并参数bool MergeWorker::mergeVideo(const QStringvideoPath, const QStringaudioPath, const QStringoutputPath, bool* cancelled){QProcess ffmpeg;QStringList args;args-ivideoPath-iaudioPath;if(m_qualityPreset0){args-c:vcopy-c:acopy;}else{args-c:vlibx264;switch(m_qualityPreset){case1: args-crf18-presetmedium;break;case2: args-crf23-presetfast;break;case3: args-crf28-presetfast;break;}args-c:aaac-b:a192k;}args-max_muxing_queue_size4096-movflagsfaststart-youtputPath;ffmpeg.start(m_ffmpegPath, args);while(!ffmpeg.waitForFinished(500)){while((int)m_paused!(int)m_stopped){QThread::msleep(100);QCoreApplication::processEvents();}if((int)m_stopped){*cancelledtrue;ffmpeg.kill();ffmpeg.waitForFinished(1000);returnfalse;}}returnffmpeg.exitCode()0;}-c:v copy -c:a copy直接复制流不重新编码速度最快且画质无损。质量预设非0时用libx264重新编码通过CRF控制画质。-max_muxing_queue_size 4096解决长视频合并时的队列溢出问题。-movflags faststart让MP4文件在网页上能边下边播。三种合并模式最初版本只支持单视频合并后续扩展了三种模式。startMerge()中通过优先级判断选择对应的处理分支void MainWindow::startMerge(){if(m_ffmpegPath.isEmpty()||!QFile::exists(m_ffmpegPath)){QMessageBox::critical(this,错误,ffmpeg.exe未找到);return;}QString outDirui-outputPathEdit-text().trimmed();if(outDir.isEmpty()||!QFile::exists(outDir)){outDirQCoreApplication::applicationDirPath();ui-outputPathEdit-setText(outDir);}bool multiModeui-multiSelectCheck-isChecked();bool batchAllModeui-batchModeCheck-isChecked();if(batchAllMode){// 创建批量工作线程处理m_allVideos全部列表}elseif(multiMode){QListQVariantMapselectedVideosgetSelectedVideos();if(selectedVideos.isEmpty()){QMessageBox::critical(this,错误,请先勾选要合并的视频);return;}// 创建批量工作线程处理selectedVideos}else{if(m_currentVideoFile.isEmpty()||m_currentAudioFile.isEmpty()){QMessageBox::critical(this,错误,请先选择一个视频);return;}// 创建单视频工作线程}}当“一键全部”与“选择多个”同时开启时以“一键全部”为准。此设计基于用户操作意图的判断——当用户开启全量模式时其核心需求即为处理全部文件而非手动筛选。多选模式下getSelectedVideos()遍历所有卡片收集勾选状态QListQVariantMapMainWindow::getSelectedVideos(){QListQVariantMapselected;for(VideoCard* card:qAsConst(m_cards)){if(card-isChecked()){selected.append(card-getData());}}returnselected;}暂停、恢复与取消通过QAtomicInt原子变量控制工作线程状态QAtomicInt m_paused;QAtomicInt m_stopped;void MergeWorker::pause(){m_paused1;}void MergeWorker::resume(){m_paused0;}void MergeWorker::stop(){m_stopped1;m_paused0;}在cleanDirtyData和mergeVideo函数的循环中检查状态。暂停时线程阻塞但不退出恢复时继续执行。停止时终止ffmpeg进程并清理资源。取消操作中遇到过一个问题直接killffmpeg进程后子进程可能残留。ffmpeg.kill()在Qt内部会尝试终止整个进程树但某些情况下子进程仍会残留。后续版本考虑用taskkill /T /F /PID作为补充。文件拖拽配对dropEvent中处理两种拖拽场景void MainWindow::dropEvent(QDropEvent* e){QListQUrlurlse-mimeData()-urls();if(urls.isEmpty())return;QStringList folders;QStringList m4sFiles;for(const QUrlurl:qAsConst(urls)){QString pathurl.toLocalFile();QFileInfo info(path);if(info.isDir()){folders.append(path);}elseif(path.endsWith(.m4s, Qt::CaseInsensitive)){m4sFiles.append(path);}}if(folders.size()1){scanMultipleFolders(folders);return;}if(folders.size()1){ui-sourcePathEdit-setText(folders.first());return;}if(m4sFiles.size()1){QString pathm4sFiles.first();QDirdirQFileInfo(path).dir();QStringList allM4sdir.entryList(QStringList()*.m4s, QDir::Files);if(allM4s.size()2){QListQPairQString, qint64fileSizes;for(const QStringf:qAsConst(allM4s)){QString fullPathdir.absoluteFilePath(f);fileSizes.append({fullPath, QFileInfo(fullPath).size()});}std::sort(fileSizes.begin(), fileSizes.end(),[](const autoa, const autob){returna.secondb.second;});m_currentAudioFilefileSizes.first().first;m_currentVideoFilefileSizes.last().first;// 更新界面}}}配对依据是文件大小——B站缓存的音频流通常比视频流小按大小排序即可区分。文件名模板模板支持{标题}、{日期}、{序号}三个占位符QString VideoCard::applyTemplate(const QStringtmpl, const QStringtitle, int index){QString resulttmpl;result.replace({标题}, title);result.replace({日期}, getCurrentDate());result.replace({序号}, QString::number(index));result.remove(QRegularExpression([\\\\/:*?\|]));if(result.isEmpty())resultmerged_video;returnresult;}移除非法字符是为了确保生成的文件名在Windows下有效。设置记忆使用QSettings存储配置支持下次启动恢复void MainWindow::saveSettings(){QSettings settings(BiliMerger,Settings);settings.setValue(sourcePath, ui-sourcePathEdit-text());settings.setValue(outputPath, ui-outputPathEdit-text());settings.setValue(geometry, saveGeometry());settings.setValue(batchMode, ui-batchModeCheck-isChecked());settings.setValue(outputFormat, ui-formatCombo-currentIndex());settings.setValue(nameTemplate, ui-templateEdit-text());settings.setValue(memoryDisk, ui-memoryDiskCheck-isChecked());}void MainWindow::loadSettings(){QSettings settings(BiliMerger,Settings);ui-sourcePathEdit-setText(settings.value(sourcePath,).toString());ui-outputPathEdit-setText(settings.value(outputPath, QCoreApplication::applicationDirPath()).toString());ui-batchModeCheck-setChecked(settings.value(batchMode,false).toBool());ui-formatCombo-setCurrentIndex(settings.value(outputFormat,0).toInt());ui-templateEdit-setText(settings.value(nameTemplate,{标题}).toString());ui-memoryDiskCheck-setChecked(settings.value(memoryDisk,false).toBool());restoreGeometry(settings.value(geometry).toByteArray());}自动关机用户勾选“完成后关机”后合并结束时触发void MainWindow::shutdownSystem(){QProcess::startDetached(shutdown, QStringList()/s/t60);QMessageBox* msgBoxnew QMessageBox(this);msgBox-setWindowTitle(即将关机);msgBox-setText(合并完成将在60秒后自动关机\n\n点击【取消】可取消关机);msgBox-setIcon(QMessageBox::Information);msgBox-addButton(取消, QMessageBox::RejectRole);msgBox-setStandardButtons(QMessageBox::NoButton);connect(msgBox,QMessageBox::buttonClicked, this,[this](QAbstractButton* button){if(button-text()取消){QProcess::startDetached(shutdown, QStringList()/a);log(已取消关机);}});msgBox-show();}60秒倒计时的设计给用户留出取消关机的缓冲时间。源码及下载地址https://github.com/Bologna-Zaicek/Tools-BiliMerger