Skip to content

Commit c7c280a

Browse files
committed
fix: address PR#5 review comments
1 parent b636b42 commit c7c280a

4 files changed

Lines changed: 96 additions & 28 deletions

File tree

apps/webuiapps/src/components/ChatPanel/CharacterPanel.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@ import {
77
generateCharacterId,
88
getCharacterList,
99
} from '@/lib/characterManager';
10-
import { deleteCharacterAsset } from '@/lib/characterAssetUpload';
10+
import { deleteCharacterAsset, isVideoAssetUrl } from '@/lib/characterAssetUpload';
1111
import { useResolvedAssetUrl } from '@/hooks/useResolvedAssetUrl';
1212
import ImageUploader from './ImageUploader';
1313
import styles from './panel.module.scss';
1414

15-
const VIDEO_REGEX = /\.(mp4|webm|mov|ogg)(\?|$)/i;
16-
1715
async function deleteReplacedCharacterAsset(
1816
previousUrl: string | undefined,
1917
nextUrl: string,
@@ -42,7 +40,7 @@ const CharacterImagePreview: React.FC<{ url: string; name: string }> = ({ url, n
4240
const EmotionAssetPreview: React.FC<{ url?: string; emotion: string }> = ({ url, emotion }) => {
4341
const resolvedUrl = useResolvedAssetUrl(url);
4442
if (!url || !resolvedUrl) return null;
45-
return VIDEO_REGEX.test(url) ? (
43+
return isVideoAssetUrl(url) ? (
4644
<video src={resolvedUrl} className={styles.emotionThumb} autoPlay loop muted playsInline />
4745
) : (
4846
<img src={resolvedUrl} alt={emotion} className={styles.emotionThumb} />
@@ -203,18 +201,9 @@ const CharacterEditor: React.FC<{
203201
const [desc, setDesc] = useState(character.character_desc);
204202
const [imageUrl, setImageUrl] = useState(character.character_meta_info?.base_image_url || '');
205203
const [emotions, setEmotions] = useState<string[]>([...character.character_emotion_list]);
206-
const [emotionImages, setEmotionImages] = useState<Record<string, string>>(() => {
207-
const images: Record<string, string> = { ...character.character_meta_info?.emotion_images };
208-
const videos = character.character_meta_info?.emotion_videos;
209-
if (videos) {
210-
for (const [emotion, urls] of Object.entries(videos)) {
211-
if (!images[emotion] && urls?.length) {
212-
images[emotion] = urls[0];
213-
}
214-
}
215-
}
216-
return images;
217-
});
204+
const [emotionImages, setEmotionImages] = useState<Record<string, string>>(() => ({
205+
...character.character_meta_info?.emotion_images,
206+
}));
218207
const [emotionVideos, setEmotionVideos] = useState<Record<string, string[]>>(() => ({
219208
...character.character_meta_info?.emotion_videos,
220209
}));
@@ -273,8 +262,24 @@ const CharacterEditor: React.FC<{
273262
setEmotions([...CHARACTER_EMOTION_LIST]);
274263
};
275264

276-
const updateEmotionImage = (emotion: string, url: string) => {
277-
setEmotionImages({ ...emotionImages, [emotion]: url });
265+
const updateEmotionAssetUrl = (emotion: string, url: string) => {
266+
const trimmedUrl = url.trim();
267+
const updatedImages = { ...emotionImages };
268+
const updatedVideos = { ...emotionVideos };
269+
270+
delete updatedImages[emotion];
271+
delete updatedVideos[emotion];
272+
273+
if (trimmedUrl) {
274+
if (isVideoAssetUrl(trimmedUrl)) {
275+
updatedVideos[emotion] = [trimmedUrl];
276+
} else {
277+
updatedImages[emotion] = trimmedUrl;
278+
}
279+
}
280+
281+
setEmotionImages(updatedImages);
282+
setEmotionVideos(updatedVideos);
278283
};
279284

280285
const handleSave = () => {
@@ -420,8 +425,8 @@ const CharacterEditor: React.FC<{
420425
</div>
421426
<input
422427
className={styles.input}
423-
value={emotionImages[e] || ''}
424-
onChange={(ev) => updateEmotionImage(e, ev.target.value)}
428+
value={getEmotionAssetUrl(e) || ''}
429+
onChange={(ev) => updateEmotionAssetUrl(e, ev.target.value)}
425430
placeholder={`Image/Video URL for "${e}" (optional)`}
426431
/>
427432
</div>

apps/webuiapps/src/components/ChatPanel/ImageUploader.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import {
44
uploadCharacterAsset,
55
getCharacterAssetUrl,
66
isExternalOrDataUrl,
7+
isVideoAssetUrl,
78
} from '@/lib/characterAssetUpload';
89
import styles from './panel.module.scss';
910

10-
const VIDEO_REGEX = /\.(mp4|webm|mov|ogg)(\?|$)/i;
11-
1211
interface ImageUploaderProps {
1312
characterId: string;
1413
emotion: string;
@@ -43,7 +42,7 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
4342
}
4443

4544
let cancelled = false;
46-
const isVid = VIDEO_REGEX.test(currentUrl);
45+
const isVid = isVideoAssetUrl(currentUrl);
4746
setIsVideo(isVid);
4847

4948
if (isExternalOrDataUrl(currentUrl)) {
@@ -62,15 +61,15 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
6261
}, [currentUrl]);
6362

6463
const handleFile = async (file: File) => {
65-
const isVid = VIDEO_REGEX.test(file.name) || file.type.startsWith('video/');
64+
const isVid = file.type.startsWith('video/') || isVideoAssetUrl(file.name);
6665
setIsVideo(isVid);
6766
setUploading(true);
6867
setError(null);
6968
try {
7069
const type = isVid ? 'video' : 'image';
7170
const path = await uploadCharacterAsset(characterId, emotion, file, type);
7271
if (expectedUrlRef.current === path || !expectedUrlRef.current) {
73-
const isVidLocal = VIDEO_REGEX.test(path) || file.type.startsWith('video/');
72+
const isVidLocal = isVid;
7473
setIsVideo(isVidLocal);
7574
if (isExternalOrDataUrl(path)) {
7675
setPreviewUrl(path);

apps/webuiapps/src/lib/__tests__/characterAssetUpload.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
CHARACTER_VIDEO_MIME_TO_EXT,
1212
getCharacterAssetKind,
1313
getCharacterAssetUrl,
14+
isImageAssetUrl,
15+
isVideoAssetUrl,
1416
isLocalCharacterAssetPath,
1517
MAX_CHARACTER_VIDEO_BYTES,
1618
uploadCharacterAsset,
@@ -107,6 +109,40 @@ describe('characterAssetUpload', () => {
107109
expect(getCharacterAssetKind(path)).toBe(type);
108110
});
109111

112+
it('detects video asset URLs with query strings and hashes', () => {
113+
expect(isVideoAssetUrl('https://cdn.example.com/avatar.mp4?version=1')).toBe(true);
114+
expect(isVideoAssetUrl('https://cdn.example.com/avatar.webm#preview')).toBe(true);
115+
expect(isVideoAssetUrl('/characters/agent/emotions/idle.mov?token=abc#clip')).toBe(true);
116+
expect(isVideoAssetUrl('https://cdn.example.com/avatar.ogg?cache=bust')).toBe(true);
117+
expect(isVideoAssetUrl('https://cdn.example.com/avatar.ogv#loop')).toBe(true);
118+
expect(isVideoAssetUrl('https://cdn.example.com/avatar.png?format=webp')).toBe(false);
119+
});
120+
121+
it('detects image asset URLs with query strings and hashes', () => {
122+
expect(isImageAssetUrl('https://cdn.example.com/avatar.jpg?version=1')).toBe(true);
123+
expect(isImageAssetUrl('https://cdn.example.com/avatar.png#preview')).toBe(true);
124+
expect(isImageAssetUrl('https://cdn.example.com/avatar.jpeg?format=webp')).toBe(true);
125+
expect(isImageAssetUrl('https://cdn.example.com/avatar.webp')).toBe(true);
126+
expect(isImageAssetUrl('https://cdn.example.com/avatar.gif?v=2')).toBe(true);
127+
expect(isImageAssetUrl('https://cdn.example.com/avatar.mp4?token=abc')).toBe(false);
128+
});
129+
130+
it('detects image data: URLs', () => {
131+
expect(isImageAssetUrl('data:image/png;base64,iVBORw0KGgo=')).toBe(true);
132+
expect(isImageAssetUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe(true);
133+
expect(isImageAssetUrl('data:image/gif;base64,R0lGODlh')).toBe(true);
134+
});
135+
136+
it('detects video data: URLs', () => {
137+
expect(isVideoAssetUrl('data:video/mp4;base64,AAAAHGZ0eXBpc29tAA==')).toBe(true);
138+
expect(isVideoAssetUrl('data:video/webm;base64,AAAAHGZ0eXBpc29tAA==')).toBe(true);
139+
});
140+
141+
it('accepts .jpeg extension for external URLs', () => {
142+
expect(isImageAssetUrl('https://cdn.example.com/photo.jpeg')).toBe(true);
143+
expect(isImageAssetUrl('https://cdn.example.com/photo.jpg?size=large')).toBe(true);
144+
});
145+
110146
it('rejects unsupported MIME types before storage', async () => {
111147
await expect(
112148
uploadCharacterAsset('agent', 'happy', createFile('application/octet-stream'), 'image'),

apps/webuiapps/src/lib/characterAssetUpload.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@ export const CHARACTER_VIDEO_MIME_TO_EXT = {
2626

2727
const IMAGE_EXTENSIONS = new Set(Object.values(CHARACTER_IMAGE_MIME_TO_EXT));
2828
const VIDEO_EXTENSIONS = new Set(Object.values(CHARACTER_VIDEO_MIME_TO_EXT));
29-
const ALLOWED_CHARACTER_ASSET_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]);
29+
const ALLOWED_CHARACTER_ASSET_EXTENSIONS = new Set([
30+
...IMAGE_EXTENSIONS,
31+
...VIDEO_EXTENSIONS,
32+
'ogg',
33+
]);
3034
const LOCAL_CHARACTER_ASSET_PATH_PATTERN = new RegExp(
3135
`^${escapeRegExp(CHARACTER_ASSETS_PATH)}/([A-Za-z0-9_-]+)/emotions/([A-Za-z0-9_-]+)\\.([A-Za-z0-9]+)$`,
3236
);
3337
let fallbackUniqueAssetId = 0;
3438

39+
const CHARACTER_IMAGE_EXTENSIONS = new Set([...Object.values(CHARACTER_IMAGE_MIME_TO_EXT), 'jpeg']);
40+
const CHARACTER_VIDEO_EXTENSIONS = new Set([...Object.values(CHARACTER_VIDEO_MIME_TO_EXT), 'ogg']);
41+
3542
type CharacterAssetType = 'image' | 'video';
3643

3744
function sanitizePathComponent(input: string): string {
@@ -127,6 +134,24 @@ function parseLocalCharacterAssetPath(path?: string): { ext: string } | undefine
127134
return { ext };
128135
}
129136

137+
function getAssetUrlExtension(url?: string): string | undefined {
138+
const pathname = url?.trim()?.split(/[?#]/, 1)[0];
139+
const extension = pathname?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
140+
return extension;
141+
}
142+
143+
export function isVideoAssetUrl(url?: string): boolean {
144+
if (url?.startsWith('data:video/')) return true;
145+
const extension = getAssetUrlExtension(url);
146+
return !!extension && CHARACTER_VIDEO_EXTENSIONS.has(extension);
147+
}
148+
149+
export function isImageAssetUrl(url?: string): boolean {
150+
if (url?.startsWith('data:image/')) return true;
151+
const extension = getAssetUrlExtension(url);
152+
return !!extension && CHARACTER_IMAGE_EXTENSIONS.has(extension);
153+
}
154+
130155
export function isExternalOrDataUrl(path: string): boolean {
131156
return path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:');
132157
}
@@ -135,11 +160,14 @@ export function isLocalCharacterAssetPath(path?: string): path is string {
135160
return !!parseLocalCharacterAssetPath(path);
136161
}
137162

163+
type ImageExt = 'png' | 'jpg' | 'webp' | 'gif' | 'svg';
164+
type VideoExt = 'mp4' | 'webm' | 'ogv' | 'mov';
165+
138166
export function getCharacterAssetKind(path?: string): CharacterAssetType | undefined {
139167
const parsedPath = parseLocalCharacterAssetPath(path);
140168
if (!parsedPath) return undefined;
141-
if (IMAGE_EXTENSIONS.has(parsedPath.ext)) return 'image';
142-
if (VIDEO_EXTENSIONS.has(parsedPath.ext)) return 'video';
169+
if (IMAGE_EXTENSIONS.has(parsedPath.ext as ImageExt)) return 'image';
170+
if (VIDEO_EXTENSIONS.has(parsedPath.ext as VideoExt)) return 'video';
143171
return undefined;
144172
}
145173

0 commit comments

Comments
 (0)