观影照片支持视频:转 H.264 + 抽首帧封面 (#84)

用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改,
要求能上传视频、视频需要转码,参考旅程中的行程做法。

事实核查:原来不允许 —— POST /movies/<id>/photos 走 Upload::image(),
那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩转运 JPEG),传视频直接拒。
旅程走 Attachment::save(),内部对 video/* 调 Video::toH264()。

Schema VERSION 35→36:
- event_photos 加 kind / thumb_key / mime 三列;
  kind 默认 image —— 老数据全是照片,默认值即正确,不需要回填 UPDATE;
- 建表语句同步改(新库直接是新结构)。

Video.php:
- 新增 poster() 抽首帧。取第 1 秒(手机视频开头常是黑场/对焦),
  宽 480 足够列表缩略;失败返回空 path 且**不阻断上传**。

ApiHandler.php 上传分支按 MIME 分流:
- 视频 → toH264 转码 + poster 抽封面 + Storage 落盘 videos/ 与 thumbs/;
  转码失败不阻断(存原视频,至少能下载后用本地播放器看);
- 图片 → 仍走 Upload::image(),裁剪压缩行为完全不变。
- 返回值加了 kind / thumb / mime,端上靠它画播放按钮。

Movie.php:
- photos() 带出 kind/thumb_key/mime;
- addPhotos() 兼容裸键数组与结构化数组,只有一条 INSERT 路径;
- deletePhoto() / destroy() 补删 thumb_key,否则删视频留孤儿封面。

验证(服务器实测):造 H.265 视频 → needsTranscode=true,toH264 成功
转成 h264/yuv420p,poster 抽出 13KB 封面;完整上传后接口返回 5 条
(4 image + 1 video 带 thumb)。迁移已跑(35→36),字段与数据核实无误,
测试视频及封面已清理,videos/ 与 thumbs/ 目录确认为空。
This commit is contained in:
2026-11-06 10:53:02 +08:00
parent 3d745b65b2
commit c6e4191bb3
4 changed files with 218 additions and 19 deletions
+81 -5
View File
@@ -2288,18 +2288,89 @@ final class ApiHandler
if ($files === []) {
Api::fail('没有收到图片。', 'unprocessable', 422);
}
$added = [];
/* 🔴 2026-11-06(用户:「确认一下观影照片是不是允许上传视频,如果不允许,
需要修改,要求能够上传视频,视频需要转码,参考旅程中的行程做法。」)
原来这里一律走 `Upload::image()` —— 那条管线**只收 JPG/PNG/GIF/WebP**
(它要裁剪、缩放、转 JPEG),视频会被直接拒掉:
「只支持 JPG / PNG / GIF / WebP 图片」。
⇒ 改成按 MIME 分流(与 `Attachment::save()` 内部同一套判断):
· 图片 → 仍走 `Upload::image()`(保留既有的裁剪/压缩,行为不变)
· 视频 → 走 `Video::toH264()` 转码 + `Storage::putFile()` 落盘,
再抽一张首帧当封面(列表里靠它显示,不解码)
⚠️ 转码失败**不阻断上传**(与 Attachment 同一取舍):原样存原视频,
至少能下载后用本地播放器看。
⚠️ 视频不生成 `thumb_key` 也可以(端上回落通用图标),
但如果抽帧成功就必须存下来,否则每次列表都要重抽。 */
$added = []; // 给 addPhotos 的结构化入参
$warns = [];
foreach ($files as $f) {
$tmp = (string)($f['tmp_name'] ?? '');
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = $tmp !== '' && is_file($tmp) ? (string)$fino->file($tmp) : '';
if (\App\Video::isVideo($mime)) {
/* ---- 视频:转码 + 抽封面 ---- */
$note = '';
if (\App\Video::needsTranscode($tmp)) {
$tr = \App\Video::toH264($tmp);
if (!empty($tr['ok'])) {
$newPath = (string)$tr['path'];
@unlink($tmp);
$tmp = $newPath;
$mime = 'video/mp4';
$note = (string)($tr['note'] ?? '');
} else {
$note = '转码失败,已保存原视频:' . (string)($tr['error'] ?? '');
error_log('[movies/photos] 视频转码失败 id=' . $id
. ' err=' . (string)($tr['error'] ?? ''));
}
}
/* 抽首帧(失败不影响视频本身) */
$thumbKey = '';
$po = \App\Video::poster($tmp);
if (!empty($po['ok'])) {
$thumbKey = 'thumbs/' . bin2hex(random_bytes(12)) . '.jpg';
$rise = Storage\Storage::putFile($fam, (string)$po['path'], $thumbKey, 'image/jpeg');
@unlink((string)$po['path']); // 抽出来的临时文件用完即删
if (empty($rise['ok'])) {
$thumbKey = '';
}
}
$ext = 'mp4';
$key = 'videos/' . bin2hex(random_bytes(12)) . '.' . $ext;
$res = Storage\Storage::putFile($fam, $tmp, $key, $mime !== '' ? $mime : 'video/mp4');
/* 转码产物用完即删(临时文件,不留在 /tmp) */
if (strpos($tmp, '.h264.mp4') !== false) {
@unlink($tmp);
}
if (!empty($res['ok'])) {
$added[] = [
'key' => $key,
'kind' => 'video',
'thumb_key' => $thumbKey,
'mime' => $mime !== '' ? $mime : 'video/mp4',
];
if ($note !== '') {
$warns[] = $note; // 不是错误,作为提示回给端上
}
} else {
$warns[] = '视频保存失败:' . (string)($res['error'] ?? '');
}
continue;
}
/* ---- 图片:保持原样(裁剪/缩放/转 JPEG) ---- */
$up = Upload::image($fam, $f, 'photo');
if ($up['ok']) {
$added[] = (string)$up['file'];
$added[] = ['key' => (string)$up['file'], 'kind' => 'image'];
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = (string)$up['error'];
$warns[] = (string)($up['error'] ?? '');
}
}
if ($added === []) {
Api::fail($warns ? implode(';', $warns) : '这几张都没存下来',
Api::fail($warns ? implode(';', $warns) : '这些文件都没存下来',
'unprocessable', 422);
}
/* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。
@@ -2307,12 +2378,17 @@ final class ApiHandler
⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */
Movie::addPhotos($id, $added);
Api::ok([
'added' => $added,
'added' => array_map(static fn(array $a): string => (string)$a['key'], $added),
'photos' => array_map(
static fn(array $p): array => [
'id' => (int)$p['id'],
'key' => (string)$p['key'],
'kind' => (string)($p['kind'] ?? 'image'),
'url' => Storage\Storage::url($fam, (string)$p['key']),
'thumb' => !empty($p['thumb_key'])
? Storage\Storage::url($fam, (string)$p['thumb_key'])
: '',
'mime' => (string)($p['mime'] ?? ''),
],
Movie::photos($id)
),