Skip to content

Commit

Permalink
Feat/manage video media (naturalsolutions#24)
Browse files Browse the repository at this point in the history
* feat: media import with mime type check

* build: use of python-magic library

* fix: mutualization of repeated code

* feat: display of video thumbnails

* fix: rename gallery component

* feat: display and navigation in annotation page

* feat(annotation): prevent video autoplay

* style: formatting

* feat: display photo/video icon and name in gallery

* style: reorder import

* test: add test_check_mime

* feat: display thumbnail icon and name

* style: thumbnail display formatting

* test: modify check mime test

* test: modify lists by sets to test data unicity

* style(api): apply black

---------

Co-authored-by: Mathilde Leclerc <[email protected]>
  • Loading branch information
ophdlv and MathildeNS authored Sep 5, 2023
1 parent 3024962 commit ddd0ee8
Show file tree
Hide file tree
Showing 12 changed files with 787 additions and 642 deletions.
3 changes: 1 addition & 2 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ ENV LC_ALL C.UTF-8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONFAULTHANDLER 1

RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

RUN apt-get update && apt-get install -y libmagic1 && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

FROM base AS python-deps

Expand Down
1 change: 1 addition & 0 deletions api/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ exif = "*"
pillow = "*"
alembic = "*"
pydantic = {extras = ["dotenv"], version = "*"}
python-magic = "*"
fastapi-keycloak = "*"

[dev-packages]
Expand Down
1,075 changes: 519 additions & 556 deletions api/Pipfile.lock

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions api/src/routers/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import List
from zipfile import ZipFile

import magic
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from sqlmodel import Session
Expand All @@ -17,6 +18,7 @@
from src.models.file import CreateFiles, Files
from src.schemas.schemas import Annotation
from src.services import dependencies, files
from src.utils import check_mime, file_as_bytes

router = APIRouter(
prefix="/files",
Expand All @@ -39,11 +41,6 @@
# return db_file


def file_as_bytes(file):
with file:
return file.read()


@router.get("/")
def get_files(db: Session = Depends(get_db)):
List_files = files.get_files(db)
Expand Down Expand Up @@ -85,13 +82,19 @@ def extract_exif(file: UploadFile = File(...), db: Session = Depends(get_db)):
@router.post("/upload/{deployment_id}")
def upload_file(deployment_id: int, file: UploadFile = File(...), db: Session = Depends(get_db)):
hash = dependencies.generate_checksum(file)
ext = file.filename.split(".")[1]

mime = magic.from_buffer(file.file.read(), mime=True)
file.file.seek(0)

if not check_mime(mime):
raise HTTPException(status_code=400, detail="Invalid type file")

insert = files.upload_file(
db=db,
hash=hash,
new_file=file.file,
filename=file.filename,
ext=ext,
ext=mime,
deployment_id=deployment_id,
)
return insert
Expand Down
6 changes: 1 addition & 5 deletions api/src/services/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

# import schemas.schemas
from src.schemas.schemas import Annotation
from src.utils import file_as_bytes

# async def stockage_image(file):
# try :
Expand All @@ -27,11 +28,6 @@
# return {"message": f"Successfuly uploaded {file.filename}"}


def file_as_bytes(file):
with file:
return file.read()


def get_hash(file):
return hashlib.sha256(file_as_bytes(file)).hexdigest()

Expand Down
22 changes: 22 additions & 0 deletions api/src/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
def file_as_bytes(file):
with file:
return file.read()


# Authorized mime types
authorized_mime = [
"video/mp4",
"video/mpeg",
"video/ogg",
"video/webm",
"image/jpeg",
"image/png",
"image/bmp",
"image/gif",
"image/tiff",
"image/webp",
]


def check_mime(mime):
return mime in authorized_mime
41 changes: 41 additions & 0 deletions api/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import pytest

from src.utils import check_mime

all_authorized_mimes = {
"video/mp4",
"video/mpeg",
"video/ogg",
"video/webm",
"image/jpeg",
"image/png",
"image/bmp",
"image/gif",
"image/tiff",
"image/webp",
}

some_unauthorized_mimes = {
"audio/aac",
"video/x-msvideo",
"application/pdf",
"application/xml",
"application/vnd.mozilla.xul+xml",
"application/zip",
"video/3gpp2",
"audio/3gpp2",
"application/x-7z-compressed",
}


if not all_authorized_mimes.isdisjoint(some_unauthorized_mimes):
raise Exception(
"Value(s) shared between lists of authorized and unauthorized mime types. Tests cannot be performed."
)


@pytest.mark.parametrize("authorized", all_authorized_mimes)
@pytest.mark.parametrize("unauthorized", some_unauthorized_mimes)
def test_check_mime(authorized, unauthorized):
assert check_mime(authorized)
assert not check_mime(unauthorized)
116 changes: 116 additions & 0 deletions frontend/src/components/GalleryItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useNavigate } from "react-router-dom";
import { Grid, Tooltip, Typography } from "@mui/material";
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import VideocamIcon from '@mui/icons-material/Videocam';


const thumbnailStyle = {
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
display: "block",
width: "100%"
}

const GalleryItem = (
props
) => {

let navigate = useNavigate();

const displayMedia = (id: string) => {
navigate(`${id}`);
};

const displayImage = (item) => {
if (item.extension.includes("image")) {
return (
<img
src={ `${item.url}` }
alt={ item.name }
loading="lazy"
onClick={ () => displayMedia(item.id) }
style={ thumbnailStyle }
/>
)
}
else {
return(
<video
style={ thumbnailStyle }
onClick={ () => displayMedia(item.id) }
>
<source
src={ `${item.url}#t=1` } // t value can be ajusted to display a specific start time as video thumbnail
type="video/mp4"
/>
{ item.name }
</video>
)
}
};

const displayName = (name) => {
return(
<Tooltip title={ name } placement="bottom" arrow>
<Typography
noWrap
component={"span"}
variant="body2"
sx={{ width: "90%" }}
>
{ name }
</Typography>
</Tooltip>
)
}

const displayThumbnail = (item) => {
if (item.extension.includes("image")) {
return (
<>
{ displayImage(item) }
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<CameraAltIcon sx={{ color: "#616161E5", width: "10%" }}/>
{ displayName(item.name) }
</Grid>
</>
)
}
else {
return (
<>
{ displayImage(item) }
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<VideocamIcon sx={{ color: "#616161E5", width: "10%" }} />
{ displayName(item.name) }
</Grid>
</>
)
};
};

return(
<div
key={ props.index }
style={{
border: "2px solid",
borderRadius: "5px",
borderColor: props.item.treated ? "green" : "red"
}}
>
{ displayThumbnail(props.item) }
</div>
)
};

export default GalleryItem;
48 changes: 36 additions & 12 deletions frontend/src/components/annotationImageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,48 @@ import { Box, Grid, capitalize } from "@mui/material";
import AnnotationImageNavigation from "./annotationImageNavigation";
import { useTranslation } from "react-i18next";

const mediaDisplayStyle = {
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
display: "block",
height: "fit-content",
maxWidth: "100%",
};

const AnnotationImageDisplay = (
props
) => {
const { t } = useTranslation()
const [isAnnotedColor, setIsAnnoted] = useState<string>("")

const displayMedia = (image) => {
if (image.extension.includes("image")) {
return (
<img
src={ image.url }
alt={ image.name }
loading="lazy"
style={ mediaDisplayStyle }
/>
)
}
else {
return (
<video
src={ image.url }
style={ mediaDisplayStyle }
controls
autoPlay={ false }
>
<source
type="video/mp4"
/>
{ image.name }
</video>
)
}
};

useEffect(() => {
(async () => {
props.image?.treated? setIsAnnoted("green") : (props.isAnnoted ? setIsAnnoted("orange") : setIsAnnoted("red"))
Expand All @@ -26,18 +61,7 @@ const AnnotationImageDisplay = (
borderColor: isAnnotedColor,
marginTop: "2vh"
}}>
<img
src={`${props.image.url}`}
alt={props.image.name}
loading="lazy"
style={{
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
display: "block",
height: "fit-content",
maxWidth: "100%",
}}
/>
{displayMedia(props.image)}
</Box>
<div className="groupNumber">
<AnnotationImageNavigation
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/imageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FC, useEffect, useState } from "react";
import { useMainContext } from "../contexts/mainContext";
import "../css/first.css";

import ImageMasonry from "./masonry";
import MediaGallery from "./mediaGallery";
import Dropzone from "react-dropzone";
import { Grid, Stack, Typography, capitalize } from "@mui/material";
import { useParams } from "react-router-dom";
Expand Down Expand Up @@ -90,7 +90,7 @@ const ImageList: FC<{}> = () => {
noContent={capitalize(t("main.cancel"))}
/>
</Stack>
<ImageMasonry />
<MediaGallery />
</Stack>
) : (
<Stack>
Expand Down
Loading

0 comments on commit ddd0ee8

Please sign in to comment.