Browse Source

拍照和录像优化

dev
wangmingyong@163.com 2 weeks ago
parent
commit
9c5a3cc154
  1. 160
      zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js
  2. 19
      zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue
  3. 14
      zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue

160
zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js

@ -98,44 +98,102 @@ const getTimeStr = () => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 去掉 file:// 前缀(w6s 的 toBase64 在 Android 下会自动补 file://,避免重复前缀导致解析失败)
const stripFileScheme = (p = '') => String(p).replace(/^file:\/\//, '')
// 水印图最长边上限(像素)。原图往往 4000+,整张重新编码是耗时主因,
// 限制到 2048 可大幅缩短耗时并减小上传体积;需要更高清可上调
const WATERMARK_MAX_SIZE = 2048
// 读取水印源图:优先用本地文件 URL(cdvfile:// 由 Cordova 拦截读取),无需把整张图转成 base64;
// 环境/路径不支持时回退到 SDK 的 toBase64
const loadWatermarkSource = async (filePath) => {
const toImg = (src) => new Promise((resolve, reject) => {
const el = document.createElement('img')
el.onload = () => resolve(el)
el.onerror = () => reject(new Error('水印源图片加载失败'))
el.src = src
})
const raw = stripFileScheme(filePath)
if (window.resolveLocalFileSystemURL) {
const fileUrl = /^(cdvfile|content):/i.test(String(filePath)) ? filePath : `file://${raw}`
try {
const entry = await new Promise((resolve, reject) => window.resolveLocalFileSystemURL(fileUrl, resolve, reject))
return await toImg(entry.toURL())
} catch (e) {
console.warn('[w6sMedia] 本地文件 URL 加载失败, 回退 toBase64:', e)
}
}
const evt = await w6s.image.toBase64({ filePath: raw })
return toImg(evt?.target?.result || evt?.result || evt)
}
// 前端自行给图片叠加时间水印,返回带水印图片的本地文件路径
// 说明:w6s.image.takePhotoAndAddWaterMark 实测不可用(原生加水印接口无有效返回),
// 改为拍照后用 canvas 在前端叠加时间水印;用 toBlob 直接落盘,省去 base64 编解码开销
const addTimeWaterMark = async (filePath, fileName) => {
console.log('[w6sMedia] 开始叠加时间水印, 源图:', filePath)
const img = await loadWatermarkSource(filePath)
// 按最长边等比缩放,避免整张原图重新编码(编码是耗时主因)
const srcW = img.naturalWidth || img.width
const srcH = img.naturalHeight || img.height
const scale = Math.min(1, WATERMARK_MAX_SIZE / Math.max(srcW, srcH))
const canvas = document.createElement('canvas')
canvas.width = Math.round(srcW * scale)
canvas.height = Math.round(srcH * scale)
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const text = getTimeStr()
const fontSize = Math.max(16, Math.round(canvas.width * 0.035))
const pad = Math.round(fontSize * 0.6)
ctx.font = `${fontSize}px sans-serif`
ctx.textAlign = 'left'
ctx.textBaseline = 'bottom'
// 描边提升浅色背景下的可读性
ctx.lineWidth = Math.max(2, Math.round(fontSize * 0.12))
ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'
ctx.fillStyle = '#FF5858'
const x = pad
const y = canvas.height - pad
ctx.strokeText(text, x, y)
ctx.fillText(text, x, y)
// toBlob 直接产出二进制,避免 toDataURL 的 base64 编码
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('水印图片编码失败'))), 'image/jpeg', 0.9)
})
const out = await writeBlobToLocalFile(blob, fileName)
console.log('[w6sMedia] 时间水印叠加完成, 输出:', out, '大小:', blob.size)
return out
}
// 拍照并加水印
// 注:w6s.image.takePhotoAndAddWaterMark 不可用,已改为 w6s.image.takePicture + 前端 canvas 叠加时间水印
export const takePhotoWithWaterMark = async () => {
await ensureW6sAuth()
// console.log('[w6sMedia] 鉴权完成, 准备获取定位')
// // 预获取定位(带缓存),第二次拍照起直接用缓存不搜星
// let locationStr = ''
// try {
// const loc = await getCachedLocation()
// locationStr = (loc.city || '') + (loc.district || '') + (loc.street || '')
// console.log('[w6sMedia] 定位地址:', locationStr)
// } catch (e) {
// console.warn('[w6sMedia] 获取定位失败, 水印不含地址:', e)
// }
// const content = `${getTimeStr()} ${locationStr}`.trim()
// console.log('[w6sMedia] 水印 content:', content, '准备调起拍照')
console.log('[w6sMedia] 鉴权完成, 准备调起拍照')
return new Promise((resolve, reject) => {
w6s.image.takePhotoAndAddWaterMark({
content: '',
fontSize: 14,
color: '#FF5858',
markDisable: false,
timeEnable: true,
locationEnable: false,
success: (res) => {
console.log('[w6sMedia] takePhotoAndAddWaterMark success 原始返回:', res)
// 1. 调起原生拍照,返回 { imageURL(压缩图本地路径), key(原图本地路径), imageInfo, mediaId }
const photo = await w6s.image.takePicture({ editable: false })
.then((res) => {
console.log('[w6sMedia] takePicture success 原始返回:', res)
const result = res?.result || res || {}
console.log('[w6sMedia] 返回字段 imageURL:', result?.imageURL, 'key:', result?.key)
resolve(result)
},
fail: (err) => {
console.error('[w6sMedia] takePhotoAndAddWaterMark fail:', err)
reject(err)
},
console.log('[w6sMedia] 拍照返回 imageURL:', result?.imageURL, 'key:', result?.key)
return result
})
.catch((err) => {
console.error('[w6sMedia] takePicture fail:', err)
throw err
})
// 2. 前端叠加时间水印,产出带水印的图片本地路径
const sourcePath = photo.imageURL || photo.key
const imageURL = await addTimeWaterMark(sourcePath, `wm-photo-${Date.now()}.jpg`)
// 3. 返回结构与原来一致(imageURL 为带水印图片的本地路径,可直接上传)
return { ...photo, imageURL }
}
// 录制视频(w6s 官方 API,文档 https://open.beeworks.cn/js-sdk/video.html)
@ -195,6 +253,37 @@ export const getLocation = async () => {
})
}
// 将 Blob 写入客户端缓存目录,返回可上传的本地文件路径
// FileTransfer 只支持文件路径,canvas 产出的图片 Blob 需先落盘
const writeBlobToLocalFile = (blob, fileName) => new Promise((resolve, reject) => {
const cordovaFile = window.cordova?.file
if (!window.resolveLocalFileSystemURL || !cordovaFile?.cacheDirectory) {
reject(new Error('当前环境不支持写入本地文件(缺少 cordova-plugin-file)'))
return
}
window.resolveLocalFileSystemURL(cordovaFile.cacheDirectory, (dirEntry) => {
dirEntry.getFile(fileName, { create: true }, (fileEntry) => {
fileEntry.createWriter((writer) => {
writer.onwriteend = () => resolve(fileEntry.toURL())
writer.onerror = (e) => reject(e)
writer.write(blob)
}, reject)
}, reject)
}, reject)
})
// base64 dataURL → Blob(仅作为兼容兜底,正常流程已用 toBlob 直接产出二进制)
const dataUrlToBlob = (dataUrl) => {
const commaIdx = dataUrl.indexOf(',')
const type = (dataUrl.slice(0, commaIdx).match(/data:(.*?);/) || [])[1] || 'image/jpeg'
const bin = atob(dataUrl.slice(commaIdx + 1))
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i)
return new Blob([bytes], { type })
}
const writeDataUrlToFile = (dataUrl, fileName) => writeBlobToLocalFile(dataUrlToBlob(dataUrl), fileName)
// 上传本地文件到后端 /app-enf/common/file/upload(Cordova FileTransfer,图片/视频共用)
export const uploadLocalFileToBackend = ({ fileURL, fileName = 'upload', mimeType = 'image/jpeg' }) => {
console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType })
@ -206,7 +295,12 @@ const uploadViaFileTransfer = async ({ fileURL, fileName, mimeType }) => {
const token = $globalStore.useCommon?.token || ''
const api = `${baseUrl}/app-enf/common/file/upload`
const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}`
console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', fileURL)
// base64 dataURL(如拍照后前端叠加水印的图片)先落盘为本地临时文件
const localFileURL = /^data:/.test(fileURL)
? await writeDataUrlToFile(fileURL, fileName)
: fileURL
console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', localFileURL)
const opts = {
fileKey: 'file',
@ -221,7 +315,7 @@ const uploadViaFileTransfer = async ({ fileURL, fileName, mimeType }) => {
const uploader = new w6s.file.upload(opts)
// upload 返回 Promise:responseCode === 200 时 resolve,否则 reject
const res = await uploader.upload({
fileURL,
fileURL: localFileURL,
server,
trustAllHosts: false,
})

19
zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue

@ -85,7 +85,7 @@
<van-icon name="cross" class="media-item--remove" @click.stop="onRemoveFile(item, fileIndex)" />
</div>
<div v-else class="media-item media-item--video" @click="onPreviewItem(item, fileIndex)">
<video :src="file.url" muted preload="metadata" class="video-thumb"></video>
<video :src="file.url" :poster="file.poster" muted preload="metadata" class="video-thumb"></video>
<van-icon name="play-circle-o" class="play-icon" />
<van-icon name="cross" class="media-item--remove" @click.stop="onRemoveFile(item, fileIndex)" />
</div>
@ -439,7 +439,7 @@ const onTakePhoto = async (item) => {
try {
const res = await takePhotoWithWaterMark()
// @w6s/sdk 2.x 返回扁平结构: { imageURL, key, imageInfo, mediaId }
console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key)
// console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key)
const localPath = res.imageURL || res.key
console.log('[NosDetail] 解析出的 localPath:', localPath)
await pushSdkFileToFileList(item, {
@ -459,12 +459,13 @@ const onRecordVideo = async (item) => {
console.log('[NosDetail] onRecordVideo 开始, item:', item)
try {
const res = await startVideoRecorder()
console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path)
console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path, 'video_thumbnail:', res?.video_thumbnail)
await pushSdkFileToFileList(item, {
localPath: res.video_path,
fileName: res.name || `video-${Date.now()}.mp4`,
mimeType: res.type || 'video/mp4',
isImage: false,
thumbnail: res.video_thumbnail,
})
} catch (err) {
console.error('[NosDetail] w6s 录制失败:', err)
@ -472,9 +473,16 @@ const onRecordVideo = async (item) => {
}
}
// w6s 录制接口返回的首帧缩略图可能是裸 base64 / dataURL / 文件路径,统一成可直接作为 poster 的地址
const toPosterSrc = (thumb) => {
if (!thumb || typeof thumb !== 'string') return ''
if (/^(data:|https?:|file:|cdvfile:)/.test(thumb)) return thumb
return `data:image/jpeg;base64,${thumb}`
}
// 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList
const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => {
console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage })
const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage, thumbnail }) => {
console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage, thumbnail })
if (!localPath) {
console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回')
return
@ -482,6 +490,7 @@ const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isIm
const fileObj = {
url: '',
isImage,
poster: toPosterSrc(thumbnail),
status: 'uploading',
message: '上传中...',
}

14
zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue

@ -60,7 +60,7 @@
<span class="upload-text">上传中...</span>
</div>
<div v-else-if="cloudVideo" class="video-preview-item">
<video :src="cloudVideo" muted preload="metadata" class="video-thumb" @click="showVideoPreview = true" />
<video :src="cloudVideo" :poster="cloudVideoPoster" muted preload="metadata" class="video-thumb" @click="showVideoPreview = true" />
<van-icon name="play-circle-o" class="play-icon" @click="showVideoPreview = true" />
<van-icon name="cross" class="delete-icon" @click="onVideoDelete" />
</div>
@ -139,6 +139,7 @@ const refreshing = ref(false);
const submitting = ref(false);
const hasRecord = ref(false);
const cloudVideo = ref('');
const cloudVideoPoster = ref('');
const cloudText = ref('');
const suggestion = ref('');
const cloudVideoPath = ref('');
@ -147,6 +148,7 @@ const videoUploading = ref(false);
const applyDetailData = (data) => {
cloudVideo.value = data.cloudVideo ? toFullFileUrl(data.cloudVideo) : '';
cloudVideoPoster.value = '';
cloudText.value = data.cloudText || '';
suggestion.value = data.suggestion || '';
hasRecord.value = !!(data.cloudVideo || data.cloudText || data.suggestion);
@ -186,9 +188,17 @@ const onRefresh = () => {
fetchDetail();
};
// w6s 录制接口返回的首帧缩略图可能是裸 base64 / dataURL / 文件路径,统一成可直接作为 poster 的地址
const toPosterSrc = (thumb) => {
if (!thumb || typeof thumb !== 'string') return '';
if (/^(data:|https?:|file:|cdvfile:)/.test(thumb)) return thumb;
return `data:image/jpeg;base64,${thumb}`;
};
const onVideoDelete = () => {
cloudVideoPath.value = '';
cloudVideo.value = '';
cloudVideoPoster.value = '';
};
const onRecordVideo = async () => {
@ -209,6 +219,8 @@ const onRecordVideo = async () => {
if (filePath) {
cloudVideoPath.value = filePath;
cloudVideo.value = toFullFileUrl(filePath);
// 用 SDK 返回的首帧作为封面,避免 <video> 默认帧不是第一帧
cloudVideoPoster.value = toPosterSrc(res.video_thumbnail);
} else {
showToast('上传失败');
}

Loading…
Cancel
Save