前提条件

开发环境

详见 面板小程序 > 搭建环境

IPC 精彩时刻功能,是涂鸦云开发者平台为所有带摄像头的智能硬件,专门打造的"AI 视频增值服务",具体运转机制为:通过 AI 大模型自动识别预设的目标或自定义事件后,就能抓取摄像头中的精彩片段(如宠物卖萌互动、宝宝第一次走路、日出日落等),并一键生成带特效的专属 Vlog。能够满足婴儿/宠物看护、生活娱乐、风景旅行等全场景需求,帮助用户轻松记录生活中的高光时刻!

功能拆分

开发者平台创建面板小程序

面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。

详细操作步骤,请参考 创建面板小程序

IDE 基于示例模板创建项目工程

打开物料广场基于 IPC 精彩时刻通用模版 物料,创建一个小程序项目,需要在 Tuya MiniApp IDE 上操作。

详细操作步骤,请参考 初始化项目工程

完成以上步骤后,一个面板小程序的开发模板初始化完成。以下为工程目录的介绍:

├── src
│  	├── api // 面板所有云端 API 请求聚合文件
│  	├── components
│  	│   ├── GlobalToast // 全局轻弹窗
│  	│   ├── IconFont // svg 图标容器组件
│  	│   ├── MusicSelectModal // 背景音乐选择弹窗
│  	│   ├── TopBar // 顶部信息栏
│  	│   ├── TouchableOpacity // 点击按钮组件
│  	│   ├── Video // 视频容器组件
│  	│   ├── VideoSeeker // 视频滑动条组件
│  	├── constant
│  	│   ├── index.ts // 存放所有的常量配置
│  	├── devices // 设备模型
│  	├── features // 业务函数集
│  	├── hooks // hooks
│  	├── i18n // 多语言
│  	├── iconfont // svg图标
│  	├── pages
│   │   ├── ai-all // 精彩时刻全量相册
│   │   ├── ai-video // On-App AI 精彩时刻 AI 编辑
│   │   ├── ai-vision-setting // 精彩时刻服务详情编辑
│   │   ├── bind-device // 服务关联相关设备
│   │   ├── device-service-setting // 精彩时刻智能识别时间设置
│   │   ├── player // 视频播放器
│   │   ├── preview-image // 图片预览组件
│   │   ├── select-lang // 多语言设置
│  	├── redux // redux
│   ├── res // 图片资源
│   ├── share // 公共资源集
│   ├── utils // 通用函数集
│   ├── styles // 全局样式
│   ├── types // 全局类型定义
│   ├── utils // 业务常用工具方法
│   ├── app.less
│   ├── app.tsx
│   ├── global.config.ts
│   ├── mixins.less // less mixins
│   ├── routes.config.ts // 配置路由
│   ├── variables.less // less variables

功能介绍

精彩时刻服务详情主要包含以下 5 个板块:

获取服务详情信息

相关代码段

import {
  showLoading,
  hideLoading,
  showToast,
  albumSettingEdit,
  getAlbumSetting,
  albumSettingSave,
} from "@ray-js/ray";

const {
  homeId,
  stockId,
  visionBoxId,
  serviceDetails,
  statusChange,
  settingRef,
  onSaved,
} = props;

import { unstable_batchedUpdates as batchedUpdates } from "@ray-core/ray";
const { data: albumSetting, runAsync: getSetting } = useRequest(
  getAlbumSetting,
  {
    manual: true,
  }
);

useEffect(() => {
  if (visionBoxId && homeId && stockId) {
    showLoading({ title: "" });
    getSetting({
      gid: homeId,
      stockId,
    })
      .then((res) => {
        if (!res) return;
        batchedUpdates(() => {
          pauseChangeStatus.current = true;
          form.setValue("albumName", res.albumName);
          form.setValue("enableStatus", !!res.enableStatus);
          form.setValue("timedCaptureConfig", res.timedCaptureConfig);
          form.setValue("smartCaptureConfig", res.smartCaptureConfig);
          pauseChangeStatus.current = false;
        }, null);
      })
      .finally(() => hideLoading());
  }
}, [visionBoxId, homeId, stockId]);

设置服务详情信息

相关代码段

import { useForm, useWatch } from "react-hook-form";

const form = useForm({ mode: "onChange" });
const [timedCaptureConfig, smartCaptureConfig] = useWatch({
  control: form.control,
  name: ["timedCaptureConfig", "smartCaptureConfig"],
});

const handSave = async (): Promise<boolean> => {
  return new Promise((resolve) => {
    if (!serviceDetails?.cloudService?.bindDeviceNum) {
      showToast({
        icon: "none",
        title: I18n.t("aiVisual.requiredDevice.tip"),
      });
      resolve(false);
      return;
    }
    if (loadingRef.current) return;
    form.trigger().then(async (res) => {
      if (res) {
        loadingRef.current = true;
        const values = form.getValues();
        const params: Parameters<typeof albumSettingEdit>[0] = {
          gid: homeId,
          stockId,
          albumId: visionBoxId,
          albumName: values.albumName,
          enableStatus: values.enableStatus ? 1 : 0,
          timedCaptureConfig: values.timedCaptureConfig || [],
          smartCaptureConfig: values.smartCaptureConfig || [],
        };
        showLoading({ title: "" });
        let ret;
        if (visionBoxId) {
          ret = await albumSettingEdit(params);
        } else {
          ret = await albumSettingSave(params);
        }
        hideLoading();
        if (ret) {
          hideLoading();
          const txt = I18n.t("common.save.success");
          showToast({
            title: txt,
            icon: "success",
          });
          changeSettingStatus("saved");
          onSaved && onSaved(visionBoxId || ret);
          resolve(true);
          loadingRef.current = false;
        } else {
          showToast({
            title: I18n.t("common.save.failed"),
            icon: "error",
          });
          resolve(false);
          loadingRef.current = false;
        }
      }
    });
  });
};

功能介绍

精彩时刻精彩时刻智能相册主要包含以下 4 块内容:

获取精彩时刻智能相册数据

相关代码段

import {
  Text,
  View,
  showToast,
  hideLoading,
  showLoading,
  albumVideoDateCount,
} from "@ray-js/ray";

const [aiDatas, setAiDatas] = useState<IAlbumVideoDateCountRes[]>([])
const [todayData, setTodayData] = useState<IAlbumVideoDateCountRes>()
const [loading, setLoading] = useState(true);

const init = () => {
  if (isNaN(Number(timeAlbumId)) || isNaN(Number(stockId))) {
    showToast({ title: "request.param.error" });
    setLoading(false);
    return;
  }
  const param: IAlbumVideoDateCountReq = {
    stockId: Number(stockId), // 库存id
    gid: homeId, // 家庭id
    timeAlbumId: Number(timeAlbumId), // 相册id
  };
  showLoading({ title: "" });
  albumVideoDateCount(param).then((result: IAlbumVideoDateCountRes[]) => {
    hideLoading();
    setLoading(false);
    if (!result || !result.length) return;
    const res = result.map((item) => {
      const fileUrl = item.coverInfo?.fileUrl || "";
      const fileName = fileUrl.split("?")[0].split("/").pop();
      return {
        ...item,
        coverInfo: { ...item.coverInfo, ...{ fileName } },
      };
    });
    if (res.length > 0) {
      if (res[0].type === "today") {
        setTodayData(res[0]);
      }
      const d = res.filter((item) => item.type !== "today");
      setAiDatas(d);
    }
  });
};

功能介绍

精彩时刻全量相册主要包含以下内容:

获取精彩时刻全量相册数据

相关代码段

import {
  Button,
  View,
  showToast,
  showModal,
  albumFileDelete,
  albumVideoFileList,
} from "@ray-js/ray";
import { useBatchDecryptImage } from "@/features";
import { deleteImages } from "@ray-js/ray-ipc-decrypt-image";

const { timeAlbumId, stockId, secret, onShow, homeId } = props;
const batchLoadInstance = useBatchDecryptImage(true, 400);

const getPageData = (params) => {
  return new Promise((resolve, reject) => {
    if (isNaN(Number(timeAlbumId)) || isNaN(Number(stockId))) {
      showToast({ title: I18n.t("request.param.error") });
      return;
    }
    const param: IAlbumVideoFileListReq = {
      stockId: Number(stockId), // 库存id
      gid: homeId, // 家庭id
      timeAlbumId: Number(timeAlbumId), // 相册id
      pageNum: params.page,
      // 为了避免
      endTime: endTimeRef.current,
      // endTime: 1740386449,
      pageSize: params.pageSize,
    };
    albumVideoFileList(param).then((result: IAlbumVideoFileListRes) => {
      if (result && result.list && result.list.length > 0) {
        const res = result.list.map((item) => {
          const fileUrl = item.coverInfo?.fileUrl || "";
          const fileName = fileUrl.split("?")[0].split("/").pop();
          batchLoadInstance.addOriginData({
            fileUrl,
            decryptKey: secret,
          });
          return {
            ...item,
            coverInfo: { ...item.coverInfo, ...{ fileName } },
          };
        });
        batchLoadInstance.loadAllOriginData();
        resolve({ data: res, total: result.total });
        return;
      }
      resolve({ data: [] });
    });
  });
};

下载、合并、删除精彩时刻全量相册视频

相关代码段

import {
  Button,
  View,
  showToast,
  showModal,
  albumFileDelete,
  albumVideoFileList,
} from '@ray-js/ray'

import {
  cancelVideoDownload,
  composeVideos,
  getAuthorize,
  imageDownload,
  videoDownload,
} from '@/features/downloadImage'

import { jumpToAiVideoEdit, jumpToPlayer } from '@/features/jump-to-page'

  const [selectedIds, setSelectedIds] = useImmer([])
  const [selectIdIndexMap, setSelectIdIndexMap] = useState<Map<number, number>>(new Map())

  const getSelectDatas = () => {
    const maxCount = selectedIds.length
    let currentCount = 0
    const result = []
    for (let i = 0; i < cloudData.length; i++) {
      if (currentCount >= maxCount) break
      const item = cloudData[i]
      const index = selectedIds.indexOf(item.id)
      if (index >= 0) {
        result[index] = item
        currentCount++
      }
    }
    return result
  }

 const onOperateCompose = async (e, type) => {
    if (type === EOperate.download) {
      isCloseDownload.current = false
      if (selectedIds.length > MAX_DOWNLOAD_SIZE) {
        showToast({
          title: I18n.format('download.count.limit.desc', { time: MAX_DOWNLOAD_SIZE }),
          icon: 'error',
        })
        return
      }
      const selectDatas = getSelectDatas()
      const bl = await getAuthorize()
      if (bl) {
        onShow({ show: true, title: I18n.t('downloading.video'), type: ELoadingType.loading })
        const indexErrs = []
        // 下载一个成功后,再去下载另一个
        for (let i = 0; i < selectDatas.length; i++) {
          if (isCloseDownload.current) {
            break
          }
          const ele = selectDatas[i]
          if (ele.type === 1 || ele.type === 2) {
            const dl = await videoDownload(deviceId, ele.mediaInfo?.fileUrl, secret)
            if (dl === -1) {
              indexErrs.push(i)
            }
          } else if (ele.type === 0) {
            // 视频不存在,就下载图片
            const dl = await imageDownload(deviceId, ele.coverInfo?.fileUrl, secret)
            if (dl === -1) {
              indexErrs.push(i)
            }
          } else {
            indexErrs.push(i) // 说明云端返回类型不对
          }
        }
        onShow({ show: false, title: I18n.t('download.success'), type: ELoadingType.success })
        if (indexErrs.length > 0) {
          const str = indexErrs.join(',')
          showModal({
            title: '',
            content: str + I18n.t('download.clip.error'),
            showCancel: false,
            confirmText: I18n.t('common.confirm'),
          })
        } else {
          setSelectedIds([])
          setSelectIdIndexMap(null)
        }
      }
    } else if (type === EOperate.compose) {
      if (selectedIds.length > MAX_COMPOSE_SIZE) {
        showToast({
          title: I18n.format('compose.count.limit.desc', { time: MAX_COMPOSE_SIZE }),
          icon: 'error',
        })
        return
      }
      const selectDatas = getSelectDatas()
      const isVideos = isExistSingleImage(selectDatas)
      if (isVideos) {
        showToast({ title: I18n.t('compose.only.media'), icon: 'error' })
        return
      }
      const bl = await getAuthorize()
      if (bl) {
        const mediaInfos = selectDatas.map((item) => {
          return {
            fileUrl: item.mediaInfo?.fileUrl || '',
            key: secret,
          }
        })
        const json = JSON.stringify({ fileInfo: mediaInfos })
        onShow({ show: true, title: I18n.t('composing.video'), type: ELoadingType.loading })
        const result = await composeVideos(deviceId, json)
        if (result === -1) {
          onShow({ show: false, title: I18n.t('compose.fail'), type: ELoadingType.error })
        } else {
          onShow({ show: false, title: I18n.t('compose.success'), type: ELoadingType.success })
          setSelectedIds([])
          setSelectIdIndexMap(null)
          const { path = '', thingfilePath = '' } = result as Record<string, any>
          setTimeout(() => {
            jumpToAiVideoEdit({ videoPath: thingfilePath, videoOriginPath: path })
          }, 0)
        }
      }
    } else if (type === EOperate.delete) {
      if (isNaN(Number(stockId))) {
        showToast({ title: I18n.t('request.param.error') })
        return
      }
      if (selectedIds.length > MAX_DELETE_SIZE) {
        showToast({
          title: I18n.format('delete.count.limit.desc', { time: MAX_DELETE_SIZE }),
          icon: 'error',
        })
        return
      }

      showModal({
        title: '',
        content: I18n.t('module.delete.desc'),
        cancelText: I18n.t('common.cancel'),
        confirmText: I18n.t('aiVisual.delete'),
        success: async ({ confirm, cancel }) => {
          console.log('res===1', confirm, cancel)
          if (confirm) {
            const param: IAlbumFileDeleteReq = {
              stockId: Number(stockId),
              gid: homeId,
              timeAlbumId: Number(timeAlbumId),
              albumRecordIds: selectedIds.join(','),
            }
            const result = await albumFileDelete(param)
            if (result) {
              showToast({ title: I18n.t('aiVisual.delete.success'), icon: 'success' })
              const curCloudDatas = getCurCloudDatas()
              const selectDatas = curCloudDatas.filter((item) => selectedIds.includes(item.id))
              const fileNames = selectDatas.map((item) => item.coverInfo.fileName)
              deleteImages(deviceId, fileNames)
              setDeletedIds([...new Set([...deletedIds, ...selectedIds])])
              setSelectedIds([])
              setSelectIdIndexMap(null)
            } else {
              showToast({ title: I18n.t('aiVisual.delete.fail'), icon: 'error' })
            }
          }
        },
      })
    }
  }

功能介绍

精彩时刻视频 AI 编辑主要包含以下内容:

视频主体突出 AI 编辑相关开发信息,可参考 AI 视频流主体突出功能模版

视频隐私保护 AI 编辑

相关代码段

import { ai } from "@ray-js/ray";
import { createTempVideoRoot } from "@/utils";

const {
  objectDetectCreate,
  objectDetectDestroy,
  objectDetectForVideo,
  objectDetectForVideoCancel,
  offVideoObjectDetectProgress,
  onVideoObjectDetectProgress,
} = ai;

// 当前 AI 视频的编辑状态
const [handleState, setHandleState] = useState("idle");
// 当前 AI 视频流处理进度
const [progressState, setProgressState] = useState(0);

// 注册 App AI 实例
useEffect(() => {
  objectDetectCreate();

  return () => {
    // 页面销毁时同时销毁 App AI 实例
    objectDetectDestroy();
    audioManager.destroy({
      success: (res) => {
        console.log("==destroy2==success", res);
      },
      fail: (error) => {
        console.log("==destroy2==fail", error);
      },
    });
  };
}, []);

// AI 视频流处理功能
const handleVideoByAI = (
  detectType: number,
  imageEditType: number,
  musicPath = ""
) => {
  const tempVideoPath = createTempVideoRoot();
  onVideoObjectDetectProgress(handleListenerProgress);
  privacyProtectDetectForVideo({
    inputVideoPath: videoOriginSrc,
    outputVideoPath: tempVideoPath,
    detectType,
    musicPath,
    originAudioVolume: volumeObj.video / 100,

    overlayAudioVolume: volumeObj.music / 100,
    imageEditType,
    audioEditType: 3,
    success: ({ path }) => {
      console.log("===path", path, tempVideoPath);
      offVideoObjectDetectProgress(handleListenerProgress);
      setProgressState(0);
      fetchVideoThumbnails({
        filePath: path,
        startTime: 0,
        endTime: 10,
        thumbnailCount: 1,
        thumbnailWidth: 375,
        thumbnailHeight: 212,
        success: (res) => {
          console.log("===fetchVideoThumbnails==res", res);
          setHandleState("success");
          setVideoSrc(path);
          setVideoOriginSrc(path);
          setPosterSrc(res?.thumbnailsPath[0]);
          showToast({
            title: I18n.t("dsc_ai_generates_success"),
            icon: "success",
          });
        },
        fail: ({ errorMsg }) => {
          console.log("==fetchVideoThumbnails==fail==", errorMsg);
        },
      });
    },
    fail: ({ errorMsg }) => {
      console.log("==objectDetectForVideo==fail==", errorMsg);
      offVideoObjectDetectProgress(handleListenerProgress);
      setProgressState(0);
      setHandleState("fail");
      setHandleState("selectSkill");
      setIsShowAISkills(true);
      showToast({
        title: I18n.t("dsc_ai_generates_fail"),
        icon: "error",
      });
    },
  });
};

// 打断 AI 隐私保护视频流生成
const handleCancelAIProcess = () => {
  objectDetectForVideoCancel({
    success: () => {
      setHandleState("select");
    },
  });
};

具体 AI 技术方案介绍,详见:视频解决方案—IPC 精彩时刻通用方案

具体 API 相关介绍,详见:开发者文档—AI 基础包