观影照片支持视频:转 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:
+81
-5
@@ -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)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user