๐ŸŽฌ ์›น์—์„œ ๊ตฌํ˜„ํ•œ ๋‹ค์–‘ํ•œ ๋น„๋””์˜ค Crop ๋ฐฉ์‹ ๋น„๊ต๊ธฐ

2025๋…„ 5์›” 29์ผ

#Video#Crop#Canvas#Fabric.js#Pixi.js#Editor

โœจ ๊ฐœ์š”

ํšŒ์‚ฌ์—์„œ ์ˆํผ ์˜์ƒ ์—๋””ํ„ฐ ํ”„๋กœ์ ํŠธ๋ฅผ ์ง„ํ–‰ํ•˜๋ฉด์„œ, ์˜์ƒ ํ”„๋ฆฌ๋ทฐ ์œ„์— ๋‹ค์–‘ํ•œ ์š”์†Œ(์˜์ƒ ํด๋ฆฝ, ํ…์ŠคํŠธ, ์ด๋ฏธ์ง€, ๋ฐฐ๊ฒฝ ์Œ์•…, ํ…œํ”Œ๋ฆฟ, ํฐํŠธ, ๋ธ”๋Ÿฌ ๋ฐ•์Šค ๋“ฑ)๋ฅผ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ–ˆ๋‹ค.

์ด๋ฅผ ์œ„ํ•ด 9:16 ๋น„์œจ์˜ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ , ๊ทธ ์•ˆ์— ์š”์†Œ๋ฅผ ๋ฐฐ์น˜ํ•˜์˜€๋‹ค. ์š”์†Œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ(DnD)๊ณผ ๋ฆฌ์‚ฌ์ด์ง•์ด ๊ฐ€๋Šฅํ•ด์•ผ ํ–ˆ์œผ๋ฉฐ, ํ…์ŠคํŠธ๋Š” ์ปจํ…Œ์ด๋„ˆ ๋‚ด์—์„œ ์ง์ ‘ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ–ˆ๋‹ค.

์ž๋ง‰ ํฌ๋งท์€ WebVTT ๋Œ€์‹  .ass(Advanced SubStation Alpha)๋ฅผ ์‚ฌ์šฉํ•˜์˜€๋‹ค. WebVTT๋Š” ๋‹จ์ˆœํ•œ ํ…์ŠคํŠธ + ๊ธฐ์กด์ ์ธ ์Šคํƒ€์ผ ํ‘œํ˜„๋งŒ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ASS๋Š” ๊ธ€๊ผด, ์ƒ‰์ƒ, ์œ„์น˜, ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋“ฑ ๋‹ค์–‘ํ•œ ์Šคํƒ€์ผ์„ ์ง€์›ํ•œ๋‹ค. ๋˜ํ•œ, ์„œ๋ฒ„์—์„œ ์‚ฌ์šฉํ•˜๋Š” ffmpeg์™€์˜ ํ˜ธํ™˜์„ฑ์„ ๊ณ ๋ คํ•˜์—ฌ ASS๋ฅผ ์ฑ„ํƒํ•˜์˜€๋‹ค.

์ด๋Ÿฌํ•œ ๊ธฐ๋Šฅ๋“ค์„ ๊ณ ๋ คํ•ด์„œ ๋‹ค์–‘ํ•œ ๋ ˆํผ๋Ÿฐ์Šค (capcut, canva ๋“ฑ ์›น ๊ธฐ๋ฐ˜์˜..) ์ฐธ๊ณ ํ–ˆ๊ณ  ๋ชจ๋‘ canvas๋ฅผ ๋ฒ ์ด์Šค๋กœ ๊ฐ€์ง€๊ณ  ๊ฐ„๋‹ค๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ๋‹ค. ๊ทธ ๋‹ค์Œ์œผ๋กœ๋Š” ์–ด๋–ค ๋ฐฉ์‹์œผ๋กœ canvas๋ฅผ ํ™œ์šฉํ•ด์•ผํ• ๊นŒ ๊ณ ๋ฏผํ–ˆ๋‹ค.

๋จผ์ €, ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋– ์˜ค๋ฅธ ๊ฒƒ์€ canvas api๋ฅผ ํ™œ์šฉํ•˜๋Š” ๊ฒƒ์ด์—ˆ๋‹ค. ์–ด๋–ค ๋ฐฉ์‹์œผ๋กœ crop์ด ๊ตฌํ˜„๋ ๊นŒ ํ–ˆ์„ ๋•Œ, ๊ฐ€์žฅ ์ž๋ฃŒ๊ฐ€ ๋งŽ์•˜์„ ๊ฒƒ์ด๋ผ ์ƒ๊ฐํ–ˆ๊ณ  ์—ญ์‹œ๋‚˜ ๊ทธ๋Ÿฌํ–ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ด์ „์— ๋Œ€ํ•™๊ต ๋•Œ ๋ฏธ๋””์–ด์˜ 2์ฐจ ์žฅ์ž‘์„ ์œ„ํ•œ draw ๊ธฐ๋ฐ˜์˜ ์›น ์—๋””ํ„ฐ๋ฅผ ๋งŒ๋“ค ๋•Œ ์‚ฌ์šฉํ–ˆ๋˜ fabric์ด ๋– ์˜ฌ๋ž๋‹ค. (๋ฒกํ„ฐ ๊ธฐ๋ฐ˜์ด๋ผ ๋ถ€๋“œ๋Ÿฌ์šด drawing์ด ๊ฐ€๋Šฅํ–ˆ๋‹ค ๋ผ๋Š” ๊ธฐ์–ต์ด ์Šค์ณ๊ฐ”๋‹ค) ๋„ˆ๋ฌด ์˜ค๋ž˜ ์ „์— ํ–ˆ๋˜ ํ”„๋กœ์ ํŠธ์ด๊ธฐ๋„ ํ–ˆ๊ณ  crop์„ ๊ตฌํ˜„ํ•ด๋ณธ์ ์ด ์—†์—ˆ์ง€๋งŒ, DnD๋‚˜ ๋ฆฌ์‚ฌ์ด์ง• ๋“ฑ ๋‚˜๋จธ์ง€ ๊ธฐ๋Šฅ๋“ค์— ๋Œ€ํ•ด ๊ตฌํ˜„์ด ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๊ณ  ์žˆ์—ˆ๋‹ค. ๋งˆ์ง€๋ง‰์œผ๋กœ๋Š” ์ด๋ฒˆ์— ์ƒˆ๋กญ๊ฒŒ ์ฐพ์€ pixi.js์ธ๋ฐ ๊ฒŒ์ž„ ์˜์ƒ ํŽธ์ง‘ ํˆด dor๋ฅผ ๋งŒ๋“  ๋„๋ฅดํŒ€์—์„œ ์“ด ๊ฐœ๋ฐœ์•„ํ‹ฐํด์„ ๋ณด๊ณ  ์•Œ๊ฒŒ ๋˜์—ˆ๊ณ  ๊ณต์‹๋ฌธ์„œ์™€ ์—ฌ๋Ÿฌ ์ž๋ฃŒ๋“ค์„ ๋ณด๊ณ  ์ถฉ๋ถ„ํžˆ ๊ฒ€์ฆํ•ด๋ณผ๋งŒํ•˜๋‹ค ํŒ๋‹จํ–ˆ๋‹ค.

๊ฒฐ๊ตญ ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์„ ์ถฉ์กฑํ•  ์ˆ˜ ์žˆ๋Š” ์™„๋ฒฝํ•œ ์†”๋ฃจ์…˜์€ ์—†์—ˆ๊ณ , ๋‹ค์–‘ํ•œ ๊ธฐ์ˆ ์„ ์‹คํ—˜ํ•œ ๋์— fabric.js๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค. ๋‹ค๋ฅธ ๋ฐฉ์‹์„ ํฌ๊ธฐํ•œ ์ด์œ ๋Š” crop ๊ธฐ๋Šฅ ๋•Œ๋ฌธ์ด ์•„๋‹ˆ๋ผ ๊ธฐํƒ€ ๋‹ค๋ฅธ ๊ธฐ๋Šฅ๋“ค์„ ๊ตฌํ˜„ํ•œ๋‹ค๊ณ  ํ–ˆ์„ ๋•Œ fabric.js๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์— ๋น„ํ•ด ์ ์ ˆ์น˜ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

์ด ๊ธ€์—์„œ๋Š” crop ๊ธฐ๋Šฅ์„ ์ค‘์‹ฌ์œผ๋กœ ์„ธ ๊ฐ€์ง€ ๊ธฐ์ˆ  ์ ‘๊ทผ ๋ฐฉ์‹์„ ๋น„๊ตํ•˜๊ณ , ์ตœ์ข… ์„ ํƒ๊นŒ์ง€์˜ ๊ณผ์ •์„ ๊ณต์œ ํ•œ๋‹ค.


๐Ÿ“ฆ Crop ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ

์„œ๋ฒ„์— ์ „๋‹ฌํ•˜๋Š” crop ์ •๋ณด๋Š” ๋‹ค์Œ ๋„ค ๊ฐ€์ง€ ํ•„๋“œ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค.

  • start_x : crop์„ ์‹œ์ž‘ํ•  x ์ขŒํ‘œ
  • start_y : crop์„ ์‹œ์ž‘ํ•  y ์ขŒํ‘œ
  • width : cropํ•  ๊ฐ€๋กœ ๊ธธ์ด
  • height : cropํ•  ์„ธ๋กœ ๊ธธ์ด

์ด ๊ฐ’๋“ค์€ ์ ˆ๋Œ€ ํ”ฝ์…€๊ฐ’์ด ์•„๋‹ˆ๋ผ, ์›๋ณธ ์˜์ƒ ํ•ด์ƒ๋„๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ•œ 0~1 ์‚ฌ์ด์˜ ๋น„์œจ ๊ฐ’์ด๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด, ํ•ด์ƒ๋„๊ฐ€ 1920ร—1080์ธ ์˜์ƒ์—์„œ ์ „์ฒด ์˜์—ญ์„ ์ง€์ •ํ•˜๋ ค๋ฉด width: 1, height: 1์„ ์„ค์ •ํ•œ๋‹ค.


๐Ÿงช ๊ธฐ์ˆ ๋ณ„ Crop ๋ฐฉ์‹ ๋น„๊ต

๊ฐ ๊ธฐ์ˆ ๋“ค์— ๋Œ€ํ•œ ๊ธฐ๋ณธ์ ์ธ ๋ Œ๋”๋ง ๋ฐฉ์‹๊ณผ ๋ฐฐ๊ฒฝ ์ง€์‹์€ ์ถ”ํ›„ ์ž์„ธํžˆ ๋‹ค๋ฃฐ ์˜ˆ์ •์ด๋‹ค.

ํ•ญ๋ชฉCanvas APIPixi.jsFabric.js
๋ Œ๋”๋ง ๋ฐฉ์‹Canvas 2DWebGL + CanvasCanvas 2D
์ •๋ฐ€ crop ์กฐ์ •์ขŒํ‘œ ๊ธฐ๋ฐ˜ ์ˆ˜๋™ ๊ณ„์‚ฐ ํ•„์š”mask๋กœ ๊ฐ€๋Šฅ๊ฐ์ฒด์— ์ง์ ‘ cropRect ์ฃผ์ž…

1๏ธโƒฃ Canvas API ํ™œ์šฉ

HTML <video> ํƒœ๊ทธ์™€ Canvas API, requestAnimationFrame()์„ ํ™œ์šฉํ•œ ๋ฐฉ์‹์ด๋‹ค.

"use client";

import { forwardRef, useCallback, useEffect, useRef } from "react";

type Props = {
  crop: { start_x: number; start_y: number; width: number; height: number };
  url: string;
  videoEl: HTMLVideoElement | null;
  metadata: {
    width: number;
    height: number;
  };
};

export default forwardRef<HTMLVideoElement, Props>(function VanillaVideoCanvas(
  props,
  ref
) {
  const { crop, url, videoEl, metadata } = props;
  // ์„œ๋ฒ„์—์„œ ์ œ๊ณตํ•ด์ฃผ๋Š” metadata ๊ฐ’
  const w = metadata.width;
  const h = metadata.height;

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const requestRef = useRef<number>(0);
  const cropRef = useRef(crop);

  useEffect(() => {
    // ๋งค ํ”„๋ ˆ์ž„๋งˆ๋‹ค render ํ˜ธ์ถœ ์‹œ crop์˜ ๋ณ€ํ™”๊ฐ€ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์•ˆ์ฃผ๊ฒŒ๋” ํ• ๋‹น
    cropRef.current = crop;
  }, [crop]);

  const render = useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx || !videoEl || videoEl.paused || videoEl.ended) return;

    const crop = cropRef.current;

    // ์‹ค์งˆ์ ์œผ๋กœ crop ์˜์—ญ์„ ๊ณ„์‚ฐํ•˜๊ณ  ๊ทธ๋ฆฌ๋Š” ๋ถ€๋ถ„
    // canvas์— ๊ทธ๋ฆด pixel์„ ๊ณ„์‚ฐ
    ctx.drawImage(
      videoEl,
      w * crop.start_x, // ๋น„๋””์˜ค ์›๋ณธ width * crop์˜ x ๊ฐ’
      h * crop.start_y, // ๋น„๋””์˜ค ์›๋ณธ height * crop์˜ y ๊ฐ’
      w * crop.width, // ๋น„๋””์˜ค ์›๋ณธ width * crop์˜ width ๊ฐ’
      h * crop.height, // ๋น„๋””์˜ค ์›๋ณธ height * crop์˜ height ๊ฐ’
      0, // canvas์— ๊ทธ๋ฆด x ์ขŒํ‘œ
      0, // canvas์— ๊ทธ๋ฆด y ์ขŒํ‘œ
      canvas.width, // cropํ•œ ์ด๋ฏธ์ง€๋ฅผ canvas์— ๊ทธ๋ฆด ๋•Œ width
      canvas.height // cropํ•œ ์ด๋ฏธ์ง€๋ฅผ canvas์— ๊ทธ๋ฆด ๋•Œ height
    );

    requestRef.current = requestAnimationFrame(render);
  }, [w, h, videoEl]);

  useEffect(() => {
    if (!videoEl) return;

    const onPlay = () => {
      render();
    };

    videoEl.addEventListener("play", onPlay);
    return () => {
      // ํด๋ฆฐ์—… ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ ์ค‘์ฒฉ๊ณผ ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜๋ฅผ ๋ฐฉ์ง€
      videoEl.removeEventListener("play", onPlay);
      cancelAnimationFrame(requestRef.current);
    };
  }, [render, videoEl]);

  return (
    <>
      <div className="absolute left-0 top-0 z-10 h-full w-full">
        <canvas
          ref={canvasRef}
          width={w}
          height={h}
          style={{ backgroundColor: "white" }}
        />
      </div>
      <video
        ref={ref}
        className="invisible absolute left-0 top-0 h-full"
        width={w}
        height={h}
        src={url}
      />
    </>
  );
});

drawImage() ์— ๋Œ€ํ•ด์—ฌ

drawImage๋Š” HTML5 Canvas์˜ 2D ๋ Œ๋”๋ง ์ปจํ…์ŠคํŠธ์—์„œ ์ด๋ฏธ์ง€, ๋น„๋””์˜ค, ๋‹ค๋ฅธ ์บ”๋ฒ„์Šค ๋“ฑ์„ ๊ทธ๋ฆด ๋•Œ ์‚ฌ์šฉํ•˜๋Š” ๋ฉ”์„œ๋“œ์ด๋‹ค ๋„˜๊ธฐ๋Š” ์ธ์ž์˜ ์ˆ˜์— ๋”ฐ๋ผ ๋„˜๊ธฐ๋Š” ์ธ์ž๋“ค์ด ๋‹ค๋ฅด๋‹ค.

drawImage(image, dx, dy);
drawImage(image, dx, dy, dWidth, dHeight);
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
์ธ์ž์„ค๋ช…
image์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด ์›๋ณธ ์ด๋ฏธ์ง€ ์š”์†Œ. ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํƒ€์ž…์€ HTMLImageElement, HTMLVideoElement, HTMLCanvasElement, ImageBitmap, OffscreenCanvas, VideoFrame ๋“ฑ
sx (optional)์›๋ณธ ์ด๋ฏธ์ง€์—์„œ ์ž˜๋ผ๋‚ผ ์˜์—ญ์˜ ์™ผ์ชฝ ์œ„ X ์ขŒํ‘œ
sy (optional)์›๋ณธ ์ด๋ฏธ์ง€์—์„œ ์ž˜๋ผ๋‚ผ ์˜์—ญ์˜ ์™ผ์ชฝ ์œ„ Y ์ขŒํ‘œ
sWidth (optional)์›๋ณธ ์ด๋ฏธ์ง€์—์„œ ์ž˜๋ผ๋‚ผ ์˜์—ญ์˜ ๋„ˆ๋น„
sHeight (optional)์›๋ณธ ์ด๋ฏธ์ง€์—์„œ ์ž˜๋ผ๋‚ผ ์˜์—ญ์˜ ๋†’์ด
dx์ž˜๋ผ๋‚ธ ์ด๋ฏธ์ง€๋ฅผ ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด ์œ„์น˜์˜ X ์ขŒํ‘œ
dy์ž˜๋ผ๋‚ธ ์ด๋ฏธ์ง€๋ฅผ ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด ์œ„์น˜์˜ Y ์ขŒํ‘œ
dWidth์ž˜๋ผ๋‚ธ ์ด๋ฏธ์ง€๋ฅผ ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด ๋•Œ์˜ ๋„ˆ๋น„ (์ถ•์†Œ/ํ™•๋Œ€ ํšจ๊ณผ)
dHeight์ž˜๋ผ๋‚ธ ์ด๋ฏธ์ง€๋ฅผ ์บ”๋ฒ„์Šค์— ๊ทธ๋ฆด ๋•Œ์˜ ๋†’์ด (์ถ•์†Œ/ํ™•๋Œ€ ํšจ๊ณผ)

์š”์•ฝํ•˜์ž๋ฉด

๋ชฉ์ ์‚ฌ์šฉ ์˜ˆ
์ด๋ฏธ์ง€/๋น„๋””์˜ค์˜ ์ผ๋ถ€๋ฅผ ์ž˜๋ผ์„œ ๊ทธ๋ฆฌ๊ธฐsx, sy, sWidth, sHeight
์œ„์น˜ ์กฐ์ •dx, dy
๋ฆฌ์‚ฌ์ด์ง• (์Šค์ผ€์ผ๋ง)dWidth, dHeight

โœ… ์žฅ์ 

  • crop ๊ธฐ๋Šฅ ์ž์ฒด๋Š” ๊ฐ„๋‹จํ•˜๊ฒŒ ๊ตฌํ˜„ ๊ฐ€๋Šฅํ•˜๋‹ค.
  • ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ๊ณผ ์„ฑ๋Šฅ ๋ชจ๋‘ ์–‘ํ˜ธํ•˜๋‹ค.

โŒ ํ•œ๊ณ„

  • ๋‹ค๋ฅธ ์š”์†Œ์ธ ํ…์ŠคํŠธ๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค๊ณ  ํ–ˆ์„ ๋•Œ ๋ธŒ๋ผ์šฐ์ €์—์„œ ํ‘œ์‹œํ•˜๋Š” text๋กœ๋Š” ํ…์ŠคํŠธ ์™ธ๊ณฝ์„ , ์ƒ‰์ƒ ๋“ฑ ์Šคํƒ€์ผ ํ‘œํ˜„์— ์ œ์•ฝ์ด ์žˆ๋‹ค.
  • ํƒ€์ž„๋ผ์ธ์— ์ถ”๊ฐ€๋œ ์š”์†Œ์˜ ์ˆ˜์— ๋”ฐ๋ผ ์ปจํ…Œ์ด๋„ˆ์— ์š”์†Œ์˜ ๋…ธ์ถœ ์—ฌ๋ถ€๋ฅผ ์ œ์–ดํ•˜๋Š” ๋กœ์ง์ด ๋ณต์žกํ–ˆ๊ณ  ์œ„์น˜์˜ ๊ฒฝ์šฐ transform๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋Š” ํ•˜์ง€๋งŒ ์ด ์‚ฌ์ด์ฆˆ์˜ ๊ฒฝ์šฐ reflow๊ฐ€ ๋นˆ๋ฒˆํ•˜๊ฒŒ ์ผ์–ด๋‚˜๋‹ˆ ๋˜ํ•œ ๋ถ„๋ช… ์„ฑ๋Šฅ์— ์˜ํ–ฅ์ด ์žˆ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๋‹ค.

๐Ÿ“Œ ํŒ๋‹จ

Crop๋งŒ ๋†“๊ณ  ๋ณด๋ฉด ๊ฐ„๋‹จํ•˜๊ณ  ๋น ๋ฅด์ง€๋งŒ, ์ „์ฒด์ ์ธ ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ถฉ์กฑํ•˜์ง€ ๋ชปํ•˜์—ฌ ๋„์ž…ํ•˜์ง€ ์•Š์•˜๋‹ค.


2๏ธโƒฃ Pixi.js ํ™œ์šฉ

Pixi.js๋Š” WebGL ๊ธฐ๋ฐ˜์˜ 2D ๋ Œ๋”๋ง ์—”์ง„์ž…๋‹ˆ๋‹ค. Canvas๋ณด๋‹ค ๊ณ ์„ฑ๋Šฅ์ด๋ฉฐ, DOM์ด ์•„๋‹Œ GPU๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ Œ๋” ํŠธ๋ฆฌ ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ์ด๋‹ค.

์ฆ‰, DOM์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ , ๋ธŒ๋ผ์šฐ์ €์˜ canvas๋ฅผ ์ด์šฉํ•ด ๋ชจ๋“  ์š”์†Œ๋ฅผ ํ…์Šค์ฒ˜ ๊ธฐ๋ฐ˜์œผ๋กœ ์ง์ ‘ ๋ Œ๋”๋งํ•œ๋‹ค.

์—ฌ๊ธฐ์„œ๋Š” v8 ๊ธฐ์ค€์œผ๋กœ ์ž‘์„ฑ๋˜์—ˆ๋‹ค.

// PIXI.Application์€ Pixi.js์˜ ์ตœ์ƒ์œ„ ์ปจํŠธ๋กค๋Ÿฌ ๊ฐ์ฒด
// Pixi ํ”„๋กœ์ ํŠธ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ ์œ„ํ•ด ํ•„์š”ํ•œ ์š”์†Œ๋“ค์„ ํ•œ ๋ฒˆ์— ๊ตฌ์„ฑํ•˜๊ณ  ๊ด€๋ฆฌํ•ด์ฃผ๋Š” ๊ฐ์ฒด์ด๋‹ค.
const app = new PIXI.Application();
await app.init({
  // options...
});

const video: HTMLVideoElement;
video.src = videoUrl;

// 1. Container ์ƒ์„ฑ
// Container๋Š” DOM์˜ <div>์ฒ˜๋Ÿผ ์ž์‹ ๋””์Šคํ”Œ๋ ˆ์ด ์˜ค๋ธŒ์ ํŠธ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ๊ณ„์ธต ๊ตฌ์กฐ ๊ฐ์ฒด
// ๋‚ด๋ถ€์ ์œผ๋กœ children ๋ฐฐ์—ด์„ ๊ฐ€์ง€๊ณ , ์ขŒํ‘œ ๋ณ€ํ™˜ ๋ฐ ๋ Œ๋” ์ˆœ์„œ๋ฅผ ์กฐ์ •
const container = new PIXI.Container({
  label: "video-container",
});

// 2. ์ƒ์„ฑํ•œ ์ปจํ…Œ์ด๋„ˆ๋ฅผ app์— ์ถ”๊ฐ€
// app.stage๋Š” Pixi์—์„œ ๋ Œ๋”๋ง ํŠธ๋ฆฌ์˜ ์ตœ์ƒ์œ„ ๋ฃจํŠธ์ด๋‹ค.
// HTML์˜ <body>์ฒ˜๋Ÿผ, Pixi์—์„œ ๋ชจ๋“  ์‹œ๊ฐ์  ์š”์†Œ(Sprite, Container, Text ๋“ฑ)๋Š” ๋ฌด์กฐ๊ฑด stage ์•„๋ž˜์— ์กด์žฌํ•ด์•ผ ํ™”๋ฉด์— ๊ทธ๋ ค์ง„๋‹ค.
// ์ž์‹ ๋…ธ๋“œ๋ฅผ ํŠธ๋ฆฌ ํ˜•ํƒœ๋กœ ๊ณ„์ธต์ ์œผ๋กœ ๊ด€๋ฆฌ
app.stage.addChild(container);

const videoLoadPromise: Promise<HTMLVideoElement> = new Promise((resolve) => {
  // ๋น„๋””์˜ค ์žฌ์ƒ์ด ์ค‘๋‹จ ์—†์ด ๊ฐ€๋Šฅํ•œ์ง€ ํ™•์ธ
  video.addEventListener(
    "canplaythrough",
    () => {
      resolve(video);
    },
    { once: true }
  );
});

// 3. ๋น„๋””์˜ค๊ฐ€ ์™„์ „ํžˆ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆผ
const loadedVideo = await videoLoadPromise;

// 4. ๋กœ๋“œ๋œ ํ›„์—๋งŒ ํ…์Šค์ฒ˜ ์ƒ์„ฑ
// ๋‚ด๋ถ€์ ์œผ๋กœ WebGL TEXTURE_2D๋กœ ์—…๋กœ๋“œ
const videoTexture = PIXI.Texture.from(video);

// 5. ๋ Œ๋”๋ฅผ ์œ„ํ•œ sprite ์ƒ์„ฑ
// sprite๋Š” ํ•ด๋‹น ํ…์Šค์ฒ˜๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ Œ๋” ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด
// ๋งˆ์น˜ <img> ํƒœ๊ทธ์ฒ˜๋Ÿผ Pixi ๋‚ด๋ถ€์—์„œ ๊ทธ๋ ค์งˆ ์ˆ˜ ์žˆ๋Š” ๋…ธ๋“œ
const videoSprite = new PIXI.Sprite(videoTexture);

๐Ÿ” ๋‚ด๋ถ€ ๋™์ž‘ ์š”์•ฝ: โ€ข HTMLVideoElement์˜ current frame โ†’ canvas โ†’ WebGL texture๋กœ ๋ณต์‚ฌ โ€ข GPU์—์„œ ์ด ํ…์Šค์ฒ˜๋ฅผ ์ƒ˜ํ”Œ๋งํ•˜์—ฌ Sprite๋กœ ๋ Œ๋”

// videoWidth์™€ videoHeight ์–ด๋””์— ์–ด๋–ป๊ฒŒ ๊ทธ๋ฆฌ๋А๋ƒ์— ๋”ฐ๋ผ ๊ธฐ์ค€์ด ๋‹ฌ๋ผ์ง„๋‹ค.
// e.g.) ์˜์ƒ ์›๋ณธ ํฌ๊ธฐ ๊ธฐ์ค€์ด๋ƒ, ์ปจํ…Œ์ด๋„ˆ์˜ ํฌ๊ธฐ ๊ธฐ์ค€์ด๋ƒ
const cropX = crop.start_x * videoWidth;
const cropY = crop.start_y * videoHeight;
const cropWidth = crop.width * videoWidth;
const cropHeight = crop.height * videoHeight;

const mask = new PIXI.Graphics().rect(cropX, cropY, cropWidth, cropHeight);

videoSprite.setMask({
  mask,
  inverse: false,
});

// Container๋Š” ๋ชจ๋“  ์ž์‹์˜ ๋ Œ๋” ํŠธ๋ฆฌ๋ฅผ ๊ด€๋ฆฌ
// ์ดํ›„ app.stage.addChild(container) ์ฒ˜๋Ÿผ ๋ฃจํŠธ ์Šคํ…Œ์ด์ง€์— ๋ถ™์ด๋ฉด ์ตœ์ข…์ ์œผ๋กœ ๋ Œ๋”๋จ
container.addChild(videoSprite, mask);
  1. ๋น„๋””์˜ค ์š”์†Œ๋ฅผ ๋น„๋™๊ธฐ์ ์œผ๋กœ ๋กœ๋“œํ•˜๊ณ , canplaythrough ์‹œ์ ์„ ๊ธฐ์ค€์œผ๋กœ ํ…์Šค์ฒ˜๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
  2. crop ์ขŒํ‘œ๋Š” normalized ๊ฐ’(0~1) ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐํ•˜์—ฌ ๋งˆ์Šคํฌ๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค.
  3. setMask๋Š” v8์—์„œ ์ œ๊ณต๋˜๋ฉฐ, v7 ์ดํ•˜์—์„œ๋Š” .mask ์†์„ฑ์œผ๋กœ ์„ค์ •ํ•ด์•ผ ํ•œ๋‹ค.
  4. ์žฌ์ƒ์˜ ๊ฒฝ์šฐ react๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด ref์— ํ• ๋‹นํ•˜์—ฌ ์ œ์–ด ๊ฐ€๋Šฅํ•˜๋‹ค
    • Sprite๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด videoSprite.texture.source.resource
    • Texture๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด videoTexture.source.resource
    • resource๋Š” GPU์— ์—…๋กœ๋“œ๋  ๋ฆฌ์†Œ์Šค์ด๋ฉฐ. ์—ฌ๊ธฐ์„œ ImageBimt / Canvas / Video ๋“ฑ์—์„œ ํ”ฝ์…€์„ ๊ฐ€์ ธ์˜จ๋‹ค

โš™๏ธ setMask์˜ ๋™์ž‘ ๋ฐฉ์‹

setMask ๋ž€?

โ€ข PIXI.DisplayObject์˜ ๋ฉ”์„œ๋“œ๋กœ, ํ•ด๋‹น ๊ฐ์ฒด์— ๋งˆ์Šคํฌ๋ฅผ ์„ค์ •ํ•œ๋‹ค. โ€ข mask๋Š” PIXI.Graphics ๋˜๋Š” PIXI.Sprite ๋“ฑ ๋งˆ์Šคํฌ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋ Œ๋”๋Ÿฌ ๋Œ€์ƒ โ€ข inverse๋Š” ๋งˆ์Šคํฌ ๋ฐ˜์ „ ์—ฌ๋ถ€๋ฅผ ์ง€์ • (๊ธฐ๋ณธ๊ฐ’: false)

๋™์ž‘ ๋ฐฉ์‹

  1. ๋ Œ๋”๋ง ์‹œ ๋งˆ์Šคํฌ๊ฐ€ ๋จผ์ € ๋ Œ๋”๋ง๋จ โ€ข GPU ์Šคํ…์‹ค ๋ฒ„ํผ(stencil buffer)๋ฅผ ์‚ฌ์šฉํ•ด ๋งˆ์Šคํ‚น ์ฒ˜๋ฆฌ
  2. ๋งˆ์Šคํฌ ์˜์—ญ ์™ธ์˜ ํ”ฝ์…€์€ ์•ŒํŒŒ๊ฐ’ 0์œผ๋กœ ์ฒ˜๋ฆฌ
  3. inverse: true์ผ ๊ฒฝ์šฐ โ†’ ๋งˆ์Šคํฌ ๋ฐ–์ด ๋ณด์ด๊ณ , ์•ˆ์ด ์ž˜๋ฆผ

โœ… ์žฅ์ 

  • GPU ๊ฐ€์†์„ ํ†ตํ•ด ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚˜๋‹ค.
  • crop์€ mask ๊ฐ์ฒด๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

โŒ ํ•œ๊ณ„

  • ๋ธ”๋Ÿฌ ํšจ๊ณผ ๊ตฌํ˜„์ด ์–ด๋ ต๋‹ค.
    • pixi์˜ filter์—๋„ blur๊ฐ€ ์žˆ์ง€๋งŒ ์ด๋Š” blur๋ฅผ ์ ์šฉํ•  ํ”ฝ์…€๋“ค์ด ํ•„์š”ํ–ˆ๊ณ  ์ด๋Š”์†Œ์Šค๋“ค์ด ์žˆ์–ด์•ผํ•จ์„ ๋งํ•œ๋‹ค.
      • ์—ฌ๊ธฐ์„œ์˜ ๋ธ”๋Ÿฌ ํšจ๊ณผ๋Š” ๋นˆ ๋ฐ•์Šค์— ๋ธ”๋Ÿฌ๋ฅผ ํ‘œํ˜„ํ•˜์—ฌ ์›ํ•˜๋Š” ์˜์—ญ์— ์œ„์น˜ํ•จ์„ ์˜๋ฏธ
      • ์ด ๋ธ”๋Ÿฌ ์˜์—ญ ์•„๋ž˜์— ๋‹ค์–‘ํ•œ ์š”์†Œ๋“ค์ด ๊ฒน์นจ
      • css์˜ backdrop-filter๋ฅผ ํ†ตํ•ด ๊ฐ€๋Šฅํ•˜์ง€๋งŒ ์ด๋Š” ์š”์†Œ๊ฐ„ ๋ ˆ์ด์–ด ๊ด€๋ฆฌ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅ ํ–ˆ๋‹ค.
  • ํ…์ŠคํŠธ ์ž…๋ ฅ ์ฒ˜๋ฆฌ์—์„œ ์ค„๋ฐ”๊ฟˆ, ์ปค์„œ ์œ„์น˜, ๋ฆฌ์‚ฌ์ด์ง• ๋“ฑ์˜ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.
    • ์ด๋ฅผ ๋„์™€์ฃผ๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ pixi/ui ๊ฐ€ ์žˆ์—ˆ์ง€๋งŒ ํ•œ๊ณ„๊ฐ€ ์žˆ์—ˆ๋‹ค.
      • ์‚ฌ์šฉ์ž๊ฐ€ ๋งŒ๋“  ์ปค์Šคํ…€ ํ…์ŠคํŠธ ์ž…๋ ฅ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์žˆ์—ˆ์ง€๋งŒ ์‚ฌ์šฉํ•˜๊ธฐ์—๋Š” ๋ฌด๋ฆฌ๊ฐ€ ์žˆ์—ˆ๋‹ค.
  • ์ปค์Šคํ„ฐ๋งˆ์ด์ง•์ด ๊ฐ€๋Šฅํ•˜๋”๋ผ๋„ ์•ˆ์ •์„ฑ ํ™•๋ณด๊ฐ€ ์–ด๋ ต๋‹ค.
  • WebGL ๊ธฐ๋ฐ˜์˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฅผ ๋‹ค๋ฃจ๋Š” ๊ฒƒ์ด ์ฒ˜์Œ์ด์—ˆ๊ธฐ์— ๋†’์€ ๋Ÿฌ๋‹์ปค๋ธŒ๊ฐ€ ์š”๊ตฌ๋˜์—ˆ๊ณ , ์ œ๋Œ€๋กœ ๋‹ค๋ฃจ๊ธฐ ์œ„ํ•ด์„œ๋Š” ์‹œ๊ฐ„์ด ํ•„์š”ํ•œ ๊ฒƒ์€ ๋ถˆ๊ฐ€ํ”ผ ํ–ˆ๋‹ค. ๋“€๋ฐ์ดํŠธ๋ฅผ ๊ณ ๋ ค ํž˜๋“ค๋‹ค๊ณ  ํŒ๋‹จ.

๐Ÿ“Œ ํŒ๋‹จ

๋ Œ๋”๋ง ์„ฑ๋Šฅ์€ ์šฐ์ˆ˜ํ•˜์ง€๋งŒ, ์˜์ƒ ํŽธ์ง‘๊ธฐ ์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๋Š” ๊ธฐ๋Šฅ์„ ์•ˆ์ •์ ์œผ๋กœ ์ œ๊ณตํ•˜๊ธฐ ์–ด๋ ค์›Œ ๋„์ž…ํ•˜์ง€ ์•Š์•˜๋‹ค. ๋น ๋ฅด๊ฒŒ ๊ธฐ๋Šฅ ๊ฒ€์ฆํ•˜๋А๋ผ ๋†“์ณค๋˜ WebGL ๊ฐœ๋…์— ๋Œ€ํ•ด ์ œ๋Œ€๋กœ ํ•™์Šตํ•ด๋ณด๊ณ  ์‹ถ๋‹ค๋Š” ์ƒ๊ฐ๋„ ๋“ค์—ˆ๋‹ค.


3๏ธโƒฃ Fabric.js ํ™œ์šฉ (์ตœ์ข… ์„ ํƒ)

fabric.js๋Š” canvas ๊ธฐ๋ฐ˜์˜ ๊ฐ์ฒด ๋ชจ๋ธ์„ ์ œ๊ณตํ•˜๋ฉฐ, ๋‹ค์–‘ํ•œ ์š”์†Œ๋ฅผ ๋…๋ฆฝ์ ์œผ๋กœ ์กฐ์ž‘ํ•  ์ˆ˜ ์žˆ๋‹ค.

fabric ์ž์ฒด์—์„œ๋Š” crop์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์ด ์—†์—ˆ๊ธฐ์— ๋ ˆํผ๋Ÿฐ์Šค๋ฅผ ์ฐพ์•„ ์ปค์Šคํ…€ํ•œ ํ›„, ์ด๋ฅผ TypeScript ํ™˜๊ฒฝ์— ๋งž์ถฐ ๋ฆฌํŒฉํ„ฐ๋งํ•˜์˜€๋‹ค.

ํ•ด๋‹น ๋ ˆํผ๋Ÿฐ์Šค๋Š” fabric 1.7.1 ๋ฒ„์ „ ์ด์–ด์„œ ๋„ˆ๋ฌด ์˜ค๋ž˜๋œ ๋ฒ„์ „์ด๊ธฐ๋„ ํ–ˆ๊ณ  ํ˜„์žฌ๋Š” 6.x ๋ฒ„์ „์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค v6๋กœ ๋„˜์–ด์˜ค๋ฉด์„œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋‚ด ๋ฉ”์†Œ๋“œ๋“ค์˜ ๋ณ€ํ™”๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์•˜๊ณ  ๋ฐ”์ด๋ธŒ ์ฝ”๋”ฉ์„ ํ†ตํ•ด ์ตœ๋Œ€ํ•œ ํ˜„์žฌ ๋ฒ„์ „์— ํ˜ธํ™˜๋˜๋Š” class๋ฅผ ๊ตฌํ˜„ํ–ˆ๋‹ค. ๋ฌด๋ ค 8๋…„ ์ „!

type FabricImageProps = fabric.ImageProps & {
  cropRect: { x: number; y: number; w: number; h: number };
};

// CropVideo ํด๋ž˜์Šค ์ •์˜
class CropVideo extends fabric.FabricImage {
  static type = "crop-video";
  cropRect?: { x: number; y: number; w: number; h: number };

  constructor(
    element: HTMLVideoElement | HTMLImageElement,
    options?: FabricImageProps
  ) {
    const defaultOpts = {
      // ...๊ธฐ๋ณธ ์˜ต์…˜
    };

    super(element as any, Object.assign({}, defaultOpts, options));
    this.cropRect = options?.cropRect;
  }

  _draw(ctx: CanvasRenderingContext2D) {
    const element = this.getElement() as HTMLVideoElement;
    const c = this.cropRect;
    const d = {
      x: -this.width / 2,
      y: -this.height / 2,
      w: this.width,
      h: this.height,
    };

    if (c) {
      ctx.drawImage(element, c.x, c.y, c.w, c.h, d.x, d.y, d.w, d.h);
    } else {
      ctx.drawImage(element, d.x, d.y, d.w, d.h);
    }
  }

  _render(ctx: CanvasRenderingContext2D) {
    this._draw(ctx);
  }
}

(fabric as any).CropVideo = CropVideo;

class CropVideo extends fabric.FabricImage {
  static type: string;
  cropRect?: { x: number; y: number; w: number; h: number };
  constructor(element: HTMLVideoElement | HTMLImageElement, options?: any);
  _draw(ctx: CanvasRenderingContext2D, w: number, h: number): void;
  _render(ctx: CanvasRenderingContext2D): void;
}

// ======= ์‹ค์ œ ์‚ฌ์šฉ =======

// CropVideo ํด๋ž˜์Šค ์ง์ ‘ ์‚ฌ์šฉ
const fabricVideo = new fabric.CropVideo(videoElement, {
  // option
});

// ์„ฑ๋Šฅ ์ตœ์ ํ™”: ์ขŒํ‘œ ์—…๋ฐ์ดํŠธ
fabricVideo.setCoords();

// ์บ”๋ฒ„์Šค์— ๋น„๋””์˜ค ๊ฐ์ฒด ์ถ”๊ฐ€
canvas.add(fabricVideo);

โœ… ์žฅ์ 

  • ํ…์ŠคํŠธ, ์ด๋ฏธ์ง€, ์˜์ƒ ๋“ฑ ๋‹ค์–‘ํ•œ ์š”์†Œ๋ฅผ ๋…๋ฆฝ์ ์œผ๋กœ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ์š”์†Œ ๊ฐ„ ๋ ˆ์ด์–ด ์šฐ์„ ์ˆœ์œ„ ๋ฐ ์ •๋ ฌ ์ฒ˜๋ฆฌ๊ฐ€ ๋ช…ํ™•ํ•˜๋‹ค.
  • ์ปค์Šคํ„ฐ๋งˆ์ด์ง• ๋ฐ ํ™•์žฅ์„ฑ์ด ๋›ฐ์–ด๋‚˜๊ณ , ์œ ์ง€๋ณด์ˆ˜๋„ ์šฉ์ดํ•˜๋‹ค.

โŒ ํ•œ๊ณ„

  • ํ•™์Šต ๊ณก์„ ์ด ์•ฝ๊ฐ„ ์กด์žฌํ•œ๋‹ค.
  • ์ผ๋ถ€ ๊ธฐ๋Šฅ์€ ์ง์ ‘ ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.
  • fabric์—์„œ๋„ ๋ธ”๋Ÿฌ ํšจ๊ณผ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐ์— ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค.
    • ๋ ˆ์ด์–ด ๊ด€๋ฆฌ๋ฅผ ์–ด๋–ป๊ฒŒ ํ• ๊นŒํ•˜๋‹ค๊ฐ€ ๋ธ”๋Ÿฌ์˜ ๊ฐ์ฒด๊ฐ€ DnD, ๋ฆฌ์‚ฌ์ด์ง• ์ค‘์ผ ๋•Œ๋Š” fabric ๋‚ด์— ํ‘œ์‹œ ๊ฐ€๋Šฅํ•œ ๊ฐ์ฒด(e.g. ๋…ธ๋ž€์ƒ‰ ๋ฐ•์Šค)๋กœ ๊ฐ ์š”์†Œ๋“ค ๊ฐ„์˜ ๋ ˆ์ด์–ด ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ์ด๋ฒคํŠธ๊ฐ€ ๋๋‚ฌ์„ ๋•Œ๋Š” backdrop-filter ๊ฐ€ ์ ์šฉ๋œ ๋ฐ•์Šค๋ฅผ ๋ Œ๋”ํ•˜๋„๋ก ํ–ˆ๋‹ค.
    • ์™„๋ฒฝํ•œ ๋Œ€์•ˆ์€ ์•„๋‹ˆ๊ธฐ์— ์ถ”ํ›„ ๊ฐœ์„ ์ด ํ•„์š”ํ–ˆ๋‹ค.\

๐Ÿ“Œ ํŒ๋‹จ

๊ฐ€์žฅ ์•ˆ์ •์ ์œผ๋กœ ํ”„๋กœ์ ํŠธ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋งŒ์กฑํ•˜์—ฌ ์ตœ์ข… ์„ ํƒํ•œ ๋ฐฉ์‹์ด๋‹ค.


๐Ÿ”š ๋งˆ๋ฌด๋ฆฌ

์„ธ ๊ฐ€์ง€ ๊ธฐ์ˆ ์„ ์ง์ ‘ ๊ตฌํ˜„ํ•˜๊ณ  ๋น„๊ตํ•˜๋ฉด์„œ crop ๊ธฐ๋Šฅ๋ฟ ์•„๋‹ˆ๋ผ, ์ „์ฒด ์—๋””ํ„ฐ์˜ ๊ตฌ์กฐ์™€ ํ™•์žฅ์„ฑ์„ ๊ณ ๋ คํ•œ ์˜์‚ฌ๊ฒฐ์ •์„ ํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค.

๋‹จ์ˆœํžˆ ์ž˜ ๋ณด์ด๋Š” UI๋ฅผ ๋„˜์–ด์„œ, ์—ฌ๋Ÿฌ ๋น„๋””์˜ค ์†Œ์Šค ๊ธฐ๋ฐ˜์˜ ์‹œ๊ฐ„ ๋ฐ ์žฌ์ƒ ์ œ์–ด, ํ…์ŠคํŠธ ์ž…๋ ฅ, ๊ฐ ์š”์†Œ๋“ค์˜ ์Šคํƒ€์ผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•, ๋ ˆ์ด์–ด ์ฒ˜๋ฆฌ ๋“ฑ ๋ณตํ•ฉ์ ์ธ ์š”๊ตฌ๋ฅผ ์ถฉ์กฑํ•˜๋ ค๋ฉด ๋งŽ์€ ์‹คํ—˜๊ณผ ์‹œํ–‰์ฐฉ์˜ค๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค.

์•„์ง๋„ ๋งŽ์€ ์‹œํ–‰์ฐฉ์˜ค๋ฅผ ๊ฒช๋Š” ์ค‘์ด๋‹ค...


์ฐธ์กฐ