如何用AI制作高中同学会回顾视频,记录从零到《二十年》的全过程?

摘要:用 AI 制作高中同学会回顾视频 —— 从零到《二十年》的全过程记录 制作全程纪实 · 2026年3月 用 AI 制作一部二十年同学会回顾电影 从一个模糊的想法,到 304 秒的完整电影——记录与 AI 协作完成视
用 AI 制作高中同学会回顾视频 —— 从零到《二十年》的全过程记录 :root { --gold: #c8a84b; --gold-light: #e8c96e; --dark-bg: #1a1a2e; --card-bg: #16213e; --text-main: #e8e8e8; --text-sub: #a0a8c0; --accent: #e94560; --green: #4caf88; --blue: #4a9eff; --border: rgba(200, 168, 75, 0.25) } * { box-sizing: border-box; margin: 0; padding: 0 } body { background: var(--dark-bg); color: var(--text-main); font-family: "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.8; font-size: 16px } .cover { min-height: 100vh; background: radial-gradient(ellipse at 60% 40%, rgba(45, 27, 94, 1) 0, rgba(26, 26, 46, 1) 60%, rgba(13, 13, 26, 1) 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 40px 20px; position: relative; overflow: hidden } .cover::before { content: ""; position: absolute; border-radius: 50%; background: radial-gradient(circle at center, rgba(200, 168, 75, 0.08) 0, rgba(0, 0, 0, 0) 70%); top: 50%; left: 50%; transform: translate(-50%, -50%) } .film-grain { position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); pointer-events: none; opacity: 0.6 } .cover-tag { font-size: 12px; letter-spacing: 4px; color: var(--gold); text-transform: uppercase; margin-bottom: 24px; opacity: 0.8 } .cover h1 { font-weight: 700; color: rgba(255, 255, 255, 1); line-height: 1.3; margin-bottom: 12px; text-shadow: 0 2px 20px rgba(200, 168, 75, 0.3) } .cover h1 em { font-style: normal; color: var(--gold-light) } .cover-sub { font-size: 16px; color: var(--text-sub); margin-bottom: 36px } .cover-meta { display: flex; gap: 32px; flex-wrap: wrap; justify-content: center } .cover-meta-item { text-align: center } .cover-meta-item .num { font-size: 32px; font-weight: 700; color: var(--gold-light); display: block } .cover-meta-item .label { font-size: 12px; color: var(--text-sub); letter-spacing: 1px } .scroll-hint { position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--text-sub); font-size: 12px; letter-spacing: 2px; animation: 2s infinite bounce } .scroll-hint::after { content: "▼"; font-size: 14px } @keyframes bounce { 0%, 100% { transform: translateX(-50%) translateY(0) } 50% { transform: translateX(-50%) translateY(8px) } } .container { margin: 0 auto; padding: 0 24px } .section { padding: 72px 0 40px } .section-label { font-size: 11px; letter-spacing: 4px; color: var(--gold); text-transform: uppercase; margin-bottom: 12px; display: flex; align-items: center; gap: 10px } .section-label::before { content: ""; width: 32px; height: 1px; background: var(--gold) } .section h2 { font-weight: 700; color: rgba(255, 255, 255, 1); margin-bottom: 28px; line-height: 1.4 } .section p { color: var(--text-main); margin-bottom: 18px; font-size: 15.5px } .section p strong { color: var(--gold-light) } .timeline { position: relative; padding-left: 32px; margin: 36px 0 } .timeline::before { content: ""; position: absolute; left: 8px; top: 0; bottom: 0; width: 2px; background: linear-gradient(to bottom, var(--gold), transparent) } .timeline-item { position: relative; margin-bottom: 32px } .timeline-item::before { content: ""; position: absolute; left: -28px; top: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 8px var(--gold) } .timeline-item .t-step { font-size: 11px; color: var(--gold); letter-spacing: 2px; margin-bottom: 6px } .timeline-item h3 { font-size: 17px; font-weight: 600; color: rgba(255, 255, 255, 1); margin-bottom: 8px } .timeline-item p { font-size: 14.5px; color: var(--text-sub); margin-bottom: 0 } .scene-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin: 32px 0 } .scene-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 20px; transition: transform 0.2s, box-shadow 0.2s } .scene-card:hover { transform: translateY(-3px); box-shadow: 0 8px 32px rgba(200, 168, 75, 0.12) } .scene-card .scene-num { font-size: 11px; color: var(--gold); letter-spacing: 2px; margin-bottom: 8px } .scene-card h4 { font-size: 15px; font-weight: 700; color: rgba(255, 255, 255, 1); margin-bottom: 8px } .scene-card .scene-dur { font-size: 12px; color: var(--accent); margin-bottom: 10px } .scene-card p { font-size: 13px; color: var(--text-sub); margin-bottom: 0 } .scene-card.highlight { border-color: var(--gold); background: linear-gradient(135deg, #1e2a4a, var(--card-bg)) } .tech-block { background: var(--card-bg); border-left: 3px solid var(--gold); border-radius: 0 12px 12px 0; padding: 24px; margin: 24px 0 } .tech-block h4 { font-size: 15px; font-weight: 600; color: var(--gold-light); margin-bottom: 12px } .tech-block p { font-size: 14px; color: var(--text-sub); margin-bottom: 8px } .tech-block p:last-child { margin-bottom: 0 } .code-block { background: rgba(13, 13, 26, 1); border: 1px solid rgba(74, 158, 255, 0.2); border-radius: 10px; padding: 20px; margin: 20px 0; font-family: "Courier New", monospace; font-size: 13px; color: rgba(168, 212, 255, 1); overflow-x: auto; line-height: 1.6 } .code-block .comment { color: rgba(90, 122, 106, 1) } .code-block .keyword { color: rgba(255, 121, 198, 1) } .code-block .string { color: rgba(241, 250, 140, 1) } .code-block .func { color: rgba(139, 233, 253, 1) } .problem-solution { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0 } @media (max-width: 600px) { .problem-solution { grid-template-columns: 1fr } } .ps-card { border-radius: 12px; padding: 20px } .ps-card.problem { background: rgba(233, 69, 96, 0.08); border: 1px solid rgba(233, 69, 96, 0.3) } .ps-card.solution { background: rgba(76, 175, 136, 0.08); border: 1px solid rgba(76, 175, 136, 0.3) } .ps-card h4 { font-size: 13px; letter-spacing: 2px; margin-bottom: 10px } .ps-card.problem h4 { color: var(--accent) } .ps-card.solution h4 { color: var(--green) } .ps-card p { font-size: 13.5px; color: var(--text-sub); margin-bottom: 0 } .stats-row { display: flex; gap: 16px; flex-wrap: wrap; margin: 28px 0 } .stat-item { flex: 1; min-width: 120px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 20px; text-align: center } .stat-item .stat-val { font-size: 28px; font-weight: 700; color: var(--gold-light); display: block; margin-bottom: 6px } .stat-item .stat-label { font-size: 12px; color: var(--text-sub) } .person-tags { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0 } .person-tag { background: rgba(200, 168, 75, 0.1); border: 1px solid rgba(200, 168, 75, 0.3); border-radius: 20px; padding: 4px 14px; font-size: 13px; color: var(--gold-light) } .person-tag.photo-only { background: rgba(74, 158, 255, 0.1); border-color: rgba(74, 158, 255, 0.3); color: rgba(122, 192, 255, 1) } blockquote { border-left: 3px solid var(--gold); padding: 16px 24px; margin: 28px 0; color: var(--text-sub); font-style: italic; font-size: 15px; line-height: 1.9 } blockquote strong { color: var(--gold-light); font-style: normal } .outro { background: radial-gradient(ellipse at top, rgba(200, 168, 75, 0.06) 0, rgba(0, 0, 0, 0) 70%); border-top: 1px solid var(--border); text-align: center; padding: 72px 24px 60px } .outro h2 { color: var(--gold-light); margin-bottom: 16px } .outro p { color: var(--text-sub); max-width: 560px; margin: 0 auto 12px; font-size: 15px } .divider { width: 60px; height: 2px; background: linear-gradient(to right, transparent, var(--gold), transparent); margin: 48px auto } .toc { position: sticky; top: 0; background: rgba(26, 26, 46, 0.92); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); z-index: 100; padding: 12px 24px } .toc ul { list-style: none; display: flex; gap: 24px; flex-wrap: wrap; max-width: 860px; margin: 0 auto } .toc ul li a { font-size: 13px; color: var(--text-sub); text-decoration: none; letter-spacing: 0.5px; transition: color 0.2s } .toc ul li a:hover { color: var(--gold-light) } table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 14px } th { background: rgba(200, 168, 75, 0.12); color: var(--gold-light); padding: 10px 14px; text-align: left; font-weight: 600; letter-spacing: 0.5px } td { padding: 10px 14px; color: var(--text-sub); border-bottom: 1px solid rgba(255, 255, 255, 0.05) } tr:hover td { background: rgba(255, 255, 255, 0.02) } td strong { color: var(--text-main) } @media (max-width: 600px) { .scene-grid { grid-template-columns: 1fr } } 制作全程纪实 · 2026年3月 用 AI 制作一部 二十年同学会回顾电影 从一个模糊的想法,到 304 秒的完整电影——记录与 AI 协作完成视频制作的全过程 251 张原始照片 50 个原始视频 19 位同学 5'04" 最终时长 11 个场景 向下滚动 第一章 一个二十年后的约定 2024年底,一群阔别二十年的高中同学重聚在曾经读书的校园。岁月改变了每个人的容颜,但笑声依然熟悉。在那次聚会结束后,有人提议:把这次相聚做成一部视频留念。 素材是现成的——251 张照片、50 个视频文件,零散地存放在一个文件夹里。但没有专业的剪辑团队,没有 Premiere 或 Final Cut 的使用经验。于是,他们选择了另一条路:与 AI 协作完成这部影片。 这篇文章,记录的正是这个从无到有的完整过程——从第一句需求描述,到最终 124.9 MB 的 .mp4 文件落盘,期间经历的每一次对话、每一次报错、每一次迭代。 251 原始照片 50 原始视频 30 精选照片入片 17 精选视频入片 第二章 需求从模糊到具体的对话过程 整个项目的起点,是一段非常口语化的需求描述。用户并没有给出精确的技术规格,而是像向朋友描述想法一样,把期望逐步说清楚。 "制作高中同学20年集会的回顾视频……格式为16:9的电影版本……视频时长在9分钟……当在教室中的场景的时候,每一个人物都需要出现在视频中,如果人物没有视频,则使用图片。" ——用户的原始需求描述(节选) 从这段描述中,AI 需要理解并拆解出以下关键信息: 需求点 01 格式规格 16:9 横版,分辨率 1920×1080,30fps,总时长约 9 分钟(实际按素材密度调整为 5 分钟) 需求点 02 场景结构 10 个明确场景:片头→清晨→见面→午间→下午→个人寄语→晚宴→大合照→欢声笑语→告别→片尾 需求点 03 人物覆盖 19 位同学全部出现,其中 16 人有个人视频,3 人(宋东岳、马理平、李仁应)仅有照片,需用 Ken Burns 动效替代 需求点 04 电影质感 胶片感片头、Ken Burns 缓慢推移、暖色调、底部节奏条、文字入场动效等视觉细节 需求点 05 BGM 要求 背景音乐贯穿全片,末尾淡出,确保情绪连贯 值得注意的是,用户最初描述的时长是"9分钟",但在实际制作中,受制于可用素材的数量和密度,最终成片定格在 5分03秒。这是一个典型的"需求与现实碰撞后的务实妥协"——不强行拉伸素材,而是在有限素材内做到最精炼。 第三章 扫描 301 个文件,摸清家底 在动手制作之前,第一步是全量扫描素材目录,建立完整的素材清单。AI 编写了一个 Python 脚本 scan_assets.py,对素材目录进行分类统计。 # scan_assets.py 核心逻辑 import os, pathlib SRC_DIR = r'C:\MyHomework\素材\2024 十年会\九班\聚会当天 素材' # 分类所有文件 photos = [f for f in os.listdir(SRC_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] videos = [f for f in os.listdir(SRC_DIR) if f.lower().endswith(('.mp4', '.mov'))] # 按人名匹配个人视频 names = ['冉金雨', '刘鑫', '卢布', '姜浩', '廖江林', ...] for name in names: matches = [f for f in videos if name in f] print(f" {'[Y]' if matches else '[N]'} {name}: {matches}") 扫描结果令人满意: 类别数量备注 照片(JPG) 251 张 包含 mmexport 抓屏截图、合照、个人照 视频(MP4/MOV) 50 个 含 mmexport 视频、个人寄语视频 个人视频匹配 16/19 人 冉金雨、刘鑫、卢布等 16 人找到对应视频 仅有照片人员 3 人 宋东岳、马理平、李仁应,使用 Ken Burns 动效 19 位同学全部找到了对应素材,这为后续"个人寄语"场景的全员覆盖奠定了基础。 冉金雨 刘鑫 卢布 姜浩 廖江林 张伟 李光明 涂存龙 王雁 陈桂林 靳军 李燕 王勇军 易勋付 王进松 谢先洋 宋东岳 ★照片 马理平 ★照片 李仁应 ★照片 金色标签:有个人视频 | 蓝色标签:仅照片,使用 Ken Burns 动效 第四章 11 个场景的分镜设计 在素材清单确认后,AI 与用户共同确定了影片的分镜结构。整部影片被拆解为 11 个场景,每个场景都有明确的情绪定位和视觉风格: SCENE 00 片头 12 秒 "二十年"大字胶片感入场,金色文字,复古颗粒特效,黑底渐入 SCENE 01 清晨见面 18 秒 早晨照片轮播,暖阳色调,叠化转场,温柔开场 SCENE 02 见面瞬间 24 秒 合照 Ken Burns 缓慢推进,"好久不见"文字渐入,情绪积累 SCENE 03 午间相聚 20 秒 mmexport 午间照片快切,暖橙渐变滤镜,活泼节奏 SCENE 04 下午时光 18 秒 大图蒙太奇,8帧溶解过渡,悠闲午后感 SCENE 05 ⭐ 个人寄语 78 秒(核心) 全部 19 人出场,16 段视频 + 3 张 Ken Burns 照片,每人附名字卡片,是全片情绪高峰 SCENE 06 晚宴时刻 24 秒 "把酒言欢"文字渐入,夜间暖光氛围,晚宴照片流 SCENE 07 大合照 17 秒 合照 Ken Burns 缓慢放大,"我们仍是少年"文字叠加 SCENE 08 欢声笑语 60 秒 27 段 mmexport 视频混剪,快节奏拼接,底部节奏条动效 SCENE 09 告别时刻 22 秒 傍晚照片淡出序列,"珍重,下次见"文字,情绪收束 SCENE 10 片尾 12 秒 "致青春"逐字显现,暖色光点收尾,BGM 末尾淡出 场景设计遵循了经典纪录片的情绪弧线:暖场开场 → 相遇高峰 → 情感深化(个人寄语) → 欢乐释放 → 温情收尾,既有叙事逻辑,也照顾到了不同情绪段落的节奏对比。 第五章 最难的不是写代码,是找到那个 Bug 在整个制作过程中,遇到了一个反复出现、几乎让项目搁浅的问题:生成的视频片段文件大小为 0KB。 问题现象 ffmpeg 命令执行后返回码为 1(失败),生成的 .mp4 文件存在但大小为 0 字节。错误信息指向:width not divisible by 2 根本原因 使用 scale=1920:1080:force_original_aspect_ratio=decrease 时,若原图宽高比特殊,计算出的输出尺寸可能为奇数(如 1919×1078),而 libx264 编码器要求宽高必须是 2 的倍数。 修复方案 在 scale 之后追加 pad=1920:1080:(ow-iw)/2:(oh-ih)/2,先将图像缩放到不超过目标尺寸,再用黑边填充至标准 1920×1080,完全规避奇数尺寸问题。 # 修复前(有问题的 vf 参数) vf = 'scale=1920:1080:force_original_aspect_ratio=decrease,setsar=1' # 修复后(稳健版本) vf = ('scale=1920:1080:force_original_aspect_ratio=decrease:flags=lanczos,' 'pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black,' 'setsar=1') # 用 test_scale.py 验证修复效果 # 输出:test_scale.mp4,文件大小 > 0 即为成功 在正式运行大批量制作前,编写了独立的 test_scale.py 脚本对这个修复方案进行验证,确认生成的测试文件大小正常后,才将该参数写入主脚本。 除此之外,还遇到了另一个 Windows 特有的问题: Windows GBK 编码问题 Python 脚本中的中文输出(如 ✓ ✗)在 Windows 默认的 GBK 终端下出现乱码,甚至导致脚本崩溃。 解决方案 将所有中文符号替换为 ASCII 等价表示(如 [Y]/[N]),并使用 python -X utf8 强制 UTF-8 模式运行脚本,彻底规避编码问题。 第六章 主脚本 reunion_v2.py 的核心技术 在问题排查完成后,正式编写了制作主脚本 reunion_v2.py。整个脚本约 600 行,涵盖了所有场景的生成逻辑。以下是几个关键技术点: 技术点 1:Ken Burns 动效实现 Ken Burns 效果(图片缓慢推移/缩放)通过 ffmpeg 的 zoompan 滤镜实现。将静态照片转化为有生命感的运动镜头: # Ken Burns 推进效果(从原尺寸缓慢放大到 1.15 倍) vf = (f'scale=3840:2160:flags=lanczos,pad=3840:2160:(ow-iw)/2:(oh-ih)/2,' f'zoompan=z=\'zoom+0.0008\':x=\'iw/2-(iw/zoom/2)\':' f'y=\'ih/2-(ih/zoom/2)\':d={int(dur*30)}:fps=30:s=1920x1080,' f'setsar=1') 技术点 2:名字卡片叠加 在个人寄语场景中,每位同学的视频下方都叠加了带名字的半透明卡片。通过 drawbox + drawtext 滤镜组合实现,无需外部字体文件: # 名字卡片:半透明黑底 + 白色名字 vf = (f'drawbox=x=0:y=920:w=1920:h=80:color=black@0.6:t=fill,' f'drawtext=text={name}:fontcolor=white:fontsize=36:' f'x=(w-text_w)/2:y=940') 技术点 3:全局 concat 合并 所有场景生成后,使用 ffmpeg concat demuxer 将 11 个片段无缝拼接,最后混入 BGM 并做末尾淡出处理: # 写入 concat 列表文件 with open('list.txt', 'w') as f: for clip in scene_files: f.write(f"file '{clip}'\n") # BGM 淡出:最后 6 秒音量渐降 afade = f'afade=t=out:st={total_dur-6}:d=6' cmd = [FFMPEG, '-f', 'concat', '-safe', '0', '-i', 'list.txt', '-i', bgm_path, '-filter_complex', f'[1:a]{afade}[bgm]', '-map', '0:v', '-map', '[bgm]', '-shortest', output] 整个制作流程高度自动化:脚本启动后,依次处理每个场景,全程无需人工干预,约 9 分钟后自动输出最终文件。 制作阶段 01 需求分析与脚本规划 解析用户需求,确定分镜结构、人员清单、素材分配策略,约 5 分钟 制作阶段 02 素材扫描与人名匹配 运行 scan_assets.py,完成 251 张照片 + 50 个视频的分类,约 2 分钟 制作阶段 03 Bug 排查与修复验证 发现并修复 scale 奇数尺寸问题,用 test_scale.py 验证,约 10 分钟 制作阶段 04 主脚本编写 编写 reunion_v2.py,覆盖全部 11 个场景,约 15 分钟 制作阶段 05 渲染输出 执行主脚本,ffmpeg 逐场景渲染,全程约 9 分钟,自动完成 第七章 304 秒,124.9 MB,一部真实的电影 脚本运行完毕后,最终文件落盘: 5'04" 最终时长 124.9 MB 文件大小 1920×1080 分辨率 30fps 帧率 9 min 总制作耗时 场景内容摘要时长技术亮点 0 片头 "二十年"金字胶片感入场 12s 孔洞边框 + 金色渐变文字 1 清晨见面 9张清晨照片轮播 18s 叠化转场,暖色滤镜 2 见面瞬间 合照 Ken Burns + "好久不见" 23s zoompan 缓推 3 午间相聚 午间照片快切 20s 暖橙渐变叠加 4 下午时光 下午大图蒙太奇 18s 8帧溶解过渡 5 个人寄语 ⭐ 全部19人出场 78s 16段视频 + 3张Ken Burns照片 + 名字卡片 6 晚宴时刻 "把酒言欢" + 晚宴照片 24s 暖光呼吸效果 7 大合照 合照 Ken Burns + "我们仍是少年" 17s 缓慢平移推进 8 欢声笑语 27段视频快节奏混剪 60s 底部节奏条动效 9 告别时刻 傍晚照片淡出 + "珍重,下次见" 22s 情绪渐弱淡出 10 片尾 "致青春"逐字显现 12s BGM 末尾 6s 淡出 个人寄语场景是整部影片的情感核心,时长 78 秒,占全片时长的 26%。19 位同学全部出场,没有一个人缺席——这也是用户最在意的一点。 第八章 复盘:这次合作我们学到了什么 回顾整个制作过程,有几点值得记录: 经验 01 先扫描,再设计 不要在不了解素材情况下就开始写制作脚本。先运行扫描脚本,摸清家底,才能做出真正可执行的分镜设计。 经验 02 单元测试是必须的 在批量生成前,用 test_scale.py 验证单个片段的生成逻辑,可以提前发现并修复问题,避免 50 个任务全部失败的灾难性后果。 经验 03 Windows 的编码陷阱 在中文 Windows 系统上做视频处理时,文件名、路径、终端输出都可能遇到 GBK/UTF-8 编码冲突,需要全程使用 python -X utf8 和 errors='replace' 进行防护。 经验 04 务实胜于完美 用户期望 9 分钟,但素材质量和数量决定了 5 分钟是更合理的选择。不强行拉伸素材,而是在有限素材内做到精炼,是专业态度。 经验 05 ffmpeg 的表达力 整个项目没有使用任何商业视频软件,仅靠 ffmpeg 的 filter_complex 体系,实现了 Ken Burns、字幕叠加、淡入淡出、音频混合等全部效果。工具本身的能力远比我们以为的强大。 关于"AI 辅助视频制作"的一些思考 这次制作过程展示了一种新的工作模式:用户负责提供素材、描述需求和作出审美判断,AI 负责将模糊的需求转化为可执行的技术方案,并处理所有底层的编码细节。 这不是"AI 替代了创作者",而是"AI 消除了技术门槛"——让没有专业视频制作背景的普通人,也能制作出具有电影质感的纪念视频。 二十年后的重聚,值得一部认真做的影片。 致那些仍是少年的人们 251 张照片、50 个视频、19 位同学、11 个场景、9 分钟制作——最终凝缩成 5 分 04 秒的影像记忆。 技术只是手段,留住的,是二十年后那些真实的笑脸。 — 完 —