
农口那边的朋友最近让我帮忙整理一批教学视频一数下来小两千个文件散落在几十层嵌套的文件夹里。他们想做两件事一是把每个视频和音频的时长写进文件名以后一眼就能看到素材长短二是把所有文件的时长信息汇总成一张Excel表方便做课程规划和备份核对。这两件事听起来都不难但真动手批量操作的时候问题就来了。Windows资源管理器虽然能看到媒体文件的时长那也仅限于在当前文件夹里用鼠标一个一个去属性里翻没法把时长批量写进文件名更没办法对孙文件夹、曾孙文件夹里所有文件一起处理。我当时搜了一圈现成小工具要么只能处理单层目录要么导出格式固定不好改要么干脆要付费。所以最后还是回到老路子自己写脚本。这篇文章就把整个思路和实现过程完整拆开来说包括工具选型、核心代码、递归扫描子孙文件夹的处理方式、Excel导出的字段设计以及实测中踩到的坑。适合手里有大量音视频素材需要整理、又不想靠人工一个个改名的朋友参考也适合刚接触脚本处理文件管理的读者拿来练手。1. 需求复盘这个工具到底要解决什么场景1.1 明明资源管理器能看时长为什么还要另做一套Windows资源管理器的详细信息视图里视频和音频文件确实自带“长度”列鼠标一放就能看到。但资源管理器的显示能力到这个程度就结束了它没法把时长写到文件名里也没法把整个目录树里所有媒体文件的时长汇总成一张清单。真实工作场景往往比这个更麻烦。拿我这次整理的课程素材举例文件结构是这样的主文件夹下按章节分了大目录每个章节目录里又分了“正式课程”“花絮”“原始素材”原始素材下面还有日期子目录也就是说MP4、MP3、MOV混在好几百个文件夹里。如果靠资源管理器逐层点进去看时长再手工修改文件名一个人一下午都不一定干得完还容易漏。所以核心需求拆开来就三条能递归获取所有子文件夹包括子孙级文件夹里的媒体文件时长能自动把格式化后的时长加到文件名尾部能生成一份完整的Excel清单记录文件路径、时长、重命名前后的信息。把这三点做透就解决了“素材管理里看不见时长”和“文件多到没法手工处理”两个痛点。1.2 “支持子孙文件夹”不是加分项是刚需标题里特别强调“支持子孙文件夹下操作”这个点很容易被低估。很多批量处理工具只支持当前文件夹或者一层子文件夹但真实素材库往往是多级嵌套的。用os.walk遍历整个目录树一级一级往下走加上一个合法的扩展名白名单做过滤就能干干净净地把树里每一个符合条件的文件都捞出来。我见过不少朋友一开始嫌递归麻烦觉得自己文件没那么多层。结果一到真用的时候发现某个分类下又套着年份、月、日三层目录工具就傻眼了最后还是绕回来做递归。与其这样倒不如一开始就把递归作为基础设计前提省得后面返工。2. 技术选型为什么用 ffprobe Python Excel 的组合2.1 获取媒体时长ffprobe是最省事的路径获取媒体文件的时长常见方案有好几种我对比过之后还是选了ffprobe。它是FFmpeg工具包里的小组件专门用来读取媒体文件的封装信息不重编码、不转码速度非常快。一个几百MB的视频文件ffprobe解析时长只需要几十毫秒到几百毫秒。它的用法也简单命令行一行就能拿到原始时长秒ffprobe -v quiet -show_entries formatduration -of csvp0 文件路径返回结果就是一个数字比如3725.034000表示这个文件时长3725.034秒。参数的含义拆开解释一下-v quiet关掉多余日志只输出结果-show_entries formatduration只读封装格式里的时长字段-of csvp0输出格式设为CSV且不要列名直接给值用生活类比来说ffprobe像一个读快递面单的工具只扫描包装盒面上的信息不打开箱子所以哪怕文件再大处理得再快也不会破坏原文件。2.2 为什么不直接在Python里解析二进制也有人说不用ffprobe直接在Python里解析MP4/MKV容器的二进制结构也能拿到时长。这个思路理论可行但有几个问题音视频封装格式几十种MP4、MKV、AVI、FLV、MOV各自的标准不同要分别写解析逻辑工作量直接翻几倍很多地方生成的MP4文件可能包含额外导航字段单靠解析box结构很容易踩到“信息没写在常规位置”的坑。ffprobe是经过大量真实文件验证过的处理异常封装的能力比个人写的解析脚本强太多。所以除非你追求的是一次不依赖第三方可执行文件的部署环境否则用ffprobe是性价比最高的选择。2.3 数据落地为什么用Excel而不是CSV考虑到最终用户是要拿这份清单去做课程表格、汇报、甚至打印留档Excel比CSV更直观。打开就能看不需要额外设置编码单元格格式、列宽、筛选也方便。这里我用的是openpyxl这个Python库专门用来读写.xlsx文件。如果你没有安装可以用下面的命令装上pip install openpyxl至于为什么不用pandas原因也很简单这个场景只需要把列表数据写入Excel表格开一个Workbook然后逐行append就可以了实在没必要把pandas这一套大依赖引进来脚本体积小一点团队里其他人接手也更容易看懂。3. 核心实现获取媒体时长与重命名逻辑3.1 用subprocess调用ffprobe的最小可运行代码下面这段就是整个工具里最核心的函数给定一个文件的完整路径返回它的时长秒。import subprocess def get_duration(file_path: str) - float: cmd [ ffprobe, -v, quiet, -show_entries, formatduration, -of, csvp0, file_path ] result subprocess.run(cmd, capture_outputTrue, textTrue, timeout30) if result.returncode ! 0: raise RuntimeError(fffprobe解析失败: {file_path} - {result.stderr.strip()}) try: return float(result.stdout.strip()) except ValueError: raise RuntimeError(f无法将输出转换为数字: {file_path} - {result.stdout.strip()})几点说明用subprocess.run而不是os.system是因为它可以捕获命令输出不至于让脚本被一堆日志刷屏。注意cmd用的是列表而不是字符串拼接这样文件路径里即使有空格、中文、括号也不会被命令行错解析。这是最常见的踩坑点后面我会专门再说。timeout30是个保险丝防止个别损坏文件导致ffprobe卡在哪里不动。按我的经验正常情况下一个文件很少需要超过几秒30秒足够宽松。如果某个文件解析失败这里直接抛异常由上层记录到失败列表里不会中断整个批处理流程。3.2 时长的格式化策略为什么不能用冒号拿到秒数之后就是格式化问题。直接显示3725.034秒既长又难读。我的做法是转换成时、分、秒并拼成适合做文件名的片段def format_duration(seconds: float) - str: total_seconds int(round(seconds)) h total_seconds // 3600 m (total_seconds % 3600) // 60 s total_seconds % 60 if h 0: return f{h:02d}h{m:02d}m{s:02d}s return f{m:02d}m{s:02d}s这里有个细节在内行看起来没人会提、但新手特别容易犯错的问题Windows文件名里不允许出现冒号。如果格式化时长时写成12:05:30重命名时操作系统会直接报错“文件名或扩展名太长”或者“包含无效字符”。所以我统一改成字母风格的,不过是h、m、s这种后缀比如1h30m05sWindows和macOS都用得。网络热搜里也提到过“linux文件名单引号怎么删除”这类问题说明文件名里的特殊字符处理跨系统都是个大痛点。如果时长不足一小时就只显示分和秒节约文件名长度。比如一个3分20秒的文件命名后缀是_03m20s。3.3 重命名逻辑与冲突保护重命名的逻辑不复杂在原文件名中去掉扩展名加上格式化时长的片段再补回扩展名。例如剪辑课进阶篇.mp4会变成剪辑课进阶篇_12m08s.mp4。直接看实现import os from pathlib import Path def rename_file(file_path: str, duration_str: str) - str: path Path(file_path) new_name f{path.stem}_{duration_str}{path.suffix} new_path path.with_name(new_name) # 如果目标名字已经存在加一个计数后缀避免覆盖 counter 1 while new_path.exists(): new_path path.with_name(f{path.stem}_{duration_str}_{counter}{path.suffix}) counter 1 os.rename(path, new_path) return new_path.namePath.with_name是Python里替换文件名部分的推荐写法比手动拼字符串更安全。目标文件已存在时我不直接用os.replace去覆盖因为那会静默丢失已有文件非常危险。这里改用加序号的方式保留两边内容虽然文件名丑一点但至少不会出事故。重命名前还有一个关键设计如果原文件名已经带上了我生成的时长标记就应该跳过。这个可以用一个正则匹配判断import re DURATION_MARK_RE re.compile(r_\d{2,}h\d{2}m\d{2}s$|_\d{2}m\d{2}s$) def already_processed(filename: str) - bool: stem Path(filename).stem return bool(DURATION_MARK_RE.search(stem))加上这个检查整个脚本就变成了可重复运行的工具。第一次跑完库文件都被改了名万一要换格式重新跑一遍也不会出现“文件名越拼越长”的情况。4. 递归遍历子孙文件夹的完整处理要点4.1 遍历方式选择os.walk比glob更合适遍历本地目录Python里常见的两种做法是glob.glob配合**模式以及os.walk。对这类“要从目录树根上往下全部扫一遍”的需求我更推荐os.walk。原因有两点。第一os.walk的语义就是从指定目录开始递归向下走到所有子目录天然支持“子孙文件夹”第二它对大目录树的性能表现比较稳定而且在遍历时可以轻松跳过指定目录。用glob当然也行但那要提前把模式写对处理Windows盘符和路径分隔符时不如os.walk来得可控。基础用法很简单import os for root, dirs, files in os.walk(base_dir): for filename in files: full_path os.path.join(root, filename) # 在这里对 full_path 做处理root是当前所在目录dirs是当前目录下所有子目录名列表files是当前目录下所有文件名列表。每进入一层文件夹这套组合就会自动更新一层一层递进。4.2 扩展名白名单只处理想处理的文件媒体文件的扩展名五花八门不加过滤的话可能把一张PNG图片也当成视频去解析。实际的做法是维护一个扩展名集合MEDIA_EXTENSIONS { # 视频 .mp4, .mov, .mkv, .avi, .wmv, .flv, .webm, .mpg, .m4v, # 音频 .mp3, .wav, .aac, .m4a, .flac, .ogg, .wma, } def is_media_file(filename: str) - bool: return Path(filename).suffix.lower() in MEDIA_EXTENSIONS扩展名统一转小写再判断避免出现.MP4和.mp4被区别对待的情况。你可能觉得这是小题大做但在真实文件系统里相机拍的素材经常是.MOV全大写手机导出的却是小写两者不统一的现象非常普遍。4.3 遍历中的坑权限、符号链接、异常文件实测过程中我踩过几个比较典型的坑。第一个坑是权限不足。某些系统目录或者加密目录下os.walk访问不了子目录时会直接报错甚至中断整个循环。解决办法很简单写一个自定义的错误回调把出错的目录记下来让遍历继续。def on_walk_error(e: OSError): print(f跳过无法访问的目录: {e.filename}, filesys.stderr) for root, dirs, files in os.walk(base_dir, onerroron_walk_error): ...第二个坑是符号链接软链接导致的循环死循环。如果你的目录树里某个子文件夹是指向上一级目录的链接os.walk有概率在极端情况下陷入无限递归。os.walk默认不递归跟随符号链接所以普通情况下问题不大但如果你之后决定开启followlinksTrue建议先在干净的测试目录下跑一遍确认结构。第三个坑是文件在遍历过程中被其他程序锁定。视频文件正被剪辑软件打开时重命名通常会失败。所以脚本里最好把重命名异常捕获下来并记录到失败列表运行结束后再统一手工处理。好的批处理工具不是追求每一次都成功而是要把失败的原因清清楚楚地告诉你。5. 数据落地导出Excel报表5.1 字段设计与排序策略Excel报表是给后面做课程规划用的字段设计不能太随意。我最后用的列结构是序号所在文件夹原始文件名重命名后文件名时长秒格式化时长状态1D:\素材\第1章导入.mp4导入_12m08s.mp4728.4512m08s成功2D:\素材\第1章未处理.avi未处理_30m01s.avi1801.2330m01s成功“所在文件夹”这一列特别重要。因为后面可能有同名文件分布在多个子目录里靠文件名区分容易混淆加上完整目录路径之后就一目了然。排序逻辑上我推荐按“所在文件夹”的路径排序同一个目录里的文件自然集中在一起后续检索会方便很多。如果还想把最长的课程挑出来可以直接在Excel里按“时长秒”列降序筛选。5.2 用openpyxl生成Excel的完整代码下面这段代码负责把前面收集到的数据一行一行写进Excelfrom openpyxl import Workbook from openpyxl.styles import Font from openpyxl.utils import get_column_letter def export_to_excel(rows: list, output_path: str): wb Workbook() ws wb.active ws.title 媒体时长清单 headers [序号, 所在文件夹, 原始文件名, 重命名后文件名, 时长秒, 格式化时长, 状态] ws.append(headers) for index, row in enumerate(rows, start1): ws.append([index] row) # 表头加粗、列宽自适应 for col_idx in range(1, len(headers) 1): cell ws.cell(row1, columncol_idx) cell.font Font(boldTrue) col_letter get_column_letter(col_idx) max_len len(headers[col_idx - 1]) for r in range(2, min(len(rows) 2, 50)): val ws.cell(rowr, columncol_idx).value if val is not None: max_len max(max_len, len(str(val))) ws.column_dimensions[col_letter].width max_len 4 wb.save(output_path) print(fExcel已导出到: {output_path})“列宽自适应”这个功能容易写得不稳定因为有些路径字符串很长完全按内容铺开会让某些列特别宽。我这里做了一个折中最多只统计前50行的数据来估算宽度这样表格既不会太挤也不会因为一列内容太长把整个工作表撑得没法看。如果你不想用openpyxl也可以退而求其次写CSV文件用utf-8-sig编码加BOM这样Excel直接打开CSV时中文才不乱码。但根据我的实际经验既然最终输出是Excel直接用openpyxl生成才是正路不用绕一圈。5.3 重命名失败的信息也要进Excel导出Excel时失败项同样要记录进去。我之前就在“状态”列写“失败”然后在原始文件名的位置保留原始名字重命名后文件名的位置填“-”后面再单独对这类文件做排查。有失败项的时候我会把失败原因同时打印到终端方便在跑脚本时就注意到。像get_duration抛出来的RuntimeError我会把具体原因是“ffprobe解析失败”还是“文件路径过长”“权限不足”都一并写进Excel。所有关键异常都不静默吞掉这是做批处理工具的一个基本素养。6. 实测运行、常见坑位与后续扩展6.1 完整运行示例把上面这些函数组合起来主流程大概长这样import os import sys from collections import defaultdict BASE_DIR rD:\课程素材 OUTPUT_EXCEL rD:\课程素材\媒体时长清单.xlsx RENAME_ENABLE True # 改False时只扫描导出Excel不改文件名 def main(): os.chdir(BASE_DIR) rows [] failures [] for root, dirs, files in os.walk(BASE_DIR, onerroron_walk_error): # 跳过临时文件目录比如系统缓存 dirs[:] [d for d in dirs if d not in [$RECYCLE.BIN, System Volume Information]] for filename in files: if not is_media_file(filename): continue full_path os.path.join(root, filename) try: duration get_duration(full_path) duration_str format_duration(duration) new_name filename if RENAME_ENABLE and not already_processed(filename): new_name rename_file(full_path, duration_str) print(f已重命名: {filename} - {new_name}) elif already_processed(filename): new_name filename rows.append([ os.path.dirname(full_path), filename, new_name, round(duration, 3), duration_str, 成功 ]) except Exception as exc: failures.append((filename, str(exc))) rows.append([ os.path.dirname(full_path), filename, -, , , f失败: {exc} ]) export_to_excel(rows, OUTPUT_EXCEL) print(f共处理 {len(rows)} 个文件失败 {len(failures)} 个) for item in failures[:20]: print( 失败文件:, item) if __name__ __main__: main()实测跑下来几百个文件基本是几秒到十几秒完成耗时的大头在ffprobe解析上重命名本身几乎不占时间。6.2 网上搜索里提到的高频问题的实际对应网络热搜词里关于这类场景的问题非常多我在这里统一回应一下都是实测中确实会碰到的“批量修改文件名”和“批量获取视频音频时长”是两个独立需求很多人分开搜索实际上完全可以一套脚本串起来做。“文件名或扩展名太长”在Windows里出现多半是路径全长度超过了260字符限制。视频文件一般存在多层目录中重命名时再次加长文件名很容易撞上限。遇到这种情况要么把文件移动到更浅的目录要么用Windows的LongPathsEnabled注册表项开启长路径支持。批处理前先看根目录深度能少掉不少头发。“excel不能复制粘贴”“excel批量处理php”这些都和本工具没直接关系真正用Python写批处理不需要牵扯进其他领域。“markdown表格转换excel”“华为交换机配置文件名”则属于不相关的热搜词不必展开。6.3 运行前必须做的检查清单跑这种会对文件系统产生写操作的脚本我个人的习惯是先做一轮检查在测试目录里放三五个不同类型的文件MP4、MP3、MOV先跑一边确认脚本逻辑无误。正式运行之前确认RENAME_ENABLE开关是你想要的值不放心就先把值设为False只导出Excel看看数据是否正常。源文件最好有备份。如果文件是从相机SD卡或者手机里拷出来的重命名导致素材丢失那损失可能就是几个月的拍摄成果了。安装ffprobe后在命令行输入ffprobe -version确认它能被直接调用。如果提示找不到命令需要把FFmpeg的bin目录加到系统PATH里或者在Python代码里写死ffprobe的完整路径。很多朋友在第一步就卡住原因往往是ffmpeg装好了但没把bin路径加入PATH环境变量。Windows下安装FFmpeg一般有两种方式一种是把官方构建包的bin目录手动加进系统PATH另一种是直接用包管理器安装比如winget install ffmpeg或者Chocolatey。装好后重新开一个终端窗口再执行ffprobe -version才能生效这个“重新开终端”的步骤也经常被忽略。6.4 后续还能往哪些方向扩展这套工具的基本盘已经很完整了如果你有更多类似需求往后扩展的空间也不小增加“时长排序”功能在导出Excel时自动把同目录下的文件按时长从长到短排列方便快速找到每节课的主视频。增加“按大小过滤”比如只处理超过100MB的视频文件避开图片、字幕、缩略图等小文件。把重命名模式改成可配置的用户传入占位符比如{name}_时长{hours}时{minutes}分{seconds}秒自由度更高。增加“重命名前先预览”模式把计划改成的名字输出到Excel的一个“预改名”列等用户确认后再执行真正重命名。对接媒体资产管理工具比如给Adobe Bridge或者Navicat相关的工作流输出标准字段。6.5 最后分享两件实测里让我印象最深的事一件是关于硬链接的。网络驱动器或者NAS上同一个文件可能被多个目录硬链接共享。你在一个目录里重命名了文件其他目录里的硬链接仍指向同一个数据块这会导致Excel里记录的文件名看起来没有时效性。做排查的时候遇到“明明改了名别的目录还是旧名字”的反馈第一反应应该看是不是硬链接而不是怀疑脚本逻辑有bug。另一件是文件名里的Unicode否定式字符。比如有些素材文件名里带有全角冒号“”这在Windows下是合法的但部分老旧的第三方工具会把全角冒号当成半角冒号来处理导致重命名前后看起来没差别实际上文件路径已经被工具搞乱了。脚本里统一用os.rename不会做这种“聪明”的修正所以反而安全。我个人在实际操作中的体会是这类工具写起来难度不大真正花时间的都是那些“大多数正常人不会遇到、但遇到一次就足够让人抓狂”的边缘情况。做文件批处理与其追求脚本一遍跑通不如把报错信息留得清清楚楚把可重跑性做好。这样即使中间出问题也能快速定位到是哪个文件、因为什么原因失败而不是把整个流程重新跑一遍去猜可能错在哪里。就这个项目而言把递归、重命名、导出Excel三件事串在一起已经足够替你在素材堆里省下好几个下午了。