diff --git a/functions/package.json b/functions/package.json index e97885e..925223c 100644 --- a/functions/package.json +++ b/functions/package.json @@ -24,11 +24,13 @@ "exif-reader": "^1.0.3", "firebase-admin": "^10.0.2", "firebase-functions": "^3.18.0", + "fluent-ffmpeg": "^2.1.2", "mkdirp": "^1.0.4", "sharp": "^0.31.2" }, "devDependencies": { "@types/exif-reader": "^1.0.0", + "@types/fluent-ffmpeg": "^2.1.20", "@types/mkdirp": "^1.0.2", "@types/sharp": "^0.31.0", "@typescript-eslint/eslint-plugin": "^5.42.0", diff --git a/functions/src/storage/media/onFinalize.ts b/functions/src/storage/media/onFinalize.ts deleted file mode 100644 index 1c2910e..0000000 --- a/functions/src/storage/media/onFinalize.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { firestore, storage } from 'firebase-admin'; -import { logger, region } from 'firebase-functions'; - -import { zonedTimeToUtc } from 'date-fns-tz'; -import exifReader from 'exif-reader'; -import mkdirp from 'mkdirp'; -import sharp from 'sharp'; - -const { increment, serverTimestamp } = firestore.FieldValue; -const getZonedTime = (date?: any) => { - if (!date) return new Date(0); - return zonedTimeToUtc(date, 'Asia/Tokyo'); -}; - -export const processUploadedMedia = region('asia-northeast1') - .storage.object() - .onFinalize(async (object) => { - const { contentType, name } = object; - - if (!contentType) return logger.log('File has no Content-Type.'); - if (!contentType.startsWith('image/')) return logger.log('This is not an image.'); - if (!name) return logger.log('File has no file name.'); - const fileName = path.basename(name).split('.')[0]; - if (fileName === 'thumbnail') return logger.log('Already a Thumbnail.'); - - // Download original file from bucket. - const localFile = path.join(os.tmpdir(), name); - await mkdirp(path.dirname(localFile)); - const bucket = storage().bucket(); - await bucket.file(name).download({ destination: localFile }); - - // Read exif from original file - const exif = await sharp(localFile) - .metadata() - .then((metadata) => metadata.exif && exifReader(metadata.exif)); - - // Generate a thumbnail using sharp. - const destinationFileName = 'thumbnail.jpg'; - const destination = path.normalize(path.join(path.dirname(name), destinationFileName)); - const localThumbnail = path.join(os.tmpdir(), destination); - await sharp(localFile) - .resize(1000) - .toFormat('jpeg') - .toFile(localThumbnail) - .catch((error) => { - logger.error('Error occurred while processing thumbnail'); - throw new Error(error); - }); - - // Upload the thumbnail. - await bucket - .upload(localThumbnail, { destination }) - .then(() => logger.info('Thumbnail uploaded.')) - .catch((error) => { - logger.error('Error occurred while uploading thumbnail'); - throw new Error(error); - }); - - // Once the image has been uploaded delete the local files to free up disk space. - fs.unlinkSync(localFile); - fs.unlinkSync(localThumbnail); - - // Register gear if needed - const gearName = exif?.image?.Model; - if (!gearName) return logger.log('No model name.'); - const gearsRef = firestore().collection('gears'); - const gear = await gearsRef - .where('model', '==', gearName) - .limit(1) - .get() - .then((querySnapshot) => { - if (querySnapshot.empty) return null; - return querySnapshot.docs[0]; - }); - let gearId = gear?.id; - if (gear) { - await gear.ref - .set({ items: increment(1), updatedAt: serverTimestamp() }, { merge: true }) - .then(() => logger.info('Gear items has been incremented.')) - .catch((error) => { - logger.error('Error occurred while updating gear'); - throw new Error(error); - }); - } else { - await gearsRef - .add({ - items: increment(1), - maker: exif.image?.Make || null, - model: gearName, - name: gearName, - type: 'photo', - createdAt: serverTimestamp(), - updatedAt: serverTimestamp(), - }) - .then((ref) => { - logger.info('New gear has been added.'); - gearId = ref.id; - }) - .catch((error) => { - logger.error('Error occurred while creating gear'); - throw new Error(error); - }); - } - - // Update item - const mediaPath = path.dirname(name); - const itemPath = mediaPath.replace('media/', 'items/'); - const itemRef = firestore().doc(itemPath); - return itemRef - .set( - { - date: getZonedTime(exif?.exif?.DateTimeOriginal), - gearId, - medium: { - exif: { - ...exif, - exif: { - ...exif?.exif, - DateTimeDigitized: getZonedTime(exif?.exif?.DateTimeDigitized), - DateTimeOriginal: getZonedTime(exif?.exif?.DateTimeOriginal), - }, - image: { - ...exif?.image, - ModifyDate: getZonedTime(exif?.image?.ModifyDate), - }, - thumbnail: { - ...exif?.thumbnail, - ModifyDate: getZonedTime(exif?.thumbnail?.ModifyDate), - }, - }, - thumbnail: `${mediaPath}/${destinationFileName}`, - }, - updatedAt: serverTimestamp(), - }, - { merge: true } - ) - .then(() => logger.info('Item information updated.')) - .catch((error) => { - logger.error('Error occurred while updating thumbnail path'); - throw new Error(error); - }); - }); diff --git a/functions/src/storage/media/onFinalize/image.ts b/functions/src/storage/media/onFinalize/image.ts new file mode 100644 index 0000000..7e1231e --- /dev/null +++ b/functions/src/storage/media/onFinalize/image.ts @@ -0,0 +1,136 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { firestore, storage } from 'firebase-admin'; +import { logger } from 'firebase-functions'; + +import { zonedTimeToUtc } from 'date-fns-tz'; +import exifReader from 'exif-reader'; +import mkdirp from 'mkdirp'; +import sharp from 'sharp'; + +const { increment, serverTimestamp } = firestore.FieldValue; +const getZonedTime = (date?: any) => { + if (!date) return new Date(0); + return zonedTimeToUtc(date, 'Asia/Tokyo'); +}; + +export default async function processUploadedImage(name: string) { + // Download original file from bucket. + const localFile = path.join(os.tmpdir(), name); + await mkdirp(path.dirname(localFile)); + const bucket = storage().bucket(); + await bucket.file(name).download({ destination: localFile }); + + // Read exif from original file + const exif = await sharp(localFile) + .metadata() + .then((metadata) => metadata.exif && exifReader(metadata.exif)); + + // Generate a thumbnail using sharp. + const destinationFileName = 'thumbnail.jpg'; + const destination = path.normalize(path.join(path.dirname(name), destinationFileName)); + const localThumbnail = path.join(os.tmpdir(), destination); + await sharp(localFile) + .resize(1000) + .toFormat('jpeg') + .toFile(localThumbnail) + .catch((error) => { + logger.error('Error occurred while processing thumbnail'); + throw new Error(error); + }); + + // Upload the thumbnail. + await bucket + .upload(localThumbnail, { destination }) + .then(() => logger.info('Thumbnail uploaded.')) + .catch((error) => { + logger.error('Error occurred while uploading thumbnail'); + throw new Error(error); + }); + + // Once the image has been uploaded delete the local files to free up disk space. + fs.unlinkSync(localFile); + fs.unlinkSync(localThumbnail); + + // Register gear if needed + const gearName = exif?.image?.Model; + if (!gearName) return logger.log('No model name.'); + const gearsRef = firestore().collection('gears'); + const gear = await gearsRef + .where('model', '==', gearName) + .limit(1) + .get() + .then((querySnapshot) => { + if (querySnapshot.empty) return null; + return querySnapshot.docs[0]; + }); + let gearId = gear?.id; + if (gear) { + await gear.ref + .set({ items: increment(1), updatedAt: serverTimestamp() }, { merge: true }) + .then(() => logger.info('Gear items has been incremented.')) + .catch((error) => { + logger.error('Error occurred while updating gear'); + throw new Error(error); + }); + } else { + await gearsRef + .add({ + items: increment(1), + maker: exif.image?.Make || null, + model: gearName, + name: gearName, + type: 'photo', + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + .then((ref) => { + logger.info('New gear has been added.'); + gearId = ref.id; + }) + .catch((error) => { + logger.error('Error occurred while creating gear'); + throw new Error(error); + }); + } + + // Update item + const mediaPath = path.dirname(name); + const itemPath = mediaPath.replace('media/', 'items/'); + const itemRef = firestore().doc(itemPath); + return itemRef + .set( + { + date: getZonedTime(exif?.exif?.DateTimeOriginal), + gearId, + medium: { + metadata: { + ...exif, + exif: { + ...exif?.exif, + DateTimeDigitized: getZonedTime(exif?.exif?.DateTimeDigitized), + DateTimeOriginal: getZonedTime(exif?.exif?.DateTimeOriginal), + }, + image: { + ...exif?.image, + ModifyDate: getZonedTime(exif?.image?.ModifyDate), + }, + thumbnail: { + ...exif?.thumbnail, + ModifyDate: getZonedTime(exif?.thumbnail?.ModifyDate), + }, + }, + thumbnail: `${mediaPath}/${destinationFileName}`, + }, + updatedAt: serverTimestamp(), + }, + { merge: true } + ) + .then(() => logger.info('Item information updated.')) + .catch((error) => { + logger.error('Error occurred while updating thumbnail path'); + throw new Error(error); + }); +} diff --git a/functions/src/storage/media/onFinalize/index.ts b/functions/src/storage/media/onFinalize/index.ts new file mode 100644 index 0000000..9d5da59 --- /dev/null +++ b/functions/src/storage/media/onFinalize/index.ts @@ -0,0 +1,19 @@ +import * as path from 'path'; + +import { logger, region } from 'firebase-functions'; + +import processUploadedImage from './image'; +import processUploadedVideo from './video'; + +export const processUploadedMedia = region('asia-northeast1') + .storage.object() + .onFinalize(async ({ contentType, name }) => { + if (!contentType) return logger.log('File has no Content-Type.'); + if (!name) return logger.log('File has no file name.'); + const fileName = path.basename(name).split('.')[0]; + if (fileName === 'thumbnail') return logger.log('Already a Thumbnail.'); + + if (contentType.startsWith('image/')) return processUploadedImage(name); + if (contentType.startsWith('video/')) return processUploadedVideo(name); + return logger.log('This content is not supported.'); + }); diff --git a/functions/src/storage/media/onFinalize/video.ts b/functions/src/storage/media/onFinalize/video.ts new file mode 100644 index 0000000..d5f59fe --- /dev/null +++ b/functions/src/storage/media/onFinalize/video.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { firestore, storage } from 'firebase-admin'; +import { logger } from 'firebase-functions'; + +import { addHours } from 'date-fns'; +import { zonedTimeToUtc } from 'date-fns-tz'; +import ffmpeg from 'fluent-ffmpeg'; +import mkdirp from 'mkdirp'; +import sharp from 'sharp'; + +const { serverTimestamp } = firestore.FieldValue; +const getZonedTime = (date?: any) => { + if (!date) return new Date(0); + return zonedTimeToUtc(addHours(date, 9), 'Asia/Tokyo'); +}; + +export default async function processUploadedVideo(name: string) { + // Download original file from bucket. + const localFile = path.join(os.tmpdir(), name); + await mkdirp(path.dirname(localFile)); + const bucket = storage().bucket(); + await bucket.file(name).download({ destination: localFile }); + + // Generate a thumbnail using ffmpeg. + const thumbnailFileName = 'thumbnail.png'; + const thumbnail = path.normalize(path.join(path.dirname(name), thumbnailFileName)); + const tmpThumbnail = path.join(os.tmpdir(), thumbnail); + let metadata: ffmpeg.FfprobeData | undefined; + await mkdirp(path.dirname(tmpThumbnail)); + await new Promise((resolve) => { + ffmpeg(localFile) + .on('end', () => resolve(undefined)) + .on('error', (error) => { + logger.error('Error occurred while processing thumbnail'); + throw new Error(error); + }) + .thumbnail({ + filename: path.basename(tmpThumbnail), + folder: path.dirname(tmpThumbnail), + timestamps: [1], + }) + .ffprobe((error, data) => { + if (error) throw error; + metadata = data; + }); + }); + + // Resize the thumbnail using sharp. + const destinationFileName = 'thumbnail.jpg'; + const destination = path.normalize(path.join(path.dirname(name), destinationFileName)); + const localThumbnail = path.join(os.tmpdir(), destination); + await sharp(tmpThumbnail) + .resize(1000) + .toFormat('jpeg') + .toFile(localThumbnail) + .catch((error) => { + logger.error('Error occurred while resizing thumbnail'); + throw new Error(error); + }); + + // Upload the thumbnail. + await bucket + .upload(localThumbnail, { destination }) + .then(() => logger.info('Thumbnail uploaded.')) + .catch((error) => { + logger.error('Error occurred while uploading thumbnail'); + throw new Error(error); + }); + + // Once the image has been uploaded delete the local files to free up disk space. + fs.unlinkSync(localFile); + fs.unlinkSync(localThumbnail); + + // Update item + const mediaPath = path.dirname(name); + const itemPath = mediaPath.replace('media/', 'items/'); + const itemRef = firestore().doc(itemPath); + return itemRef + .set( + { + date: getZonedTime(metadata?.format.tags?.creation_time), + medium: { + metadata: + { + ...metadata, + format: { + ...metadata?.format, + tags: { + ...metadata?.format.tags, + creation_time: getZonedTime(metadata?.format.tags?.creation_time), + }, + }, + } || {}, + thumbnail: `${mediaPath}/${destinationFileName}`, + }, + updatedAt: serverTimestamp(), + }, + { merge: true } + ) + .then(() => logger.info('Item information updated.')) + .catch((error) => { + logger.error('Error occurred while updating thumbnail path'); + throw new Error(error); + }); +} diff --git a/web/src/adapters/userInterface/components/atoms/aspectRatioImage/index.tsx b/web/src/adapters/userInterface/components/atoms/aspectRatioImage/index.tsx deleted file mode 100644 index 9b73a58..0000000 --- a/web/src/adapters/userInterface/components/atoms/aspectRatioImage/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import React, { useLayoutEffect, useRef, useState } from 'react'; - -import { Box, BoxProps } from '@mui/material'; - -type Props = Pick & { ratio?: number; src: string }; - -export default function AspectRatioImage({ borderRadius, ratio = 1 / 1, src, sx }: Props) { - const ref = useRef(); - const [height, setHeight] = useState(1); - - useLayoutEffect(() => { - if (ref.current) { - const rect = ref.current.getBoundingClientRect(); - setHeight(rect.width * ratio); - } - }, [ref]); - - return ( - - ); -} diff --git a/web/src/adapters/userInterface/components/atoms/aspectRatioMedia/index.tsx b/web/src/adapters/userInterface/components/atoms/aspectRatioMedia/index.tsx new file mode 100644 index 0000000..8de3eb9 --- /dev/null +++ b/web/src/adapters/userInterface/components/atoms/aspectRatioMedia/index.tsx @@ -0,0 +1,50 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { useLayoutEffect, useRef, useState } from 'react'; + +import { Box, BoxProps, Typography } from '@mui/material'; + +type Props = Pick & { + component: 'img' | 'video'; + duration?: number; + ratio?: number; + src: string; +}; + +export default function AspectRetioMedia({ + borderRadius, + component, + duration, + ratio = 1 / 1, + src, + sx, +}: Props) { + const ref = useRef(); + const [height, setHeight] = useState(1); + + useLayoutEffect(() => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect(); + setHeight(rect.width * ratio); + } + }, [ref]); + + return ( + + + {duration && ( + + {`${`00${Math.floor(duration / 60) % 60}`.slice(-2)}:${`00${duration % 60}`.slice(-2)}`} + + )} + + ); +} diff --git a/web/src/adapters/userInterface/components/organisms/form/item/index.tsx b/web/src/adapters/userInterface/components/organisms/form/item/index.tsx index 6d070cf..0bfa1cd 100644 --- a/web/src/adapters/userInterface/components/organisms/form/item/index.tsx +++ b/web/src/adapters/userInterface/components/organisms/form/item/index.tsx @@ -6,7 +6,7 @@ import { Box, Button, BoxProps, Grid } from '@mui/material'; import { useForm } from 'react-hook-form'; import { ItemSubmit } from '../../../../../../interface/useCase/item'; -import AspectRetioImage from '../../../atoms/aspectRatioImage'; +import AspectRetioMedia from '../../../atoms/aspectRatioMedia'; type Form = Omit; type Props = { loading: boolean; onSubmit: (data: ItemSubmit) => void; sx?: BoxProps['sx'] }; @@ -27,7 +27,7 @@ export default function ItemForm({ loading, onSubmit, sx }: Props) {