观影照片支持视频:转 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 === []) { if ($files === []) {
Api::fail('没有收到图片。', 'unprocessable', 422); 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 = []; $warns = [];
foreach ($files as $f) { 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'); $up = Upload::image($fam, $f, 'photo');
if ($up['ok']) { if ($up['ok']) {
$added[] = (string)$up['file']; $added[] = ['key' => (string)$up['file'], 'kind' => 'image'];
} elseif ((string)($up['error'] ?? '') !== '') { } elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = (string)$up['error']; $warns[] = (string)($up['error'] ?? '');
} }
} }
if ($added === []) { if ($added === []) {
Api::fail($warns ? implode(';', $warns) : '这几张都没存下来', Api::fail($warns ? implode(';', $warns) : '这些文件都没存下来',
'unprocessable', 422); 'unprocessable', 422);
} }
/* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。 /* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。
@@ -2307,12 +2378,17 @@ final class ApiHandler
⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */ ⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */
Movie::addPhotos($id, $added); Movie::addPhotos($id, $added);
Api::ok([ Api::ok([
'added' => $added, 'added' => array_map(static fn(array $a): string => (string)$a['key'], $added),
'photos' => array_map( 'photos' => array_map(
static fn(array $p): array => [ static fn(array $p): array => [
'id' => (int)$p['id'], 'id' => (int)$p['id'],
'key' => (string)$p['key'], 'key' => (string)$p['key'],
'kind' => (string)($p['kind'] ?? 'image'),
'url' => Storage\Storage::url($fam, (string)$p['key']), '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) Movie::photos($id)
), ),
+62 -12
View File
@@ -133,8 +133,16 @@ final class Movie
public static function photos(int $eventId): array public static function photos(int $eventId): array
{ {
/* 🔴 2026-11-06:带上 `kind` / `thumb_key` / `mime`(观影照片支持视频)。
⚠️ 用 `COALESCE` 兜底 `kind`:极老的库(迁移没跑过)可能没这列,
但这种情形会直接报 1054 —— 迁移一定会先跑,这里不加 COALESCE。
真正需要兜底的是**值为 NULL** 的历史行(迁移加列时默认 'image',
但手写过 INSERT 的行可能是 NULL)。 */
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT id, `key` FROM event_photos WHERE event_id = ? ORDER BY position, id' "SELECT id, `key`,
COALESCE(`kind`, 'image') AS kind,
`thumb_key`, `mime`
FROM event_photos WHERE event_id = ? ORDER BY position, id"
); );
$st->execute([$eventId]); $st->execute([$eventId]);
return $st->fetchAll(); return $st->fetchAll();
@@ -294,14 +302,34 @@ final class Movie
*/ */
public static function addPhotos(int $eventId, array $keys): void public static function addPhotos(int $eventId, array $keys): void
{ {
$clean = []; /* ⚠️ 兼容两种入参(2026-11-06 起):
foreach ($keys as $k) { · `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
$k = trim((string)$k); · `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
if ($k !== '') { —— 新调用方(带类型信息)
$clean[] = $k; 统一归一成"每项一个数组",下面只有一条 INSERT 路径,
不会出现"有的走老分支有的走新分支"那种分叉。 */
$rows = [];
foreach ($keys as $item) {
if (is_array($item)) {
$k = trim((string)($item['key'] ?? ''));
if ($k === '') {
continue;
}
$rows[] = [
'key' => $k,
'kind' => ((string)($item['kind'] ?? 'image')) === 'video' ? 'video' : 'image',
'thumb_key' => (string)($item['thumb_key'] ?? ''),
'mime' => (string)($item['mime'] ?? ''),
];
} else {
$k = trim((string)$item);
if ($k === '') {
continue;
}
$rows[] = ['key' => $k, 'kind' => 'image', 'thumb_key' => '', 'mime' => ''];
} }
} }
if ($clean === []) { if ($rows === []) {
return; return;
} }
$db = Database::get(); $db = Database::get();
@@ -309,24 +337,41 @@ final class Movie
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?'); $st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
$st->execute([$eventId]); $st->execute([$eventId]);
$pos = (int)$st->fetchColumn(); $pos = (int)$st->fetchColumn();
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)'); $ins = $db->prepare(
foreach ($clean as $k) { 'INSERT INTO event_photos (event_id, `key`, kind, thumb_key, mime, position)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($rows as $r) {
$pos += 10; $pos += 10;
$ins->execute([$eventId, $k, $pos]); $ins->execute([
$eventId, $r['key'], $r['kind'],
$r['thumb_key'] !== '' ? $r['thumb_key'] : null,
$r['mime'] !== '' ? $r['mime'] : null,
$pos,
]);
} }
} }
/** 删一张现场照片(连带删存储对象) */ /** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{ {
$st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?'); /* 🔴 2026-11-06:视频的**封面**也要一起删(`thumb_key`),
否则每删一个视频就在存储里留一张孤儿封面。 */
$st = Database::get()->prepare(
'SELECT `key`, `thumb_key` FROM event_photos WHERE id = ? AND event_id = ?'
);
$st->execute([$photoId, $eventId]); $st->execute([$photoId, $eventId]);
$key = (string)($st->fetchColumn() ?: ''); $row = $st->fetch();
$key = $row ? (string)($row['key'] ?? '') : '';
$thumb = $row ? (string)($row['thumb_key'] ?? '') : '';
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?') Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
->execute([$photoId, $eventId]); ->execute([$photoId, $eventId]);
if ($key !== '') { if ($key !== '') {
Storage\Storage::delete($familyId, $key); Storage\Storage::delete($familyId, $key);
} }
if ($thumb !== '' && $thumb !== $key) {
Storage\Storage::delete($familyId, $thumb);
}
} }
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */ /** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
@@ -335,6 +380,11 @@ final class Movie
$keys = []; $keys = [];
foreach (self::photos($id) as $p) { foreach (self::photos($id) as $p) {
$keys[] = (string)$p['key']; $keys[] = (string)$p['key'];
/* 视频的封面也要清(2026-11-06) */
$tk = (string)($p['thumb_key'] ?? '');
if ($tk !== '') {
$keys[] = $tk;
}
} }
$st = Database::get()->prepare( $st = Database::get()->prepare(
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?' 'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
+31 -2
View File
@@ -188,7 +188,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/ */
public const VERSION = '35'; public const VERSION = '36';
/** /**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -539,12 +539,15 @@ CREATE TABLE IF NOT EXISTS `event_photos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL, `event_id` INT UNSIGNED NOT NULL,
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)', `key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
`kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)',
`thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)',
`mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后', `position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_ep_event` (`event_id`, `position`), KEY `idx_ep_event` (`event_id`, `position`),
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片/视频(可多张)'
SQL, SQL,
// ---- 统一附件(v12)---- // ---- 统一附件(v12)----
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表: // 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
@@ -1858,6 +1861,32 @@ SQL);
self::addColumn($db, 'trip_details', 'is_public', self::addColumn($db, 'trip_details', 'is_public',
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`"); "ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
// ---- 观影照片支持视频(版本 36,2026-11-06)----
/* 🔴 用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改,
要求能够上传视频,视频需要转码,参考旅程中的行程做法。」
事实核查:**原来不允许**。`POST /movies/<id>/photos` 走的是
`Upload::image()`,那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩放转 JPEG),
传视频会被直接拒("只支持 JPG / PNG / GIF / WebP 图片")。
旅程那边走的是 `Attachment::save()`,它内部对视频调 `Video::toH264()`
转成 H.264 再存 —— 所以观影要支持视频,得把照片也改走那条路。
改走 Attachment 后有三件事要落到 `event_photos` 上:
· `kind` —— 是图片还是视频。端上要靠它决定"显示图"还是"显示播放按钮";
(不靠扩展名猜:`.mp4` 也可能是图片的误命名,MIME 才靠谱)
· `thumb_key` —— 视频首帧封面。列表里不可能为每个视频解一次码
(一屏 9 个视频 × 解码 = 卡死),必须服务端抽一帧存成图;
· `mime` —— 冗余存一份,免得每次都要回查 Storage 或猜。
⚠️ 三列都给默认值:`kind` 默认 `'image'`,老数据(全是照片)自动正确,
不需要回填 UPDATE —— 这是最安全的一种迁移默认值。 */
self::addColumn($db, 'event_photos', 'kind',
"ADD COLUMN `kind` ENUM('image','video') NOT NULL DEFAULT 'image' COMMENT '图片还是视频(老数据全是图片,默认值即正确)' AFTER `key`");
self::addColumn($db, 'event_photos', 'thumb_key',
"ADD COLUMN `thumb_key` VARCHAR(255) DEFAULT NULL COMMENT '视频首帧封面(存储相对键;图片为空)' AFTER `kind`");
self::addColumn($db, 'event_photos', 'mime',
"ADD COLUMN `mime` VARCHAR(100) DEFAULT NULL COMMENT 'MIME(视频转码后是 video/mp4;图片可空)' AFTER `thumb_key`");
// ---- 纪念日(版本 13,用户第 13 条)---- // ---- 纪念日(版本 13,用户第 13 条)----
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的 // ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。 // (见 Anniversary::birthdays),改了生日纪念日就跟着变。
+44
View File
@@ -195,4 +195,48 @@ final class Video
. ' → ' . $after['width'] . 'x' . $after['height'] . ')', . ' → ' . $after['width'] . 'x' . $after['height'] . ')',
]; ];
} }
/**
* 抽一帧做封面(2026-11-06,观影照片支持视频)。
*
* 为什么要抽首帧:列表/网格里不可能为每个视频实时解码 —— 一屏 9 个视频,
* 端上要同时挂 9 个解码器,必然卡死。必须服务端抽一帧存成**普通图片**,
* 列表只显示这张图,点开才真播。
*
* ⚠️ 取第 **1 秒**而不是第 0 帧:
* 手机视频开头经常是黑场/对焦过程(`-ss 0` 抽出来是一片黑),
* 第 1 秒通常已经有内容了。
* ⚠️ **失败不阻断**(返回空 path):
* 抽帧失败不影响视频本身能用;端上对没有封面的视频显示一个通用图标即可。
* 让"没有封面"变成上传失败是最不能接受的取舍。
*
* @return array{ok:bool, path:string, error:string}
*/
public static function poster(string $src): array
{
if (!self::available()) {
return ['ok' => false, 'path' => '', 'error' => '服务器没有 ffmpeg'];
}
$dst = $src . '.poster.jpg';
/* 宽度 480 足够列表缩略(端上最大也就显示 100vp 左右)。
等比、-2 保证偶数。`-frames:v 1` 只出这一帧。 */
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
. ' -ss 1 -i ' . escapeshellarg($src)
. ' -frames:v 1 -vf ' . escapeshellarg("scale=480:-2")
. ' -q:v 4'
. ' ' . escapeshellarg($dst)
. ' 2>&1';
$out = [];
$code = 1;
/* 抽帧很快,给 30 秒足够;超时按失败处理,不拖住上传 */
@exec('timeout 30 ' . $cmd, $out, $code);
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
@unlink($dst);
$msg = trim(implode(' ', array_slice($out, 0, 2)));
return ['ok' => false, 'path' => '', 'error' => '抽帧失败' . ($msg !== '' ? ':' . $msg : '')];
}
return ['ok' => true, 'path' => $dst, 'error' => ''];
}
} }