diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..d2ff20141ceed86d87c0ea5d99481973005bab2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000000000000000000000000000000000000..d36e2b8f70e79df708f1c9f361c2204868079950 --- /dev/null +++ b/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.example.customcamera", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "icon": "$media:layered_image", + "label": "$string:app_name" + } +} diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..dcff7a161c7e37223d7fa221466db8b975b499a8 --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "CustomCamera" + } + ] +} diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f Binary files /dev/null and b/AppScope/resources/base/media/background.png differ diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..97014d3e10e5ff511409c378cd4255713aecd85f Binary files /dev/null and b/AppScope/resources/base/media/foreground.png differ diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000000000000000000000000000000000000..fb49920440fb4d246c82f9ada275e26123a2136a --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..18795a48d6b12fcdc1aa7bac9a9cb99f83815267 --- /dev/null +++ b/LICENSE @@ -0,0 +1,78 @@ + Copyright (c) 2025 Huawei Device Co., Ltd. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Apache License, Version 2.0 +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: +1.You must give any other recipients of the Work or Derivative Works a copy of this License; and +2.You must cause any modified files to carry prominent notices stating that You changed the files; and +3.You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +4.If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.en.md b/README.en.md deleted file mode 100644 index 286905763d700366e53171e8fde8cc08350d6faa..0000000000000000000000000000000000000000 --- a/README.en.md +++ /dev/null @@ -1,36 +0,0 @@ -# CustomCamera - -#### Description -{**When you're done, you can delete the content in this README and update the file with details for others getting started with your repository**} - -#### Software Architecture -Software architecture description - -#### Installation - -1. xxxx -2. xxxx -3. xxxx - -#### Instructions - -1. xxxx -2. xxxx -3. xxxx - -#### Contribution - -1. Fork the repository -2. Create Feat_xxx branch -3. Commit your code -4. Create Pull Request - - -#### Gitee Feature - -1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md -2. Gitee blog [blog.gitee.com](https://blog.gitee.com) -3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) -4. The most valuable open source project [GVP](https://gitee.com/gvp) -5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) -6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/README.md b/README.md index 7b8d0055ab0b9a505dfe6758b804852a57bc4899..af578c77a9c206c232ee055cec013f77b1d9a19a 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,87 @@ -# CustomCamera +## 自定义相机开发实践 -#### 介绍 -{**以下是 Gitee 平台说明,您可以替换此简介** -Gitee 是 OSCHINA 推出的基于 Git 的代码托管平台(同时支持 SVN)。专为开发者提供稳定、高效、安全的云端软件开发协作平台 -无论是个人、团队、或是企业,都能够用 Gitee 实现代码托管、项目管理、协作开发。企业项目请看 [https://gitee.com/enterprises](https://gitee.com/enterprises)} +### 介绍 -#### 软件架构 -软件架构说明 +本示例基于Camera Kit相机服务,实现预览、双路预览、拍照、录像等功能。为开发者提供基于自定义相机的开发指导。 +### 效果预览 -#### 安装教程 +| 拍照 & 预览 | 录像 & 预览 | +|--------------------------------------|--------------------------------------| +| ![](./screenshots/devices/photo.png) | ![](./screenshots/devices/video.png) | -1. xxxx -2. xxxx -3. xxxx -#### 使用说明 +使用说明: +1. 打开应用,授权后展示预览界面。 +2. 上方从左至右按钮功能依次为:闪光灯设置、延迟拍照模式设置、动态拍照模式设置、单双段拍照模式设置(单段拍照模式不支持动态拍摄)。 +3. 切换录像模式,上方按钮依次为:闪关灯设置、防抖模式设置。 +4. 右侧按钮依次为:网格线、水平仪。 +5. 下方按钮可拍照,录像,切换前后置摄像头。 -1. xxxx -2. xxxx -3. xxxx +### 工程目录 -#### 参与贡献 +``` +├──camera/src/ +│ ├──main/ets/ +│ │ ├──components +│ │ │ ├──GridLine.ets // 网格线组件 +│ │ │ └──LevelIndicator.ets // 水平仪组件 +│ │ ├──constants +│ │ │ └──CameraConstants.ets // 常量文件 +│ │ └──cameraManagers +│ │ ├──CamaraManager.ets // 相机会话管理类 +│ │ ├──ImageReceiverManager.ets // ImageReceiver预览流管理类 +│ │ ├──OutputManager.ets // 输出流管理类抽象接口 +│ │ ├──PhotoManager.ets // 拍照流管理类 +│ │ ├──VideoManager.ets // 视频流管理类 +│ │ └──PreviewManager.ets // 预览流管理类 +│ └──Index.ets // 相机模块导出文件 +├──commons/src/main/ets/ +│ └──utils +│ └──Logger.ets // 日志类 +├──entry/src/main/ets/ +│ ├──entryability +│ │ └──EntryAbility.ets // 程序入口类 +│ ├──constants +│ │ └──Constants.ets // 常量文件 +│ ├──pages +│ │ └──Index.ets // 入口预览页面 +│ ├──views +│ │ ├──ModeButtonsView.ets // 拍照模式切换按钮视图 +│ │ ├──OperateButtonsView.ets // 操作按钮视图 +│ │ ├──SettingButtonsView.ets // 设置按钮视图 +│ │ └──ZoomButtonsView.ets // 设置焦距按钮视图 +│ ├──viewModels +│ │ └──PreviewViewModel.ets // 预览相关的状态管理类 +│ └──utils +│ ├──CommonUtil.ets // 通用工具函数模块 +│ ├──PermissionManager.ets // 权限管理类 +│ ├──RefreshableTimer.ets // 定时器管理类 +│ └──WindowUtil.ets // 窗口工具类 +└──entry/src/main/resources // 应用静态资源目录 +``` -1. Fork 本仓库 -2. 新建 Feat_xxx 分支 -3. 提交代码 -4. 新建 Pull Request +### 具体实现 +1. 使用Camera Kit相关能力。 -#### 特技 +### 相关权限 + +- ohos.permission.CAMERA:用于相机操作 +- ohos.permission.MICROPHONE:麦克风权限,用于录像 +- ohos.permission.MEDIA_LOCATION: 用于获取地理信息 +- ohos.permission.WRITE_IMAGEVIDEO:用于写入媒体文件 +- hos.permission.READ_IMAGEVIDEO:用于读取媒体文件 +- ohos.permission.ACCELEROMETER:用于加速度传感器 + + +### 约束与限制 + +1.本示例仅支持标准系统上运行,支持设备:华为手机、平板。 + +2.HarmonyOS系统:HarmonyOS 5.0.0 Release及以上。 + +3.DevEco Studio版本:DevEco Studio 5.0.0 Release及以上。 + +4.HarmonyOS SDK版本:HarmonyOS 5.0.0 Release SDK及以上。 -1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md -2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) -3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 -4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 -5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) -6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..f799a25ed0cdd13394c636ed016873a5eb1e8599 --- /dev/null +++ b/build-profile.json5 @@ -0,0 +1,50 @@ +{ + "app": { + "signingConfigs": [], + "products": [ + { + "name": "default", + "signingConfig": "default", + "targetSdkVersion": "5.0.4(16)", + "compatibleSdkVersion": "5.0.4(16)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug", + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + }, + { + "name": "camera", + "srcPath": "./camera" + }, + { + "name": "commons", + "srcPath": "./commons", + } + ] +} \ No newline at end of file diff --git a/camera/.gitignore b/camera/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e2713a2779c5a3e0eb879efe6115455592caeea5 --- /dev/null +++ b/camera/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/camera/Index.ets b/camera/Index.ets new file mode 100644 index 0000000000000000000000000000000000000000..ccfbb991d25157b984dd7911b8e1bcb8dffb967b --- /dev/null +++ b/camera/Index.ets @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { CameraManager } from './src/main/ets/cameramanagers/CameraManager'; +export { PreviewManager } from './src/main/ets/cameramanagers/PreviewManager'; +export { PhotoManager } from './src/main/ets/cameramanagers/PhotoManager'; +export { VideoManager, AVRecorderState } from './src/main/ets/cameramanagers/VideoManager'; +export { ImageReceiverManager } from './src/main/ets/cameramanagers/ImageReceiverManager'; +export { GridLine } from './src/main/ets/components/GridLine'; +export { LevelIndicator } from './src/main/ets/components/LevelIndicator'; diff --git a/camera/build-profile.json5 b/camera/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..b7cdea1eacdfeed02f087f59a735d470c6752d09 --- /dev/null +++ b/camera/build-profile.json5 @@ -0,0 +1,21 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + + }, + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest" + } + ] +} diff --git a/camera/hvigorfile.ts b/camera/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..805c5d7f6809c51cff0b4adcc1142979f8f864b6 --- /dev/null +++ b/camera/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/camera/oh-package-lock.json5 b/camera/oh-package-lock.json5 new file mode 100644 index 0000000000000000000000000000000000000000..ebb8b315c3eb0c6696e7e8e12bd9aaab4c3dd2d2 --- /dev/null +++ b/camera/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "commons@../commons": "commons@../commons" + }, + "packages": { + "commons@../commons": { + "name": "commons", + "version": "1.0.0", + "resolved": "../commons", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/camera/oh-package.json5 b/camera/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..38b9469f09b3f247b681945414a4278dbdb95b9e --- /dev/null +++ b/camera/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "name": "camera", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "Index.ets", + "author": "", + "license": "Apache-2.0", + "dependencies": { + "commons": "file:../commons" + } +} \ No newline at end of file diff --git a/camera/src/main/ets/cameramanagers/CameraManager.ets b/camera/src/main/ets/cameramanagers/CameraManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..08cd25e9a3fd278f19c6bd0cabfe392d8331ac34 --- /dev/null +++ b/camera/src/main/ets/cameramanagers/CameraManager.ets @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { camera } from "@kit.CameraKit"; +import { Logger } from "commons/src/main/ets/utils/Logger"; +import { BusinessError } from "@kit.BasicServicesKit"; +import OutputManager, { CreateOutputConfig } from "./OutputManager"; + +const TAG = 'CameraManager'; + +export class CameraManager { + private cameraManager: camera.CameraManager; + session?: camera.PhotoSession | camera.VideoSession; + private cameraInput?: camera.CameraInput; + private outputManagers: OutputManager[] = []; + + constructor(context: Context, outputManagers: OutputManager[]) { + this.cameraManager = camera.getCameraManager(context); + this.outputManagers = outputManagers; + this.addCameraStatusListener(); + } + + addCameraStatusListener() { + this.cameraManager.on('cameraStatus', (err: BusinessError, statusInfo: camera.CameraStatusInfo) => { + if (err && err.code) { + Logger.error(TAG, 'cameraStatus with errorCode = ' + err.code); + return; + } + Logger.info(TAG, `cameraStatusInfo: camera is ${statusInfo.camera.cameraId}, status is ${statusInfo.status}`); + }); + } + + getCameraManager() { + return this.cameraManager; + } + + async start( + xComponentSurfaceId: string, + cameraPosition: camera.CameraPosition, + sceneMode: camera.SceneMode, + profile: camera.Profile + ) { + try { + const device = this.getCameraDevice(cameraPosition); + if (!device) { + return; + } + this.cameraInput = this.cameraManager.createCameraInput(device); + await this.cameraInput.open(); + const session = this.cameraManager.createSession(sceneMode); + session.beginConfig(); + session.addInput(this.cameraInput); + const config: CreateOutputConfig = { + cameraManager: this.cameraManager, + device, + sceneMode, + profile, + surfaceId: xComponentSurfaceId + }; + for (const outputManager of this.outputManagers) { + if (outputManager.isActive) { + const output = await outputManager.createOutput(config); + session.addOutput(output); + } + }; + await session.commitConfig(); + await session.start(); + this.session = session as (camera.PhotoSession | camera.VideoSession); + this.session.setFocusMode(camera.FocusMode.FOCUS_MODE_AUTO); + } catch (e) { + Logger.error(TAG, `Failed to start camera session. Cause ${JSON.stringify(e)}`); + } + } + + async refreshOutput(oldOutput: camera.CameraOutput, newOutput: camera.CameraOutput) { + await this.session?.stop(); + this.session?.beginConfig(); + this.session?.removeOutput(oldOutput); + this.session?.addOutput(newOutput); + await this.session?.commitConfig(); + await this.session?.start(); + } + + async release() { + await this.session?.stop(); + for (const outputManager of this.outputManagers) { + if (outputManager.isActive) { + await outputManager.release(); + } + }; + await this.cameraInput?.close(); + await this.session?.release(); + } + + getCameraDevice(cameraPosition: camera.CameraPosition) { + const cameraDevices = this.cameraManager.getSupportedCameras(); + const device = cameraDevices?.find(device => device.cameraPosition === cameraPosition); + if (!device) { + Logger.error(TAG, `Failed to get camera device. cameraPosition: ${cameraPosition}}`); + } + return device; + } + + getZoomRange() { + return this.session!.getZoomRatioRange(); + } + + setFocusMode(focusMode: camera.FocusMode) { + try { + this.session?.setFocusMode(focusMode); + } catch(e) { + Logger.error(TAG, 'setFocusMode error ' + JSON.stringify(e)); + } + } + + setFocusPoint(point: camera.Point) { + try { + this.session?.setFocusPoint(point); + } catch(e) { + Logger.error(TAG, 'setFocusPoint error ' + JSON.stringify(e)); + } + } + + setZoomRatio(zoom: number) { + try { + this.session?.setZoomRatio(zoom); + } catch(e) { + Logger.error(TAG, 'setZoomRatio error ' + JSON.stringify(e)); + } + } + + setSmoothZoom(zoom: number) { + try { + this.session?.setSmoothZoom(zoom); + } catch(e) { + Logger.error(TAG, 'setSmoothZoom error ' + JSON.stringify(e)); + } + } + + setFlashMode(flashMode: camera.FlashMode) { + try { + this.session?.setFlashMode(flashMode); + } catch(e) { + Logger.error(TAG, 'setFlashMode error ' + JSON.stringify(e)); + } + } + + setVideoStabilizationMode(stabilizationMode: camera.VideoStabilizationMode) { + try { + const session = this.session as camera.VideoSession; + let isSupported: boolean = session.isVideoStabilizationModeSupported(stabilizationMode); + if (isSupported) { + session.setVideoStabilizationMode(stabilizationMode); + } else { + Logger.error(TAG, 'stabilizationMode is not supported: ' + JSON.stringify(stabilizationMode)); + } + } catch (e) { + Logger.error(TAG, 'setVideoStabilizationMode error ' + JSON.stringify(e)); + } + } +} \ No newline at end of file diff --git a/camera/src/main/ets/cameramanagers/ImageReceiverManager.ets b/camera/src/main/ets/cameramanagers/ImageReceiverManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..ce6485d76089d78389172a09674f2242e3020947 --- /dev/null +++ b/camera/src/main/ets/cameramanagers/ImageReceiverManager.ets @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { image } from "@kit.ImageKit"; +import { camera } from "@kit.CameraKit"; +import { BusinessError } from "@kit.BasicServicesKit"; +import { Logger } from "commons/src/main/ets/utils/Logger"; +import OutputManager, { CreateOutputConfig } from "./OutputManager"; +import { display } from "@kit.ArkUI"; +import CameraConstant from "../constants/CameraConstants"; + +const TAG = 'ImageReceiverManager'; + +export class ImageReceiverManager implements OutputManager { + output?: camera.PreviewOutput; + isActive: boolean = true; + callback: (px: PixelMap) => void; + private position: camera.CameraPosition = camera.CameraPosition.CAMERA_POSITION_BACK; + + constructor(cb: (px: PixelMap) => void) { + this.callback = cb; + } + + async createOutput(config: CreateOutputConfig) { + const cameraOutputCap = config.cameraManager.getSupportedOutputCapability(config.device, config.sceneMode); + const displayRatio = config.profile.size.width / config.profile.size.height; + const profileWidth = config.profile.size.width; + const previewProfile = cameraOutputCap.previewProfiles + .sort((a, b) => Math.abs(a.size.width - profileWidth) - Math.abs(b.size.width - profileWidth)) + .find(pf => { + const pfDisplayRatio = pf.size.width / pf.size.height; + return Math.abs(pfDisplayRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE; + }); + if (!previewProfile) { + Logger.error(TAG, 'Failed to get preview profile'); + return; + } + const surfaceId = await this.init(config.profile.size); + this.output = config.cameraManager.createPreviewOutput(previewProfile, surfaceId); + this.position = config.device.cameraPosition; + return this.output; + } + + async release() { + await this.output?.release(); + this.output = undefined; + } + + async init(size: Size, format = image.ImageFormat.JPEG, capacity = 8) { + const receiver = image.createImageReceiver(size, format, capacity); + const surfaceId = await receiver.getReceivingSurfaceId(); + this.onImageArrival(receiver); + return surfaceId; + } + + getRotate(): number { + const displayRotation = display.getDefaultDisplaySync().rotation; + if (this.position === camera.CameraPosition.CAMERA_POSITION_BACK) { + return (displayRotation * camera.ImageRotation.ROTATION_90 + 90) % 360; + } + const rotation = displayRotation * camera.ImageRotation.ROTATION_90; + const angle = (displayRotation * camera.ImageRotation.ROTATION_90 + 270) % 360; + if (rotation === 90 || rotation === 270) { + return (angle + 180) % 360; + } + return angle; + } + + async getPixelMap(imgComponent: image.Component, width: number, height: number, stride: number) { + if (stride === width) { + return await image.createPixelMap(imgComponent.byteBuffer, { + size: { height: height, width: width }, + srcPixelFormat: 8, + }); + } + const dstBufferSize = width * height * 1.5 + const dstArr = new Uint8Array(dstBufferSize); + for (let j = 0; j < height * 1.5; j++) { + const srcBuf = new Uint8Array(imgComponent.byteBuffer, j * stride, width); + dstArr.set(srcBuf, j * width); + } + return await image.createPixelMap(dstArr.buffer, { + size: { height: height, width: width }, + srcPixelFormat: 8, + }); + } + + onImageArrival(receiver: image.ImageReceiver): void { + receiver.on('imageArrival', () => { + Logger.info(TAG, 'image arrival'); + receiver.readNextImage((err: BusinessError, nextImage: image.Image) => { + if (err || nextImage === undefined) { + Logger.error(TAG, 'readNextImage failed'); + return; + } + nextImage.getComponent(image.ComponentType.JPEG, async (err: BusinessError, imgComponent: image.Component) => { + if (err || imgComponent === undefined) { + Logger.error(TAG, 'getComponent failed'); + } + if (imgComponent.byteBuffer) { + const width = nextImage.size.width; + const height = nextImage.size.height; + const stride = imgComponent.rowStride; + Logger.info(TAG, `getComponent with width:${width} height:${height} stride:${stride}`); + const pixelMap = await this.getPixelMap(imgComponent, width, height, stride); + await pixelMap.rotate(this.getRotate()); + if (this.position === camera.CameraPosition.CAMERA_POSITION_FRONT) { + await pixelMap.flip(true, false); + } + this.callback(pixelMap); + } else { + Logger.error(TAG, 'byteBuffer is null'); + } + nextImage.release(); + Logger.info(TAG, 'image process done'); + }) + }) + }) + } +} diff --git a/camera/src/main/ets/cameramanagers/OutputManager.ets b/camera/src/main/ets/cameramanagers/OutputManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..48cb67609e092a91719c44fee811733e2d48f395 --- /dev/null +++ b/camera/src/main/ets/cameramanagers/OutputManager.ets @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { camera } from "@kit.CameraKit"; + +export interface CreateOutputConfig { + cameraManager: camera.CameraManager; + device: camera.CameraDevice; + profile: camera.Profile; + sceneMode?: camera.SceneMode; + surfaceId?: string; +} + +export default interface OutputManager { + output?: camera.CameraOutput; + isActive: boolean; + createOutput: (config: CreateOutputConfig) => Promise; + release: () => Promise; +} \ No newline at end of file diff --git a/camera/src/main/ets/cameramanagers/PhotoManager.ets b/camera/src/main/ets/cameramanagers/PhotoManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..61d33f4ffa56a9978577fc13f6509f8ef75ec862 --- /dev/null +++ b/camera/src/main/ets/cameramanagers/PhotoManager.ets @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { camera } from '@kit.CameraKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { sensor } from '@kit.SensorServiceKit'; +import { Decimal } from '@kit.ArkTS'; +import { image } from '@kit.ImageKit'; +import { Logger } from 'commons/src/main/ets/utils/Logger'; +import OutputManager, { CreateOutputConfig } from './OutputManager'; +import CameraConstant from '../constants/CameraConstants'; + +const LOG_TAG = 'PhotoManager'; + +export class PhotoManager implements OutputManager { + output?: camera.PhotoOutput; + isActive: boolean = true; + context: Context; + isSingle: boolean = false; + private callback: (pixelMap: image.PixelMap, url: string) => void = () => { + }; + + constructor(context: Context, isActive = true, isSingle: boolean) { + this.context = context; + this.isActive = isActive; + this.isSingle = isSingle; + } + + setIsActive(isActive: boolean) { + this.isActive = isActive; + } + + setCallback(callback: (pixelMap: image.PixelMap, url: string) => void) { + this.callback = callback; + } + + async createOutput(config: CreateOutputConfig) { + let cameraPhotoOutput: camera.PhotoOutput | undefined = undefined; + cameraPhotoOutput = this.createPhotoOutput(config.cameraManager, config.device, config.profile); + if (cameraPhotoOutput) { + this.output = cameraPhotoOutput; + this.setPhotoOutputCallback(this.isSingle); + } + return cameraPhotoOutput; + } + + public createPhotoOutput(cameraManager: camera.CameraManager, cameraDevice: camera.CameraDevice, + profile: camera.Profile) { + let cameraPhotoOutput: camera.PhotoOutput | undefined = undefined; + const cameraOutputCapability = + cameraManager.getSupportedOutputCapability(cameraDevice, camera.SceneMode.NORMAL_PHOTO); + let photoProfilesArray: Array | undefined = cameraOutputCapability?.photoProfiles; + if (photoProfilesArray?.length) { + try { + const displayRatio = profile.size.width / profile.size.height; + const profileWidth = profile.size.width; + const PhotoProfile = photoProfilesArray + .sort((a, b) => Math.abs(a.size.width - profileWidth) - Math.abs(b.size.width - profileWidth)) + .find(pf => { + const pfDisplayRatio = pf.size.width / pf.size.height; + return Math.abs(pfDisplayRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE; + }); + cameraPhotoOutput = cameraManager.createPhotoOutput(PhotoProfile); + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `Failed to createPhotoOutput. error: ${JSON.stringify(err)}`); + } + } + this.output = cameraPhotoOutput; + return cameraPhotoOutput; + } + + async mediaLibSavePhoto(photoAsset: photoAccessHelper.PhotoAsset, + phAccessHelper: photoAccessHelper.PhotoAccessHelper): Promise { + try { + let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest = + new photoAccessHelper.MediaAssetChangeRequest(photoAsset); + assetChangeRequest.saveCameraPhoto(); + await phAccessHelper.applyChanges(assetChangeRequest); + phAccessHelper.release(); + } catch (err) { + Logger.error(LOG_TAG, `apply saveCameraPhoto failed with error: ${err.code}, ${err.message}`); + } + } + + async mediaLibRequestBuffer(photoAsset: photoAccessHelper.PhotoAsset, context: Context, + callback: (pixelMap: image.PixelMap, url: string) => void) { + class MediaDataHandler implements photoAccessHelper.MediaAssetDataHandler { + onDataPrepared(data: ArrayBuffer) { + if (data === undefined) { + Logger.error(LOG_TAG, 'Error occurred when preparing data'); + return; + } + let imageSource = image.createImageSource(data); + imageSource.createPixelMap((err: BusinessError, data: image.PixelMap) => { + if (err) { + Logger.info(LOG_TAG, `createPixelMap err:${err.code}`); + return; + } + Logger.info(LOG_TAG, 'createPixelMap is called'); + callback(data, photoAsset.uri); + }) + } + } + + let requestOptions: photoAccessHelper.RequestOptions = { + deliveryMode: photoAccessHelper.DeliveryMode.FAST_MODE, + } + const handler = new MediaDataHandler(); + await photoAccessHelper.MediaAssetManager.requestImageData(context, photoAsset, requestOptions, handler); + } + + public setPhotoOutputCbDouble(cameraPhotoOutput: camera.PhotoOutput) { + cameraPhotoOutput.on('photoAssetAvailable', + async (_err: BusinessError, photoAsset: photoAccessHelper.PhotoAsset): Promise => { + let accessHelper: photoAccessHelper.PhotoAccessHelper = + photoAccessHelper.getPhotoAccessHelper(this.context); + this.mediaLibSavePhoto(photoAsset, accessHelper); + this.mediaLibRequestBuffer(photoAsset, this.context, this.callback); + }); + } + + setPhotoOutputCbSingle(photoOutput: camera.PhotoOutput, context: Context) { + photoOutput.on('photoAvailable', (errCode: BusinessError, photo: camera.Photo): void => { + Logger.info(LOG_TAG, 'getPhoto start'); + if (errCode || photo === undefined) { + Logger.error(LOG_TAG, 'getPhoto failed'); + return; + } + let imageObj: image.Image = photo.main; + imageObj.getComponent(image.ComponentType.JPEG, async (errCode: BusinessError, component: image.Component) => { + Logger.info(LOG_TAG, 'getComponent start'); + if (errCode || component === undefined) { + Logger.error(LOG_TAG, 'getComponent failed'); + return; + } + let buffer: ArrayBuffer; + if (component.byteBuffer) { + buffer = component.byteBuffer; + } else { + Logger.error(LOG_TAG, 'byteBuffer is null'); + return; + } + let photoType: photoAccessHelper.PhotoType = photoAccessHelper.PhotoType.IMAGE; + let extension: string = 'jpg'; + let options: photoAccessHelper.CreateOptions = { + title: 'testPhoto' + } + let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest = + photoAccessHelper.MediaAssetChangeRequest.createAssetRequest(context, photoType, extension, options); + assetChangeRequest.addResource(photoAccessHelper.ResourceType.IMAGE_RESOURCE, buffer) + assetChangeRequest.saveCameraPhoto(); + let accessHelper: photoAccessHelper.PhotoAccessHelper = + photoAccessHelper.getPhotoAccessHelper(context); + await accessHelper.applyChanges(assetChangeRequest); + let imageSource = image.createImageSource(buffer); + let pixelmap = imageSource.createPixelMapSync(); + this.callback(pixelmap, assetChangeRequest.getAsset().uri); + accessHelper.release(); + imageObj.release(); + }); + }); + } + + setPhotoOutputCallback(isSingle: boolean) { + if (!this.output) { + return; + } + if (isSingle) { + this.output?.off('photoAssetAvailable'); + this.setPhotoOutputCbSingle(this.output, this.context); + } else { + this.output?.off('photoAvailable'); + this.setPhotoOutputCbDouble(this.output); + } + } + + preparePhoto(session: camera.Session, zoomRatio?: number, flashMode?: camera.FlashMode, + focusMode?: camera.FocusMode) { + const photoSession = session as camera.PhotoSession; + this.setPhotoFlash(photoSession, flashMode); + this.setPhotoFocus(photoSession, focusMode); + this.setPhotoZoomRatio(photoSession, zoomRatio || 0); + } + + public checkFlash(photoSession: camera.PhotoSession) { + let flashModeStatus: boolean = false; + if (photoSession.hasFlash()) { + flashModeStatus = photoSession.isFlashModeSupported(camera.FlashMode.FLASH_MODE_AUTO); + } + return flashModeStatus; + } + + public setPhotoFlash(photoSession: camera.PhotoSession, flashMode?: camera.FlashMode) { + try { + if (this.checkFlash(photoSession)) { + photoSession.setFlashMode(flashMode || camera.FlashMode.FLASH_MODE_CLOSE); + } + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `Failed to hasFlash. error: ${JSON.stringify(err)}`); + } + } + + public setPhotoFocus(photoSession: camera.PhotoSession, focusMode?: camera.FocusMode) { + const defaultMode = camera.FocusMode.FOCUS_MODE_CONTINUOUS_AUTO; + try { + let focusModeStatus: boolean = photoSession.isFocusModeSupported(focusMode || defaultMode); + if (focusModeStatus) { + photoSession.setFocusMode(focusMode || defaultMode); + } + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `Failed to check whether the focus mode is supported. error: ${JSON.stringify(err)}`); + } + } + + public setFocusPoint(photoSession: camera.PhotoSession, focusPoint: camera.Point): void { + try { + photoSession.setFocusPoint(focusPoint); + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `The setFocusPoint call failed. error code: ${err.code}`); + } + } + + public setPhotoZoomRatio(photoSession: camera.PhotoSession, zoomRatio?: number) { + let photoZoomRatio = 0; + if (!zoomRatio) { + try { + let zoomRatioRange: number[] = photoSession.getZoomRatioRange(); + if (zoomRatioRange?.length) { + photoZoomRatio = zoomRatioRange[0]; + } + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `Failed to get the zoom ratio range. error: ${JSON.stringify(err)}`); + } + } + photoSession.setZoomRatio(zoomRatio || photoZoomRatio); + } + + public async capture(isFront: boolean) { + const rotation = await this.getPhotoRotation(isFront); + let settings: camera.PhotoCaptureSetting = { + quality: camera.QualityLevel.QUALITY_LEVEL_HIGH, + rotation, + mirror: isFront + }; + this.output?.capture(settings, (err: BusinessError) => { + if (err) { + Logger.error(LOG_TAG, `Failed to capture the photo. error: ${JSON.stringify(err)}`); + return; + } + + Logger.info(LOG_TAG, 'Callback invoked to indicate the photo capture request success.'); + }); + } + + private calculateDeviceDegree(x: number, y: number, z: number): number { + let deviceDegree: number = 0; + if ((x * x + y * y) * 3 < z * z) { + return deviceDegree; + } else { + let sd: Decimal = Decimal.atan2(y, -x); + let sc: Decimal = Decimal.round(Number(sd) / Math.PI * 180); + deviceDegree = 90 - Number(sc); + deviceDegree = deviceDegree >= 0 ? deviceDegree % 360 : deviceDegree % 360 + 360; + } + return deviceDegree; + } + + private getPhotoRotation(isFront: boolean) { + const promise: Promise = new Promise(resolve => { + let rotation = camera.ImageRotation.ROTATION_0; + sensor.once(sensor.SensorId.ACCELEROMETER, (data: sensor.AccelerometerResponse) => { + let degree = this.calculateDeviceDegree(data.x, data.y, data.z); + if (degree >= 0 && (degree <= 30 || degree >= 300)) { + rotation = camera.ImageRotation.ROTATION_0; + } else if (degree > 30 && degree <= 120) { + rotation = isFront ? camera.ImageRotation.ROTATION_270 : camera.ImageRotation.ROTATION_90; + } else if (degree > 120 && degree <= 210) { + rotation = camera.ImageRotation.ROTATION_180; + } else if (degree > 210 && degree < 300) { + rotation = isFront ? camera.ImageRotation.ROTATION_90 : camera.ImageRotation.ROTATION_270; + } + resolve(rotation); + }); + }) + return promise; + } + + public isMovingPhotoSupported(photoOutput: camera.PhotoOutput): boolean { + let isSupported: boolean = false; + try { + isSupported = photoOutput.isMovingPhotoSupported(); + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `The isMovingPhotoSupported call failed. error code: ${err.code}`); + } + return isSupported; + } + + public enableMovingPhoto(enabled: boolean): void { + try { + this.output?.enableMovingPhoto(enabled); + } catch (error) { + let err = error as BusinessError; + Logger.error(LOG_TAG, `The enableMovingPhoto call failed. error code: ${err.code}`); + } + } + + async release() { + await this.output?.release(); + if (this.isSingle) { + this.output?.off('photoAvailable'); + } else { + this.output?.off('photoAssetAvailable'); + } + this.output = undefined; + } +} diff --git a/camera/src/main/ets/cameramanagers/PreviewManager.ets b/camera/src/main/ets/cameramanagers/PreviewManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..ddb0f4bf172ffd1045088a4092373d479770794b --- /dev/null +++ b/camera/src/main/ets/cameramanagers/PreviewManager.ets @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { camera } from "@kit.CameraKit"; +import { Logger } from "commons/src/main/ets/utils/Logger"; +import { BusinessError } from "@kit.BasicServicesKit"; +import OutputManager, { CreateOutputConfig } from "./OutputManager"; +import CameraConstant from "../constants/CameraConstants"; + +const TAG_LOG = 'PreviewManager' + +export class PreviewManager implements OutputManager { + output?: camera.PreviewOutput; + isActive: boolean = true; + + async createOutput(config: CreateOutputConfig) { + const cameraOutputCap = config.cameraManager.getSupportedOutputCapability(config.device, config.sceneMode); + const displayRatio = config.profile.size.width / config.profile.size.height; + const profileWidth = config.profile.size.width; + const previewProfile = cameraOutputCap.previewProfiles + .sort((a, b) => Math.abs(a.size.width - profileWidth) - Math.abs(b.size.width - profileWidth)) + .find(pf => { + const pfDisplayRatio = pf.size.width / pf.size.height; + return Math.abs(pfDisplayRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE; + }); + if (!previewProfile) { + Logger.error(TAG_LOG, 'Failed to get preview profile'); + return; + } + this.output = config.cameraManager.createPreviewOutput(previewProfile, config.surfaceId); + this.addOutputListener(this.output); + return this.output; + } + + addOutputListener(output: camera.PreviewOutput) { + this.addFrameStartEventListener(output); + this.addFrameEndEventListener(output); + } + + addFrameStartEventListener(output: camera.PreviewOutput) { + output.on('frameStart', (err: BusinessError) => { + if (err !== undefined && err.code !== 0) { + Logger.error(TAG_LOG, `FrameStart callback Error, errorCode: ${err.code}`); + return; + } + Logger.info(TAG_LOG, 'Preview frame started'); + }); + } + + addFrameEndEventListener(output: camera.PreviewOutput) { + output.on('frameEnd', (err: BusinessError) => { + if (err !== undefined && err.code !== 0) { + Logger.error(TAG_LOG, `FrameStart callback Error, errorCode: ${err.code}`); + return; + } + Logger.info(TAG_LOG, 'Preview frame end'); + }); + } + + async release() { + await this.output?.release(); + this.output = undefined; + } + + getSupportedFrameRates() { + return this.output?.getSupportedFrameRates(); + } + + setFrameRate(minFps: number, maxFps: number) { + try { + this.output?.setFrameRate(minFps, maxFps); + } catch(e) { + Logger.error(TAG_LOG, 'setActiveFrameRate error ' + JSON.stringify(e)); + } + } +} diff --git a/camera/src/main/ets/cameramanagers/VideoManager.ets b/camera/src/main/ets/cameramanagers/VideoManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..ead02bfc4bd22c55463f7f75ba1f5e92e5e231eb --- /dev/null +++ b/camera/src/main/ets/cameramanagers/VideoManager.ets @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { media } from '@kit.MediaKit'; +import { camera } from '@kit.CameraKit'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { sensor } from '@kit.SensorServiceKit'; +import { Decimal } from '@kit.ArkTS'; +import { image } from '@kit.ImageKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { Logger } from 'commons/src/main/ets/utils/Logger'; +import OutputManager, { CreateOutputConfig } from './OutputManager'; +import CameraConstant from '../constants/CameraConstants'; + +const LOG_TAG = 'video'; + +enum QualityLevel { + NORMAL, + HIGHER +} + +export enum AVRecorderState { + IDLE = "idle", + PREPARED = "prepared", + STARTED = "started", + PAUSED = "paused", + STOPPED = "stopped", + RELEASED = "released", + ERROR = "error" +} + +export class VideoManager implements OutputManager { + private avRecorder: media.AVRecorder | undefined = undefined; + private avConfig: media.AVRecorderConfig | undefined = undefined; + private avProfile: media.AVRecorderProfile | undefined = undefined; + private videoProfile: camera.VideoProfile | undefined = undefined; + private context: Context | undefined = undefined; + private cameraPosition: number = 0; + private qualityLevel: QualityLevel = QualityLevel.NORMAL; + output: camera.VideoOutput | undefined = undefined; + private videoUri: string = ''; + private file: fileIo.File | undefined = undefined; + state: media.AVRecorderState = AVRecorderState.IDLE; + isActive: boolean = false; + private callback: (pixelMap: image.PixelMap, url: string) => void = () => { + }; + + constructor(context: Context) { + this.context = context; + } + + setIsActive(isActive: boolean) { + this.isActive = isActive; + } + + async createOutput(config: CreateOutputConfig) { + try { + this.avRecorder = await media.createAVRecorder(); + this.avRecorder.on('stateChange', state => { + this.state = state; + Logger.info(LOG_TAG, 'on avRecorder state change: ', state) + }); + } catch (error) { + Logger.error(LOG_TAG, 'createAVRecorder call failed. error code: %{public}s', (error as BusinessError).code); + } + if (this.avRecorder === undefined || this.avRecorder === null) { + return; + } + + this.setVideoProfile(config.cameraManager, config.profile, config.device); + await this.setAVConfig(); + await this.prepare(); + await this.createVideoOutput(config.cameraManager); + return this.output; + } + + // 配置录制参数。 + async prepare() { + try { + if (this.avRecorder?.state === AVRecorderState.IDLE && this.avConfig) { + await this.avRecorder.prepare(this.avConfig); + Logger.info(LOG_TAG, 'Succeeded in preparing'); + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to prepare and catch error is ${error.message}`); + } + } + + async start() { + try { + if (this.avRecorder?.state === AVRecorderState.PREPARED) { + await this.avRecorder.updateRotation(this.getVideoRotation(await this.getGravity())); // 配置录像旋转角度。 + await this.output?.start(); + await this.avRecorder?.start(); + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to start and catch error is ${error.message}`); + } + } + + async stop() { + try { + if (this.avRecorder?.state === AVRecorderState.STARTED + || this.avRecorder?.state === AVRecorderState.PAUSED) { + await this.avRecorder.stop(); + await this.output?.stop(); + const thumbnail = await this.getVideoThumbnail(); + if (thumbnail) { + this.callback(thumbnail, this.videoUri); + } + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to stop and catch error is ${error.message}`); + } + } + + // 暂停录制。 + async pause() { + try { + if (this.avRecorder?.state === AVRecorderState.STARTED) { + await this.avRecorder.pause(); + await this.output?.stop(); + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to pause and catch error is ${error.message}`); + } + } + + async resume() { + try { + if (this.avRecorder?.state === AVRecorderState.PAUSED) { + await this.output?.start(); + await this.avRecorder.resume(); + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to resume and catch error is ${error.message}`); + } + } + + async release() { + await this.avRecorder?.release(); + await this.output?.release(); + this.file && await fileIo.close(this.file.fd); + this.avRecorder?.off('stateChange'); + this.avRecorder = undefined; + this.output = undefined; + this.file = undefined; + } + + async createVideoOutput(cameraManager: camera.CameraManager) { + if (!this.avRecorder || this.avRecorder.state !== AVRecorderState.PREPARED) { + return; + } + try { + let videoSurfaceId = await this.avRecorder.getInputSurface(); + this.output = cameraManager.createVideoOutput(this.videoProfile, videoSurfaceId); + } catch (error) { + Logger.error(LOG_TAG, + `Failed to create the output instance. error code: ${(error as BusinessError).code}`); + } + } + + getCurrentOutput() { + return this.output; + } + + setVideoCallback(callback: (pixelMap: image.PixelMap, url: string) => void) { + this.callback = callback; + } + + setVideoProfile(cameraManager: camera.CameraManager, targetProfile: camera.Profile, + device: camera.CameraDevice) { + this.cameraPosition = device.cameraPosition; + let cameraOutputCap: camera.CameraOutputCapability | undefined = + cameraManager.getSupportedOutputCapability(device, + camera.SceneMode.NORMAL_VIDEO); + let videoProfilesArray: camera.VideoProfile[] | undefined = cameraOutputCap?.videoProfiles; + const displayRatio = targetProfile.size.height / targetProfile.size.width; + const videoProfile = videoProfilesArray?.find((profile: camera.VideoProfile) => { + const profileRatio = profile.size.height / profile.size.width + if (this.cameraPosition === 1) { + return profile.size.width >= 1080 && profile.size.height >= 1080 && + Math.abs(profileRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE && + profile.frameRateRange.max === 30; + } + if (this.qualityLevel === QualityLevel.NORMAL) { + return profile.size.width <= 1920 && profile.size.width >= 1080 && profile.size.height >= 1080 && + Math.abs(profileRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE * profile.size.width && + profile.frameRateRange.max === 60; + } + if (this.qualityLevel === QualityLevel.HIGHER && this.cameraPosition === 0) { + return profile.size.width <= 4096 && profile.size.width >= 3000 && + Math.abs(profileRatio - displayRatio) <= CameraConstant.PROFILE_DIFFERENCE * profile.size.width && + profile.frameRateRange.max === 60; + } + return false; + }) + if (!videoProfile) { + Logger.error(LOG_TAG, 'Failed to get videoProfile') + } + this.videoProfile = videoProfile; + } + + async setAVConfig() { + let options: photoAccessHelper.CreateOptions = { + title: Date.now().toString() + }; + let accessHelper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context); + this.videoUri = await accessHelper.createAsset(photoAccessHelper.PhotoType.VIDEO, 'mp4', options); + this.file = fileIo.openSync(this.videoUri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); + this.avProfile = { + audioBitrate: 48000, + audioChannels: 2, + audioCodec: media.CodecMimeType.AUDIO_AAC, + audioSampleRate: 48000, + fileFormat: media.ContainerFormatType.CFT_MPEG_4, + videoBitrate: 32000000, + videoCodec: (this.qualityLevel === QualityLevel.HIGHER && this.cameraPosition === 0) ? + media.CodecMimeType.VIDEO_HEVC : media.CodecMimeType.VIDEO_AVC, + videoFrameWidth: this.videoProfile?.size.width, + videoFrameHeight: this.videoProfile?.size.height, + videoFrameRate: this.cameraPosition === 0 ? 60 : 30, + } + + this.avConfig = { + audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_CAMCORDER, + videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV, + profile: this.avProfile, + url: `fd://${this.file.fd}`, + metadata: { + videoOrientation: this.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT ? + camera.ImageRotation.ROTATION_270.toString() : camera.ImageRotation.ROTATION_90.toString(), + } + } + } + + getRealData(data: sensor.GravityResponse): number { + let getDeviceDegree: number = 0; + let x = data.x; + let y = data.y; + let z = data.z; + if ((x * x + y * y) * 3 < z * z) { + return getDeviceDegree; + } else { + let sd: Decimal = Decimal.atan2(y, -x); + let sc: Decimal = Decimal.round(Number(sd) / 3.141592653589 * 180) + getDeviceDegree = 90 - Number(sc); + getDeviceDegree = getDeviceDegree >= 0 ? getDeviceDegree % 360 : getDeviceDegree % 360 + 360; + } + return getDeviceDegree; + } + + async getGravity(): Promise { + try { + let isSupportedGravity: boolean = false; + let data = await sensor.getSensorList(); + for (let i = 0; i < data.length; i++) { + if (data[i].sensorId === sensor.SensorId.GRAVITY) { + isSupportedGravity = true; + break; + } + } + if (isSupportedGravity === true) { + const promise: Promise = new Promise((resolve) => { + sensor.once(sensor.SensorId.GRAVITY, (data: sensor.GravityResponse) => { + resolve(this.getRealData(data)); + }); + }) + return promise; + } else { + const promise: Promise = new Promise((resolve) => { + sensor.once(sensor.SensorId.ACCELEROMETER, (data: sensor.AccelerometerResponse) => { + resolve(this.getRealData(data as sensor.GravityResponse)); + }); + }) + return promise; + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to getGravity and catch error is ${error.message}`); + return 0 + } + } + + getVideoRotation(deviceDegree: number): camera.ImageRotation { + let videoRotation: camera.ImageRotation = + this.cameraPosition === camera.CameraPosition.CAMERA_POSITION_FRONT ? camera.ImageRotation.ROTATION_270 : + camera.ImageRotation.ROTATION_90; + try { + if (this.output) { + videoRotation = this.output.getVideoRotation(deviceDegree); + Logger.info(LOG_TAG, `Video rotation is: ${videoRotation}`); + } + } catch (error) { + Logger.error(LOG_TAG, `Failed to getVideoRotation and catch error is: ${error.message}`); + } + return videoRotation; + } + + async getVideoThumbnail() { + let pixelMap: image.PixelMap | undefined = undefined; + try { + let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator(); + let dataSrc: media.AVFileDescriptor = { + fd: this.file!.fd, + }; + avImageGenerator.fdSrc = dataSrc; + let timeUs = 0; + let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC; + let param: media.PixelMapParams = { + width: 300, + height: 300 + }; + pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param); + avImageGenerator.release(); + } catch (error) { + Logger.error(LOG_TAG, `Failed to getVideoThumbnail and catch error is ${error.message}`); + } + return pixelMap; + } + + isRecording() { + return this.state === AVRecorderState.STARTED || this.state === AVRecorderState.PAUSED; + } +} \ No newline at end of file diff --git a/camera/src/main/ets/components/GridLine.ets b/camera/src/main/ets/components/GridLine.ets new file mode 100644 index 0000000000000000000000000000000000000000..acbaeea29fd27c096ca4a0d2ad458dd1b83eb47a --- /dev/null +++ b/camera/src/main/ets/components/GridLine.ets @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@Component +export struct GridLine { + private settings: RenderingContextSettings = new RenderingContextSettings(true); + private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); + @Prop cols: number = 3; + @Prop rows: number = 3; + @Prop strokeStyle: string |number |CanvasGradient | CanvasPattern = Color.White; + @Prop lineWidth: number = 1; + + draw() { + const ctx = this.context; + ctx.strokeStyle = this.strokeStyle; + ctx.lineWidth = this.lineWidth; + const height = this.context.height; + const width = this.context.width; + // hor + for (let i = 1; i < this.cols; i++) { + const x = (width / this.cols) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + // vertical + for (let i = 1; i < this.rows; i++) { + const y = (height / this.rows) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + } + + build() { + Canvas(this.context) + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + .onReady(() => this.draw()) + } +} \ No newline at end of file diff --git a/camera/src/main/ets/components/LevelIndicator.ets b/camera/src/main/ets/components/LevelIndicator.ets new file mode 100644 index 0000000000000000000000000000000000000000..f594a6257272a63d19ddc743d09cf3aae388aed2 --- /dev/null +++ b/camera/src/main/ets/components/LevelIndicator.ets @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { curves, display } from "@kit.ArkUI"; +import { sensor } from "@kit.SensorServiceKit"; + +@Component +export struct LevelIndicator { + @Prop acc: sensor.AccelerometerResponse; + + getRotate() { + const orientation = display.getDefaultDisplaySync().orientation; + if (orientation === display.Orientation.LANDSCAPE || orientation === display.Orientation.LANDSCAPE_INVERTED) { + return -Math.atan2(-this.acc.y, this.acc.x) * (180 / Math.PI); + } + return -Math.atan2(-this.acc.x, this.acc.y) * (180 / Math.PI); + } + + isAlign() { + return Math.abs(this.getRotate()) - 0 <= 3 + || Math.abs(Math.abs(this.getRotate()) - 90) <= 3; + } + + build() { + Stack({ alignContent: Alignment.Center }) { + Line({ + width: 200, + height: 1 + }) + .stroke(Color.White) + .endPoint([200, 0]) + .strokeDashArray([3, this.isAlign() ? 0 : 3]) + .opacity(this.isAlign() ? 1 : 0.5) + .rotate({ angle: this.getRotate(), centerX: '50%', centerY: '50%' }) + .animation({ + curve: curves.springMotion(0.6, 0.8), + iterations: 1, + playMode: PlayMode.Normal + }) + Circle() + .width(48) + .height(48) + .stroke(Color.White) + .fill(Color.Transparent) + .opacity(this.isAlign() ? 1 : 0.5) + } + .width('100%') + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + } +} \ No newline at end of file diff --git a/camera/src/main/ets/constants/CameraConstants.ets b/camera/src/main/ets/constants/CameraConstants.ets new file mode 100644 index 0000000000000000000000000000000000000000..780f111cdc5b073fe0ef0f4c987da03823b69f21 --- /dev/null +++ b/camera/src/main/ets/constants/CameraConstants.ets @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class CameraConstant { + static readonly PROFILE_DIFFERENCE = 1e-10; +} + +export default CameraConstant; \ No newline at end of file diff --git a/camera/src/main/module.json5 b/camera/src/main/module.json5 new file mode 100644 index 0000000000000000000000000000000000000000..c5409dfaf8befd98d4e0d6076a3d487d2c7e565e --- /dev/null +++ b/camera/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "camera", + "type": "har", + "deviceTypes": [ + "default", + "tablet" + ] + } +} \ No newline at end of file diff --git a/commons/.gitignore b/commons/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e2713a2779c5a3e0eb879efe6115455592caeea5 --- /dev/null +++ b/commons/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/commons/Index.ets b/commons/Index.ets new file mode 100644 index 0000000000000000000000000000000000000000..621cafbe0df5e8ab8fb2fe0240a9ecc6378d2dc5 --- /dev/null +++ b/commons/Index.ets @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Logger } from './src/main/ets/utils/Logger'; diff --git a/commons/build-profile.json5 b/commons/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..284a1c3355b0643262a13c0b842eb667a433e2d3 --- /dev/null +++ b/commons/build-profile.json5 @@ -0,0 +1,20 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + }, + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest" + } + ] +} diff --git a/commons/hvigorfile.ts b/commons/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..805c5d7f6809c51cff0b4adcc1142979f8f864b6 --- /dev/null +++ b/commons/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/commons/oh-package.json5 b/commons/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..d50184a32b1cf153f7dc71afeb97d601086fa6a7 --- /dev/null +++ b/commons/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "commons", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "Index.ets", + "author": "", + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/commons/src/main/ets/utils/Logger.ets b/commons/src/main/ets/utils/Logger.ets new file mode 100644 index 0000000000000000000000000000000000000000..c769a6076e62e5ec82a07d80992354b31e0b5702 --- /dev/null +++ b/commons/src/main/ets/utils/Logger.ets @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { hilog } from '@kit.PerformanceAnalysisKit'; + +export class Logger { + private static readonly DOMAIN: number = 0xFF00; + private static readonly TAG: string = 'com.example.customcamera'; + private static readonly PREFIX: string = '[camera-log]'; + + public static debug(logTag: string, messageFormat: string, ...args: Object[]): void { + hilog.debug(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args); + } + + public static info(logTag: string, messageFormat: string, ...args: Object[]): void { + hilog.info(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args); + } + + public static warn(logTag: string, messageFormat: string, ...args: Object[]): void { + hilog.warn(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args); + } + + public static error(logTag: string, messageFormat: string, ...args: Object[]): void { + hilog.error(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args); + } + + public static fatal(logTag: string, messageFormat: string, ...args: Object[]): void { + hilog.fatal(Logger.DOMAIN, Logger.TAG, `${Logger.PREFIX} ${logTag}: ${messageFormat}`, args); + } + + private constructor() { + } +} diff --git a/commons/src/main/module.json5 b/commons/src/main/module.json5 new file mode 100644 index 0000000000000000000000000000000000000000..6bae1d9e86d12d221ca82d741d95b8e9e59eb1f1 --- /dev/null +++ b/commons/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "commons", + "type": "har", + "deviceTypes": [ + "default", + "tablet" + ] + } +} diff --git a/entry/.gitignore b/entry/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e2713a2779c5a3e0eb879efe6115455592caeea5 --- /dev/null +++ b/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000000000000000000000000000000000000..50e33db6ec3d25c19652a894127becfb3761a01b --- /dev/null +++ b/entry/build-profile.json5 @@ -0,0 +1,26 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6edcd90486dd5a853cf7d34c8647f08414ca7a3 --- /dev/null +++ b/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 new file mode 100644 index 0000000000000000000000000000000000000000..01723a1209faf07a9d88680f47976b49b776a0e9 --- /dev/null +++ b/entry/oh-package-lock.json5 @@ -0,0 +1,29 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "camera@../camera": "camera@../camera", + "commons@../commons": "commons@../commons" + }, + "packages": { + "camera@../camera": { + "name": "camera", + "version": "1.0.0", + "resolved": "../camera", + "registryType": "local", + "dependencies": { + "commons": "file:../commons" + } + }, + "commons@../commons": { + "name": "commons", + "version": "1.0.0", + "resolved": "../commons", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..4f1c83bce2a51958343ae478f42843ac49efba07 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,13 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": { + "commons": "file:../commons", + "camera": "file:../camera" + } +} + diff --git a/entry/src/main/ets/constants/Constants.ets b/entry/src/main/ets/constants/Constants.ets new file mode 100644 index 0000000000000000000000000000000000000000..a8f12a9734ea8f557865c96b5e2a54d7170c2245 --- /dev/null +++ b/entry/src/main/ets/constants/Constants.ets @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Permissions } from "@kit.AbilityKit"; + +class CameraConstant { + static readonly RATIO_PHOTO: number = 4 / 3; + static readonly RATIO_VIDEO: number = 16 / 9; + static readonly PERMISSIONS: Permissions[] = [ + 'ohos.permission.CAMERA', + 'ohos.permission.MICROPHONE', + 'ohos.permission.MEDIA_LOCATION', + 'ohos.permission.WRITE_IMAGEVIDEO', + 'ohos.permission.READ_IMAGEVIDEO' + ]; + static readonly PROFILE_DIFFERENCE = 1e-10; +} + +export default CameraConstant; \ No newline at end of file diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000000000000000000000000000000000000..fe3138ba2df7682844b5eba1433f0a721afde588 --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigurationConstant, UIAbility } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; +import WindowUtil from '../utils/WindowUtil'; + +const DOMAIN = 0x0000; + +export default class EntryAbility extends UIAbility { + onCreate(): void { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); + } + + onDestroy(): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + // Main window is created, set main page for this ability + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); + + WindowUtil.enterImmersive(windowStage.getMainWindowSync()); + + windowStage.loadContent('pages/Index', (err) => { + if (err.code) { + hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); + return; + } + hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + }); + } + + onWindowStageDestroy(): void { + // Main window is destroyed, release UI related resources + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); + } + + onForeground(): void { + // Ability has brought to foreground + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); + } + + onBackground(): void { + // Ability has back to background + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000000000000000000000000000000000000..4cb6e8ced48a57c0d9835a10c51dd1bcb38631e7 --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CameraConstant from '../constants/Constants'; +import { getClampedChildPosition, limitNumberInRange } from '../utils/CommonUtil'; +import RefreshableTimer from '../utils/RefreshableTimer'; +import PermissionManager from '../utils/PermissionManager'; +import ZoomButtonsView from '../views/ZoomButtonsView'; +import ModeButtonsView from '../views/ModeButtonsView'; +import SettingButtonsView from '../views/SettingButtonsView'; +import OperateButtonsView from '../views/OperateButtonsView'; +import { sensor } from '@kit.SensorServiceKit'; +import { common } from '@kit.AbilityKit'; +import { display } from '@kit.ArkUI'; +import PreviewViewModel from '../viewmodels/PreviewViewModel'; +import { CameraManager, + GridLine, + ImageReceiverManager, + LevelIndicator, + PhotoManager, PreviewManager, VideoManager } from 'camera'; + +@Extend(SymbolGlyph) +function funcButtonStyle() { + .fontSize(22) + .fontColor([Color.White]) + .borderRadius('50%') + .padding(12) + .backgroundColor('#664D4D4D') +} + +@Entry +@Component +struct Index { + private context: Context = this.getUIContext().getHostContext()!; + private applicationContext = this.context.getApplicationContext(); + private windowClass = (this.context as common.UIAbilityContext).windowStage.getMainWindowSync(); + + @State videoManager: VideoManager = new VideoManager(this.context); + @State isSinglePhoto: boolean = false; + @State isLivePhoto: boolean = false; + private photoManager: PhotoManager = new PhotoManager(this.context, true, this.isSinglePhoto); + private previewManager: PreviewManager = new PreviewManager(); + private imageReceiverManager: ImageReceiverManager = new ImageReceiverManager(px => { + this.onImageReceiver(px); + }); + private cameraManager: CameraManager = new CameraManager(this.context, [this.previewManager, + this.photoManager, this.videoManager, this.imageReceiverManager]); + + @State previewVM: PreviewViewModel = new PreviewViewModel(this.getUIContext()); + + @State isGridLineVisible: boolean = false; + @State isLevelIndicatorVisible: boolean = false; + + @State isFocusBoxVisible: boolean = false; + @State focusBoxPosition: Edges = { top: 0, left: 0 }; + private focusBoxSize: Size = { width: 80, height: 80 }; + private focusBoxTimer: RefreshableTimer = new RefreshableTimer(() => { + this.isFocusBoxVisible = false; + }, 3 * 1000); + + @State isSleeping: boolean = false; + private sleepTimer?: RefreshableTimer; + + private zoomRange: number[] = []; + @State zooms: number[] = [1, 5, 10]; + @State currentZoom: number = 1; + @State isZoomPinching: boolean = false; + private originZoomBeforePinch: number = 1; // record zoom after pinch, sale base it. + + @State blurRadius: number = 0; + + @State previewImage: PixelMap | ResourceStr = ''; + private PreviewImageHeight: number = 80; + + @State photoDelayTime: number = 0; + @State photoRemainder: number = 0; + @State isDelayTakePhoto:boolean = false; + + @State acc: sensor.AccelerometerResponse = { x: 0, y: 0, z: 0 } as sensor.AccelerometerResponse; + private setPreviewSize: () => void = () => { this.previewVM.setPreviewSize(); } + + async aboutToAppear() { + sensor.on(sensor.SensorId.ACCELEROMETER, (data) => { + this.acc = data; + }, { interval: 100 * 1000 * 1000 }); // 100ms + this.initSleepTimer(); + this.registerApplicationStateChange(); + this.addOrientationChangeEventListener(); + } + + aboutToDisappear(): void { + this.removeOrientationChangeEventListener(); + } + + addOrientationChangeEventListener() { + this.windowClass.on('windowSizeChange', this.setPreviewSize); + } + + removeOrientationChangeEventListener() { + this.windowClass.off('windowSizeChange', this.setPreviewSize); + } + + onImageReceiver(pixelMap: PixelMap) { + this.previewImage = pixelMap; + } + + initSleepTimer() { + this.sleepTimer = new RefreshableTimer(() => { + this.isSleeping = true; + this.cameraManager.release(); + }, 30 * 1000); + this.sleepTimer.start(); + const observer = this.getUIContext().getUIObserver(); + observer.on('willClick', () => { + this.sleepTimer?.refresh(); + }) + } + + openPreviewBlur() { + this.blurRadius = 150; + } + + closePreviewBlur() { + this.blurRadius = 0; + } + + registerApplicationStateChange() { + this.applicationContext.on('applicationStateChange', { + onApplicationForeground: async () => { + await this.startCamera(); + this.syncButtonSettings(); + }, + onApplicationBackground: () => { + this.cameraManager.release(); + } + }) + } + + initZooms() { + const zoomRange = this.cameraManager.getZoomRange(); + const minZoom = zoomRange[0]; + this.zoomRange = zoomRange; + if (minZoom < this.zooms[0]) { + this.zooms.unshift(minZoom); + } + } + + initRates() { + const frameRates = this.previewManager.getSupportedFrameRates(); + if (frameRates && frameRates[0]) { + const minRate = frameRates[0].min; + const maxRate = frameRates[0].max; + this.previewVM.rates = [minRate, maxRate]; + this.previewVM.currentRate = maxRate; + this.previewManager.setFrameRate(maxRate, maxRate); + }; + } + + async startCamera() { + const cameraPosition = this.previewVM.getCameraPosition(); + const sceneMode = this.previewVM.getSceneMode(); + const profile = this.previewVM.getProfile(); + await this.cameraManager.start(this.previewVM.surfaceId, cameraPosition, sceneMode, profile); + } + + exitApp() { + this.applicationContext.killAllProcesses(); + } + + syncButtonSettings() { + this.previewManager.setFrameRate(this.previewVM.currentRate, this.previewVM.currentRate); + this.photoManager.enableMovingPhoto(this.isLivePhoto); + this.photoManager.setPhotoOutputCallback(this.isSinglePhoto); + } + + @Builder + preview() { + Stack({ + alignContent: Alignment.Center + }) { + XComponent({ + type: XComponentType.SURFACE, + controller: this.previewVM.xComponentController + }) + .onLoad(async () => { + await PermissionManager.request(CameraConstant.PERMISSIONS, this.context) + .catch(() => { this.exitApp() }); + this.previewVM.surfaceId = this.previewVM.xComponentController.getXComponentSurfaceId(); + this.previewVM.setPreviewSize(); + this.previewVM.xComponentController.setXComponentSurfaceRotation({ lock: true }) + await this.startCamera(); + this.initZooms(); + this.initRates(); + }) + .gesture( + PinchGesture({ fingers: 2 }) + .onActionStart(() => { + this.originZoomBeforePinch = this.currentZoom; + this.isZoomPinching = true; + }) + .onActionUpdate((event: GestureEvent) => { + const targetZoom = this.originZoomBeforePinch * event.scale; + this.currentZoom = limitNumberInRange(targetZoom, this.zoomRange); + this.cameraManager.setZoomRatio(this.currentZoom); + }) + .onActionEnd(() => { + this.isZoomPinching = false; + }) + ) + .onClick(event => { + this.isFocusBoxVisible = true; + const previewSize = this.previewVM.previewSize; + // unit + const x = this.getUIContext().vp2px(event.y) / previewSize.height; + const y = 1 - this.getUIContext().vp2px(event.x) / previewSize.width; + this.cameraManager.setFocusPoint({ x, y }); + + this.focusBoxPosition = getClampedChildPosition(this.focusBoxSize, { + width: this.getUIContext().px2vp(previewSize.width), + height: this.getUIContext().px2vp(previewSize.height) + }, event) + this.focusBoxTimer.refresh(); + }) + // gird line + if (this.isGridLineVisible) { + GridLine() + } + // level indicator + if (this.isLevelIndicatorVisible) { + LevelIndicator({ + acc: this.acc + }) + } + // focus box + if (this.isFocusBoxVisible) { + Image($r('app.media.focus_box')) + .width(80) + .height(80) + .position(this.focusBoxPosition) + } + + if(this.isDelayTakePhoto){ + Text(`${this.photoRemainder}S`) + .fontSize(44) + .fontWeight(FontWeight.Regular) + .fontColor(Color.White) + } + } + .alignRules({ + middle: { anchor: '__container__', align: HorizontalAlign.Center } + }) + .width(this.previewVM.getPreviewWidth()) + .height(this.previewVM.getPreviewHeight()) + .margin({ top: this.previewVM.getPreviewTop() }) + .blur(this.blurRadius) + } + + @Builder + wakeupMask() { + Column() { + Text($r('app.string.wakeup_text')) + .fontColor(Color.White) + .opacity(0.6) + } + .width('100%') + .height('100%') + .backgroundColor(Color.Black) + .justifyContent(FlexAlign.Center) + .onClick(async () => { + this.isSleeping = false; + this.sleepTimer?.refresh(); + await this.startCamera(); + this.syncButtonSettings(); + }) + } + + @Builder + gridLineButton() { + SymbolGlyph( + this.isGridLineVisible + ? $r('sys.symbol.camera_assistive_grid') + : $r('sys.symbol.camera_assistive_grid_slash') + ) + .funcButtonStyle() + .onClick(() => { + this.isGridLineVisible = !this.isGridLineVisible + }) + } + + @Builder + levelButton() { + SymbolGlyph($r('sys.symbol.horizontal_level')) + .funcButtonStyle() + .onClick(() => { + this.isLevelIndicatorVisible = !this.isLevelIndicatorVisible; + }) + } + + getPreviewImageWidth() { + const orientation = display.getDefaultDisplaySync().orientation; + const isLandscape = orientation === display.Orientation.LANDSCAPE_INVERTED + || orientation === display.Orientation.LANDSCAPE; + const ratio = this.previewVM.getPreviewRatio(); + const displayRatio = isLandscape ? 1 / ratio : ratio; + return this.PreviewImageHeight / displayRatio; + } + + @Builder + previewImageView() { + Image(this.previewImage) + .width(this.getPreviewImageWidth()) + .height(this.PreviewImageHeight) + .alignRules({ + bottom: { anchor: '__container__', align: VerticalAlign.Bottom }, + left: { anchor: '__container__', align: HorizontalAlign.Start } + }) + .margin({ + bottom: 10, + left: 10 + }) + } + + @Builder + funcButtonsView() { + Column({ space: 24 }) { + this.gridLineButton() + this.levelButton() + } + .alignRules({ + top: { anchor: 'settingButtonsView', align: VerticalAlign.Bottom }, + right: { anchor: 'settingButtonsView', align: HorizontalAlign.End } + }) + .margin({ + top: 40, + right: 10 + }) + } + + build() { + RelativeContainer() { + this.preview() + this.previewImageView() + this.funcButtonsView() + SettingButtonsView({ + previewVM: this.previewVM, + cameraManager: this.cameraManager, + previewManager: this.previewManager, + photoManager: this.photoManager, + videoManager: this.videoManager, + photoDelayTime: this.photoDelayTime, + isSinglePhoto: this.isSinglePhoto, + isLivePhoto: this.isLivePhoto + }) + if (!this.photoRemainder) { + if (!this.previewVM.isFront) { + ZoomButtonsView({ + cameraManager: this.cameraManager, + zoomRange: this.zoomRange, + zooms: this.zooms, + currentZoom: this.currentZoom + }) + } + ModeButtonsView({ + previewVM: this.previewVM, + photoManager: this.photoManager, + videoManager: this.videoManager, + cameraManager: this.cameraManager, + syncButtonSettings: () => { + this.syncButtonSettings(); + } + }) + OperateButtonsView({ + previewVM: this.previewVM, + cameraManager: this.cameraManager, + photoManager: this.photoManager, + videoManager: this.videoManager, + isDelayTakePhoto: this.isDelayTakePhoto, + photoDelayTime: this.photoDelayTime, + photoRemainder: this.photoRemainder, + syncButtonSettings: () => { + this.syncButtonSettings(); + } + }) + } + if (this.isSleeping) { + this.wakeupMask() + } + } + .height('100%') + .width('100%') + .backgroundColor(Color.Black) + .onClick(() => {}) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/utils/CommonUtil.ets b/entry/src/main/ets/utils/CommonUtil.ets new file mode 100644 index 0000000000000000000000000000000000000000..3407ed45f663b8c845b6c23edc4e9ff4160cef7f --- /dev/null +++ b/entry/src/main/ets/utils/CommonUtil.ets @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from "@kit.TestKit" + +export function limitNumberInRange(src: number, range: number[]) { + if (range.length < 2) return src; + if (src < range[0]) return range[0]; + if (src > range[1]) return range[1]; + return src; +} + +// 1.5 [0, 1, 5, 10] return 1 +export function findRangeIndex(target: number, arr: number[]) { + if (arr.length === 0) { + return -1; + } + if (target >= arr[arr.length - 1]) { + return arr.length - 1; + } + return [...arr].sort((a, b) => a - b).findIndex((n, i) => { + return target >= n && target < arr[i + 1]; + }); +} + +// toFixed(9.97, 1) -> 9.9 +export function toFixed(num: number, digit: number): string { + const scale = 10 ** digit; + return (Math.floor(num * scale) / scale).toFixed(digit); +} + +// cal absolute position in parent area +export function getClampedChildPosition(childSize: Size, parentSize: Size, point: Point): Edges { + // center point + let left = point.x - childSize.width / 2; + let top = point.y - childSize.height / 2; + // limit in left + if (left < 0) { + left = 0; + } + // limit in right + if (left + childSize.width > parentSize.width) { + left = parentSize.width - childSize.width; + } + // limit in top + if (top < 0) { + top = 0; + } + // limit in bottom + if (top + childSize.height > parentSize.height) { + top = parentSize.height - childSize.height; + } + return { left, top }; +} \ No newline at end of file diff --git a/entry/src/main/ets/utils/PermissionManager.ets b/entry/src/main/ets/utils/PermissionManager.ets new file mode 100644 index 0000000000000000000000000000000000000000..c3d3ae4011f9505fd355206115d3c7e8e0cd42ac --- /dev/null +++ b/entry/src/main/ets/utils/PermissionManager.ets @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { abilityAccessCtrl, Context, Permissions } from "@kit.AbilityKit"; +import { Logger } from "commons/src/main/ets/utils/Logger"; + +const TAG = 'PermissionManager'; + +class PermissionManager { + private static atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); + + static async request(permissions: Permissions[], context: Context) { + const data = await PermissionManager.atManager.requestPermissionsFromUser(context, permissions); + const grantStatus: number[] = data.authResults; + const deniedPermissions = permissions.filter((_, i) => grantStatus[i] !== 0); + for (const permission of deniedPermissions) { + const secondGrantStatus = await PermissionManager.atManager.requestPermissionOnSetting(context, [permission]); + if (secondGrantStatus[0] !== 0) { + Logger.error(TAG, 'permission denied'); + throw new Error('permission denied'); + } + } + } +} + +export default PermissionManager; \ No newline at end of file diff --git a/entry/src/main/ets/utils/RefreshableTimer.ets b/entry/src/main/ets/utils/RefreshableTimer.ets new file mode 100644 index 0000000000000000000000000000000000000000..1cff64773844eff07b52051ece53716afa6e16c3 --- /dev/null +++ b/entry/src/main/ets/utils/RefreshableTimer.ets @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class RefreshableTimer { + private timerId?: number; + private readonly timeout: number; + private callback: () => void; + private isActive: boolean = false; + + constructor(callback: () => void, timeout: number) { + this.callback = callback; + this.timeout = timeout; + } + + start(): void { + this.timerId = setTimeout(() => { + this.callback(); + this.isActive = false; + }, this.timeout); + this.isActive = true; + } + + clear(): void { + clearTimeout(this.timerId); + this.timerId = undefined; + this.isActive = false; + } + + refresh(): void { + this.clear(); + this.start(); + } + + isRunning(): boolean { + return this.isActive; + } +} + +export default RefreshableTimer; diff --git a/entry/src/main/ets/utils/WindowUtil.ets b/entry/src/main/ets/utils/WindowUtil.ets new file mode 100644 index 0000000000000000000000000000000000000000..b533e767bf8f7ff51c668f7812d3be80e4e1642c --- /dev/null +++ b/entry/src/main/ets/utils/WindowUtil.ets @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { display, window } from "@kit.ArkUI"; +import { Logger } from "commons/src/main/ets/utils/Logger"; + +const TAG = 'WindowUtil' + +class WindowUtil { + static async enterImmersive(window: window.Window): Promise { + try { + await window.setWindowLayoutFullScreen(true); + await window.setWindowSystemBarEnable([]); + } catch (exception) { + Logger.error(TAG, 'Failed to enter immersive. Cause:', JSON.stringify(exception)); + } + } + + // Given a ratio, obtain the maximum display width and height based on the screen width and height. + static getMaxDisplaySize(ratio: number): Size { + const defaultDisplay: display.Display = display.getDefaultDisplaySync(); + const windowWidth: number = defaultDisplay.width; + const windowHeight: number = defaultDisplay.height; + if (windowWidth > windowHeight) { + ratio = 1 / ratio; + } + // Calculate the height based on the screen width. + const calculatedHeight = windowWidth * ratio; + if (calculatedHeight <= windowHeight) { + return { + width: windowWidth, + height: calculatedHeight + }; + } else { + return { + width: windowHeight / ratio, + height: windowHeight + }; + } + } + + static getWindowRatio(): number { + const defaultDisplay: display.Display = display.getDefaultDisplaySync(); + return defaultDisplay.height / defaultDisplay.width; + } +} + +export default WindowUtil; \ No newline at end of file diff --git a/entry/src/main/ets/viewmodels/PreviewViewModel.ets b/entry/src/main/ets/viewmodels/PreviewViewModel.ets new file mode 100644 index 0000000000000000000000000000000000000000..d9b8db0df07f6cf4005dcff13f58a54f788e3eb2 --- /dev/null +++ b/entry/src/main/ets/viewmodels/PreviewViewModel.ets @@ -0,0 +1,96 @@ +import CameraConstant from "../constants/Constants"; +import { camera } from "@kit.CameraKit"; +import WindowUtil from "../utils/WindowUtil"; +import { display } from "@kit.ArkUI"; + +export enum CameraMode { + PHOTO, + VIDEO +} + +/** + * States and methods related to preview. + */ +class PreviewViewModel { + private uiContext: UIContext; + isFront: boolean = false; + cameraMode: CameraMode = CameraMode.PHOTO; + xComponentController: XComponentController = new XComponentController(); + surfaceId: string = ''; + previewSize: Size = WindowUtil.getMaxDisplaySize(CameraConstant.RATIO_PHOTO); + rates?: number[] = []; + currentRate: number = 0; + + constructor(uiContext: UIContext) { + this.uiContext = uiContext; + } + + getPreviewRatio() { + return this.cameraMode === CameraMode.PHOTO + ? CameraConstant.RATIO_PHOTO + : CameraConstant.RATIO_VIDEO; + } + + getSceneMode() { + return this.cameraMode === CameraMode.PHOTO + ? camera.SceneMode.NORMAL_PHOTO + : camera.SceneMode.NORMAL_VIDEO; + } + + getCameraPosition() { + return this.isFront + ? camera.CameraPosition.CAMERA_POSITION_FRONT + : camera.CameraPosition.CAMERA_POSITION_BACK; + } + + getProfile() { + const displaySize: Size = WindowUtil.getMaxDisplaySize(this.getPreviewRatio()); + const orientation = display.getDefaultDisplaySync().orientation; + const isLandscape = orientation === display.Orientation.LANDSCAPE_INVERTED + || orientation === display.Orientation.LANDSCAPE; + const profile: camera.Profile = { + format: camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP, + size: { + height: isLandscape ? displaySize.height : displaySize.width, + width: isLandscape ? displaySize.width : displaySize.height + } + }; + return profile; + } + + setPreviewSize() { + const displaySize: Size = WindowUtil.getMaxDisplaySize(this.getPreviewRatio()) + this.previewSize = displaySize; + this.xComponentController.setXComponentSurfaceRect({ + surfaceWidth: displaySize.width, + surfaceHeight: displaySize.height + }) + } + + getPreviewTop() { + const previewRatio = this.getPreviewRatio(); + return WindowUtil.getWindowRatio() > previewRatio ? 85 : 0; + } + + getPreviewWidth() { + return this.uiContext.px2vp(this.previewSize.width); + } + + getPreviewHeight() { + return this.uiContext.px2vp(this.previewSize.height); + } + + isPhotoMode() { + return this.cameraMode === CameraMode.PHOTO; + } + + isVideoMode() { + return this.cameraMode === CameraMode.VIDEO; + } + + isCurrentCameraMode(mode: CameraMode) { + return this.cameraMode === mode; + } +} + +export default PreviewViewModel; \ No newline at end of file diff --git a/entry/src/main/ets/views/ModeButtonsView.ets b/entry/src/main/ets/views/ModeButtonsView.ets new file mode 100644 index 0000000000000000000000000000000000000000..8fa97a3f8759feb040c609d06ed33230ec4cf7b8 --- /dev/null +++ b/entry/src/main/ets/views/ModeButtonsView.ets @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CameraManager, PhotoManager, VideoManager } from "camera"; +import PreviewViewModel, { CameraMode } from "../viewmodels/PreviewViewModel"; + +export interface CameraModeButton { + title: ResourceStr; + mode: CameraMode; + onClick?: () => void; +} + +@Component +struct ModeButtonsView { + private cameraModeButtons: CameraModeButton[] = [ + { + title: $r('app.string.photo'), + mode: CameraMode.PHOTO + }, + { + title: $r('app.string.video'), + mode: CameraMode.VIDEO + } + ] + @Link previewVM: PreviewViewModel; // Do not use @prop, otherwise deep copying, some underlying data will be lost. + @Require cameraManager: CameraManager; + @Require photoManager: PhotoManager; + @Require videoManager: VideoManager; + @Require syncButtonSettings: () => void; + + build() { + Row() { + ForEach(this.cameraModeButtons, (modeBtn: CameraModeButton) => { + Text(modeBtn.title) + .fontColor(Color.White) + .fontSize(14) + .fontWeight(this.previewVM.isCurrentCameraMode(modeBtn.mode) ? FontWeight.Bold : FontWeight.Normal) + .onClick(async () => { + if (modeBtn.onClick) { + modeBtn.onClick(); + } else { + if (this.previewVM.isCurrentCameraMode(modeBtn.mode)) { + return; + } + this.previewVM.cameraMode = modeBtn.mode; + this.previewVM.setPreviewSize(); + const sceneMode = this.previewVM.getSceneMode(); + const cameraPosition = this.previewVM.getCameraPosition(); + const profile = this.previewVM.getProfile(); + await this.cameraManager.release(); + this.photoManager.setIsActive(this.previewVM.isPhotoMode() ? true : false); + this.videoManager.setIsActive(this.previewVM.isPhotoMode() ? false : true); + await this.cameraManager.start(this.previewVM.surfaceId, cameraPosition, sceneMode, profile); + this.syncButtonSettings(); + } + }) + }, (modeBtn: CameraModeButton) => modeBtn.mode.toString()) + } + .id('modeButtonsView') + .width('40%') + .justifyContent(FlexAlign.SpaceAround) + .alignRules({ + bottom: { anchor: 'operateButtonsView', align: VerticalAlign.Top }, + middle: { anchor: 'operateButtonsView', align: HorizontalAlign.Center } + }) + .margin({ bottom: 40 }) + } +} + +export default ModeButtonsView; \ No newline at end of file diff --git a/entry/src/main/ets/views/OperateButtonsView.ets b/entry/src/main/ets/views/OperateButtonsView.ets new file mode 100644 index 0000000000000000000000000000000000000000..a43f7f13f30824a5d3366ec2c34030e88d807a45 --- /dev/null +++ b/entry/src/main/ets/views/OperateButtonsView.ets @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { image } from "@kit.ImageKit"; +import { common } from "@kit.AbilityKit"; +import { camera } from "@kit.CameraKit"; +import PreviewViewModel from "../viewmodels/PreviewViewModel"; +import { AVRecorderState, CameraManager, PhotoManager, VideoManager } from "camera"; + +@Component +struct OperateButtonsView { + @Link isDelayTakePhoto: boolean; + @Link previewVM: PreviewViewModel; + @Require cameraManager: CameraManager; + @Link videoManager: VideoManager; // Do not use @prop, otherwise deep copying, some underlying data will be lost. + @Require photoManager: PhotoManager; + @Prop @Require photoDelayTime: number; + @Link photoRemainder: number; + private photoDelayTimer:number = 0; + @State thumbnail: image.PixelMap | string = ''; + @State thumbnailUrl: string = ''; + @Require syncButtonSettings: () => void; + private context = this.getUIContext().getHostContext() as common.UIAbilityContext; + private setThumbnail: (pixelMap: image.PixelMap, url: string) => void = (pixelMap: image.PixelMap, url: string) => { + this.thumbnail = pixelMap + this.thumbnailUrl = url + } + + aboutToAppear(): void { + this.photoManager.setCallback(this.setThumbnail); + this.videoManager.setVideoCallback(this.setThumbnail); + } + + @Builder + photoButton() { + Column() { + Column() + .width(48) + .height(48) + .borderRadius('50%') + .border({ + color: Color.White, + width: 24 + }) + } + .width(64) + .height(64) + .borderRadius('50%') + .border({ + color: Color.White, + width: 1 + }) + .justifyContent(FlexAlign.Center) + .onClick(() => { + if (this.photoDelayTime) { + this.isDelayTakePhoto = true + this.photoRemainder = this.photoDelayTime + this.photoDelayTimer = setInterval(()=>{ + this.photoRemainder--; + if(this.photoRemainder === 0){ + this.photoManager.capture(this.previewVM.isFront) + this.isDelayTakePhoto = false + clearTimeout(this.photoDelayTimer) + } + },1000) + } else { + this.photoManager.capture(this.previewVM.isFront) + } + }) + } + + @Builder + videoStartButton() { + Column() { + Column() + .width(52) + .height(52) + .backgroundColor(Color.Red) + .borderRadius('50%') + .border({ + color: Color.White, + width: 8 + }) + } + .width(64) + .height(64) + .borderRadius('50%') + .border({ + color: Color.White, + width: 1 + }) + .justifyContent(FlexAlign.Center) + .onClick(() => { + this.videoManager.start() + }) + } + + @Builder + videoStopButton() { + Column() { + Column() + .width(28) + .height(28) + .borderRadius(10) + .backgroundColor(Color.Red) + } + .width(64) + .height(64) + .borderRadius('50%') + .border({ + color: Color.White, + width: 1 + }) + .justifyContent(FlexAlign.Center) + .onClick(async () => { + if (this.videoManager.state === AVRecorderState.STARTED || this.videoManager.state === AVRecorderState.PAUSED) { + await this.videoManager.stop(); + await this.cameraManager.release(); + const cameraPosition = this.previewVM.getCameraPosition(); + const sceneMode = this.previewVM.getSceneMode(); + const profile = this.previewVM.getProfile(); + await this.cameraManager.start(this.previewVM.surfaceId, cameraPosition, sceneMode, profile); + this.syncButtonSettings(); + } + }) + } + + @Builder + videoPauseButton() { + SymbolGlyph($r('sys.symbol.pause')) + .fontColor([Color.White]) + .fontSize(22) + .fontWeight(FontWeight.Bolder) + .padding(11) + .borderWidth(1) + .borderColor(Color.White) + .borderRadius('50%') + .symbolEffect(new ReplaceSymbolEffect(EffectScope.WHOLE), true) + .onClick(async () => { + this.videoManager.pause() + }) + } + + @Builder + videoResumeButton() { + SymbolGlyph($r('sys.symbol.play')) + .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR) + .fontColor([Color.White]) + .fontSize(22) + .padding(11) + .borderWidth(1) + .borderColor(Color.White) + .borderRadius('50%') + .onClick(async () => { + this.videoManager.resume() + }) + } + + @Builder + thumbnailButton() { + Image(this.thumbnail) + .width(48) + .height(48) + .borderRadius('50%') + .borderWidth(this.thumbnail ? 1 : 0) + .borderColor(Color.White) + .clip(true) + .onClick(()=>{ + if(this.thumbnailUrl){ + this.context.startAbility({ + parameters: { uri: this.thumbnailUrl }, + action: 'ohos.want.action.viewData', + bundleName: 'com.huawei.hmos.photos', + abilityName: 'com.huawei.hmos.photos.MainAbility' + }); + } + }) + } + + @Builder + delayTakePhotoCancelButton() { + SymbolGlyph($r('sys.symbol.pause')) + .fontColor([Color.White]) + .fontSize(22) + .fontWeight(FontWeight.Bolder) + .padding(11) + .borderWidth(1) + .borderColor(Color.White) + .borderRadius('50%') + .symbolEffect(new ReplaceSymbolEffect(EffectScope.WHOLE), true) + .onClick(async () => { + this.isDelayTakePhoto = false; + clearTimeout(this.photoDelayTimer); + this.photoRemainder = 0; + }) + } + + @Builder + toggleCameraPositionButton() { + Image($r('app.media.toggle_position')) + .width(48) + .height(48) + .onClick(async () => { + this.previewVM.isFront = !this.previewVM.isFront; + const cameraPosition = this.previewVM.getCameraPosition(); + const profile: camera.Profile = this.previewVM.getProfile(); + const sceneMode = this.previewVM.getSceneMode(); + await this.cameraManager.release(); + await this.cameraManager.start(this.previewVM.surfaceId, cameraPosition, sceneMode, profile); + this.syncButtonSettings(); + }) + } + + build() { + Row() { + this.thumbnailButton() + if (this.previewVM.isPhotoMode()) { + this.photoButton() + } else { + if (this.videoManager.isRecording()) { + this.videoStopButton() + } else { + this.videoStartButton() + } + } + if (!this.videoManager.isRecording()) { + this.toggleCameraPositionButton() + } + if (this.previewVM.isVideoMode() && this.videoManager.state === AVRecorderState.STARTED) { + this.videoPauseButton() + } else if (this.previewVM.isVideoMode() && this.videoManager.state === AVRecorderState.PAUSED) { + this.videoResumeButton() + } + } + .justifyContent(FlexAlign.SpaceAround) + .id('operateButtonsView') + .alignRules({ + bottom: { anchor: '__container__', align: VerticalAlign.Bottom }, + middle: { anchor: '__container__', align: HorizontalAlign.Center }, + }) + .margin({ bottom: 90 }) + .width('90%') + } +} + +export default OperateButtonsView; \ No newline at end of file diff --git a/entry/src/main/ets/views/SettingButtonsView.ets b/entry/src/main/ets/views/SettingButtonsView.ets new file mode 100644 index 0000000000000000000000000000000000000000..d1cf8b65f4b5f656f5ab7f7a44de5b3117c3bc2c --- /dev/null +++ b/entry/src/main/ets/views/SettingButtonsView.ets @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { camera } from "@kit.CameraKit"; +import { AVRecorderState, CameraManager, PhotoManager, PreviewManager, VideoManager } from "camera"; +import PreviewViewModel from "../viewmodels/PreviewViewModel"; + +@Component +struct SettingButtonsView { + private flashImageMap: Map = new Map([ + [camera.FlashMode.FLASH_MODE_CLOSE, $r('sys.symbol.camera_flash_slash')], + [camera.FlashMode.FLASH_MODE_OPEN, $r('sys.symbol.camera_flash')], + [camera.FlashMode.FLASH_MODE_AUTO, $r('sys.symbol.camera_flash_auto')], + [camera.FlashMode.FLASH_MODE_ALWAYS_OPEN, $r('sys.symbol.lightbulb_1')], + ]); + private photoFlashMenuTitleMap: Map = new Map([ + ['off', camera.FlashMode.FLASH_MODE_CLOSE], + ['on', camera.FlashMode.FLASH_MODE_OPEN], + ['auto', camera.FlashMode.FLASH_MODE_AUTO], + ['always_on', camera.FlashMode.FLASH_MODE_ALWAYS_OPEN] + ]); + private videoFlashMenuTitleMap: Map = new Map([ + ['off', camera.FlashMode.FLASH_MODE_CLOSE], + ['always_on', camera.FlashMode.FLASH_MODE_ALWAYS_OPEN] + ]); + @State flashMode: camera.FlashMode = camera.FlashMode.FLASH_MODE_CLOSE; + @Link isLivePhoto: boolean; + @Require cameraManager: CameraManager; + @Require previewManager: PreviewManager; + @Require photoManager: PhotoManager; + @Link videoManager: VideoManager; + @Link photoDelayTime: number; + @State isStabilizationEnabled: boolean = false; + @Link isSinglePhoto: boolean; + @Link previewVM: PreviewViewModel; + + getFlashMenuElements(flashMenuTitleMap: Map): MenuElement[] { + return Array.from(flashMenuTitleMap.keys()).map(text => { + const flashMode = flashMenuTitleMap.get(text); + const menuElement: MenuElement = { + value: text, + action: () => { + this.flashMode = flashMode!; + this.cameraManager.setFlashMode(this.flashMode); + } + }; + return menuElement; + }); + } + + @Builder + flashButton(flashMenuElements: MenuElement[]) { + SymbolGlyph(this.flashImageMap.get(this.flashMode)) + .fontSize(22) + .fontColor([Color.White]) + .bindMenu(flashMenuElements) + } + + @Builder + videoTimerBuilder() { + if (this.videoManager.isRecording()) { + Row({ space: 5 }) { + SymbolGlyph(this.videoManager.state === AVRecorderState.STARTED ? $r('sys.symbol.record_circle') : + $r('sys.symbol.pause')) + .fontSize(22) + .fontColor(this.videoManager.state === AVRecorderState.STARTED ? [Color.Red, 'rgba(255,0,0,0)'] : + [Color.White]) + .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_COLOR) + Text(this.videoManager.state === AVRecorderState.STARTED ? $r('app.string.recording') : $r('app.string.paused')) + .fontColor(Color.White) + .fontSize(12) + } + } + } + + @Builder + livePhotoButton() { + SymbolGlyph(this.isLivePhoto + ? $r('sys.symbol.livephoto') + : $r('sys.symbol.livephoto_slash')) + .onClick(() => { + this.isLivePhoto = !this.isLivePhoto + this.photoManager.enableMovingPhoto(this.isLivePhoto) + }) + .fontSize(22) + .fontColor([Color.White]) + } + + @Builder + rateButton() { + if (this.previewVM.rates) { + Text(`${this.previewVM.currentRate} fps`) + .fontColor(Color.White) + .fontSize(16) + .bindMenu(this.previewVM.rates.map(rate => { + const menuElement: MenuElement = { + value: rate + 'fps', + action: () => { + this.previewManager.setFrameRate(rate, rate); + this.previewVM.currentRate = rate; + } + }; + return menuElement; + })) + } + } + + @Builder + delayPhotoButton(photoDelayTimeElements: MenuElement[]) { + if (this.photoDelayTime) { + Text(`${this.photoDelayTime}s`) + .fontColor(Color.White) + .fontSize(16) + .bindMenu(photoDelayTimeElements) + } else { + SymbolGlyph($r('sys.symbol.time_lapse')) + .fontSize(22) + .fontColor([Color.White]) + .bindMenu(photoDelayTimeElements) + } + } + + getPhotoDelayTimeElements(): MenuElement[] { + const menuTextMap: Map = new Map([ + ['off', 0], + ['2s', 2], + ['5s', 5], + ['10s', 10] + ]) + return Array.from(menuTextMap.keys()).map(text => { + const time = menuTextMap.get(text); + const menuElement: MenuElement = { + value: text, + action: () => { + this.photoDelayTime = time! + } + }; + return menuElement; + }); + } + + @Builder + stabilizationButton() { + SymbolGlyph(this.isStabilizationEnabled + ? $r('sys.symbol.motion_stabilization') + : $r('sys.symbol.motion_stabilization_slash')) + .onClick(() => { + this.isStabilizationEnabled = !this.isStabilizationEnabled; + this.cameraManager.setVideoStabilizationMode(camera.VideoStabilizationMode.AUTO); + }) + .fontSize(22) + .fontColor([Color.White]) + } + + @Builder + togglePhotoModeButton() { + SymbolGlyph(this.isSinglePhoto + ? $r('sys.symbol.picture') + : $r('sys.symbol.picture_on_square_1')) + .onClick(() => { + this.isSinglePhoto = !this.isSinglePhoto; + this.photoManager.setPhotoOutputCallback(this.isSinglePhoto); + if (this.isSinglePhoto) { + this.isLivePhoto = false; + } + this.photoManager.enableMovingPhoto(this.isLivePhoto); + }) + .fontSize(22) + .fontColor([Color.White]) + } + + build() { + Row() { + if (this.previewVM.isPhotoMode()) { + this.rateButton() + this.flashButton(this.getFlashMenuElements(this.photoFlashMenuTitleMap)) + this.delayPhotoButton(this.getPhotoDelayTimeElements()) + if (!this.isSinglePhoto) { + this.livePhotoButton() + } + this.togglePhotoModeButton() + } else { + if (this.videoManager.isRecording()) { + this.videoTimerBuilder() + } else { + this.rateButton() + this.flashButton(this.getFlashMenuElements(this.videoFlashMenuTitleMap)) + this.stabilizationButton() + } + } + } + .id('settingButtonsView') + .width('100%') + .margin({ top: 50 }) + .justifyContent(FlexAlign.SpaceAround) + .alignRules({ top: { anchor: '__container__', align: VerticalAlign.Top } }) + } +} + +export default SettingButtonsView; \ No newline at end of file diff --git a/entry/src/main/ets/views/ZoomButtonsView.ets b/entry/src/main/ets/views/ZoomButtonsView.ets new file mode 100644 index 0000000000000000000000000000000000000000..127279176b90fe00f00ee8092d16b4f2cddbbdce --- /dev/null +++ b/entry/src/main/ets/views/ZoomButtonsView.ets @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 ("the License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CameraManager } from "camera"; +import { findRangeIndex, toFixed } from "../utils/CommonUtil"; + +@Component +struct ZoomButtonsView { + @Prop @Require zooms: number[]; + @Prop @Require zoomRange: number[] = []; + @Link currentZoom: number; + @Require cameraManager: CameraManager; + + getZoomButtonText(zoom: number, index: number): string { + const minZoom = this.zoomRange[0]; + const currentZoomIndex: number = findRangeIndex(this.currentZoom, this.zooms); + if (index === 0 && (this.currentZoom === minZoom || currentZoomIndex !== index)) { + return 'w'; + } + if (this.currentZoom === zoom || currentZoomIndex !== index) { + return `${zoom}x`; + } + return `${toFixed(this.currentZoom, 1)}x`; + } + + getZoomButtonBorderWidth(index: number): number { + const currentZoomIndex: number = findRangeIndex(this.currentZoom, this.zooms); + return currentZoomIndex === index ? 1.5 : 0; + } + + build() { + Row({ space: 15 }) { + ForEach(this.zooms, (zoom: number, index: number) => { + Text(this.getZoomButtonText(zoom, index)) + .width(36) + .height(36) + .fontColor(Color.White) + .fontSize(10) + .fontWeight(FontWeight.Bold) + .borderWidth(this.getZoomButtonBorderWidth(index)) + .borderColor(Color.White) + .borderRadius('50%') + .textAlign(TextAlign.Center) + .onClick(() => { + this.cameraManager.setSmoothZoom(zoom) + this.currentZoom = zoom + }) + }, (zoom: number) => zoom.toString()) + } + .margin({ bottom: 40 }) + .alignRules({ + bottom: { anchor: 'modeButtonsView', align: VerticalAlign.Top }, + middle: { anchor: 'modeButtonsView', align: HorizontalAlign.Center } + }) + } +} +export default ZoomButtonsView; \ No newline at end of file diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000000000000000000000000000000000000..fb70c5e2a8a79e04068e3ee5271a08073e7bfbc4 --- /dev/null +++ b/entry/src/main/module.json5 @@ -0,0 +1,89 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone", + "tablet" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "action.system.home" + ] + } + ] + } + ], + "requestPermissions": [ + { + "name": "ohos.permission.CAMERA", + "reason": "$string:permission_CAMERA", + "usedScene": {} + }, + { + "name": "ohos.permission.MICROPHONE", + "reason": "$string:reason_microphone", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + }, + { + "name": "ohos.permission.MEDIA_LOCATION", + "reason": "$string:reason_media_location", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + }, + { + "name": "ohos.permission.WRITE_IMAGEVIDEO", + "reason": "$string:reason_write_imagevideo", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + }, + { + "name": "ohos.permission.READ_IMAGEVIDEO", + "reason": "$string:reason_read_imagevideo", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + }, + }, + { + "name": "ohos.permission.ACCELEROMETER", + "reason": "$string:permission_SENSOR", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000000000000000000000000000000000000..3c712962da3c2751c2b9ddb53559afcbd2b54a02 --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000000000000000000000000000000000000..33ea22304f9b1485b5f22d811023701b5d4e35b6 --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..7ebb0d54d9711340093401c6accb9e3470d3d8be --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -0,0 +1,60 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "CustomCamera" + }, + { + "name": "permission_CAMERA", + "value": "用于相机操作" + }, + { + "name": "reason_microphone", + "value": "用于相机录像场景" + }, + { + "name": "reason_media_location", + "value": "用于相机获取媒体信息场景" + }, + { + "name": "reason_write_imagevideo", + "value": "用于相机读写媒体文件" + }, + { + "name": "reason_read_imagevideo", + "value": "用于相机读写媒体文件" + }, + { + "name": "permission_SENSOR", + "value": "用于传感器" + }, + { + "name": "recording", + "value": "录制中" + }, + { + "name": "paused", + "value": "已暂停" + }, + { + "name": "wakeup_text", + "value": "点击屏幕唤醒相机" + }, + { + "name": "photo", + "value": "拍照" + }, + { + "name": "video", + "value": "录像" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/flash_10s.png b/entry/src/main/resources/base/media/flash_10s.png new file mode 100644 index 0000000000000000000000000000000000000000..b6de041fe17fa2dc0ff7a6784e0bee0526e2277f Binary files /dev/null and b/entry/src/main/resources/base/media/flash_10s.png differ diff --git a/entry/src/main/resources/base/media/flash_2s.png b/entry/src/main/resources/base/media/flash_2s.png new file mode 100644 index 0000000000000000000000000000000000000000..183c0ec5c8b99261dbf6130373cb60b08534f672 Binary files /dev/null and b/entry/src/main/resources/base/media/flash_2s.png differ diff --git a/entry/src/main/resources/base/media/flash_5s.png b/entry/src/main/resources/base/media/flash_5s.png new file mode 100644 index 0000000000000000000000000000000000000000..1822496b37069f119060e789f1228cea746e1327 Binary files /dev/null and b/entry/src/main/resources/base/media/flash_5s.png differ diff --git a/entry/src/main/resources/base/media/focus_box.png b/entry/src/main/resources/base/media/focus_box.png new file mode 100644 index 0000000000000000000000000000000000000000..7a5d9b649e08465de0e8e8883a9a37d7c3bb9f36 Binary files /dev/null and b/entry/src/main/resources/base/media/focus_box.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..97014d3e10e5ff511409c378cd4255713aecd85f Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000000000000000000000000000000000000..fb49920440fb4d246c82f9ada275e26123a2136a --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..205ad8b5a8a42e8762fbe4899b8e5e31ce822b8b Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/media/toggle_position.png b/entry/src/main/resources/base/media/toggle_position.png new file mode 100644 index 0000000000000000000000000000000000000000..0843141ece2b81e910903dcd2b153f37ee4bf612 Binary files /dev/null and b/entry/src/main/resources/base/media/toggle_position.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000000000000000000000000000000000000..78f40ae7c494d71e2482278f359ec790ca73471a --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000000000000000000000000000000000000..1898d94f58d6128ab712be2c68acc7c98e9ab9ce --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000000000000000000000000000000000000..79b11c2747aec33e710fd3a7b2b3c94dd9965499 --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/en_US/element/string.json b/entry/src/main/resources/en_US/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..e0f57eb8698404d31892d8f9d01f072be5d43825 --- /dev/null +++ b/entry/src/main/resources/en_US/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "CustomCamera" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce1de976e8b2aaaa94fa33c89849eb508ec4324 --- /dev/null +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "自定义相机" + } + ] +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000000000000000000000000000000000000..5bebc9755447385d82ce4138f54d991b1f85f348 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,22 @@ +{ + "modelVersion": "5.0.5", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3cb9f1a87a81687554a76283af8df27d8bda775 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000000000000000000000000000000000000..2857b556e1b879745efca26121d68bb5d43c38cf --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,10 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": {}, + "packages": {} +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000000000000000000000000000000000000..f440d114b8a9d9aeadd0e891bba0d0d076e70936 --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,7 @@ +{ + "modelVersion": "5.0.5", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": {} +} diff --git a/screenshots/devices/photo.png b/screenshots/devices/photo.png new file mode 100644 index 0000000000000000000000000000000000000000..0f70e9a9c7b4b12318dafa933b0161b6a7469f73 Binary files /dev/null and b/screenshots/devices/photo.png differ diff --git a/screenshots/devices/video.png b/screenshots/devices/video.png new file mode 100644 index 0000000000000000000000000000000000000000..a233ed9c764edbb7401d8c0726d43a4f02a57cec Binary files /dev/null and b/screenshots/devices/video.png differ