feat(notes): support vidéo + transcodage audio AAC universel
Audio : ffmpeg transcode toute entrée (webm/ogg/m4a) vers AAC/m4a au moment de l'upload → lecture Safari iOS garantie. Vidéo : nouveau save_video(), webm transcodé en H.264/mp4, mp4/quicktime stocké directement. Lecteur <video> inline dans NoteCard. Frontend : - Bouton vidéo (fa-video) dans les actions de chaque note - Icônes fa-image / fa-microphone / fa-video / fa-location-dot dans la méta - Filtres rapides : Photo / Audio / Vidéo / GPS (avec icônes fa) - Boutons actions migrés vers icônes Font Awesome - client_max_body_size nginx : 15m → 200m pour les vidéos v0.5.4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc postgresql-client \
|
||||
libpq-dev gcc postgresql-client ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.core.database import get_session
|
||||
from app.core.redis import enqueue
|
||||
from app.models.notes import NoteItem, NoteAttachment
|
||||
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
|
||||
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES
|
||||
from app.services.media import save_image, save_audio, save_video, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES, ALLOWED_VIDEO_TYPES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,6 +21,7 @@ async def list_notes(
|
||||
tag: str | None = Query(default=None),
|
||||
has_photo: bool | None = Query(default=None),
|
||||
has_audio: bool | None = Query(default=None),
|
||||
has_video: bool | None = Query(default=None),
|
||||
has_gps: bool | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
@@ -55,15 +56,11 @@ async def list_notes(
|
||||
notes = result.scalars().all()
|
||||
|
||||
if has_photo is not None:
|
||||
notes = [
|
||||
n for n in notes
|
||||
if has_photo == any(a.file_type == "image" for a in n.attachments)
|
||||
]
|
||||
notes = [n for n in notes if has_photo == any(a.file_type == "image" for a in n.attachments)]
|
||||
if has_audio is not None:
|
||||
notes = [
|
||||
n for n in notes
|
||||
if has_audio == any(a.file_type == "audio" for a in n.attachments)
|
||||
]
|
||||
notes = [n for n in notes if has_audio == any(a.file_type == "audio" for a in n.attachments)]
|
||||
if has_video is not None:
|
||||
notes = [n for n in notes if has_video == any(a.file_type == "video" for a in n.attachments)]
|
||||
|
||||
return notes
|
||||
|
||||
@@ -137,6 +134,9 @@ async def add_attachment(
|
||||
elif ct in ALLOWED_AUDIO_PREFIXES:
|
||||
media = await save_audio(file)
|
||||
file_type = "audio"
|
||||
elif ct in ALLOWED_VIDEO_TYPES:
|
||||
media = await save_video(file)
|
||||
file_type = "video"
|
||||
else:
|
||||
raise HTTPException(400, f"Type non supporté : {file.content_type}")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -13,7 +14,8 @@ ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml",
|
||||
"image/heic", "image/heif",
|
||||
}
|
||||
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a"}
|
||||
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a", "audio/aac"}
|
||||
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-m4v", "video/3gpp"}
|
||||
|
||||
MAX_ORIG_SIZE = (500, 500)
|
||||
|
||||
@@ -48,7 +50,6 @@ async def save_image(file: UploadFile, context: str = "note") -> dict:
|
||||
orig_path = orig_dir / f"{file_id}.webp"
|
||||
img = Image.open(io.BytesIO(content)).convert("RGB")
|
||||
|
||||
# Redimensionne l'original à 500×500 max en conservant l'aspect ratio
|
||||
img.thumbnail(MAX_ORIG_SIZE, Image.LANCZOS)
|
||||
img.save(orig_path, "WEBP", quality=85)
|
||||
|
||||
@@ -78,18 +79,79 @@ async def save_audio(file: UploadFile) -> dict:
|
||||
audio_dir = UPLOAD_DIR / "audio"
|
||||
audio_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = ".webm" if "webm" in (file.content_type or "") else ".m4a"
|
||||
audio_path = audio_dir / f"{file_id}{ext}"
|
||||
audio_path.write_bytes(await file.read())
|
||||
raw_ext = ".ogg" if "ogg" in ct else (".webm" if "webm" in ct else ".m4a")
|
||||
raw_path = audio_dir / f"{file_id}_raw{raw_ext}"
|
||||
raw_path.write_bytes(await file.read())
|
||||
|
||||
# Transcode vers AAC/mp4 pour lecture universelle (Safari iOS, Chrome, Firefox)
|
||||
out_path = audio_dir / f"{file_id}.m4a"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-i", str(raw_path),
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
if out_path.exists():
|
||||
raw_path.unlink(missing_ok=True)
|
||||
final_path = out_path
|
||||
else:
|
||||
# ffmpeg indisponible ou échec — conserver le fichier brut
|
||||
final_path = audio_dir / f"{file_id}{raw_ext}"
|
||||
raw_path.rename(final_path)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"file_path": str(audio_path.relative_to(UPLOAD_DIR)),
|
||||
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
|
||||
"thumbnail_path": None,
|
||||
"file_type": "audio",
|
||||
}
|
||||
|
||||
|
||||
async def save_video(file: UploadFile) -> dict:
|
||||
ct = (file.content_type or "").lower().split(";")[0].strip()
|
||||
if ct not in ALLOWED_VIDEO_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Format vidéo non supporté : {file.content_type}")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
video_dir = UPLOAD_DIR / "videos"
|
||||
video_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content = await file.read()
|
||||
|
||||
if "webm" in ct:
|
||||
# Transcode webm → H.264/mp4 pour Safari iOS
|
||||
raw_path = video_dir / f"{file_id}_raw.webm"
|
||||
raw_path.write_bytes(content)
|
||||
out_path = video_dir / f"{file_id}.mp4"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-i", str(raw_path),
|
||||
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.communicate()
|
||||
if out_path.exists():
|
||||
raw_path.unlink(missing_ok=True)
|
||||
final_path = out_path
|
||||
else:
|
||||
final_path = video_dir / f"{file_id}.webm"
|
||||
raw_path.rename(final_path)
|
||||
else:
|
||||
# mp4/quicktime : déjà compatible, stockage direct
|
||||
final_path = video_dir / f"{file_id}.mp4"
|
||||
final_path.write_bytes(content)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
|
||||
"thumbnail_path": None,
|
||||
"file_type": "video",
|
||||
}
|
||||
|
||||
|
||||
def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None:
|
||||
(UPLOAD_DIR / file_path).unlink(missing_ok=True)
|
||||
if thumbnail_path:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 15m;
|
||||
client_max_body_size 200m;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface NoteAttachment {
|
||||
id: string
|
||||
file_path: string | null
|
||||
thumbnail_path: string | null
|
||||
file_type: 'image' | 'audio' | null
|
||||
file_type: 'image' | 'audio' | 'video' | null
|
||||
original_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface NoteFilters {
|
||||
tag?: string
|
||||
has_photo?: boolean
|
||||
has_audio?: boolean
|
||||
has_video?: boolean
|
||||
has_gps?: boolean
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ export async function fetchNotes(filters: NoteFilters = {}): Promise<Note[]> {
|
||||
if (filters.tag) params.set('tag', filters.tag)
|
||||
if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo))
|
||||
if (filters.has_audio !== undefined) params.set('has_audio', String(filters.has_audio))
|
||||
if (filters.has_video !== undefined) params.set('has_video', String(filters.has_video))
|
||||
if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps))
|
||||
const res = await fetch(`${BASE}/?${params}`)
|
||||
if (!res.ok) throw new Error('Erreur chargement notes')
|
||||
|
||||
@@ -21,22 +21,25 @@ function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt }: {
|
||||
function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, onDeleteAtt }: {
|
||||
note: Note
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAddPhoto: (file: File) => void
|
||||
onAddAudio: (file: File) => void
|
||||
onAddVideo: (file: File) => void
|
||||
onDeleteAtt: (attId: string) => void
|
||||
}) {
|
||||
const photoRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLInputElement>(null)
|
||||
const videoRef = useRef<HTMLInputElement>(null)
|
||||
const [recording, setRecording] = useState(false)
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
|
||||
const images = note.attachments.filter(a => a.file_type === 'image')
|
||||
const audios = note.attachments.filter(a => a.file_type === 'audio')
|
||||
const videos = note.attachments.filter(a => a.file_type === 'video')
|
||||
|
||||
async function startRecord() {
|
||||
try {
|
||||
@@ -116,6 +119,26 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vidéos */}
|
||||
{videos.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{videos.map(vid => (
|
||||
<div key={vid.id} style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={`/media/${vid.file_path}`}
|
||||
controls
|
||||
playsInline
|
||||
style={{ width: '100%', maxHeight: 220, borderRadius: 6, background: '#000', display: 'block' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onDeleteAtt(vid.id)}
|
||||
style={{ position: 'absolute', top: 6, right: 6, background: 'rgba(0,0,0,0.65)', border: 'none', color: '#fff', borderRadius: '50%', width: 22, height: 22, cursor: 'pointer', fontSize: 11, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Méta */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center', ...noSelect }}>
|
||||
<span style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatDate(note.created_at)}</span>
|
||||
@@ -125,7 +148,12 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
|
||||
{note.tags.map(t => (
|
||||
<span key={t} style={{ background: 'var(--bg-5)', color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)', borderRadius: 999, padding: '1px 7px' }}>{t}</span>
|
||||
))}
|
||||
{note.gps_lat != null && <i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat?.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />}
|
||||
{images.length > 0 && <i className="fa-solid fa-image" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${images.length} photo(s)`} />}
|
||||
{audios.length > 0 && <i className="fa-solid fa-microphone" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${audios.length} audio(s)`} />}
|
||||
{videos.length > 0 && <i className="fa-solid fa-video" style={{ color: 'var(--ink-4)', fontSize: 11 }} title={`${videos.length} vidéo(s)`} />}
|
||||
{note.gps_lat != null && (
|
||||
<i className="fa-solid fa-location-dot" style={{ color: 'var(--ok)', fontSize: 12 }} title={`${note.gps_lat.toFixed(4)}, ${note.gps_lon?.toFixed(4)}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -135,24 +163,31 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt
|
||||
onClick={() => photoRef.current?.click()}
|
||||
title="Ajouter une photo"
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
>📷</button>
|
||||
><i className="fa-solid fa-camera" /></button>
|
||||
|
||||
<input ref={audioRef} type="file" accept="audio/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={recording ? stopRecord : startRecord}
|
||||
title={recording ? 'Arrêter l\'enregistrement' : 'Enregistrer un audio'}
|
||||
title={recording ? "Arrêter l'enregistrement" : 'Enregistrer un audio'}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: recording ? 'var(--err)' : 'var(--bg-4)', color: recording ? '#fff' : 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
>{recording ? '⏹ Stop' : '🎤'}</button>
|
||||
><i className={`fa-solid fa-${recording ? 'stop' : 'microphone'}`} /></button>
|
||||
|
||||
<input ref={videoRef} type="file" accept="video/*" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} />
|
||||
<button
|
||||
onClick={() => videoRef.current?.click()}
|
||||
title="Ajouter une vidéo"
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }}
|
||||
><i className="fa-solid fa-video" /></button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={onEdit}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'var(--bg-5)', color: 'var(--ink-2)', cursor: 'pointer', fontSize: 13 }}
|
||||
>✏️</button>
|
||||
><i className="fa-solid fa-pen" /></button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{ padding: '4px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
|
||||
>✕</button>
|
||||
><i className="fa-solid fa-xmark" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -248,6 +283,15 @@ export default function NotesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddVideo(noteId: string, file: File) {
|
||||
try {
|
||||
await addAttachment(noteId, file)
|
||||
void load()
|
||||
} catch {
|
||||
setError('Erreur upload vidéo')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAtt(noteId: string, attId: string) {
|
||||
try {
|
||||
await deleteAttachment(noteId, attId)
|
||||
@@ -257,7 +301,7 @@ export default function NotesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_gps
|
||||
const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_video || filters.has_gps
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
@@ -277,28 +321,36 @@ export default function NotesPage() {
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
{/* Filtres rapides */}
|
||||
{(['📷 Photo', '🎤 Audio', '📍 GPS'] as const).map((label, i) => {
|
||||
const key = ['has_photo', 'has_audio', 'has_gps'][i] as keyof NoteFilters
|
||||
{([
|
||||
{ key: 'has_photo', icon: 'fa-image', label: 'Photo' },
|
||||
{ key: 'has_audio', icon: 'fa-microphone', label: 'Audio' },
|
||||
{ key: 'has_video', icon: 'fa-video', label: 'Vidéo' },
|
||||
{ key: 'has_gps', icon: 'fa-location-dot', label: 'GPS' },
|
||||
] as const).map(({ key, icon, label }) => {
|
||||
const active = filters[key] === true
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
key={key}
|
||||
onClick={() => setFilters(f => ({ ...f, [key]: active ? undefined : true }))}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 999, border: 'none',
|
||||
padding: '5px 10px', borderRadius: 999, border: 'none',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-3)',
|
||||
color: active ? '#1d2021' : 'var(--ink-3)',
|
||||
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
...noSelect,
|
||||
}}
|
||||
>{label}</button>
|
||||
>
|
||||
<i className={`fa-solid ${icon}`} style={{ fontSize: 11 }} />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_gps: undefined }))}
|
||||
onClick={() => setFilters(f => ({ ...f, has_photo: undefined, has_audio: undefined, has_video: undefined, has_gps: undefined }))}
|
||||
style={{ padding: '5px 10px', borderRadius: 999, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 12, ...noSelect }}
|
||||
>✕ Filtres</button>
|
||||
><i className="fa-solid fa-xmark" /> Filtres</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -345,6 +397,7 @@ export default function NotesPage() {
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onAddVideo={f => void handleAddVideo(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
@@ -365,6 +418,7 @@ export default function NotesPage() {
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
onAddPhoto={f => void handleAddPhoto(note.id, f)}
|
||||
onAddAudio={f => void handleAddAudio(note.id, f)}
|
||||
onAddVideo={f => void handleAddVideo(note.id, f)}
|
||||
onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user