/* * 壁纸**上传前**的压图计划 —— 纯逻辑,无 UI / 无 SDK 依赖。 * * ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。 * * ── 为什么单独一层(P4c)── * * 手机直出照片是 4–8MB,而服务端壁纸上限 **4MB**(`appearanceMaxBytes()`,可配)—— * 直传必然 413。WebUI 那边的做法是「先压缩再上传」,这里把**同一套阈值与两档策略** * 搬成纯逻辑,于是"缩到多大、什么质量、什么时候放弃"这些**数值**能被判据钉住, * 而不是埋在一个 `.ets` 的 async 函数里(那种地方判据只能拿正则去猜)。 * * ── 与 WebUI 的对齐(取值出处,不是凭印象)── * * | 这一层 | WebUI 出处 | * |---|---| * | `MAX_EDGE = 2560` | `stores/backgroundStore.ts:135` | * | 首档质量 0.85 + 最长边 2560 | 同文件 `prepareImage`:`drawScaled(bitmap, MAX_EDGE)` + `toDataURL('image/jpeg', 0.85)` | * | 超限后缩到 1280 + 质量 0.78 | 同处:`drawScaled(bitmap, MAX_EDGE / 2)` + `toDataURL('image/jpeg', 0.78)` | * | 原图 > 20MB 直接拒 | 同处:`file.size > 20 * 1024 * 1024` | * | 压缩后仍超限 ⇒ 明确拒绝并说原因 | 同处:『图片压缩后仍过大,请换一张更小的图片』 | * * ── 一处**故意不对齐**,以及为什么 ── * * WebUI 判的是 **data URL 的长度**(`MAX_DATA_URL_BYTES = 2_400_000`,含 base64 膨胀 * 与 `data:image/jpeg;base64,` 前缀),因为它必须把 data URL 存进 localStorage 并渲染。 * 鸿蒙侧**不经过 data URL**:`packing()` 直接给 `ArrayBuffer` 交给 multipart 上传, * 既没有 base64 的 4/3 膨胀也没有前缀。拿 WebUI 那个数当**字节**上限会平白少收 1/4 的图。 * * 所以这里判的是**估算后的字节数**,上限取服务端那道真正的门(4MB)留出余量后的值。 * ⇒ 这条差异是**有意的**,不是抄漏;判据同时钉住"两个数都在"和"别把它当成同一件事"。 */ /** 缩放的最长边(px)。与 WebUI `MAX_EDGE` 相等 —— 观感一致的来源。 */ export const MAX_EDGE: number = 2560; /** 首档 JPEG 质量 */ export const FIRST_QUALITY: number = 0.85; /** 首档失败后的第二档最长边(= MAX_EDGE / 2,与 WebUI 同) */ export const RETRY_EDGE: number = 1280; /** 第二档 JPEG 质量 */ export const RETRY_QUALITY: number = 0.78; /** 原图超过这个大小就不读了(解码本身会卡住主线程)。与 WebUI 的 20MB 同。 */ export const MAX_SOURCE_BYTES: number = 20 * 1024 * 1024; /** * 压完之后的**字节**上限。 * * 服务端那道门是 `appearanceMaxBytes()`(默认 4MiB,可配),它卡的是**文件内容**, * 而且外层还有一个 `max+1MiB` 的请求体限制(multipart 边界也占地方)。 * 取 3.5MiB:贴着 4MiB 会在"服务端把上限调小"时变成 413, * 而离得太远又白扔分辨率。 */ export const MAX_UPLOAD_BYTES: number = 3_670_016; /** 一档压缩的参数 */ export class CompressPass { /** 最长边(px) */ maxEdge: number = MAX_EDGE; /** JPEG 质量(0~1) */ quality: number = FIRST_QUALITY; /** 这是第几档(1 起;界面文案与判据都认它) */ attempt: number = 1; } /** 压图计划 */ export class CompressPlan { /** 按顺序要试的档(第一档超限才试第二档) */ passes: CompressPass[] = []; /** 等比缩放后的目标尺寸(与 pass 无关:两档都从同一张原位图缩,见下) */ targetWidth: number = 0; targetHeight: number = 0; } /** * 等比缩放到最长边不超过 `maxEdge`。 * * ★ **不放大小图**(`scale > 1` 时取 1):把小图放大会同时变糊和变大, * 而"变大"会浪费掉那道字节上限。WebUI 的 `drawScaled` 同样 `Math.min(1, …)`。 * * ★ 除以 `Math.max(w, h)` 而不是 `w`:竖拍照片(h > w)按宽算会**超出**最长边。 */ export function scaleToMaxEdge(width: number, height: number, maxEdge: number): { width: number; height: number } { const longest: number = Math.max(width, height); if (longest <= 0) { return { width: 0, height: 0 }; } const scale: number = Math.min(1, maxEdge / longest); return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }; } /** * 估算 JPEG 压完的字节数。 * * JPEG 是变长编码,**没有**能算准的公式;这里要的不是准,而是"够用来判要不要试下一档"。 * 用 `宽 × 高 × 每像素字节` 的上界估计(0.5 B/px 对高质量照片偏保守), * 偏保守的方向是**对的**:宁可多试一档,也别上传一个必然 413 的东西。 * * ★ 这个函数的估算**误差写在这里**,别让读的人以为它是测量值: * 真实字节数只有 `packing()` 之后才知道;所以要**在拿到真实长度后再判一次** * (见 `shouldRetryWithActual`),估算只用来决定"是否值得先试第一档"。 */ export function estimateJpegBytes(width: number, height: number, quality: number): number { const pixels: number = Math.max(0, width) * Math.max(0, height); // 质量越高,每像素位越多;0.85 → 约 0.5 B/px,0.78 → 约 0.4 B/px const bytesPerPixel: number = 0.2 + 0.35 * Math.max(0, Math.min(1, quality)); return Math.round(pixels * bytesPerPixel); } /** * 真实字节数出来后,是否该退到下一档。 * * 单独一个函数是为了让"两档都超限"这条路径**能被判据跑**: * 界面在那种情况下必须**明确说原因**(P4c 三条里的第二条), * 而不是静默什么都没发生 —— 静默失败在 WebUI 那边踩过。 */ export function shouldRetryWithActual(actualBytes: number): boolean { return actualBytes > MAX_UPLOAD_BYTES; } /** 两档都超限时给用户看的话(与 WebUI 的文案同义) */ export const TOO_LARGE_REASON: string = '图片压缩后仍过大,请换一张更小的图片'; /** 原图就过大时给用户看的话 */ export const SOURCE_TOO_LARGE_REASON: string = '图片过大(超过 20MB),请先裁剪'; /** 选到非图片时给用户看的话 */ export const NOT_IMAGE_REASON: string = '请选择图片文件'; /** * 原件体积的**估算**(picker 只给 uri,拿不到文件大小)。 * * ★ 这里刻意**不**去 `fileIo.stat` 拿真实大小:那要多一次 IO,而这一步只用于 * "20MB 以上就别解码了"这一道**粗筛**(真正的门是压完之后的字节数与服务端上限)。 * ★ 系数取 **偏小**(0.35 B/px,手机 JPEG 直出的典型值):两个方向的代价不对称 —— * 估偏小 = "极大图可能走到解码那一步才会卡",可接受; * 估偏大 = **误拒正常照片**,那是把好需求挡在门外。 */ export function estimateSourceBytes(width: number, height: number): number { return Math.round(Math.max(0, width) * Math.max(0, height) * 0.35); } /** * 造压图计划:按原图尺寸决定**缩放目标**,并给出要试的档。 * * 两档都用**同一张原位图**缩放(不是把第一档的结果再缩一次)—— * 二次缩放会累加两次重采样损失,而重新从原图缩只损失一次。 * WebUI 的 `prepareImage` 也是两次都从 `bitmap` 缩。 */ export function planCompress(sourceWidth: number, sourceHeight: number): CompressPlan { const plan: CompressPlan = new CompressPlan(); const first: CompressPass = new CompressPass(); first.maxEdge = MAX_EDGE; first.quality = FIRST_QUALITY; first.attempt = 1; const second: CompressPass = new CompressPass(); second.maxEdge = RETRY_EDGE; second.quality = RETRY_QUALITY; second.attempt = 2; plan.passes = [first, second]; // 目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果) const scaled = scaleToMaxEdge(sourceWidth, sourceHeight, MAX_EDGE); plan.targetWidth = scaled.width; plan.targetHeight = scaled.height; return plan; } /** `judgePick` 的结果 */ export class PickJudgement { ok: boolean = true; /** 不 ok 时的原因(要给用户看,原样显示) */ reason: string = ''; } /** * 选图之后的**入口检查**:先判能不能处理,再判要不要压。 * * 返回 `{ ok, reason }`:`ok === false` 时 `reason` **必须**被显示出来。 * 这一条是 P4c 三条里的第二条("失败必须给原因,别静默失败")的落点 —— * 单独成函数而不是写在 `.ets` 的 async 里,就是为了让每条拒绝路径都有判据。 */ export function judgePick(sizeBytes: number, mimeType: string): PickJudgement { const j: PickJudgement = new PickJudgement(); if (!mimeType.startsWith('image/')) { j.ok = false; j.reason = NOT_IMAGE_REASON; return j; } if (sizeBytes > MAX_SOURCE_BYTES) { j.ok = false; j.reason = SOURCE_TOO_LARGE_REASON; return j; } j.ok = true; j.reason = ''; return j; } /** 上传后的文案:服务端是权威,所以成功后要重新拉一次(P4c 第三条) */ export const UPLOAD_OK_HINT: string = '壁纸已上传;正在以服务端那份为准重新同步(换设备也会跟着走)。'; /** 上传失败时**不要把原因吞掉**:服务端的文案(如"壁纸必须是图片…")是唯一能让人立刻改的东西 */ export function uploadFailureHint(serverMessage: string): string { if (serverMessage.length === 0) { return '上传失败(服务端没有给原因)'; } return '上传失败:' + serverMessage; } /* * ★ 这里原本还写了一个手写 `bytesToBase64`(含一个对应的解码函数), * 理由是"压完的图在内存里、而上传要文件,所以内存转 base64 上传"。 * **核了 SDK 之后删掉了**:`@ohos.net.http.d.ts` 的 `MultiFormData` 里 * `data?: string | Object | ArrayBuffer`(since 11,本工程 target/compatible 是 6.1.0(23)), * 而紧邻的注释写明「If data has a value, filePath does not take effect」—— * **内存字节可以直传**,不需要 base64,也不需要临时文件。 * * 记这一笔是因为它是个典型形状:**在假设 SDK 能力不足的前提下写了一层, * 而那一层自己又会成为新的错源**(手写 base64 的移位与补齐错了是静默错: * 语法合法、字节不对)。结论:先核 SDK 再决定要不要自己实现。 */