mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(api): route all image uploads through mana-media for CAS, thumbnails & Photos gallery
Picture, Contacts, Planta, Storage, and NutriPhi image uploads now go through mana-media instead of directly to S3. This enables SHA-256 deduplication, automatic thumbnail generation, EXIF extraction, and makes all images visible in the Photos gallery. Non-image files (PDFs, audio, docs) continue to use shared-storage directly. SVG avatars in Contacts also stay on shared-storage since Sharp can't process SVGs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46dae20fa3
commit
502813f49c
10 changed files with 238 additions and 213 deletions
|
|
@ -10,6 +10,7 @@
|
|||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/media-client": "workspace:*",
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
|
|
|
|||
40
apps/api/src/lib/media.ts
Normal file
40
apps/api/src/lib/media.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Shared media helper — routes image uploads through mana-media
|
||||
* for CAS deduplication, thumbnail generation, and Photos gallery visibility.
|
||||
*/
|
||||
|
||||
import { MediaClient, type MediaResult } from '@manacore/media-client';
|
||||
|
||||
const MEDIA_URL = process.env.MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
let client: MediaClient | null = null;
|
||||
|
||||
function getMediaClient(): MediaClient {
|
||||
if (!client) client = new MediaClient(MEDIA_URL);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function uploadImageToMedia(
|
||||
buffer: ArrayBuffer,
|
||||
filename: string,
|
||||
options: { app: string; userId: string }
|
||||
): Promise<MediaResult> {
|
||||
return getMediaClient().upload(buffer, {
|
||||
app: options.app,
|
||||
userId: options.userId,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMediaUrls(mediaId: string) {
|
||||
const c = getMediaClient();
|
||||
return {
|
||||
original: c.getOriginalUrl(mediaId),
|
||||
thumbnail: c.getThumbnailUrl(mediaId),
|
||||
medium: c.getMediumUrl(mediaId),
|
||||
large: c.getLargeUrl(mediaId),
|
||||
};
|
||||
}
|
||||
|
||||
export function isImageMimeType(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/') && mimeType !== 'image/svg+xml';
|
||||
}
|
||||
|
|
@ -29,22 +29,37 @@ routes.post('/:id/avatar', async (c) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const { createContactsStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
);
|
||||
const storage = createContactsStorage();
|
||||
const key = generateUserFileKey(
|
||||
userId,
|
||||
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`
|
||||
);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
const result = await storage.upload(key, buffer, {
|
||||
contentType: getContentType(file.name),
|
||||
public: true,
|
||||
});
|
||||
if (file.type === 'image/svg+xml') {
|
||||
// SVGs stay on shared-storage (Sharp can't process SVG)
|
||||
const { createContactsStorage, generateUserFileKey } = await import(
|
||||
'@manacore/shared-storage'
|
||||
);
|
||||
const storage = createContactsStorage();
|
||||
const key = generateUserFileKey(userId, `avatar-${c.req.param('id')}.svg`);
|
||||
const result = await storage.upload(key, Buffer.from(buffer), {
|
||||
contentType: 'image/svg+xml',
|
||||
public: true,
|
||||
});
|
||||
return c.json({ avatarUrl: result.url }, 201);
|
||||
}
|
||||
|
||||
return c.json({ avatarUrl: result.url }, 201);
|
||||
// Raster images -> mana-media for dedup, thumbnails & Photos gallery
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const result = await uploadImageToMedia(
|
||||
buffer,
|
||||
`avatar-${c.req.param('id')}.${file.name.split('.').pop()}`,
|
||||
{ app: 'contacts', userId }
|
||||
);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
avatarUrl: result.urls.thumbnail || result.urls.original,
|
||||
mediaId: result.id,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch {
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,15 @@ const routes = new Hono();
|
|||
// ─── Photo Analysis (server-only: Gemini Vision) ────────────
|
||||
|
||||
routes.post('/analysis/photo', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { imageBase64, mimeType } = await c.req.json();
|
||||
if (!imageBase64) return c.json({ error: 'imageBase64 required' }, 400);
|
||||
|
||||
const mime = mimeType || 'image/jpeg';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
// Run AI analysis and mana-media upload in parallel
|
||||
const analysisPromise = fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -41,7 +45,7 @@ routes.post('/analysis/photo', async (c) => {
|
|||
{ type: 'text', text: 'Analysiere diese Mahlzeit.' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${mimeType || 'image/jpeg'};base64,${imageBase64}` },
|
||||
image_url: { url: `data:${mime};base64,${imageBase64}` },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -52,12 +56,29 @@ routes.post('/analysis/photo', async (c) => {
|
|||
}),
|
||||
});
|
||||
|
||||
// Store meal photo in mana-media for Photos gallery & persistence
|
||||
const ext = mime.split('/')[1] || 'jpg';
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = Uint8Array.from(atob(imageBase64), (ch) => ch.charCodeAt(0));
|
||||
const mediaPromise = uploadImageToMedia(buffer.buffer, `meal-${Date.now()}.${ext}`, {
|
||||
app: 'nutriphi',
|
||||
userId,
|
||||
}).catch(() => null); // Don't fail analysis if media upload fails
|
||||
|
||||
const [res, mediaResult] = await Promise.all([analysisPromise, mediaPromise]);
|
||||
|
||||
if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502);
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
const analysis = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
|
||||
// Attach media info so the frontend can store photoMediaId on the meal
|
||||
if (mediaResult) {
|
||||
analysis.mediaId = mediaResult.id;
|
||||
analysis.photoUrl = mediaResult.urls.thumbnail || mediaResult.urls.original;
|
||||
}
|
||||
|
||||
return c.json(analysis);
|
||||
} catch (err) {
|
||||
console.error('Photo analysis failed:', err);
|
||||
|
|
|
|||
|
|
@ -98,7 +98,27 @@ routes.post('/generate', async (c) => {
|
|||
|
||||
await consumeCredits(userId, 'AI_IMAGE_GENERATION', cost, `Image: ${prompt.slice(0, 50)}`);
|
||||
|
||||
return c.json({ imageUrl, prompt, model: model || 'flux-schnell' });
|
||||
// Store generated image in mana-media for dedup, thumbnails & Photos gallery
|
||||
try {
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const imgRes = await fetch(imageUrl);
|
||||
const imgBuffer = await imgRes.arrayBuffer();
|
||||
const media = await uploadImageToMedia(imgBuffer, `generated-${Date.now()}.png`, {
|
||||
app: 'picture',
|
||||
userId,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
imageUrl: media.urls.original,
|
||||
mediaId: media.id,
|
||||
thumbnailUrl: media.urls.thumbnail,
|
||||
prompt,
|
||||
model: model || 'flux-schnell',
|
||||
});
|
||||
} catch {
|
||||
// Fallback: return raw imageUrl if mana-media is unavailable
|
||||
return c.json({ imageUrl, prompt, model: model || 'flux-schnell' });
|
||||
}
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Generation failed' }, 500);
|
||||
}
|
||||
|
|
@ -115,19 +135,19 @@ routes.post('/upload', async (c) => {
|
|||
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'Max 10MB' }, 400);
|
||||
|
||||
try {
|
||||
const { createPictureStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await uploadImageToMedia(buffer, file.name, { app: 'picture', userId });
|
||||
|
||||
return c.json(
|
||||
{
|
||||
storagePath: result.id,
|
||||
publicUrl: result.urls.original,
|
||||
mediaId: result.id,
|
||||
thumbnailUrl: result.urls.thumbnail,
|
||||
},
|
||||
201
|
||||
);
|
||||
const storage = createPictureStorage();
|
||||
const key = generateUserFileKey(userId, file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const result = await storage.upload(key, buffer, {
|
||||
contentType: getContentType(file.name),
|
||||
public: true,
|
||||
});
|
||||
|
||||
return c.json({ storagePath: key, publicUrl: result.url }, 201);
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,19 +24,19 @@ routes.post('/photos/upload', async (c) => {
|
|||
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400);
|
||||
|
||||
try {
|
||||
const { createPlantaStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await uploadImageToMedia(buffer, file.name, { app: 'planta', userId });
|
||||
|
||||
return c.json(
|
||||
{
|
||||
storagePath: result.id,
|
||||
publicUrl: result.urls.original,
|
||||
mediaId: result.id,
|
||||
plantId,
|
||||
},
|
||||
201
|
||||
);
|
||||
const storage = createPlantaStorage();
|
||||
const key = generateUserFileKey(userId, file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const result = await storage.upload(key, buffer, {
|
||||
contentType: getContentType(file.name),
|
||||
public: true,
|
||||
});
|
||||
|
||||
return c.json({ storagePath: key, publicUrl: result.url, plantId }, 201);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
|
|
|
|||
|
|
@ -22,14 +22,37 @@ routes.post('/files/upload', async (c) => {
|
|||
if (file.size > 100 * 1024 * 1024) return c.json({ error: 'Max 100MB' }, 400);
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const { isImageMimeType } = await import('../../lib/media');
|
||||
|
||||
// Images -> mana-media for dedup, thumbnails & Photos gallery
|
||||
if (isImageMimeType(file.type)) {
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const result = await uploadImageToMedia(buffer, file.name, { app: 'storage', userId });
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
storagePath: result.id,
|
||||
storageKey: result.id,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
parentFolderId: folderId,
|
||||
mediaId: result.id,
|
||||
},
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
// Non-images -> shared-storage as before
|
||||
const { createStorageStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
);
|
||||
const storage = createStorageStorage();
|
||||
const key = generateUserFileKey(userId, file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
await storage.upload(key, buffer, {
|
||||
await storage.upload(key, Buffer.from(buffer), {
|
||||
contentType: getContentType(file.name),
|
||||
public: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export interface LocalMeal extends BaseRecord {
|
|||
portionSize?: string | null;
|
||||
confidence: number;
|
||||
nutrition?: NutritionData | null;
|
||||
photoMediaId?: string | null;
|
||||
photoUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalGoal extends BaseRecord {
|
||||
|
|
@ -65,5 +67,7 @@ export interface MealWithNutrition {
|
|||
portionSize?: string | null;
|
||||
confidence: number;
|
||||
nutrition: NutritionData | null;
|
||||
photoMediaId?: string | null;
|
||||
photoUrl?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue