观影照片支持视频+转码;同看的人改头像标签 (#84, #83)

用户:① 同看的人,按照头像加名字组成标签的形式进行展示。
      ② 确认观影照片是否允许上传视频,不允许则需要改,要求能上传视频,
         视频需要转码,参考旅程中的行程做法。

① 同看的人改头像胶囊(#83 追加)
- 原来一行纯文字「已选 2 人:杨永全、红围脖」,人多了被 maxLines(1) 截断;
- 改成每人一颗胶囊:头像(FbAvatar,有图显示图/没图回落色块字) + 名字 + 小叉,
  Flex(wrap) 流式换行(Row 不换行会横向溢出,本项目 v76 同款事故);
- ⚠️ FbAvatar 必须传 imageUrl,否则有头像的人也退化成色块字
  (首页观影卡 2026-10-19 踩过同一个坑)。

② 观影照片支持视频(#84)
事实核查:原来不允许。POST /movies/<id>/photos 走 Upload::image(),
只收 JPG/PNG/GIF/WebP,传视频直接拒。旅程走 Attachment::save(),
内部对 video/* 调 Video::toH264() —— 观影要支持就得接上这条路。

服务端:
- Schema VERSION 35→36,event_photos 加 kind/thumb_key/mime 三列;
  kind 默认 image(老数据全是照片,默认值即正确,不需要回填 UPDATE);
  建表语句同步改,新库直接是新结构。
- 上传按 MIME 分流:视频走 toH264 + 新增的 Video::poster() 抽首帧,
  图片仍走 Upload::image()(裁剪压缩行为完全不变)。
- Video::poster() 取第 1 秒(手机视频开头常是黑场),失败不阻断上传。
- deletePhoto / destroy 补删 thumb_key,否则删视频留孤儿封面。
- addPhotos 兼容裸键数组与结构化数组,只有一条 INSERT 路径。

端上:
- 选择器 IMAGE_TYPE → IMAGE_VIDEO_TYPE(与旅程 FbTripNodeSheet 对齐);
  海报选择器保持 IMAGE_TYPE(海报必须是图)。
- FbMovieDetail.photos 从 string[] 升到 FbMovieMedia[](带 kind/thumb/mime),
  靠类型检查确保所有读取点都改到。
- 网格里视频显示首帧+播放角标(不解码,一屏 9 个视频各挂解码器必卡死);
  全屏查看器用 Video 播放。⚠️ VideoOptions 只有 src/currentProgressRate/
  previewUri/controller,controls/autoPlay 是属性方法不是构造参数。

验证:造 H.265 视频实测 needsTranscode=true、toH264 成功转成 h264/yuv420p、
poster 抽出 13KB 封面;完整上传后接口返回 5 条(4 image + 1 video 带 thumb);
端上网格出现 ▶ 角标、点击进全屏查看器且视频在播。
迁移已跑(35→36),4 个 PHP 文件 md5 逐个核对一致。测试数据已清理。
hvigorw assembleHap BUILD SUCCESSFUL。
This commit is contained in:
2026-11-06 10:52:42 +08:00
parent ac666dd7f4
commit 05b86262ff
3 changed files with 336 additions and 64 deletions
+158 -37
View File
@@ -43,11 +43,13 @@
import { FbColors, FbMetric, fbAlpha } from '../common/Theme';
import { FbModuleDef, fbModuleOf, fbThemeVars } from '../model/Modules';
import { FbCinemaItem, FbDoubanFetch, FbDoubanSuggest, FbMovieAttendeeRow, FbMovieDetail, FbMovieDraft,
FbMovieRepo } from '../data/FbMovieRepo';
FbMovieMedia, FbMovieRepo } from '../data/FbMovieRepo';
import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { fbApiBase, fbImageUrl } from '../data/FbHttp';
import { fbImageSrc } from '../data/FbAttachQueue';
/* 🔴 2026-11-06:同看的人改成「头像 + 名字」胶囊,需要 FbAvatar */
import { FbAvatar } from './Widgets';
import { FbSheetRing } from './FbSheetRing';
import { FbSheetClose, FbSheetTitleBar } from './FbSheetClose';
import { FbCinemaPickSheet } from './FbCinemaPickSheet';
@@ -141,7 +143,7 @@ export struct FbMovieNewSheet {
* ⚠️ 与 `photoUris` 分开存:那个是"待上传的本地 uri",语义不同,
* 混在一起会让"保存时传哪些"变得说不清。
*/
@Local existingPhotos: string[] = [];
@Local existingPhotos: FbMovieMedia[] = [];
/** 豆瓣抓到的元数据 JSON(存进库,详情页另有展示) */
@Local doubanMeta: string = '';
@@ -498,7 +500,12 @@ export struct FbMovieNewSheet {
private async pickPhotos(): Promise<void> {
try {
const opts: picker.PhotoSelectOptions = new picker.PhotoSelectOptions();
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
/* 🔴 2026-11-06:从 `IMAGE_TYPE` 改成 `IMAGE_VIDEO_TYPE` ——
用户:「确认一下观影照片是不是允许上传视频,如果不允许,需要修改,
要求能够上传视频,视频需要转码,参考旅程中的行程做法。」
旅程那边就是 `IMAGE_VIDEO_TYPE`(见 `FbTripNodeSheet.pickPhotos`),
这里对齐。转码在服务端做(`Video::toH264`),端上只负责把文件传上去。 */
opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE;
opts.maxSelectNumber = 9;
const p: picker.PhotoViewPicker = new picker.PhotoViewPicker();
const sel: picker.PhotoSelectResult = await p.select(opts);
@@ -1022,38 +1029,115 @@ export struct FbMovieNewSheet {
.fontColor(this.accentColor())
.width('100%')
/* 第二排:已选人 + 「选择」 */
Row({ space: 8 }) {
Text(this.pickedLabel())
.fontSize(FbMetric.fontSmall)
.fontColor(this.picked.length > 0 ? FbColors.ink : FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
.flexShrink(1)
Text('选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
/* 第二排:已选的人 —— **头像 + 名字的胶囊标签**,流式换行 */
Column({ space: 8 }) {
if (this.picked.length === 0) {
/* 没人选:还是一行可点的空框(点它去选) */
Row({ space: 8 }) {
Text('还没有选人')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.layoutWeight(1)
Text('选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
this.openPersonPick();
})
} else {
/* 🔴 用户 2026-11-06:「同看的人,按照头像加名字组成标签的形式进行展示。」
⇒ 每人一颗胶囊:头像(有图显示图、没图回落色块字)+ 名字 + 小叉(删掉)。
用 `Flex(wrap)` 而不是 `Row`:人多时 `Row` 不换行会横向溢出(本项目 v76 的坑)。 */
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.pickedPeople(), (p: FbRosterPerson) => {
Row({ space: 6 }) {
FbAvatar({
name: p.shown !== '' ? p.shown : p.name,
color: p.avatarColor !== '' ? p.avatarColor : this.accentColor(),
dia: 24,
/* ⚠️ 必须传 imageUrl:不传的话有头像的人也退化成色块字
(首页观影卡踩过同一个坑,见 FbMovieDetailSheet 的注释) */
imageUrl: p.avatar !== '' ? fbImageSrc(p.avatar, this.token()) : ''
})
Text(p.shown !== '' ? p.shown : p.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
/* 小叉:从已选里移除(不影响名册,只是这次不放他) */
Text('✕')
.fontSize(10)
.fontColor(FbColors.faint)
.padding({ left: 2, right: 2 })
.onClick((): void => {
this.picked = this.picked.filter((x: number): boolean => x !== p.id);
})
}
.padding({ left: 4, right: 8, top: 3, bottom: 3 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (p: FbRosterPerson): string => 'pk_' + p.id.toString())
}
.width('100%')
/* 底部「+ 选择」:加人 */
Row({ space: 4 }) {
Text('+ 选择')
.fontSize(FbMetric.fontTiny)
.fontColor(this.accentColor())
}
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusPill)
.border({ width: 1, color: fbAlpha(this.accentColor(), 0.4) })
.onClick((): void => {
this.openPersonPick();
})
}
}
.width('100%')
.height(42)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
.onClick((): void => {
if (this.roster.length === 0) {
this.bad = true;
this.note2 = '名册还没同步,同看人稍后可以在网页端补';
return;
}
this.personPickOpen = true;
})
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/**
* 已选的同看人(按 `picked` 的顺序映射成完整人物对象)。
*
* ⚠️ 返回数组而不是在模板里内联 find:模板里做复杂查询会让
* `ForEach` 的依赖追踪变模糊(本项目反复踩过),这里一次算清。
* ⚠️ 名单里找不到的 id(人被删了)直接跳过,不占位。
*/
private pickedPeople(): FbRosterPerson[] {
const out: FbRosterPerson[] = [];
for (const id of this.picked) {
const p: FbRosterPerson | undefined =
this.roster.find((x: FbRosterPerson): boolean => x.id === id);
if (p !== undefined) {
out.push(p);
}
}
return out;
}
/** 打开「选同看人」弹框(名册没同步时给提示,不硬开一个空列表) */
private openPersonPick(): void {
if (this.roster.length === 0) {
this.bad = true;
this.note2 = '名册还没同步,同看人稍后可以在网页端补';
return;
}
this.personPickOpen = true;
}
/**
* 海报(1 张;可从豆瓣抓、也可自己选)。
*
@@ -1259,15 +1343,52 @@ export struct FbMovieNewSheet {
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.existingPhotos, (k: string) => {
Image(fbImageSrc(k, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
.margin({ right: 8, bottom: 8 })
}, (k: string): string => 'exph_' + k)
ForEach(this.existingPhotos, (m: FbMovieMedia) => {
/* 🔴 2026-11-06:已有的视频显示**首帧 + 播放角标**(不解码),
与详情页同一套呈现口径。 */
Stack({ alignContent: Alignment.Center }) {
if (m.kind === 'video') {
if (m.thumb !== '') {
Image(fbImageSrc(m.thumb, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
} else {
Column()
.width(60)
.height(60)
.borderRadius(4)
.backgroundColor(FbColors.inputBg)
Image($r('app.media.fb_ic_movies'))
.width(22)
.height(22)
}
Column() {
Text('▶')
.fontSize(11)
.fontColor(Color.White)
}
.width(22)
.height(22)
.borderRadius(11)
.backgroundColor('#99000000')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
} else {
Image(fbImageSrc(m.key, this.token()))
.width(60)
.height(60)
.borderRadius(4)
.objectFit(ImageFit.Cover)
.alt($r('app.media.fb_ic_movies'))
}
}
.width(60)
.height(60)
.margin({ right: 8, bottom: 8 })
}, (m: FbMovieMedia): string => 'exph_' + m.key)
}
.width('100%')
}