<div
[ngClass]="value ? '' : 'border-2 border-dashed border-primary'"
class="flex flex-col items-center justify-center w-full transition-all rounded-md active:border-primary-200 active:text-primary-300 md:rounded"
style="aspect-ratio: 9/16"
(click)="handleClick($event)"
>
<video class="w-full h-full rounded-md" *ngIf="value" controls autoplay muted>
<source [src]="value" type="video/mp4" />
</video>
<div class="flex items-end gap-4">
<app-icon
*ngIf="!value"
name="mdi:movie-open-play"
class="w-8 h-8 transition-all text-primary active:text-primary-200"
/>
</div>
</div>
- 템플릿
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, HostBinding, Output } from '@angular/core';
import {
CustomValueAccessor,
IconComponent,
createProviders,
} from '@common/lib';
import {
ActionSheetController,
IonicModule,
LoadingController,
} from '@ionic/angular';
import { pickFileWithThumbnail } from 'apps/client/src/libs/pick-images';
import { forkJoin } from 'rxjs';
import { UploadService } from '../../services/upload/upload.service';
@Component({
selector: 'app-video-uploader',
standalone: true,
imports: [CommonModule, IonicModule, IconComponent],
templateUrl: './video-uploader.component.html',
styleUrls: ['./video-uploader.component.scss'],
providers: createProviders(VideoUploaderComponent),
})
export class VideoUploaderComponent extends CustomValueAccessor<string> {
imageChangedEvent: any = '';
croppedImage: any = '';
fileServer = `${process.env['NX_API_ENDPOINT']}`;
@HostBinding('class') class = 'w-full';
@HostBinding('style') style = 'aspect-ratio : 9/16';
@Output() upload: EventEmitter<{ video: any; thumbnail: any }> =
new EventEmitter<{ video: any; thumbnail: any }>();
@Output() delete: EventEmitter<string> = new EventEmitter<string>();
constructor(
private readonly actionSheetController: ActionSheetController,
private readonly uploadService: UploadService,
private readonly loadingController: LoadingController
) {
super();
}
async handleClick(ev: any) {
ev.stopPropagation();
const loading = await this.loadingController.create({
message: '동영상을 업로드 중입니다.',
});
const actionSheet = await this.actionSheetController.create({
header: '동영상',
buttons: [
{
text: '동영상 선택',
handler: async () => {
const files = await pickFileWithThumbnail(1, 'video');
if (!files || files.length < 2) return;
loading.present();
const [videoFile, thumbnailFile] = files as [File, File];
forkJoin([
this.uploadService.uploadFiles([videoFile]),
this.uploadService.uploadFiles([thumbnailFile]),
]).subscribe(
([video, thumbnail]: [string[], string[]]) => {
this.writeValue(video[0]);
this.upload.emit({ video, thumbnail });
loading.dismiss();
},
(error: any) => {
console.error('Error uploading video and thumbnail', error);
loading.dismiss();
}
);
},
},
{
text: '취소',
},
],
});
actionSheet.present();
}
}
- 업로드 컴포넌트
export const pickFile = async (
count: number,
type: string
): Promise<File[]> => {
return new Promise(async (resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = `${type}/*`;
input.multiple = true;
const selectedImages: File[] = [];
const fileChangePromise = new Promise((fileResolve) => {
input.addEventListener('change', () => {
const fileList = input.files;
if (!fileList) {
fileResolve(selectedImages);
return;
}
for (let i = 0; i < fileList.length; i++) {
selectedImages.push(fileList[i]);
}
fileResolve(selectedImages);
});
});
input.click();
await fileChangePromise;
resolve(selectedImages);
});
};
export const pickFileWithThumbnail = async (
count: number,
type: string
): Promise<File[]> => {
return new Promise(async (resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = `${type}/*`;
input.multiple = true;
const limitSize = 100;
const selectedFiles: File[] = [];
const fileChangePromise = new Promise(async (fileResolve) => {
input.addEventListener('change', async () => {
const fileList = input.files;
if (!fileList) {
fileResolve(selectedFiles);
return;
}
let isSizeOk = true;
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
if (file.size > limitSize * 1024 * 1024) {
alert(`${limitSize}MB 이하의 파일만 업로드 가능합니다.`);
isSizeOk = false;
continue;
}
selectedFiles.push(file);
if (file.type.startsWith('video/')) {
try {
const thumbnail = await getVideoThumbnail(file);
selectedFiles.push(thumbnail);
} catch (err) {
console.error('Error generating video thumbnail', err);
}
}
}
fileResolve(selectedFiles);
});
});
input.click();
await fileChangePromise;
resolve(selectedFiles);
});
};
export const getVideoThumbnail = (file: File): Promise<File> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
video.src = URL.createObjectURL(file);
video.addEventListener('loadeddata', () => {
const middleTime = Math.floor(video.duration / 2);
video.currentTime = middleTime;
});
video.addEventListener('seeked', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context!.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
canvas.toBlob(
(blob) => {
if (blob) {
const thumbnailFileName =
file.name.replace(/\.[^/.]+$/, '') + '_thumbnail.png'; // 원본 파일 이름에 "_thumbnail" 추가
const thumbnailFile = new File([blob], thumbnailFileName, {
type: blob.type,
lastModified: Date.now(),
});
resolve(thumbnailFile);
} else {
reject(new Error('Failed to create thumbnail'));
}
URL.revokeObjectURL(video.src);
},
'image/png',
0.9
);
});
video.addEventListener('error', (err) => {
reject(err);
URL.revokeObjectURL(video.src);
});
});
};
- 사용자로부터 동영상을 입력받아 썸네일을 추출하는 메소드
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class UploadService {
baseUrl = process.env['NX_API_ENDPOINT'];
constructor(private httpClient: HttpClient) {}
uploadFiles(files: File[]): Observable<string[]> {
const formData = new FormData();
files.map((file) => {
formData.append('file', file);
});
return this.httpClient.post<string[]>(`${this.baseUrl}/upload`, formData);
}
deleteFile(filename: string): Observable<boolean> {
return this.httpClient.delete<boolean>(
`${this.baseUrl}/upload/${filename}`
);
}
}
- 클라이언트 사이드 업로드 서비스