Fixing the video-to-file conversion error and audio appearance changes.

This commit is contained in:
2026-09-29 20:36:15 +03:30
parent 70bcd15847
commit 7996fc7f73
3 changed files with 109 additions and 38 deletions
+81 -26
View File
@@ -89,27 +89,34 @@ function normalizeMediaType(raw: string): ChatMediaType {
return 'text';
}
const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
const VIDEO_EXT_RE = /\.(mp4|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
const AUDIO_EXT_RE = /\.(webm|ogg|mp3|m4a|wav|aac|opus|oga)(?:\?|$)/i;
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)(?:\?|$)/i;
const VOICE_NAME_RE = /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i;
function looksLikeVideo(pathOrName: string): boolean {
return VIDEO_EXT_RE.test(pathOrName);
}
function looksLikeVoiceAsset(pathOrName: string): boolean {
return VOICE_NAME_RE.test(pathOrName) || AUDIO_EXT_RE.test(pathOrName);
}
function inferMediaType(
declared: ChatMediaType,
content: string,
fileName?: string,
): ChatMediaType {
const probe = `${fileName || ''} ${content}`;
if (declared === 'video' || looksLikeVideo(probe)) return 'video';
if (declared === 'image') return 'image';
// Declared voice always wins — recordings are often .webm and must stay as audio UI
if (declared === 'voice') return 'voice';
// Path/name cues beat wrong declared types (MediaRecorder video/webm, backend "video" on voice)
if (looksLikeVoiceAsset(probe) && !looksLikeVideo(probe)) return 'voice';
if (looksLikeVideo(probe)) return 'video';
if (declared === 'video') return 'video';
if (declared === 'image') return 'image';
if (declared === 'file' || declared === 'text') {
if (looksLikeVideo(probe)) return 'video';
if (IMAGE_EXT_RE.test(probe)) return 'image';
if (AUDIO_EXT_RE.test(probe)) return 'voice';
if (declared === 'file') return 'file';
}
return declared;
@@ -121,6 +128,7 @@ export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content'
if (msg.type === 'video') return 'ویدیو';
if (msg.type === 'file') {
if (looksLikeVideo(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
if (looksLikeVoiceAsset(`${msg.fileName || ''} ${msg.content}`)) return 'پیام صوتی';
return msg.fileName?.trim() || 'فایل';
}
return msg.content || 'پیام';
@@ -176,19 +184,38 @@ function extractUploadedPath(result: Record<string, unknown>): string {
return String(value || '').trim();
}
function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
/** Classify upload/attach files for chat UI + WS. Exported for composer pending previews. */
export function classifyChatFile(file: File): Exclude<ChatMediaType, 'text'> {
const mime = (file.type || '').toLowerCase();
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('audio/')) return 'voice';
if (mime.startsWith('video/')) return 'video';
const name = file.name || '';
if (looksLikeVoiceAsset(name) || mime.startsWith('audio/')) return 'voice';
if (mime.startsWith('image/') || IMAGE_EXT_RE.test(name)) return 'image';
// Audio-only MediaRecorder often reports video/webm / video/ogg
if (
mime.startsWith('video/webm') ||
mime.startsWith('video/ogg') ||
(mime.startsWith('video/') && AUDIO_EXT_RE.test(name) && !looksLikeVideo(name))
) {
return 'voice';
}
if (mime.startsWith('video/') || looksLikeVideo(name)) return 'video';
if (AUDIO_EXT_RE.test(name)) return 'voice';
return 'file';
}
/** Chat backend (Hamkar-compatible) accepts text|image|voice|file — not video. */
function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
return classifyChatFile(file);
}
/**
* Chat backend accepts text|image|voice reliably.
* Real video is sent as `file` (path + .mp4 name) — `video` is dropped; `image` can also fail for large binaries.
*/
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
if (type === 'image') return 'image';
if (type === 'video') return 'file';
if (type === 'voice') return 'voice';
if (type === 'video' || type === 'file') return 'file';
if (type === 'file') return 'file';
return 'text';
}
@@ -283,10 +310,15 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
type:
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
? 'video'
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
@@ -316,10 +348,15 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
...prev,
...incoming,
optimistic: false,
type:
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
? 'video'
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
localPreviewUrl: prev.localPreviewUrl,
uploadProgress: undefined,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
@@ -703,13 +740,21 @@ export function useOrgChat() {
const id = opts?.tempId || crypto.randomUUID();
const reply = opts?.reply ?? null;
const wsType = wsMediaType(type);
const mediaUrl = buildChatFileUrl(filePath, session.downloadUrl);
let filePathHint = filePath;
if (
(type === 'video' || type === 'voice') &&
!/\.[a-z0-9]{2,5}(?:\?|$)/i.test(filePathHint)
) {
const ext = opts?.fileName?.match(/(\.[a-z0-9]{2,5})$/i)?.[1];
if (ext) filePathHint = `${filePathHint}${ext}`;
}
const mediaUrl = buildChatFileUrl(filePathHint, session.downloadUrl);
const payload: Record<string, string> = {
id,
type: wsType,
room_id: roomId,
content: filePath,
content: filePathHint,
};
if (reply) payload.reply = reply;
if (opts?.fileName) payload.file_name = opts.fileName;
@@ -719,7 +764,7 @@ export function useOrgChat() {
const next = [...messages.value];
next[existingIdx] = {
...next[existingIdx],
content: filePath,
content: filePathHint,
type,
mediaUrl,
fileName: opts?.fileName,
@@ -733,7 +778,7 @@ export function useOrgChat() {
id,
roomId,
senderId: session.chatUserId,
content: filePath,
content: filePathHint,
type,
createdAt: new Date().toISOString(),
reply,
@@ -748,7 +793,11 @@ export function useOrgChat() {
emitRoomLive({
roomId,
preview: previewLabelForMessage({ type, content: filePath, fileName: opts?.fileName }),
preview: previewLabelForMessage({
type,
content: filePathHint,
fileName: opts?.fileName,
}),
at: new Date().toISOString(),
fromSelf: true,
});
@@ -807,7 +856,13 @@ export function useOrgChat() {
try {
setProgress(5);
const fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
if (type === 'video' && !looksLikeVideo(fileForUpload.name)) {
const base = fileForUpload.name.replace(/\.[^.]+$/, '') || 'video';
fileForUpload = new File([fileForUpload], `${base}.mp4`, {
type: fileForUpload.type || 'video/mp4',
});
}
setProgress(12);
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
setProgress(12 + Math.round(p * 0.86));
@@ -815,7 +870,7 @@ export function useOrgChat() {
setProgress(99);
await sendMediaPath(roomId, uploadedPath, type, {
reply,
fileName: file.name,
fileName: fileForUpload.name || file.name,
localPreviewUrl,
tempId: id,
});