ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【共创稿事节】鸿蒙文搜图 · 回忆放映室:一句话搜出照片,放映成 Ken Burns 回忆短片,安全控件免权限存入系统图库

【共创稿事节】鸿蒙文搜图 · 回忆放映室:一句话搜出照片,放映成 Ken Burns 回忆短片,安全控件免权限存入系统图库 【共创稿事节】鸿蒙文搜图 · 回忆放映室一句话搜出照片放映成 Ken Burns 回忆短片安全控件免权限存入系统图库文搜图系列前三篇走完「找到 → 留住 → 带走」一句话进、相似度排序的图片网格出找到封存成带倒计时的时间胶囊留住编排成海报落盘成文件带走。但第三篇结尾留了一个尾巴——海报落在应用沙箱里用户在系统相册里看不到它「保存了等于没保存」。这一篇补上最后一公里走出沙箱写入系统图库。而且不是简单「导出」。这一篇把「搜出的一组照片」做成回忆放映室检索命中的一组照片自动放映成回忆短片——Ken Burns 式缓动缩放让每张照片有电影感、胶片打孔条和字幕条构成影院视觉签名、进度点即帧导航、放映中随时一键把当前帧存入系统相册。全程零联网、零权限申请。技术上的关键差异前三篇的写入边界都是应用沙箱这一篇第一次走出沙箱——SaveButton安全控件提供 10 秒免授权窗口不申请ohos.permission.WRITE_IMAGEVIDEO窗口内photoAccessHelper.createAsset把 JPEG 字节流写进公共媒体库相册立即可见。UI 也彻底换风格前三篇分别是浅色一、深蓝紫二、三这一篇是影院胶片风——暖黑底 琥珀金主色 胶片打孔条 字幕条。工程地址LI_harmonyOS/memory-cinema运行环境HarmonyOS 7.0API 26 ArkTS 严格模式已在模拟器安装运行、全流程验证含媒体库写入 URI 字节级确认。包名com.example.memorycinemamodule.json5没有申请任何权限——包括媒体库写入。一、效果展示一条「搜出 → 放映 → 入库」的完整叙事线完整流程四步载入 → 搜索 → 放映 → 存入图库。以下截图均为演示模式内置 12 张真实照片素材模拟器运行真机模式走同一套 UI、只把检索内核换成 NPU。第 1 步就绪。应用启动后初始化完成影院胶片风首屏——暖黑底、琥珀金主色、胶片打孔条标题区 搜索框 五个快捷回忆词 空态引导第 2 步载入 搜索。点「载入回忆」把 12 张照片登记进端侧索引点快捷词「宠物」350ms 返回命中 4 张状态栏提示点「▶ 放映」开始回忆短片第 3 步放映。点「▶ 放映」放映定时器每 4 秒推进一帧每帧 Ken Burns 缓动缩放1.0 ↔ 1.12 交替 字幕延迟淡入放映中画面如下第 1 帧构图随缓动缩放在变具体机关见第三节第 4 步暂停 存入图库。点「⏸ 暂停」定格当前帧进度点即帧导航。点底部 SaveButton 安全控件系统渲染文案「下载」首次触发系统授权弹窗「安全保存图片和视频」——点「允许」后当前帧写入公共媒体库完整过程截图见第五节 5.2。hilog 字节级确认frame saved to gallery urifile://media/Photo/1/IMG_1790339306_000/回忆放映_狗.jpg bytes36123 query宠物——真实文件真实写入公共媒体库相册立即可见。二、这个 App 解决什么问题「搜出一组照片想慢慢看、想把好看的存进相册」这个需求现有路径都很别扭方案死角网格逐张翻看一组照片被压成平铺缩略图没有「看一段回忆」的沉浸感逐张长按保存保存要权限弹窗或每次确认存到哪张是哪张没有字幕没有叙事第三方相册 slideshow选片靠人肉没有「按语义成组」的概念更没有端侧检索回忆放映室的答案是语义检索天然给出「一组相关照片」放映机把这组照片变成有节奏的短片安全控件让「存入相册」零权限一步到位。三个技术点各管一段选片靠语义一句话召回语义相关的一组照片天然成组不用人肉多选节奏靠放映机定时器 Ken Burns 缓动缩放 字幕淡入把静态照片变成有呼吸感的短片入库靠安全控件SaveButton点击即授权 10 秒窗口窗口内createAsset写公共媒体库——不申请权限、不弹系统权限对话框。三、交互设计放映机是一个小型状态机页面状态沿用系列纪律单真相源、每条异步路径专属文案、loading 闸门防重入。这一篇的新增状态围绕「放映」展开State ready: boolean false; State indexedCount: number 0; State loading: boolean false; State queryText: string ; State resultList: SearchHit[] []; State statusText: string 初始化中…; /** 放映中 */ State playing: boolean false; /** 当前放映帧下标 */ State frameIndex: number 0; /** Ken Burns 缩放相位每帧翻转驱动 animateTo */ State zoomPhase: number 0; /** 字幕可见帧切换后淡入 */ State captionVisible: boolean false; /** 存入图库中 */ State saving: boolean false; /** 已存入的相册 URI非空时展示成功条 */ State savedUri: string ;放映机核心是三个联动状态playing定时器开关、frameIndex当前帧、zoomPhase缩放相位。帧推进时翻转缩放相位是 Ken Burns 效果的机关——相邻两帧缩放方向交替视觉上永远有缓动在发生不会出现「静止的一张图」3.1 检索即排片一次异步流程两件事doSearch里检索完成后重置放映状态——检索和排片是同一次用户操作的两个阶段新片源进放映机时旧放映必须停/** 检索防重入loading 闸门 */ private async doSearch(): Promisevoid { if (this.loading) { return; } const keyword: string this.queryText.trim(); if (!keyword) { this.toast(先写一句回忆比如「海边日落」); return; } this.loading true; this.savedUri ; this.stopTimer(); this.playing false; this.statusText 正在端侧检索回忆…; try { const hits: SearchHit[] await this.service.search(keyword); this.resultList hits; if (hits.length 0) { this.frameIndex 0; this.statusText 找到 ${hits.length} 张点「▶ 放映」开始回忆短片; } else { this.statusText 没有找到这段回忆换个说法试试; } } finally { this.loading false; } }注意this.savedUri stopTimer()playing false三连——新检索开始时旧放映停机、旧入库记录失效和第三篇「新检索清空旧海报保存记录」同一条纪律。空结果也是一等公民状态栏给「没有找到这段回忆换个说法试试」放映区回落空态引导。3.2 放映定时器帧推进 进帧动效放映机的引擎是setInterval每FRAME_MS4 秒推进一帧循环播放/** 放映定时器每帧 FRAME_MS 推进 */ private startTimer(): void { this.stopTimer(); this.timerId setInterval(() { this.frameIndex (this.frameIndex 1) % this.resultList.length; this.enterFrame(); }, FRAME_MS); } private stopTimer(): void { if (this.timerId 0) { clearInterval(this.timerId); this.timerId -1; } } /** 进帧翻转缩放相位 字幕延迟淡入 */ private enterFrame(): void { this.zoomPhase this.frameIndex % 2; this.captionVisible false; setTimeout(() { this.getUIContext().animateTo({ duration: CAPTION_MS, curve: Curve.EaseOut }, () { this.captionVisible true; }); }, 200); }三个细节值得抄走startTimer先stopTimer定时器重启前先清旧句柄防止双定时器并行导致帧速翻倍——和系列前作「生命周期成对收口」同一条纪律enterFrame翻转zoomPhaseframeIndex % 2让相邻帧缩放方向交替配合.animation()属性动画每帧都在 4 秒里从 1.0 缓动到 1.12或反向——这就是 Ken Burns 的全部机关没有用任何转场框架字幕延迟 200ms 淡入帧切换瞬间字幕先隐藏200ms 后animateTo淡入——字幕跟着帧走不是常驻装饰。animateTo走 UIContext 版this.getUIContext().animateTo和系列前作 Toast 走getUIContext().getPromptAction()同一逻辑全局单例 API 一律让位给 UIContext 版。3.3 银幕渲染缩放相位驱动属性动画银幕本体是「上打孔条 帧 字幕条 下打孔条」的胶片结构帧的 Ken Burns 缩放靠.scale.animation声明式实现// 放映帧Ken Burns 缓动缩放 Stack() { Image($rawfile(this.currentFrame().imagePath)) .width(100%) .height(250) .objectFit(ImageFit.Cover) .scale(this.zoomPhase 0 ? { x: 1.0, y: 1.0 } : { x: 1.12, y: 1.12 }) .animation({ duration: FRAME_MS, curve: Curve.Friction, iterations: 1, playMode: PlayMode.Normal }) }zoomPhase变化 →.scale目标值变化 →.animation自动补间——这是声明式动画的标准范式不写插值代码只声明「属性变了怎么动」。duration: FRAME_MS让缩放动画时长恰好等于帧时长动画结束的下一刻定时器切帧节奏无缝衔接。Curve.Friction是摩擦曲线开头快结尾慢比 EaseInOut 更有「电影镜头推近」的质感。字幕条的淡入用transitionif (this.captionVisible) { Text(「${this.currentFrame().caption ?? }」) .fontSize(14) .fontColor(#E8C77E) .width(100%) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .transition(TransitionEffect.OPACITY.animation({ duration: CAPTION_MS, curve: Curve.EaseOut })) }captionVisible切换触发if分支增删节点transition给节点出入场挂透明度动画——条件渲染 transition 是「组件级淡入淡出」的标准解比常驻节点改 opacity 语义更清晰。四、工程结构memory-cinema/ ├── entry/src/main/ │ ├── ets/ │ │ ├── pages/Index.ets # 检索 放映机状态机 银幕渲染 入库流程 │ │ └── common/ │ │ ├── CinemaService.ets # 单例服务检索 排片 媒体库写入 │ │ └── DemoData.ets # 12 张示例图标签 主题 字幕文案 │ └── resources/rawfile/demo/ # 12 张真实照片素材旅程/拼搏/陪伴三层职责与系列前作同构服务层多了一段「媒体库写入」职责层文件职责不知道的事页面层Index.ets放映机状态机、银幕渲染、SaveButton 触发不知道媒体库写入的 API 细节服务层CinemaService.ets检索 saveFrameToGallery不知道放映节奏和 UI 动效数据层DemoData.ets素材定义标签 主题 字幕不知道自己被谁消费SearchHit接口比前作多一个caption字段——放映字幕跟着检索结果走不是写死的装饰export interface SearchHit { imagePath: string; similarity: number; rawfile?: boolean; title?: string; /** 放映字幕文案 */ caption?: string; /** 回忆主题 */ theme?: string; }接口即防火墙依然成立真机换 NPU 检索只改search内部放映机和入库链路与检索内核零耦合。五、核心实现5.1 走出沙箱SaveButton createAsset 三步入库saveFrameToGallery是本篇的技术核心——系列第一次把文件写出应用沙箱public async saveFrameToGallery(frame: SearchHit, query: string): Promisestring { const context: common.Context this.getContext(); // 1. rawfile → PixelMap → JPEG 字节流 const buf: ArrayBuffer await this.encodeFrame(frame.imagePath); // 2. createAsset 建媒体资产拿到相册 URI const helper: photoAccessHelper.PhotoAccessHelper photoAccessHelper.getPhotoAccessHelper(context); const title: string 回忆放映_${frame.title ?? 帧}; const options: photoAccessHelper.CreateOptions { title: title, subtype: photoAccessHelper.PhotoSubtype.DEFAULT }; const uri: string await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, jpg, options); // 3. URI 写入字节流 const file: fileIo.File fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY); try { fileIo.writeSync(file.fd, buf); } finally { fileIo.closeSync(file.fd); } hilog.info(DOMAIN, TAG, frame saved to gallery uri%{public}s bytes%{public}d query%{public}s, uri, buf.byteLength, query); return uri; }三步各自有讲究先编码再入库rawfile 素材先解码成 PixelMap 再压成 JPEG 字节流quality 92入库的是标准 JPEG——媒体库不认 rawfile 路径只认字节流createAsset返回 URI先在公共媒体库建一个空资产拿到file://media/Photo/...URI再往 URI 写字节流——建资产和写内容是两步URI 就是应用世界和媒体库世界的桥CreateOptions.title写入时带标题回忆放映_狗相册里显示的是这个标题而不是乱码文件名——入库的元信息跟着数据走。5.2 SaveButton安全控件的授权窗口页面层的 SaveButton 是整个入库链路的钥匙// 存入系统图库SaveButton 安全控件免权限写入公共媒体库 // 安全控件样式受限只设置尺寸不套用通用布局属性 if (this.resultList.length 0) { SaveButton() .width(220) .height(44) .onClick((_event: ClickEvent, result: SaveButtonOnClickResult) { if (result SaveButtonOnClickResult.SUCCESS) { this.saveCurrentFrame(); } else { this.toast(保存授权未完成); } }) }安全控件的规则必须吃透免权限SaveButton是系统渲染的控件用户点击它这个动作本身就是授权——应用不需要在module.json5申请ohos.permission.WRITE_IMAGEVIDEO本工程零权限申请10 秒窗口点击后授权窗口约 10 秒窗口内createAsset可用窗口外调用直接报code 201 Permission denied。实测验证过绕过 SaveButton 直接调saveFrameToGalleryhilog 报saveFrameToGallery failed, code: 201, message: Permission denied——安全控件不是装饰是唯一的门样式受限安全控件由系统渲染fontSize/backgroundColor等通用样式属性不生效只设尺寸220×44。系统渲染的文案是「下载」——文案不可自定义这是安全控件换取免权限的代价onClick带授权结果回调第二参数SaveButtonOnClickResult.SUCCESS表示授权成功先判结果再干活——授权失败时不该执行写入。首次使用还会触发系统授权弹窗「安全保存图片和视频」告知用户这个按钮的权限范围用户点「允许」后WRITE_IMAGEVIDEO临时授权生效hilog 可见ActiveStatusChange记录之后写入一路畅通。5.3 编码链路rawfile → PixelMap → JPEGencodeFrame是入库前的编码流水线资源成对收口/** rawfile 解码 → JPEG 编码quality 92返回字节流 */ private async encodeFrame(rawfilePath: string): PromiseArrayBuffer { const context: common.Context this.getContext(); const resourceMgr context.resourceManager; const raw: Uint8Array await resourceMgr.getRawFileContent(rawfilePath); const imageSource: image.ImageSource image.createImageSource(raw.buffer as ArrayBuffer); const pixelMap: image.PixelMap await imageSource.createPixelMap(); try { const packer: image.ImagePacker image.createImagePacker(); try { const opts: image.PackingOption { format: image/jpeg, quality: 92 }; return await packer.packing(pixelMap, opts); } finally { await packer.release(); } } finally { await imageSource.release(); await pixelMap.release(); } }三层 try-finally 嵌套packer、imageSource、pixelMap 各自一层收口任何一步抛异常都不会泄漏底层句柄。实测入库帧 36KBquality 92 的 JPEG相册立即可见。image.createImageSource传raw.buffer as ArrayBuffer——rawfile 内容是 Uint8ArrayArkTS 严格模式要求显式转 ArrayBuffer这个 cast 在系列前作里也踩过。5.4 放映控制暂停态手动切帧放映中「⏸ 暂停」停定时器暂停态「⏮ 上一帧 / 下一帧 ⏭」手动切帧进度点直接跳帧/** 上一帧 / 下一帧暂停态手动切帧 */ private stepFrame(delta: number): void { if (this.resultList.length 0) { return; } const n: number this.resultList.length; this.frameIndex (this.frameIndex delta n) % n; this.enterFrame(); }(frameIndex delta n) % n是环形算术——delta 为负时先加 n 保证非负模运算结果永远落在[0, n)。手动切帧也走enterFrame()自动放映和手动切帧共用同一套进帧动效视觉行为一致。六、真机运行指南演示模式默认点「载入回忆」载入 12 张内置素材标签匹配引擎出结果任何设备完整跑通载入→搜索→放映→入库全流程五个快捷词按命中数演示不同长度的放映——「海边日落」1 张、「深夜加班」2 张、「风景」3 张、「宠物」4 张、「和宠物的旅行」8 张真机模式把CinemaService.search内部替换为textSearchImage调用与前作RealTextImageSearchService同款放映与入库部分零改动入库走 SaveButton 安全控件不申请任何权限首次使用有系统授权弹窗点「允许」一次即可保存的照片在系统相册立即可见标题为回忆放映_帧标题全程端侧、不联网、不上传hilog 里frame saved to gallery uri... bytes...可字节级确认入库结果。七、踩坑清单速查表现象原因修法createAsset报 code 201 Permission denied不在 SaveButton 授权窗口内调用只在onClick(result SUCCESS)回调里执行写入module.json5申请了 WRITE_IMAGEVIDEO 仍被拒该权限对普通应用不开放ACL 受限用 SaveButton 安全控件彻底不申请SaveButton 设置backgroundColor等样式不生效安全控件系统渲染样式受限只设 width/height接受系统默认样式SaveButton 在 dump/无障碍树里找不到文本系统渲染文案是「下载」且节点特殊按type:SaveButton定位节点image.createImageSource传 Uint8Array 报错严格模式要求 ArrayBufferraw.buffer as ArrayBuffer显式转换ImageSource/ImagePacker 用后卡顿底层句柄未释放多层 try-finally 成对release()缩放动画每帧都从 1.0 开始显得机械缩放相位不翻转zoomPhase frameIndex % 2相邻帧方向交替双定时器并行帧速翻倍startTimer 前没清旧句柄startTimer 首行先 stopTimer字幕常驻显得呆板字幕不跟帧切换帧切换隐藏 200ms 后 animateTo 淡入animateTo报废弃用告警全局版已废弃this.getUIContext().animateTo()八、总结至此文搜图四部曲走完一句话进、相似度排序的图片出找到→ 封存成时间胶囊留住→ 编排成海报落盘成文件带走→ 放映成回忆短片存入系统图库收藏。检索内核四部曲同源接口即防火墙换 NPU 内核 UI 零改动放映机是一个小型状态机定时器 缩放相位翻转 字幕延迟淡入把静态照片变成有呼吸感的 Ken Burns 短片声明式动画的标准范式属性目标值变化 .animation自动补间不写插值代码系列第一次走出应用沙箱SaveButton 安全控件 10 秒授权窗口 createAsset建资产写字节流零权限申请、相册立即可见安全控件不是装饰是唯一的门绕过它直接写媒体库code 201 立刻打脸——实测验证过。
返回列表