观影照片支持视频:转 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)
|
||||
),
|
||||
|
||||
+62
-12
@@ -133,8 +133,16 @@ final class Movie
|
||||
|
||||
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(
|
||||
'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]);
|
||||
return $st->fetchAll();
|
||||
@@ -294,14 +302,34 @@ final class Movie
|
||||
*/
|
||||
public static function addPhotos(int $eventId, array $keys): void
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = trim((string)$k);
|
||||
if ($k !== '') {
|
||||
$clean[] = $k;
|
||||
/* ⚠️ 兼容两种入参(2026-11-06 起):
|
||||
· `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
|
||||
· `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
|
||||
—— 新调用方(带类型信息)
|
||||
统一归一成"每项一个数组",下面只有一条 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;
|
||||
}
|
||||
$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->execute([$eventId]);
|
||||
$pos = (int)$st->fetchColumn();
|
||||
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
|
||||
foreach ($clean as $k) {
|
||||
$ins = $db->prepare(
|
||||
'INSERT INTO event_photos (event_id, `key`, kind, thumb_key, mime, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($rows as $r) {
|
||||
$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
|
||||
{
|
||||
$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]);
|
||||
$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 = ?')
|
||||
->execute([$photoId, $eventId]);
|
||||
if ($key !== '') {
|
||||
Storage\Storage::delete($familyId, $key);
|
||||
}
|
||||
if ($thumb !== '' && $thumb !== $key) {
|
||||
Storage\Storage::delete($familyId, $thumb);
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
|
||||
@@ -335,6 +380,11 @@ final class Movie
|
||||
$keys = [];
|
||||
foreach (self::photos($id) as $p) {
|
||||
$keys[] = (string)$p['key'];
|
||||
/* 视频的封面也要清(2026-11-06) */
|
||||
$tk = (string)($p['thumb_key'] ?? '');
|
||||
if ($tk !== '') {
|
||||
$keys[] = $tk;
|
||||
}
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
|
||||
|
||||
+31
-2
@@ -188,7 +188,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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,
|
||||
`event_id` INT UNSIGNED NOT NULL,
|
||||
`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 '展示先后',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ep_event` (`event_id`, `position`),
|
||||
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,
|
||||
// ---- 统一附件(v12)----
|
||||
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
|
||||
@@ -1858,6 +1861,32 @@ SQL);
|
||||
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`");
|
||||
|
||||
// ---- 观影照片支持视频(版本 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 条)----
|
||||
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||
|
||||
@@ -195,4 +195,48 @@ final class Video
|
||||
. ' → ' . $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' => ''];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user