<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Never give up</title>
    <link>https://devmemory.tistory.com/</link>
    <description>This blog is to store and share my memory while developing.</description>
    <language>ko</language>
    <pubDate>Mon, 11 May 2026 00:44:58 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>대기만성 개발자</managingEditor>
    <image>
      <title>Never give up</title>
      <url>https://tistory1.daumcdn.net/tistory/4094024/attach/7f8b505900234bf1a50e17f17e8b8526</url>
      <link>https://devmemory.tistory.com</link>
    </image>
    <item>
      <title>Node - appending row to Google spread sheet</title>
      <link>https://devmemory.tistory.com/137</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. aws s3에 업로드 되는 파일이 보기 편하면 좋겠다(링크, 날짜, 해당 파일 정보)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 에러 발생시 에러대응 가능하도록 트래킹 할수있게 해달라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 요구를 받아서 비개발자가 보기에 가장 편한방식이 뭐가 있을까 고민해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 개발진행된 상황으로는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 특정 pc에만 배포되어있는 react, node&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 외부 서버 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 해당 pc는 유저들이 사용하는 pc&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 고민해봤던게 2가지로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;slack을 사용하는 환경으로 webhook추가(빠른 알람 및 대응)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google spread sheet를 사용해 줄추가를 해보게 됐습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;webhook은 url이랑 data약속만 지키면 되니 건너뛰고 예제를 만들어봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(사실 대부분이 url이랑 약속만 잘지키면 됩..)&lt;/s&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1758006177038&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const appendToGoogleSheet = async ({
  state,
  songInfo,
  uuid,
}: {
  state: number;
  songInfo?: string[];
  uuid: string;
}) =&amp;gt; {
  try {
    const client = (await auth.getClient()) as any;
    const sheets = google.sheets({ version: &quot;v4&quot;, auth: client });

    const s3Link = process.env.S3_BUCKET_FOLDER + uuid + &quot;/&quot;;

    let values: string[][];
    let range: string;

    if (state === MEDIA_STATE.error) {
      values = [[s3Link, getCurrentDate()]];
      range = getRange(&quot;error&quot;, values[0].length);
    } else {
      values = [[...songInfo!, s3Link, getCurrentDate()]];
      range = getRange(&quot;log&quot;, values[0].length);
    }

    await sheets.spreadsheets.values.append({
      spreadsheetId: SPREADSHEET_ID,
      range,
      valueInputOption: &quot;RAW&quot;,
      requestBody: {
        values,
      },
    });
  } catch (err) {
    logger.error(`[google] error: ${getErrMsg(err)}`);
  }
};

const getRange = (sheet: string, columnCount: number) =&amp;gt; {
  const lastColumn = String.fromCharCode(&quot;A&quot;.charCodeAt(0) + columnCount - 1);
  return `${sheet}!A:${lastColumn}`;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google api를 사용하다보니 서비스계정이 필요하고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;api 설정, 편집자 등록 후 GoogleAuth를 콜해서 인증을 해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 values에 넣을 데이터를 적어줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 range에 &quot;sheet name!첫번째행:마지막행&quot;으로 적어주시면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;multiple row를 appending하고 싶은 경우&lt;/p&gt;
&lt;pre id=&quot;code_1759194147431&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let values:string[][] = []

for(const e of list){
  const value = [...e.songInfo, s3Link, getCurrentDate()]
  values.push(value)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;request body의 values 부분만 변경해 넣어주시면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 후 확인해보면 정상적으로 작동되는데, 한가지 아쉬움이 있다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 데이터를 맨 위로 쌓는 기능은 제공하지 않더군요&lt;/p&gt;</description>
      <category>WEB</category>
      <category>append row</category>
      <category>google api</category>
      <category>node</category>
      <category>spread sheet</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/137</guid>
      <comments>https://devmemory.tistory.com/137#entry137comment</comments>
      <pubDate>Tue, 16 Sep 2025 16:17:28 +0900</pubDate>
    </item>
    <item>
      <title>React, Next - localization with google spread sheet</title>
      <link>https://devmemory.tistory.com/136</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 다국어 지원 서비스를 개발할 때, 비개발자랑 같이 진행하게 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;json만 던져주고 알아서 수정하라기에는 난이도가 있다보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google spread sheet를 공유하면서 작업하는게 더 생산성이 높다 생각합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 간단하게 예제를 만들어봤는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(오랜만에 localization 작업하다가 정리 안해놨더니 다시 작업할 때 삽질 포인트를 반복해서 적는건 안비밀)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 필자가 원하는 저장경로는 대략 /[lang code]/common.json으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로가 다르다면 구현할 때 해당 부분을 조금 신경써주시면 될거 같습니다&lt;/p&gt;
&lt;pre id=&quot;code_1758003206787&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const Papa = require(&quot;papaparse&quot;);
const fs = require(&quot;fs/promises&quot;);

async function fetchTranslations() {
  const url = 'https://docs.google.com/spreadsheets/d/e/[id]/pub?output=csv';

  console.log(&quot;Fetching translations from Google Sheets...&quot;, { url });
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to fetch sheet: ${res.statusText}`);

  const csvText = await res.text();
  const parsed = Papa.parse(csvText, { header: true });

  const rows = parsed.data;
  if (!rows.length) throw new Error(&quot;No data found in the sheet.&quot;);

  const languages = Object.keys(rows[0]).filter((col) =&amp;gt; col !== &quot;key&quot;);

  const translations = {};
  languages.forEach((lang) =&amp;gt; {
    translations[lang] = {};
  });

  rows.forEach((row) =&amp;gt; {
    const key = row.key;
    if (!key) return;
    languages.forEach((lang) =&amp;gt; {
      translations[lang][key] = row[lang] || &quot;&quot;;
    });
  });
  
  for (const lang of languages) {
    await fs.mkdir(`./src/i18n/${lang}`, { recursive: true });
    const filePath = `./src/i18n/${lang}/common.json`;
    await fs.writeFile(
      filePath,
      JSON.stringify(translations[lang], null, 2),
      &quot;utf-8&quot;
    );
    console.log(`Saved ${filePath}`);
  }

  console.log(&quot;All translation files saved successfully!&quot;);
}

fetchTranslations().catch((err) =&amp;gt; {
  console.error(&quot;Error fetching translations:&quot;, err);
  process.exit(1);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 spread sheet에서 웹에 게시를 해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(google api를 사용하실 예정이면 서비스계정으로 진행하시면 되겠습니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;google api를 사용하지 않은 이유는 중요한정보를 글로서리로 관리하지 않았고 렌딩페이지 전용이었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(가장 중요한 이유는.. 권한을 안주셨어요)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fetch로 csv파일 형태로 가져오고, papaparse라는 csv 파싱 라이브러리를 이용해 작업을 하는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 헤더에서 lang code를 추출하고, key value 형태로 저장합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(excel 형태에 따라 변경될 수 있습니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 각각 언어에 맞게 폴더 생성 및 저장을 진행해주면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에 해당 파일을 node를 이용해서 해당 코드를 실행해주시면 되겠습니다&lt;/p&gt;
&lt;pre id=&quot;code_1758065937567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;download:lang&quot;: &quot;node src/i18n/index.js&quot;,
    &quot;build&quot;: &quot;yarn download:lang &amp;amp;&amp;amp; next build&quot;,
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 말이죠&lt;/p&gt;</description>
      <category>WEB</category>
      <category>Google sheet</category>
      <category>localization</category>
      <category>react</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/136</guid>
      <comments>https://devmemory.tistory.com/136#entry136comment</comments>
      <pubDate>Tue, 16 Sep 2025 15:32:13 +0900</pubDate>
    </item>
    <item>
      <title>React - Multiple camera recording</title>
      <link>https://devmemory.tistory.com/135</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 여러개의 카메라를 제어할 일이 생겼는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번에 모든 카메라를 보여주는 형태는 아니고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 시점에 카메라를 스위칭 후 화면에 보여주고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 화면을 원하는 형태로 편집 후 녹화해 주는 기능이 필요했습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;output.gif&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; data-alt=&quot;&amp;amp;lt; 3초마다 변경되는 화면 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ucdn4/btsQAhcR6cp/u90Iq8ErQeMP3iRwX0SOik/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;405&quot; data-filename=&quot;output.gif&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;405&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 3초마다 변경되는 화면 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 고민했던 부분은 카메라를 껏다 켯다 해보는거 였는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;media recorder n개를 사용해서 각각 녹화 후 timeline에 맞게 ffmpeg으로 붙여주는 번거로운 작업이 예상 되었고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;지연이랑 플리커링 부분 &lt;/span&gt;그리고 만약 카메라가 원하는 시점에 안켜지면 서비스 품질이 떨어질거라 예상해 다른 방법을 선택했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. streaming은 계속 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. canvas를 이용해서 카메라 보여주기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 시간 혹은 특정 이벤트에 따른 video 스위칭&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. canvas를 media recorder로 녹화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 코드로 확인해보겠습니다&lt;/p&gt;
&lt;pre id=&quot;code_1757991987514&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;export const getMultipleMedia = async () =&amp;gt; {
  const list = await getMultipleCameras();

  let streams: MediaStream[] = [];

  for (let i = 0; i &amp;lt; list.length; i++) {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: i === 0,
      video: {
        deviceId: { exact: list[i] },
        width: VIDEO_SIZE.width,
        height: VIDEO_SIZE.height,
        aspectRatio: VIDEO_SIZE.aspectRatio,
        frameRate: { ideal: 30, max: 60 },
      },
    });

    streams = [...streams, stream];
  }

  return streams;
};

const getMultipleCameras = async () =&amp;gt; {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    return [];
  }

  // Request permission
  const stream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });

  try {
    const devices = await navigator.mediaDevices.enumerateDevices();

    // 비디오 입력 장치 (카메라) 필터링
    const videoDevices = devices.filter(
      (device) =&amp;gt; device.kind === &quot;videoinput&quot;
    );

    let list: string[] = [];

    for (const e of videoDevices) {
      const label = e.label.toLowerCase();

      if (사용하는 카메라 label) {
        list = [...list, e.deviceId];
      }
    }

    return list;
  } finally {
    // Stop all tracks to turn off camera/mic
    stream.getTracks().forEach((track) =&amp;gt; track.stop());
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용전에 먼저 permission을 가져와야되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 사용하던 request permission은 deprecated돼서, 껏다가 켜는 형태로 진행 했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 사용 가능한 기기들을 검색 후, 원하는 카메라를 가져옵니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 카메라 id를 이용해서 stream들을 불러옵니다&lt;/p&gt;
&lt;pre id=&quot;code_1757991609039&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useTestRecord = () =&amp;gt; {
  const videoRefList = useRef&amp;lt;HTMLVideoElement[]&amp;gt;([]);

  const frameId = useRef&amp;lt;number&amp;gt;(null);

  const canvasRef = useRef&amp;lt;HTMLCanvasElement&amp;gt;(null);

  const [streams, setStreams] = useState&amp;lt;MediaStream[]&amp;gt;([]);

  const activeIdx = useRef&amp;lt;number&amp;gt;(0);

  const { startInterval, stopInterval } = useInterval(() =&amp;gt; {
    activeIdx.current = (activeIdx.current + 1) % streams.length;
  }, 3000);

  console.log(videoRefList.current, activeIdx);

  const {
    videoBlob,
    setVideoBlob,
    setRecordVideo,
    startRecordingVideo,
    stopRecordingVideo,
  } = useTestVideo();

  useEffect(() =&amp;gt; {
    init();

    commonUtil.delay(5000).then(() =&amp;gt; {
      onStartRecording();
    });
    return () =&amp;gt; {
      onTurnOffMedia();
    };
  }, []);

  useEffect(() =&amp;gt; {
    onStreamMedia();
  }, [streams]);

  useEffect(() =&amp;gt; {
    if (videoBlob) {
      downloadBlob(videoBlob, &quot;test.webm&quot;);
    }
  }, [videoBlob]);

  const downloadBlob = (blob: Blob, filename: string) =&amp;gt; {
    const url = URL.createObjectURL(blob);
    const a = document.createElement(&quot;a&quot;);
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  };

  const init = async () =&amp;gt; {
    const streams = await getMultipleMedia();

    setStreams(streams);
  };

  const onStreamMedia = () =&amp;gt; {
    if (streams.length === 0) {
      return;
    }

    for (let i = 0; i &amp;lt; streams.length; i++) {
      videoRefList.current[i].srcObject = streams[i];
    }

    videoRefList.current[0].onloadedmetadata = () =&amp;gt; {
      drawFrame();
      startInterval();
    };

    const videoStream = canvasRef.current!.captureStream(30);

    setRecordVideo(videoStream);
  };

  const onTurnOffMedia = () =&amp;gt; {
    for (let i = 0; i &amp;lt; streams.length; i++) {
      const mediaStream = streams[i];
      mediaStream.getTracks().forEach((track) =&amp;gt; {
        track.stop();
      });
    }

    videoRefList.current.forEach((video) =&amp;gt; {
      video.pause();
      video.srcObject = null;
    });

    stopRecordingVideo();

    stopInterval();
    setVideoBlob(null);

    if (frameId.current) {
      cancelAnimationFrame(frameId.current);
      frameId.current = null;
    }
  };

  const onStartRecording = () =&amp;gt; {
    startRecordingVideo();
  };

  const drawFrame = () =&amp;gt; {
    drawCanvas(canvasRef.current, videoRefList.current[activeIdx.current]);

    frameId.current = requestAnimationFrame(drawFrame);
  };

  return {
    videoRefList,
    onTurnOffMedia,
    canvasRef,
    streams,
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 hook에서는 canvas를 이용해서 video를 그려서 출력해주는 부분을 진행해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 video 세팅 후 interval이 지나면 active index를 바꿔주면서 canvas에 그려주는 작업을 수행합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;drawCanvas는 여러가지 세팅이 들어갈텐데&amp;nbsp; 필요한 부분만 보자면&lt;/p&gt;
&lt;pre id=&quot;code_1757992721735&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ctx = canvas.getContext(&quot;2d&quot;);
ctx.drawImage(video, ...기타 크기 세팅);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;video 태그에 출력되는 부분을 canvas에 그려주는 형태입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 해당 부분은 media recorder를 통해 record하는 부분인데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 코드는 예제가 많이 있을테니 생략하도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 지금 현재 고민되는 포인트는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 여러 카메라의 스트리밍을 한 pc에서 받기에 더 나은 쿨링 솔루션이 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. media tracking을 중단했다가 시작하는게 성능적으로 문제 없고, 발열제어에 도움이 될지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. deviceId 지정 불가능 및 가변적&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3번 부분은 gpt신에게 물어보니&lt;/p&gt;
&lt;pre id=&quot;code_1757996500165&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;IDs are only unstable if: unplug/replug, switch ports, or site loses camera permission.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 작동 시키고 별도로 코드분리, 전원 on/off, permission변경만 없으면 동일하다 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 시나리오로 전원켜기 -&amp;gt; 어드민에서 지정 -&amp;gt; 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분을 자동화할 수 있을지 확인해봐야될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 프로젝트를 더 진행해가면서 맞춰봐야될거 같습니다&lt;/p&gt;</description>
      <category>WEB</category>
      <category>Camera</category>
      <category>Canvas</category>
      <category>getUserMedia</category>
      <category>MediaDevices</category>
      <category>multiple camera</category>
      <category>react</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/135</guid>
      <comments>https://devmemory.tistory.com/135#entry135comment</comments>
      <pubDate>Tue, 16 Sep 2025 12:21:34 +0900</pubDate>
    </item>
    <item>
      <title>Ubuntu - install cursor easily</title>
      <link>https://devmemory.tistory.com/134</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(해당 작업은 1.3 .AppImage만 다운로드 가능한 시점에 만들어졌습니다)&lt;br /&gt;&lt;br /&gt;사용하는 우분투 pc에 cursor ide를 설치하려고 보니 조금 귀찮은 설정이 필요하더군요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 프로세스를 누가 정리해놔서 확인해보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(링크 : &lt;a href=&quot;https://gist.github.com/evgenyneu/5c5c37ca68886bf1bea38026f60603b6&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://gist.github.com/evgenyneu/5c5c37ca68886bf1bea38026f60603b6&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. appImage 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. libfuse2 설치&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. appImage 실행 가능하도록 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4.1 script로 실행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4.2 파일로 만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐 한번 해놓으면 문제 없지만 앞으로 사용할 pc마다 하는건 여간 귀찮은일이 아닐 수 없습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 자동화를 한번 해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로세스를 간단히 설명드리자면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. curl을 이용해 cursor appimage download&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. cursor 아이콘 임시 폴더에 설치 후 특정 폴더로 이동(해당 폴더로 다운로드시 permission문제 발생)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. appimage, 아이콘 permission 변경&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 앱 표시부분에 등록&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 임시폴더 삭제&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 삭제할 때는 설치된 경로에 있는 파일 제거로 진행을 해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용법은 다음과 같고&lt;/p&gt;
&lt;pre id=&quot;code_1752047248667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//설치시
curl -fsSL https://raw.githubusercontent.com/devmemory/cursor-ubuntu-installer/main/scripts/cursor.sh | sudo bash -s -- install

//삭제시
curl -fsSL https://raw.githubusercontent.com/devmemory/cursor-ubuntu-installer/main/scripts/cursor.sh | sudo bash -s -- uninstall&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 궁금하신 분들은 아래 링크를 참고해주세요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(링크: &lt;a href=&quot;https://github.com/devmemory/cursor-ubuntu-installer&quot;&gt;https://github.com/devmemory/cursor-ubuntu-installer&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-07-09 16-49-02.png&quot; data-origin-width=&quot;229&quot; data-origin-height=&quot;217&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; data-alt=&quot;&amp;amp;lt; 설치 후 등록된 아이콘 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LTnwM/btsPaZEL6zY/G8MbU8omjLMgt0WkVoOyLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLTnwM%2FbtsPaZEL6zY%2FG8MbU8omjLMgt0WkVoOyLK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;229&quot; height=&quot;217&quot; data-filename=&quot;스크린샷 2025-07-09 16-49-02.png&quot; data-origin-width=&quot;229&quot; data-origin-height=&quot;217&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 설치 후 등록된 아이콘 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;-- 이하 필자의 헛소리가 있으니 필요하신부분을 얻으셨다면 그냥 넘겨주시면 됩니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 필자는 cursor를 한번도 안사용해봤고 cody ai라는 vs code extension을 사용했었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(한참 일할 때 회사에서 안사줬어요..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰다보니까 이런 무료 extension도 나름 훌륭(?)한데 ide로 내놓는건 얼마나 훌륭할까 해서 써보려하니..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치부터 귀찮아보이고 삭제도 귀찮아보였습니다 개발자는 귀찮은 일을 해결하는게 주 업무다보니(?)&lt;s&gt;&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런거 하나 만들어볼까 해서 만들어봤는데 GPT가 잘못알려줘서 삽질한 부분이 있었던거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커서 설치후 이것저것 테스트 해봐야되는데 셀 스크립트만 열심히 만진건 안비밀..&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends2&quot; data-emoticon-name=&quot;068&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/068.png&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends2/large/068.png&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;</description>
      <category>해왔던 삽질..</category>
      <category>command</category>
      <category>cursor</category>
      <category>cursor-ubuntu-installer</category>
      <category>how to install cursor on ubuntu</category>
      <category>ubuntu</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/134</guid>
      <comments>https://devmemory.tistory.com/134#entry134comment</comments>
      <pubDate>Wed, 9 Jul 2025 16:44:30 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 4. outro</title>
      <link>https://devmemory.tistory.com/133</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분에서는 구현한 코드 깃헙 주소와 결과물 gif&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 작업하던중 발생한 문제에 대해 공유해보고자 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;core.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; data-alt=&quot;&amp;amp;lt; upload UI 개선 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bPUukS/btsMUzgbQhT/3ciIN1Doku1k5cK9RcpNMk/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;core.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; upload UI 개선 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;hls.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; data-alt=&quot;&amp;amp;lt; upload video, streaming HLS&amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/JzhRb/btsMP1YBHtk/cKxo7oV5qKJ0SfRUlFlqp0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;hls.gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; upload video, streaming HLS&amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버쪽 작업하면서 가장 시간 많이잡아먹은 부분은 다름아닌 nodemon문제 였습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nodemon default가 js나 ts가 변경될 때 재시작 되는 특성을 가지는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ts도 아닌 바이너리 파일(segment.ts)가 변경될때마다 서버가 재시작이 돼서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;api는 cancel됐는데 영상은 남아있는 상황이 생겼던거죠..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거 원인 찾느라 들인 시간이 구현하는 부분보다 더 많이들었었습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 클라이언트쪽에서 테스트하는데 serving url잘못 잡아놔서 발생했던 문제가 조금 있었습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분을 조금 더 빠르게 지나갔으면 더 빨리 완성했을텐데 하는 아쉬움이 남았습니다..&lt;/p&gt;
&lt;figure contenteditable=&quot;false&quot; data-ke-type=&quot;emoticon&quot; data-ke-align=&quot;alignCenter&quot; data-emoticon-type=&quot;friends1&quot; data-emoticon-name=&quot;015&quot; data-emoticon-isanimation=&quot;false&quot; data-emoticon-src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/015.gif&quot;&gt;&lt;img src=&quot;https://t1.daumcdn.net/keditor/emoticon/friends1/large/015.gif&quot; width=&quot;150&quot; /&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 예제 깃헙 주소 : &lt;a href=&quot;https://github.com/devmemory/hls_example&quot;&gt;https://github.com/devmemory/hls_example&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intro : &lt;a href=&quot;https://devmemory.tistory.com/130&quot;&gt;https://devmemory.tistory.com/130&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node server :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/131&quot;&gt;https://devmemory.tistory.com/131&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React client :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/132&quot;&gt;https://devmemory.tistory.com/132&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>HLS</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/133</guid>
      <comments>https://devmemory.tistory.com/133#entry133comment</comments>
      <pubDate>Wed, 19 Mar 2025 18:06:14 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 3. HLS player client</title>
      <link>https://devmemory.tistory.com/132</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트쪽은 코드가 상대적으로 많을예정인데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 영상 플레이어 자체가 이런저런 기능이 들어가다보니 상대적으로 많아진것도 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 api 구현부분부터 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;services/api.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372238625&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default class Api {
  private instance: AxiosInstance;

  constructor(
    baseURL = &quot;/&quot;,
    timeout = 60000
  ) {
    this.instance = axios.create({
      baseURL,
      timeout,
    });

    this.instance.interceptors.request.use(
      (config) =&amp;gt; {
        return config;
      },
      (error: AxiosError) =&amp;gt; {
        return Promise.reject(error);
      }
    );

    this.instance.interceptors.response.use(
      (response) =&amp;gt; {
        return response;
      },
      async (error: AxiosError) =&amp;gt; {
        return Promise.reject(error)
      }
    );
  }

  /** - params setting : get(url, {a:1,b:2}) =&amp;gt; url?a=1&amp;amp;b=2 */
  protected async get&amp;lt;T&amp;gt;(url: string, params?: any) {
    const res = await this.instance.get&amp;lt;T&amp;gt;(url, { params });

    const rm = res.data;

    return rm;
  }

  protected async post&amp;lt;T&amp;gt;(url: string, data: any, config?: AxiosRequestConfig) {
    const res = await this.instance.post&amp;lt;T&amp;gt;(url, data, config);

    const rm = res.data;

    return rm;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언제나 사용하는 공통 axios 셋팅.. 여기에 인증인가 부분이랑 re issue부분은 생략했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;services/videoApi.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372306278&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class VideoApi extends Api {
  async getVideoList() {
    return await super.get&amp;lt;string[]&amp;gt;(&quot;/api/video/list&quot;);
  }

  async uploadVideo(
    file: File,
    onUploadProgress?: (e: AxiosProgressEvent) =&amp;gt; void
  ) {
    const formData = new FormData();
    formData.append(&quot;file&quot;, file);

    const res = await super.post&amp;lt;{ msg: string; url: string }&amp;gt;(
      &quot;/api/video/upload&quot;,
      formData,
      {
        headers: { &quot;Content-Type&quot;: &quot;multipart/form-data&quot; },
        onUploadProgress,
      }
    );

    return res.msg;
  }
}

export const getVideoList = async () =&amp;gt; {
  const api = new VideoApi();

  return await api.getVideoList();
};

export const uploadVideo = async (model: {
  file: File;
  onUploadProgress?: (e: AxiosProgressEvent) =&amp;gt; void;
}) =&amp;gt; {
  const api = new VideoApi();

  return await api.uploadVideo(model.file, model.onUploadProgress);
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 tanstack query를 이용하고 있어서 선언부 클래스 따로, 사용하는 함수로 정의하고 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제에 사용하는 api는 2개로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. video 리스트 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 비디오 업로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;route/upload/index.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372483334&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const UploadPage = () =&amp;gt; {
  const controller = useUploadController();

  return (
    &amp;lt;div className={styles.div_container}&amp;gt;
      &amp;lt;label
        className={`${styles.label_file} ${controller.isIn ? styles.in : &quot;&quot;} ${controller.isLoading ? styles.pending : &quot;&quot;}`}
        onClick={controller.onClickPrevent}
        onDragOver={controller.onDragEnter}
        onDragLeave={controller.onDragLeave}
        onDrop={controller.onDropFiles}&amp;gt;
        &amp;lt;LabelComponent.Loading when={controller.isLoading}&amp;gt;
          &amp;lt;Loading centerFloat={false} /&amp;gt;
        &amp;lt;/LabelComponent.Loading&amp;gt;
        &amp;lt;LabelComponent.Indicator when={controller.percent !== 0}&amp;gt;
          &amp;lt;div&amp;gt;uploading - {controller.percent}%&amp;lt;/div&amp;gt;
        &amp;lt;/LabelComponent.Indicator&amp;gt;
        &amp;lt;LabelComponent.Text
          when={!controller.isLoading &amp;amp;&amp;amp; controller.percent === 0}&amp;gt;
          &amp;lt;div className={styles.div_upload_btn}&amp;gt;
            Click here to upload video file
            &amp;lt;img
              className={styles.img_upload}
              width={30}
              height={30}
              src=&quot;/assets/images/upload.svg&quot;
              onClick={controller.onOpenExplorer}
            /&amp;gt;
          &amp;lt;/div&amp;gt;
          &amp;lt;p&amp;gt;Or&amp;lt;/p&amp;gt;
          &amp;lt;div&amp;gt;Video file in box&amp;lt;/div&amp;gt;
          {controller.errMsg &amp;amp;&amp;amp; (
            &amp;lt;p className={styles.p_error}&amp;gt;{controller.errMsg}&amp;lt;/p&amp;gt;
          )}
        &amp;lt;/LabelComponent.Text&amp;gt;
      &amp;lt;/label&amp;gt;
      &amp;lt;input
        ref={controller.ref}
        className=&quot;hidden&quot;
        type=&quot;file&quot;
        onChange={controller.onChangeFile}
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;hooks/useUploadController.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372634244&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useUploadController = () =&amp;gt; {
  const ref = useRef&amp;lt;HTMLInputElement&amp;gt;(null);

  const [isIn, setIsIn] = useState&amp;lt;boolean&amp;gt;(false);
  const [errMsg, setErrMsg] = useState&amp;lt;string | null&amp;gt;(null);
  const [percent, setPercent] = useState&amp;lt;number&amp;gt;(0);

  const mutation = useMutation({
    mutationFn: uploadVideo,
    onSuccess(result) {
      toast.success(result);
    },
    onError(error) {
      toast.error(error.message);
      setPercent(0);
      ref.current!.value = &quot;&quot;;
    },
  });

  const onDropFiles = (e: DragEvent&amp;lt;HTMLLabelElement&amp;gt;) =&amp;gt; {
    e.preventDefault();

    const fileList = e.dataTransfer.files;
    const file = _validateFile(fileList);

    if (file) {
      onUploadFile(file);
    }

    onDragLeave();
  };

  const onChangeFile = (e: ChangeEvent&amp;lt;HTMLInputElement&amp;gt;) =&amp;gt; {
    const files = e.target.files;

    const file = _validateFile(files);

    console.log({ file });

    if (file) {
      onUploadFile(file);
    }
  };

  const onUploadFile = (file: File) =&amp;gt; {
    try {
      mutation.mutate({ file, onUploadProgress });
    } catch (e) {
      ref.current!.value = &quot;&quot;;
      setErrMsg(`${e}`);
    }
  };

  const checkFileSize = (file: File) =&amp;gt; {
    if (file.size &amp;gt; MAX_FILE_SIZE) {
      const max = Math.round(MAX_FILE_SIZE / 1024 / 1024);
      const uploaded = Math.round(file.size / 1024 / 1024);
      return `File size is too big. Maximum size is ${max}Mb, uploaded file size is ${uploaded}Mb`;
    }

    return null;
  };

  const onUploadProgress = (e: AxiosProgressEvent) =&amp;gt; {
    const uploadPercent = Math.round((e.loaded * 100) / (e.total ?? 1));

    if (uploadPercent &amp;lt; 100) {
      setPercent(uploadPercent);
    } else {
      setTimeout(() =&amp;gt; {
        ref.current!.value = &quot;&quot;;
        setPercent(0);
      }, 3000);
    }
  };

  const _validateFile = (list: FileList | null) =&amp;gt; {
    if (list === null) {
      setErrMsg(&quot;File doesn't exist&quot;);
      return;
    }

    if (list.length &amp;gt; 1) {
      setErrMsg(&quot;Please drop only one zip file&quot;);
      return;
    }

    const file = list[0];

    if (!file.type.startsWith(&quot;video/&quot;)) {
      setErrMsg(&quot;Only video files are allowed.&quot;);
      ref.current!.value = &quot;&quot;;

      return;
    } else {
      const msg = checkFileSize(file);

      setErrMsg(msg);
    }

    return file;
  };

  const onOpenExplorer = () =&amp;gt; {
    ref.current?.click();
  };

  const onClickPrevent = (e: MouseEvent) =&amp;gt; {
    e.preventDefault();
  };

  const onDragEnter = (e: MouseEvent) =&amp;gt; {
    onClickPrevent(e);
    if (!isIn) {
      setIsIn(true);
    }
  };

  const onDragLeave = () =&amp;gt; {
    if (isIn) {
      setIsIn(false);
    }
  };

  return {
    ref,
    onDropFiles,
    onChangeFile,
    onOpenExplorer,
    onClickPrevent,
    onDragEnter,
    onDragLeave,
    isIn,
    errMsg,
    percent,
    isLoading: mutation.isPending
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(css를 생략해도 view 코드가 참 많은거 같습니다..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 input 제어하는부분이랑 서버에 업로드 하는 부분만 구현해놨고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서브 컴포넌트를 통해, 어떤 상태일 떄 보여지는지 구분을 해봤는데 조금 더 가독성이 나은거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 업로드 완료시 toast호출로 표시되는부분도 만들어봤습니다&lt;s&gt;&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업로드 시간 표시는 용량 작은거로 테스트할때는 거의 %가 안보이고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 response pending상태로 들어가게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 동영상 파일도 FFmpeg로 변환시 생각보다 오래 걸립니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;route/viewer/index.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742372925605&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ViewerPage = () =&amp;gt; {
  const controller = useVideoController();

  return (
    &amp;lt;div&amp;gt;
      {controller.videoError !== undefined ? (
        &amp;lt;p className={styles.p_error}&amp;gt;{controller.videoError}&amp;lt;/p&amp;gt;
      ) : (
        &amp;lt;div
          ref={controller.container}
          className={
            controller.isFullScreen
              ? `${styles.div_container} ${styles.full_screen}`
              : styles.div_container
          }&amp;gt;
          {controller.loading &amp;amp;&amp;amp; &amp;lt;Loading /&amp;gt;}
          &amp;lt;video ref={controller.videoRef} onClick={controller.togglePlay} /&amp;gt;
          &amp;lt;Overlay
            isPlaying={controller.isPlaying}
            saveMode={controller.saveMode.start}
            startFlag={controller.startFlag.current}
          /&amp;gt;
          &amp;lt;Control
            {...controller}
            saveMode={controller.saveMode.start}
            onChangeTime={controller.seek}
          /&amp;gt;
          &amp;lt;VideoList
            {...controller}
            onChangeTime={controller.seek}
            onChangeAutoPlay={controller.setAutoPlay}
            onClickVideo={controller.setVideoIdx}
          /&amp;gt;
        &amp;lt;/div&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;container ref부분은 전체화면 처리 용도로 사용하고 있고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Overlay는 좌우 이동, 타임라인 저장시 화면에 표시 등을 위해 만들어봤고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Control은 재생/정지 토글버튼, 음량, 비디오 앞으로/뒤로, 타임라인 저장, 전체화면, 화질 조정 등이 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VideoList는 영상들, 저장된 타임라인을 보여주는 부분으로 구성해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;outro에서 github 링크부분에서 확인해주시면 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(전부다 넣기에는 너무 많아서...)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;hooks/useVideoController.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742373222237&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const useVideoController = () =&amp;gt; {
  const { data } = useQuery({
    queryKey: [&quot;video_list&quot;],
    queryFn: getVideoList,
  });

  const videoList = data ?? [];

  const videoRef = useRef&amp;lt;HTMLVideoElement&amp;gt;(null);

  /** - video, control element */
  const container = useRef&amp;lt;HTMLDivElement&amp;gt;(null);

  /** - hls */
  const hlsRef = useRef&amp;lt;Hls&amp;gt;(null);

  /** - throttle */
  const isCalled = useRef&amp;lt;boolean&amp;gt;(false);

  /** - to prevent auto play */
  const startFlag = useRef&amp;lt;boolean&amp;gt;(false);

  /** - video quality levels */
  const [levelList, setLevelList] = useState&amp;lt;Level[]&amp;gt;([]);

  /** - video total and current duration */
  const [duration, setDuration] = useState&amp;lt;DurationModel&amp;gt;({
    current: 0,
    total: 0,
  });

  /** - loading for video */
  const [loading, setLoading] = useState&amp;lt;boolean&amp;gt;(false);

  /** - selected video idx */
  const [videoIdx, setVideoIdx] = useState&amp;lt;number&amp;gt;(0);

  /** - quality level idx */
  const [levelIdx, setLevelIdx] = useState&amp;lt;number&amp;gt;(0);

  /** - to toggle button */
  const [isPlaying, setIsPlaying] = useState&amp;lt;boolean&amp;gt;(false);

  /** - check screen mode */
  const [isFullScreen, setIsFullScreen] = useState&amp;lt;boolean&amp;gt;(false);

  /** - video error msg */
  const [videoError, setVideoError] = useState&amp;lt;string&amp;gt;();

  /** - play next video automatically */
  const [autoPlay, setAutoPlay] = useState&amp;lt;boolean&amp;gt;(true);

  /** - saved time line */
  const [savedTimes, setSavedTimes] = useState&amp;lt;TimelineModel[]&amp;gt;([]);

  /** - save mode on/off */
  const [saveMode, setSaveMode] = useState&amp;lt;SaveTimeModel&amp;gt;({
    start: false,
    startTime: 0,
    endTime: 0,
  });

  // init media and listener
  useEffect(() =&amp;gt; {
    if (videoList.length &amp;gt; 0) {
      _setVideoListener();
      _initMedia();
    }

    return () =&amp;gt; {
      _setVideoListener(true);
      _reset();
    };
  }, [videoList, videoIdx]);

  // screen mode change
  useEffect(() =&amp;gt; {
    document.onfullscreenchange = () =&amp;gt; {
      setIsFullScreen(!isFullScreen);
    };

    return () =&amp;gt; {
      document.onfullscreenchange = null;
    };
  }, [isFullScreen]);

  // save timeline
  useEffect(() =&amp;gt; {
    if (saveMode.startTime &amp;lt; saveMode.endTime) {
      const { startTime, endTime, img } = saveMode;

      setSavedTimes([...savedTimes, { startTime, endTime, img }]);
    }
  }, [saveMode]);

  // space : play/pause toggle, left/right arrow : move video time
  useEffect(() =&amp;gt; {
    document.addEventListener(&quot;keydown&quot;, _onKeyDown);

    return () =&amp;gt; {
      document.removeEventListener(&quot;keydown&quot;, _onKeyDown);
    };
  }, [isPlaying, duration.current]);

  /** - set video url */
  const _initMedia = async () =&amp;gt; {
    setLoading(true);

    const url = `/api/video/${videoList[videoIdx]}/master.m3u8`;

    console.log({ url, videoList });

    if (Hls.isSupported()) {
      hlsRef.current = new Hls();
      hlsRef.current.loadSource(url);
      hlsRef.current.attachMedia(videoRef.current!);

      hlsRef.current.on(Events.MANIFEST_PARSED, (_, data) =&amp;gt; {
        console.log({ data });
        setLevelIdx(data.firstLevel);
        setLevelList(data.levels);
      });
    } else {
      toast.error(&quot;This browser doesn't support HLS&quot;);
      videoRef.current!.src = url;
    }
  };

  /** - set video listeners */
  const _setVideoListener = (reset = false) =&amp;gt; {
    if (reset) {
      if(videoRef.current){
        videoRef.current.onloadedmetadata = null;
        videoRef.current.oncanplay = null;
        videoRef.current.ontimeupdate = null;
        videoRef.current.onended = null;
        videoRef.current.onplay = null;
        videoRef.current.onpause = null;
        videoRef.current.onerror = null;
      }
    } else {
      videoRef.current!.onloadedmetadata = _onLoadMetaData;
      videoRef.current!.oncanplay = _canPlayVideo;
      videoRef.current!.ontimeupdate = _onPlayingTimeUpdate;
      videoRef.current!.onended = onNextVideo;
      videoRef.current!.onplay = () =&amp;gt; {
        setIsPlaying(true);
      };
      videoRef.current!.onpause = () =&amp;gt; {
        setIsPlaying(false);
      };
      videoRef.current!.onerror = (e) =&amp;gt; {
        setVideoError(`[Error] ${JSON.stringify(e)}`);
      };
    }
  };

  /** - setting duration */
  const _onLoadMetaData = (e: Event) =&amp;gt; {
    const target = e.target as HTMLVideoElement;

    setDuration((state) =&amp;gt; {
      return { ...state, total: target.duration };
    });

    if (startFlag.current &amp;amp;&amp;amp; autoPlay) {
      _play();
    }
  };

  /** - check if video is playable */
  const _canPlayVideo = () =&amp;gt; {
    if (videoRef.current!.readyState === 4) {
      setLoading(false);
    } else {
      setLoading(true);
    }
  };

  /** - move to the next video */
  const onNextVideo = () =&amp;gt; {
    const nextIdx = videoIdx + 1;

    if (nextIdx &amp;lt; videoList.length) {
      setVideoIdx(nextIdx);
    } else {
      setVideoIdx(0);
    }
  };

  /** - move to previous video */
  const onPreviousVideo = () =&amp;gt; {
    const previousIdx = videoIdx - 1;

    if (previousIdx &amp;gt;= 0) {
      setVideoIdx(previousIdx);
    } else {
      setVideoIdx(videoList.length - 1);
    }
  };

  /** - update playing time */
  const _onPlayingTimeUpdate = (e: Event) =&amp;gt; {
    // when level is changed, stop updating
    if (videoRef.current?.paused) {
      return;
    }

    const target = e.target as HTMLVideoElement;

    setDuration((state) =&amp;gt; {
      return { ...state, current: target.currentTime };
    });
  };

  /** - reset video data and remove listener, saved times */
  const _reset = () =&amp;gt; {
    hlsRef.current?.destroy();

    setLevelList([]);
    setSavedTimes([]);
    setSaveMode({ start: false, startTime: 0, endTime: 0 });
    setDuration({ current: 0, total: 0 });
  };

  /** - keyboard event */
  const _onKeyDown = (e: KeyboardEvent) =&amp;gt; {
    switch (e.code) {
      case KEY_CODE.space:
        togglePlay();
        break;
      case KEY_CODE.arrowRight:
        seek(duration.current + 1);
        break;
      case KEY_CODE.arrowLeft:
        seek(duration.current - 1);
        break;
    }
  };

  /** - play video */
  const _play = async () =&amp;gt; {
    if (videoRef.current!.paused) {
      await videoRef.current?.play();
    }
  };

  /** - pause video */
  const _pause = () =&amp;gt; {
    if (!videoRef.current!.paused) {
      videoRef.current?.pause();
    }
  };

  /** - move to position */
  const seek = (time: number) =&amp;gt; {
    if (isCalled.current) {
      return;
    } else {
      isCalled.current = true;

      commonUtil.delay(100).then(() =&amp;gt; {
        isCalled.current = false;
      });
    }

    videoRef.current!.currentTime = time;

    setLoading(true);

    setDuration((state) =&amp;gt; {
      return { ...state, current: time };
    });
  };

  /** - change volume */
  const onChangeVolume = (value: number) =&amp;gt; {
    videoRef.current!.volume = value;
  };

  /** - toggle save time mode */
  const toggleSaveMode = async () =&amp;gt; {
    let img: string | undefined;
    if (!saveMode.start) {
      img = await _getThumbnail();
      console.log({ img });
    }

    setSaveMode((state) =&amp;gt; {
      if (state.start) {
        return { ...state, start: !state.start, endTime: duration.current };
      } else {
        return {
          ...state,
          start: !state.start,
          startTime: duration.current,
          img,
        };
      }
    });
  };

  /** - toggle play/pause */
  const togglePlay = () =&amp;gt; {
    if (isPlaying) {
      _pause();
    } else {
      if (!startFlag.current) {
        startFlag.current = true;
      }
      _play();
    }
  };

  /** - toggle save time mode */
  const toggleSaveMode = async () =&amp;gt; {
    let img: string | undefined;
    if (!saveMode.start) {
      img = await _getThumbnail();
      console.log({ img });
    }

    setSaveMode((state) =&amp;gt; {
      if (state.start) {
        state.endTime = duration.current;
      } else {
        state.startTime = duration.current;
        state.img = img;
      }

      return { ...state, start: !state.start };
    });
  };

  /** - get thumbnail from video */
  const _getThumbnail = async () =&amp;gt; {
    const canvas = await html2canvas(videoRef.current!, { scale: 0.1 });
    const imageFile = canvas.toDataURL(&quot;image/jpg&quot;, 0.1);

    return imageFile;
  };

  /** - change quality level */
  const onChangeLevel = (i: number) =&amp;gt; {
    setLevelIdx(i);

    hlsRef.current!.currentLevel = i;

    if (startFlag.current) {
      hlsRef.current!.detachMedia();

      hlsRef.current!.attachMedia(videoRef.current!);

      setLoading(true);
      _pause();
    }
  };

  return {
    container,
    videoRef,
    levelList,
    levelIdx,
    videoList,
    videoIdx,
    setVideoIdx,
    duration,
    isFullScreen,
    toggleScreenMode,
    togglePlay,
    toggleSaveMode,
    saveMode,
    savedTimes,
    seek,
    loading,
    onChangeVolume,
    isPlaying,
    onNextVideo,
    onPreviousVideo,
    videoError,
    autoPlay,
    setAutoPlay,
    onChangeLevel,
    startFlag,
  };
};

export default useVideoController;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편의 기능들이 많이 있어서 해당 부분에서는 HLS 사용 부분&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 thumbnail생성 부분만 다뤄봐도 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 setVideoListener부터 보면, HLS관련 이벤트 설정/해제 부분으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 시간 셋팅, play/stop/update 등 영상 시간과 관련된 부분과 실행 가능여부 콜백들을 설정해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 initMedia부분을 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS에 master.m3u8을 조회하고, level(화질 리스트)를 설정해주면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;attachMedia로 설정된 video 태그에 영상이 전달되게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크에 따른 화질을 자동으로 셋팅하고 싶으면 다음과 같게 하면 됩니다&lt;/p&gt;
&lt;pre id=&quot;code_1742373931075&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const hls = new Hls({
  startLevel: -1, // -1이면 가장 적절한 화질을 자동 선택
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 영상을 변경할 때는 onChangeLevel쪽을 보면 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 HLS 레벨 설정, detachMedia, attachMedia순으로 콜 해주면 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 특정 타임라인 저장하는 부분을 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캡쳐하는 duration 저장 및 html2canvs라이브러리를 이용해서 해당 돔을 이미지로 추출합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 timeline과 같이 저장해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나머지는 간단한 키보드 이벤트, duration 계산 기타 등등이니 넘어가도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intro : &lt;a href=&quot;https://devmemory.tistory.com/130&quot;&gt;https://devmemory.tistory.com/130&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node server :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/131&quot;&gt;https://devmemory.tistory.com/131&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Outro :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/133&quot;&gt;https://devmemory.tistory.com/133&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>hls.js</category>
      <category>html2canvas</category>
      <category>react</category>
      <category>thumbnail</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/132</guid>
      <comments>https://devmemory.tistory.com/132#entry132comment</comments>
      <pubDate>Wed, 19 Mar 2025 17:58:21 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 2. converting and serving server</title>
      <link>https://devmemory.tistory.com/131</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 일반 영상을 올릴 때, FFmpeg으로 변환하는 과정과&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 데이터를 클라이언트에 전달하는 부분을 보도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;index.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742369649833&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const app = express();

const options = {
  origin: &quot;http://localhost:3000&quot;,
  credentials: true,
  optionsSuccessStatus: 200,
};

app.use(cors(options));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use(&quot;/api/video&quot;, video);

app.listen(8080, () =&amp;gt; {
  console.log(`[server] running on localhost:${8080} `);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 /api/video만 사용하고 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;route/video&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742369459161&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const router = Router();

router.get(&quot;/list&quot;, videoController.getList);
router.get(&quot;/:videoId/master.m3u8&quot;, videoController.getMaster)
router.get(&quot;/:videoId/:name/index.m3u8&quot;, videoController.getVideo);
router.get(&quot;/:videoId/:name/:segment&quot;, videoController.getSegment);
router.post(&quot;/upload&quot;, videoController.upload);

export default router;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 사용하는 api는 총 5개로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. /list: 영상 리스트 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. /:videoId/master.m3u8: master 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. /:videoId/:name/index.m3u8: 화질(name)에 따른 index.m3u8 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. /:videoId/:name/:segment: 화질과 영상 순서에 따른 segment 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. /upload: 영상 업로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;models/ResolutionModel.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742369723778&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export interface ResolutionModel {
  name: string;
  width: number;
  height: number;
  bitrate: string;
  audioBitrate: string;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 화질, 너비/높이, bitrate, audio bitrate를 정의 한 모델로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 예제에서는 360p, 720p부분만 해당 모델에 매핑해서 사용했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;controllers/videoController.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742369809489&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { spawn } from &quot;child_process&quot;;
import { Request, Response } from &quot;express&quot;;
import formidable from &quot;formidable&quot;;
import { accessSync, mkdirSync, readdir, statSync, writeFileSync } from &quot;fs&quot;;
import { cpus } from &quot;os&quot;;
import path from &quot;path&quot;;
import { STATUS_CODE } from &quot;../constants/code&quot;;
import {
  fileConst,
  hlsDir,
  MASTER_FORMAT,
  resolutions,
} from &quot;../constants/file&quot;;
import { ResolutionModel } from &quot;../models/ResolutionModel&quot;;

class VideoController {
  private cores = cpus().length;

  public getList(_: Request, res: Response) {
    readdir(hlsDir, (err, files) =&amp;gt; {
      if (err) {
        res
          .status(STATUS_CODE.serverError)
          .json({ msg: &quot;Error reading video folders&quot; });
        return;
      }

      const list = files.filter((file) =&amp;gt;
        statSync(path.join(hlsDir, file)).isDirectory()
      );
      console.log(&quot;[video] get list&quot;, { list });
      res.status(STATUS_CODE.ok).json(list);
    });
  }

  public async getMaster(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const masterPath = path.join(hlsDir, videoId, &quot;master.m3u8&quot;);

    console.log(&quot;[video] get master&quot;, { masterPath });

    try {
      accessSync(masterPath);
      res.header(&quot;Content-Type&quot;, &quot;application/vnd.apple.mpegurl&quot;);
      res.sendFile(masterPath);
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: &quot;File doesn't exist&quot; });
    }
  }

  public async getVideo(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const name = req.params.name;
    const m3u8Path = path.join(hlsDir, videoId, name, &quot;index.m3u8&quot;);

    console.log(&quot;[video] get m3u8&quot;, { m3u8Path });

    try {
      accessSync(m3u8Path);
      res.header(&quot;Content-Type&quot;, &quot;application/vnd.apple.mpegurl&quot;);
      res.sendFile(m3u8Path);

      console.log(&quot;[video] m3u8&quot;);
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: &quot;File doesn't exist&quot; });
    }
  }

  public async getSegment(req: Request, res: Response) {
    const videoId = req.params.videoId;
    const name = req.params.name;
    const segment = req.params.segment;
    const segmentPath = path.join(hlsDir, videoId, name, segment);

    console.log(&quot;[video] get segment&quot;, { segmentPath });

    try {
      accessSync(segmentPath);
      res.header(&quot;Content-Type&quot;, &quot;video/mp2t&quot;);
      res.sendFile(segmentPath);
      console.log(&quot;[video] segment&quot;);
    } catch (err) {
      res.status(STATUS_CODE.clientError).send({ msg: &quot;Segment not found.&quot; });
    }
  }

  public upload = async (req: Request, res: Response) =&amp;gt; {
    console.log(&quot;[upload] video&quot;);

    const form = formidable({
      keepExtensions: true,
      multiples: false,
      maxFileSize: fileConst.fileSize,
      filename(_, __, part) {
        const ext = part.originalFilename?.split(&quot;.&quot;).pop();

        return `${Date.now()}.${ext}`;
      },
    });

    try {
      const [, files] = await form.parse(req);

      if (!files.file) {
        res
          .status(STATUS_CODE.clientError)
          .json({ msg: &quot;Invalid file format.&quot; });
        return;
      }

      const file = files.file[0];
      const filePath = file.filepath;
      const outputFolder = path.join(hlsDir, path.parse(filePath).name);

      await this._convertToHLS(filePath, outputFolder);

      res.status(STATUS_CODE.ok).json({ msg: &quot;Upload &amp;amp; conversion complete&quot; });
    } catch (err) {
      console.error(&quot;[upload] Conversion error:&quot;, err);
      res.status(STATUS_CODE.serverError).json({ msg: `${err}` });
    }
  };

  private _convertToHLS = async (
    inputFilePath: string,
    outputFolder: string
  ) =&amp;gt; {
    mkdirSync(outputFolder, { recursive: true });

    const conversionPromises = resolutions.map((resolution) =&amp;gt;
      this._convertSingleBitrate({ ...resolution, inputFilePath, outputFolder })
    );

    try {
      await Promise.all(conversionPromises);

      writeFileSync(path.join(outputFolder, &quot;master.m3u8&quot;), MASTER_FORMAT);
      console.log(&quot;[convert] Master M3U8 file created!&quot;);
    } catch (error) {
      console.error(&quot;[convert] Error in FFmpeg conversion:&quot;, error);
    }
  };

  private _convertSingleBitrate = ({
    name,
    width,
    height,
    bitrate,
    audioBitrate,
    inputFilePath,
    outputFolder,
  }: ResolutionModel &amp;amp; { outputFolder: string; inputFilePath: string }) =&amp;gt; {
    return new Promise&amp;lt;void&amp;gt;((res, rej) =&amp;gt; {
      const resFolder = path.join(outputFolder, name);
      mkdirSync(resFolder, { recursive: true });

      const ffmpegProcess = spawn(&quot;ffmpeg&quot;, [
        &quot;-i&quot;,
        inputFilePath,
        &quot;-preset&quot;,
        &quot;ultrafast&quot;,
        &quot;-g&quot;,
        &quot;100&quot;,
        &quot;-sc_threshold&quot;,
        &quot;0&quot;,
        &quot;-hls_time&quot;,
        &quot;10&quot;,
        &quot;-hls_list_size&quot;,
        &quot;0&quot;,
        &quot;-hls_segment_filename&quot;,
        path.join(resFolder, &quot;segment_%03d.ts&quot;),
        &quot;-f&quot;,
        &quot;hls&quot;,
        &quot;-threads&quot;,
        `${this.cores}`,
        &quot;-c:v&quot;,
        &quot;libx264&quot;,
        &quot;-b:v&quot;,
        bitrate,
        &quot;-s&quot;,
        `${width}x${height}`,
        &quot;-c:a&quot;,
        &quot;aac&quot;,
        &quot;-b:a&quot;,
        audioBitrate,
        path.join(resFolder, &quot;index.m3u8&quot;),
      ]);

      ffmpegProcess.stdout.on(&quot;data&quot;, (data) =&amp;gt;
        console.log(`[FFmpeg ${name} stdout]: ${data}`)
      );
      ffmpegProcess.stderr.on(&quot;data&quot;, (data) =&amp;gt;
        console.error(`[FFmpeg ${name} stderr]: ${data}`)
      );

      ffmpegProcess.on(&quot;close&quot;, (code) =&amp;gt; {
        if (code === 0) {
          const m3u8Path = `/static/hls/${path.basename(resFolder)}/index.m3u8`;
          console.log(&quot;[convert] Success! HLS file:&quot;, m3u8Path);
          res();
        } else {
          console.log(&quot;[convert] FFmpeg exited with error code:&quot;, code);
          rej(new Error(`FFmpeg failed with code ${code}`));
        }
      });
    });
  };
}

export default new VideoController();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. getList는 영상이 저장된 폴더를 조회해서 클라이언트에 전달합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. getMaster, getVideo, getSegment는 생성된 텍스트, 영상 파일을 클라이언트에 전달합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. upload는 클라이언트에서 업로드한 비디오를 FFmpeg을 이용해서 HLS를 사용할 수 있는 포맷으로 변환하고, 클라이언트에 메시지를 전달합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FFmpeg부분도 간단하게 정리해봤습니다&lt;s&gt;(GPT님 감사합니다)&lt;/s&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742371635524&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;-i&quot;, inputFilePath // 원본 동영상
&quot;-preset&quot;, &quot;ultrafast&quot; // 인코딩 속도 (빠를수록 파일 크기 작음)
&quot;-g&quot;, &quot;100&quot; // 키 프레임으로 100프레임마다 키프레임을 생성 30fps 기준 3.3초마다 키프레임 생성, 숫자가 클수록 압축률이 높고 탐색이 어려움
&quot;-sc_threshold&quot;, &quot;0&quot; // 자동 키프레임 삽입 비활성
&quot;-hls_time&quot;, &quot;10&quot; // 세그먼트 길이(세그먼트 분할 초)
&quot;-hls_list_size&quot;, &quot;0&quot; // 모든 세그먼트를 리스트에 유지
&quot;-hls_segment_filename&quot;, path.join(resFolder, &quot;segment_%03d.ts&quot;) // 세그먼트 파일네임 지정
&quot;-f&quot;, &quot;hls&quot; // 출력 형식을 HLS로 설정
&quot;-threads&quot;, `${this.cores} // 성능최적화로 n개의 코어 사용
&quot;-c:v&quot;, &quot;libx264&quot; // 비디오 코덱 설정 (H.264)
&quot;-b:v&quot;, bitrate // 비디오 비트레이트 설정
&quot;-s&quot;, `${width}x${height}` // 출력 해상도 설정
&quot;-c:a&quot;, &quot;aac&quot; // 오디오 코덱 aac로 설정
&quot;-b:a&quot;, audioBitrate // 오디오 비트레이트 설정
path.join(resFolder, &quot;index.m3u8&quot;) // 출력위치 설정

// 해당 예제에 미적용한 별도 옵션
&quot;-crf&quot;, &quot;23&quot; // 일정한 품질 유지 (낮을수록 고화질)
&quot;-maxrate&quot;, bitrate // 최대 비트레이트 제한
&quot;-bufsize&quot;, &quot;2M&quot; // 비트레이트 변동 부드럽게 처리
&quot;-tune&quot;, &quot;zerolatency&quot; // 저지연으로 실시간 스트리밍할 때 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;constants/file.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1742370848424&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import path from &quot;path&quot;;
import { ResolutionModel } from &quot;../models/ResolutionModel&quot;;

export const fileConst = {
  fileSize: 500 * 1024 * 1024,
} as const;

export const resolutions: ResolutionModel[] = [
  {
    name: &quot;360p&quot;,
    width: 640,
    height: 360,
    bitrate: &quot;800k&quot;,
    audioBitrate: &quot;64k&quot;,
  },
  {
    name: &quot;720p&quot;,
    width: 1280,
    height: 720,
    bitrate: &quot;2500k&quot;,
    audioBitrate: &quot;128k&quot;,
  },
] as const;

export const MASTER_FORMAT = `#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,NAME=&quot;360&quot;
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,NAME=&quot;720&quot;
720p/index.m3u8
`;

export const hlsDir = path.join(__dirname, &quot;../../static/hls&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분은 file max size, bitrate설정 부분, master.m3u8 text 정의, 저장 위치를 정의해주는 부분입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 intro에서 언급했던 부분은데, index.m3u8은 FFmpeg에서 생성되지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;master.m3u8은 별도로 생성 해줍니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 이정도로 정리해보면 될거같고, 다음에는 클라이언트 부분입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intro :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/130&quot;&gt;https://devmemory.tistory.com/130&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React client :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/132&quot;&gt;https://devmemory.tistory.com/132&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Outro :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://devmemory.tistory.com/133&quot;&gt;https://devmemory.tistory.com/133&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>Express</category>
      <category>ffmpeg</category>
      <category>HLS</category>
      <category>M3U8</category>
      <category>segment</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/131</guid>
      <comments>https://devmemory.tistory.com/131#entry131comment</comments>
      <pubDate>Wed, 19 Mar 2025 17:08:56 +0900</pubDate>
    </item>
    <item>
      <title>HLS - 1. Intro</title>
      <link>https://devmemory.tistory.com/130</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브, 숲 등 영상 스트리밍 서비스를 하는 곳에서 주로 HLS를 사용하는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 사용하는지 어떤 특징을 가지고 있는지 간단하게 알아보겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 HLS는 HTTP Live Streaming으로 적응형 비디오 스트리밍 프로토콜입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebRTC와 달리 네트워크 환경에 따라 비트레이트를 조정해&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 인터넷 환경에서는 저화질, 빠른 인터넷 환경에서는 고화질로 품질을 전환합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 WebRTC는 실시간성이 좋지만 HLS는 지연시간이 상대적으로 깁니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Low Latency HLS라는것도 있는데, 영상통화나 회의, 게임등을 할때는 WebRTC를 선택해야됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 HTTP 기반이다 보니 별도의 미디어 서버가 필요하지 않고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CDN과 쉽게 통합이 가능해서 글로벌 서비스에 조금 더 적합&lt;/b&gt;합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그외에도 라이브 스트리밍 지원, 다양한 플랫폼 지원, 보안 등 있지만 이 부분은 생략하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS를 위해 영상을 FFmpeg을 이용해서 변환하게 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 한개의 m3u8, sgement_n.ts가 생성이 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 화질별로 여러개 변환하게 되면 다음과 같이 파일이 생성되는데&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-19 15-29-44.png&quot; data-origin-width=&quot;215&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLkXYJ/btsMPPw0IaX/pkgUj2rdfFKrf4dPpE6St0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLkXYJ/btsMPPw0IaX/pkgUj2rdfFKrf4dPpE6St0/img.png&quot; data-alt=&quot;&amp;amp;lt; 변환된 파일 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLkXYJ/btsMPPw0IaX/pkgUj2rdfFKrf4dPpE6St0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLkXYJ%2FbtsMPPw0IaX%2FpkgUj2rdfFKrf4dPpE6St0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;215&quot; height=&quot;376&quot; data-filename=&quot;스크린샷 2025-03-19 15-29-44.png&quot; data-origin-width=&quot;215&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 변환된 파일 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나씩 알아보도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 m3u8은 HLS스트리밍에서 재생목록을 관리하는 텍스트파일으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트 내용은 다음과 같습니다&lt;/p&gt;
&lt;pre id=&quot;code_1742366341677&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.000000,
segment_000.ts
#EXTINF:10.000000,
segment_001.ts
#EXTINF:2.093000,
segment_002.ts
#EXT-X-ENDLIST&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10초 단위로 어떤 segment를 바라봐야되는지 정의되어있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 segment는 확장자가 ts라 TypeScript인가 하고 오해할 수 있지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상정보 바이너리 파일입니다 그래서 열어볼수는 없지만 네트워크 탭으로 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(의미 없는짓 해보자면..)&lt;/s&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-19 15-41-58.png&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;645&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d2Eh48/btsMO6zupRj/cvtzfT8edtRH3B6ScvYlkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d2Eh48/btsMO6zupRj/cvtzfT8edtRH3B6ScvYlkk/img.png&quot; data-alt=&quot;&amp;amp;lt; segment file binary &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d2Eh48/btsMO6zupRj/cvtzfT8edtRH3B6ScvYlkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd2Eh48%2FbtsMO6zupRj%2FcvtzfT8edtRH3B6ScvYlkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;568&quot; height=&quot;645&quot; data-filename=&quot;스크린샷 2025-03-19 15-41-58.png&quot; data-origin-width=&quot;568&quot; data-origin-height=&quot;645&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; segment file binary &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 형태로 나오게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로는 master.m3u8부분을 볼텐데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 파일은 FFmpeg으로 &lt;b&gt;변환된 파일이 아닌 index.m3u8을 바라보게 직접 생성&lt;/b&gt;해주는 해주는 마스터 파일 입니다&lt;/p&gt;
&lt;pre id=&quot;code_1742366851220&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,NAME=&quot;360&quot;
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,NAME=&quot;720&quot;
720p/index.m3u8&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용을 보면 대역폭, 화질, 이름, 경로 부분을 설정해주게 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 NAME을 별도로 설정해주지 않으면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS.js(클라이언트 라이브러리)에서 name 추출이 안되는 부분이 있어서 꼭 적어주셔야됩니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-03-19 15-51-04.png&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;75&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c15BGI/btsMOEiXuIu/2FxdceBuUjnQhJdK1ky1s0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c15BGI/btsMOEiXuIu/2FxdceBuUjnQhJdK1ky1s0/img.png&quot; data-alt=&quot;&amp;amp;lt; 영상 실행시 전달받는 파일 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c15BGI/btsMOEiXuIu/2FxdceBuUjnQhJdK1ky1s0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc15BGI%2FbtsMOEiXuIu%2F2FxdceBuUjnQhJdK1ky1s0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;538&quot; height=&quot;75&quot; data-filename=&quot;스크린샷 2025-03-19 15-51-04.png&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;75&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 영상 실행시 전달받는 파일 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. master.m3u8을 조회해서 화질 정보를 클라이언트에서 파싱&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 클라이언트에서 화질 선택(자동/수동)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 해당되는 화질의 index.m3u8을 조회해서 현재 플레이에 필요한 segement 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 해당 segment재생&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 특정 시간에 도달할 때, 다음 segment조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 순서대로 진행됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브 스트리밍은 어떤식으로 진행되는지 스터디 해봤는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 녹화 -&amp;gt; RTMP로 변환 -&amp;gt; HLS로 변환 하는 과정을 거치는 형태인거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 의문이 드는게 왜 바로 HLS로 변환 안하지 였는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고민을 좀 해보니 &lt;b&gt;HLS로 보내면 여러 파일을 실시간으로 서버에 업로드&lt;/b&gt; 해야되고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 큰 대역폭 소모가 있을거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;360p 480p 720p 1080p 만 보내도 이미 4 * n개의 파일을 실시간으로 보내야되는 겁니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고로 실시간에는 RTMP를 사용하는것이다 라고 정리하면 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인트로는 이정도로 마무리 다음은 서버 구현, 클라이언트 구현 순으로 진행하도록 하겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node server : &lt;a href=&quot;https://devmemory.tistory.com/131&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devmemory.tistory.com/131&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React client : &lt;a href=&quot;https://devmemory.tistory.com/132&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://devmemory.tistory.com/132&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outro : &lt;a href=&quot;https://devmemory.tistory.com/133&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://devmemory.tistory.com/133&lt;/a&gt;&lt;/p&gt;</description>
      <category>HLS</category>
      <category>HLS</category>
      <category>http live streaming</category>
      <category>M3U8</category>
      <category>segment</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/130</guid>
      <comments>https://devmemory.tistory.com/130#entry130comment</comments>
      <pubDate>Wed, 19 Mar 2025 16:03:16 +0900</pubDate>
    </item>
    <item>
      <title>CCTV with NVR and DVR(Feat. RTSP)</title>
      <link>https://devmemory.tistory.com/129</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 진행한 프로젝트 중 CCTV와 연결하는 프로젝트가 있었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맡은 역할은 프론트엔드쪽 WebRTC 연결부분이었지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전반적인 부분을 이해하고자 간단하게 스터디 한 부분을 남겨볼까 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 NVR과 DVR에 대해 알아보도록 하겠습니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;NVR&lt;/b&gt;(Network Video Recorder) : 카메라에서 &lt;b&gt;디지털 신호로 변환된 영상&lt;/b&gt;을 네트워크를 통해 전송 후 저장&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DVR&lt;/b&gt;(Digital Video Recorder) : 카메라에서 &lt;b&gt;전송하는 아날로그 영상&lt;/b&gt;을 디지털로 변환하여 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;55.png&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQynel/btsMMeRdsSj/VrHHJijH903Tox4GI2DWZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQynel/btsMMeRdsSj/VrHHJijH903Tox4GI2DWZK/img.png&quot; data-alt=&quot;&amp;amp;lt; LAN 연결 NVR / 동축 케이블 연결 DVR &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQynel/btsMMeRdsSj/VrHHJijH903Tox4GI2DWZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQynel%2FbtsMMeRdsSj%2FVrHHJijH903Tox4GI2DWZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;644&quot; height=&quot;270&quot; data-filename=&quot;55.png&quot; data-origin-width=&quot;644&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; LAN 연결 NVR / 동축 케이블 연결 DVR &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 공동점은 다음과 같습니다&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;CCTV 카메라에서 수집한 영상 저장 및 관리&lt;/li&gt;
&lt;li&gt;실시간 녹화, 일정 시간 후 자동삭제 지원&lt;/li&gt;
&lt;li&gt;인터넷 연결을 통한 원격 모니터링&lt;/li&gt;
&lt;li&gt;사용자&amp;nbsp;인증,&amp;nbsp;암호&amp;nbsp;설정&amp;nbsp;등&amp;nbsp;보안&amp;nbsp;기능&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이점은 다음과 같습니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카메라&lt;br /&gt;NVR : LAN 케이블을 이용한 IP 카메라 사용&lt;br /&gt;DVR : BNC 케이블을 이용한 아날로그 카메라 사용&lt;/li&gt;
&lt;li&gt;화질&amp;nbsp;및&amp;nbsp;성능&lt;br /&gt;NVR : 고해상도 영상 지원(데이터 압축 효율이 높음)&lt;br /&gt;DVR : 아날로그 신호의 한계로 FHD이하&lt;/li&gt;
&lt;li&gt;인터넷&amp;nbsp;및&amp;nbsp;원격&amp;nbsp;접근&lt;br /&gt;NVR : 네트워크 기반이라 원격 접속이 쉽고, 클라우드 연동이 원할&lt;br /&gt;DVR : 원격 접속이 필요한 경우, 별도의 네트워크 장비 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(장비 부분은 개발자가 쓸 때 NVR을 사용하는게 압도적으로 유리할거 같습니다)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 개발자가 궁극적으로 필요한 부분은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 CCTV영상을 실시간으로 보여줄 수 있을지 일텐데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 제공하는 방식은 2가지입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. MJPEG&amp;nbsp;:&amp;nbsp;Motion&amp;nbsp;JPEG&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;각&amp;nbsp;영상&amp;nbsp;프레임을&amp;nbsp;JPEG&amp;nbsp;이미지로&amp;nbsp;압축하여&amp;nbsp;전송하는&amp;nbsp;방식&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;단순&amp;nbsp;카메라&amp;nbsp;스트리밍이나&amp;nbsp;저대역폭&amp;nbsp;환경에서&amp;nbsp;사용&lt;br /&gt;&amp;nbsp;&amp;nbsp;- 오디오 지원 안함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; - 단방향 통신&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;사용&amp;nbsp;프로토콜&amp;nbsp;:&amp;nbsp;HTTP(TCP/IP)&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;사용&amp;nbsp;방식&amp;nbsp;:&amp;nbsp;http://&amp;lt;IP_ADDRESS&amp;gt;/mjpeg&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. RTSP&amp;nbsp;:&amp;nbsp;Real-Time&amp;nbsp;Streaming&amp;nbsp;Protocol&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;실시간&amp;nbsp;스트리밍을&amp;nbsp;위한&amp;nbsp;프로토콜&amp;nbsp;사용&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;H.264,&amp;nbsp;H.265와&amp;nbsp;같은&amp;nbsp;압축&amp;nbsp;코덱을&amp;nbsp;사용해&amp;nbsp;조금&amp;nbsp;더&amp;nbsp;나은&amp;nbsp;품질과&amp;nbsp;낮은&amp;nbsp;대역폭&amp;nbsp;유지&amp;nbsp;가능&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;오디오&amp;nbsp;지원함(AAC:&amp;nbsp;Advanced&amp;nbsp;Audio&amp;nbsp;Coding)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; - 양방향 통신 지원&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;사용&amp;nbsp;방식&amp;nbsp;:&amp;nbsp;rtsp://&amp;lt;IP_ADDRESS&amp;gt;:&amp;lt;PORT&amp;gt;/path&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 대역폭을 비교해보면 720p, 30fps 기준&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;MJPEG&amp;nbsp;:&amp;nbsp;2~5&amp;nbsp;Mbps&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;RTSP&amp;nbsp;:&amp;nbsp;H264&amp;nbsp;&amp;ndash;&amp;nbsp;1.5~3&amp;nbsp;Mbps&amp;nbsp;/&amp;nbsp;H265&amp;nbsp;&amp;ndash;&amp;nbsp;1~1.5&amp;nbsp;Mbps&amp;nbsp;/&amp;nbsp;AAC&amp;nbsp;&amp;ndash;&amp;nbsp;64kbps&amp;nbsp;~&amp;nbsp;128&amp;nbsp;kbps&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MJPEG을 사용할때는 &lt;b&gt;별도의 변환 없이 image 태그의 src를 사용&lt;/b&gt;하면 됩니다&lt;/p&gt;
&lt;pre id=&quot;code_1742027709961&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;http://&amp;lt;IP_ADDRESS&amp;gt;/mjpeg&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 상대적으로 고대역을 필요로 하고, 단방향, 오디오 미지원같은 문제가 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 음성이 필요하거나, 양방향 통신(영상 + 음성 통화)가 필요한 경우 RTSP를 사용해야됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 &lt;b&gt;HTML에서는 기본적으로 rtsp를 지원하지 않습니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 변환서버를 사용해야되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 로드랑, 대역폭 관리, 오디오 사용 가능여부등에 따라 구현방식이 조금 달라집니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 2가지를 권장하는데 FFmpeg을 이용해 RTP 혹은 M3U8로 변환 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RTP는 WebRTC의 프로토콜 부분, M3U8은 HLS를 사용할 때 사용하는 파일 포맷입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;저지연이 필요하면 WebRTC&lt;/b&gt;, &lt;b&gt;고지연 저~고화질 지원을 한다면 HLS&lt;/b&gt;를 선택하면 되겠습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 FFmpeg을 이용한 변환 하는 방법은 다음과 같습니다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1105&quot; data-origin-height=&quot;132&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vZraD/btsMM50t0tR/uprIyi5lPKr5TeluLYoqA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vZraD/btsMM50t0tR/uprIyi5lPKr5TeluLYoqA1/img.png&quot; data-alt=&quot;&amp;amp;lt; RTP로 변환 할 때 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vZraD/btsMM50t0tR/uprIyi5lPKr5TeluLYoqA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvZraD%2FbtsMM50t0tR%2FuprIyi5lPKr5TeluLYoqA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;72&quot; data-origin-width=&quot;1105&quot; data-origin-height=&quot;132&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; RTP로 변환 할 때 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;151&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cC2jqU/btsML8Ksrrg/lMSVwvhG1S1BmkipSkn3K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cC2jqU/btsML8Ksrrg/lMSVwvhG1S1BmkipSkn3K1/img.png&quot; data-alt=&quot;&amp;amp;lt; M3U8로 변환 할 때 &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cC2jqU/btsML8Ksrrg/lMSVwvhG1S1BmkipSkn3K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcC2jqU%2FbtsML8Ksrrg%2FlMSVwvhG1S1BmkipSkn3K1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;82&quot; data-origin-width=&quot;1106&quot; data-origin-height=&quot;151&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; M3U8로 변환 할 때 &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필자의 프로젝트에서는 관리자와 유저가 CCTV를 이용해서 통화하는 부분이 있어서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebRTC를 이용해서 진행했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 peer가 bowser나 app이 아닌 영상 변환 서버가 조금 다른 부분이지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결방식은 web, app이랑 다르지 않습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>WebRTC</category>
      <category>DVR</category>
      <category>HLS</category>
      <category>M3U8</category>
      <category>MJPEG</category>
      <category>NVR</category>
      <category>RTP</category>
      <category>RTSP</category>
      <category>webrtc</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/129</guid>
      <comments>https://devmemory.tistory.com/129#entry129comment</comments>
      <pubDate>Sat, 15 Mar 2025 17:56:56 +0900</pubDate>
    </item>
    <item>
      <title>Three.js - Gerber 3D viewer</title>
      <link>https://devmemory.tistory.com/128</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Three.js를 찍먹만 해보다가 회사에서 우연히 3D circuit viewer를 개발할 기회가 생겼었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 어디서 이상한(?) 오픈소스 긁어온거 유지보수 하다보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제가 많아서 이렇게 해보면 어떨까 하고 만들어 봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;링크: &lt;a href=&quot;https://devmemory.github.io/gerber-3d-viewer/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devmemory.github.io/gerber-3d-viewer/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;gerber.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;319&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GR3ce/btsLz8kPSfU/HRMyTZHZvllHBUqDoTMuL0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GR3ce/btsLz8kPSfU/HRMyTZHZvllHBUqDoTMuL0/img.gif&quot; data-alt=&quot;&amp;amp;lt; 3D circuit viewer &amp;amp;gt;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GR3ce/btsLz8kPSfU/HRMyTZHZvllHBUqDoTMuL0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/GR3ce/btsLz8kPSfU/HRMyTZHZvllHBUqDoTMuL0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;319&quot; data-filename=&quot;gerber.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;319&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&amp;lt; 3D circuit viewer &amp;gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Gerber 파일은 PCB 제조할 때 필수적인 파일로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;copper layers, solder mask, SilkScreen, Dril files등 pcb의 납땜되는 부분, 홀, 텍스트 등을 표현한 파일입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제 데이터를 하나 보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735396922625&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;G04 Layer: BottomPasteMaskLayer*
G04 EasyEDA v6.5.44, 2024-08-30 12:30:28*
G04 77815516a58b4829bcb509a324bd7d2e,788fc08b779143ca9705167730dd7b56,00*
G04 Gerber Generator version 0.2*
G04 Scale: 100 percent, Rotated: No, Reflected: No *
G04 Dimensions in millimeters *
G04 leading zeros omitted , absolute positions ,4 integer and 5 decimal *
%FSLAX45Y45*%
%MOMM*%

%AMMACRO1*4,1,4,0.742,-0.0349,0.0349,-0.742,-0.742,0.0349,-0.035,0.742,0.742,-0.0349,0*%
%AMMACRO2*4,1,4,0.742,-0.035,0.0349,-0.742,-0.742,0.0349,-0.0349,0.742,0.742,-0.035,0*%
%AMMACRO3*4,1,4,-0.0353,-0.7424,-0.7424,-0.0353,0.0353,0.7424,0.7424,0.0353,-0.0353,-0.7424,0*%
%AMMACRO4*4,1,4,0.0353,0.7424,0.7424,0.0353,-0.0353,-0.7424,-0.7424,-0.0353,0.0353,0.7424,0*%
%AMMACRO5*4,1,4,0.742,-0.0349,0.0349,-0.742,-0.742,0.035,-0.0349,0.742,0.742,-0.0349,0*%
%AMMACRO6*4,1,4,0.742,-0.0349,0.035,-0.742,-0.742,0.0349,-0.0349,0.742,0.742,-0.0349,0*%
%AMMACRO7*4,1,4,-0.7424,0.0353,-0.0353,0.7424,0.7424,-0.0353,0.0353,-0.7424,-0.7424,0.0353,0*%
%AMMACRO8*4,1,4,-0.5005,-0.6999,0.5005,-0.6999,0.5005,0.6999,-0.5005,0.6999,-0.5005,-0.6999,0*%
%AMMACRO9*4,1,4,-0.5,-0.7,0.5,-0.7,0.5,0.7,-0.5,0.7,-0.5,-0.7,0*%
%AMMACRO10*4,1,4,-0.7417,0.0346,-0.0346,0.7417,0.7417,-0.0346,0.0346,-0.7417,-0.7417,0.0346,0*%
%ADD10MACRO1*%
%ADD11MACRO2*%
%ADD12MACRO3*%
%ADD13MACRO4*%
%ADD14MACRO5*%
%ADD15MACRO6*%
%ADD16MACRO7*%
%ADD17MACRO8*%
%ADD18MACRO9*%
%ADD20MACRO10*%

%LPD*%
D10*
G01*
X8649223Y-1402848D03*
D11*
G01*
X8529012Y-1523057D03*
D12*
G01*
X8649329Y-8635191D03*
G01*
X8529120Y-8514981D03*
D13*
G01*
X8275120Y-8260981D03*
G01*
X8395329Y-8381191D03*
D12*
G01*
X1791258Y-1777094D03*
G01*
X1671048Y-1656883D03*
D14*
G01*
X1791294Y-8260946D03*
D15*
G01*
X1671083Y-8381155D03*
D16*
G01*
X8275048Y-1777094D03*
G01*
X8395258Y-1656883D03*
D13*
G01*
X1416977Y-1402812D03*
G01*
X1537186Y-1523022D03*
D17*
G01*
X1050150Y-1135189D03*
D18*
G01*
X1145222Y-915187D03*
G01*
X955103Y-915187D03*
D20*
G01*
X1417048Y-8635118D03*
G01*
X1537258Y-8514910D03*
M02*&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 의미인지 잘 모르겠는 데이터로 이뤄져있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 부분을 해결하기 위해&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2가지 방법을 생각해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. gerber file을 json으로 만들어서 line, circle등 직접 다 그리기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/gerber-parser&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/gerber-parser&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. gerber file을 이미지로 만들어서 판 생성 후 덮기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/pcb-stackup&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/pcb-stackup&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 정말 많았다면 1번을 해보겠지만 2번을 선택할만한 이유가 많았습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(시간은 결국 돈이다 보니 어쩔 수 없는 선택을..)&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번으로 작업할 때, 해당 프로세스는 이렇게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. zip파일 해제(jszip링크 참고: &lt;a href=&quot;https://devmemory.tistory.com/127&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://devmemory.tistory.com/127&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 파일 확장자명으로 validation&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 거버 파일 to svg&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. svg to png&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 보드 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 배경, 조명, 인터렉션 등 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 svg를 왜 굳이 png로 만드냐면 &lt;s&gt;해보면 압니다&lt;/s&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 3D에 텍스쳐를 입힐 때, 3d image 혹은 vector graphic등을 rasterizing하는 작업이 이뤄지는데&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;간단하게 이미지를 픽셀화 한다고 생각하시면 될거 같습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;rasterizing이란 기술에 대해 조금 이해해볼 필요가 있을거 같아서 링크도 붙입니다&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(&lt;a href=&quot;https://ko.wikipedia.org/wiki/%EB%9E%98%EC%8A%A4%ED%84%B0%ED%99%94&quot;&gt;https://ko.wikipedia.org/wiki/%EB%9E%98%EC%8A%A4%ED%84%B0%ED%99%94&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;svg를 loader로 불러와서 직접 매핑하면 비트맵을 생성해서 붙이게 되는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뿌옇게 보이는 이슈가 발생합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stackoverflow, chatGPT등 여기저기를&amp;nbsp; 참고해봤을 때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;png로 만드는게 더 합리적이겠다라는 생각이 들어서 png로 변환을 해서 붙이는 쪽으로 작업을 해봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Three.js</category>
      <category>Gerber</category>
      <category>jszip</category>
      <category>react</category>
      <category>three.js</category>
      <author>대기만성 개발자</author>
      <guid isPermaLink="true">https://devmemory.tistory.com/128</guid>
      <comments>https://devmemory.tistory.com/128#entry128comment</comments>
      <pubDate>Sun, 29 Dec 2024 00:12:27 +0900</pubDate>
    </item>
  </channel>
</rss>