From c6e4191bb3639623c51f4b475b7d3ea9cceeb6c4 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 6 Nov 2026 10:53:02 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E7=85=A7=E7=89=87=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=A7=86=E9=A2=91=EF=BC=9A=E8=BD=AC=20H.264=20+=20?= =?UTF-8?q?=E6=8A=BD=E9=A6=96=E5=B8=A7=E5=B0=81=E9=9D=A2=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改, 要求能上传视频、视频需要转码,参考旅程中的行程做法。 事实核查:原来不允许 —— POST /movies//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/ 目录确认为空。 --- app/ApiHandler.php | 86 +++++++++++++++++++++++++++++++++++++++++++--- app/Movie.php | 74 ++++++++++++++++++++++++++++++++------- app/Schema.php | 33 ++++++++++++++++-- app/Video.php | 44 ++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 19 deletions(-) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index ab3ebb7..b992724 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -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) ), diff --git a/app/Movie.php b/app/Movie.php index 5244183..bfd7c06 100644 --- a/app/Movie.php +++ b/app/Movie.php @@ -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 = ?' diff --git a/app/Schema.php b/app/Schema.php index 323bc26..c933da3 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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//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),改了生日纪念日就跟着变。 diff --git a/app/Video.php b/app/Video.php index bc7621f..881520c 100644 --- a/app/Video.php +++ b/app/Video.php @@ -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' => '']; + } } \ No newline at end of file