<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>개발과정 &amp;mdash; Hyun1008.log</title>
    <link>https://blog.daydream.ink/jyhyun1008/tag:개발과정</link>
    <description>재연의 개발블로그</description>
    <pubDate>Tue, 28 Apr 2026 00:51:47 +0900</pubDate>
    <item>
      <title>JS 가로스크롤 만들기</title>
      <link>https://blog.daydream.ink/jyhyun1008/js-garoseukeurol-mandeulgi</link>
      <description>&lt;![CDATA[#개발과정 #VanillaJS&#xA;&#xA;포트폴리오 사이트를 만드는 중 프로젝트를 보여줄 때는 가로스크롤을 하는 게 좋을 것 같아서 만들어 보았습니다.&#xA;&#xA;우선 이렇게 구조를 짰습니다.&#xA;&#xA;        div id=&#34;content1&#34;&#xA;            div id=&#34;content-scroll&#34;&#xA;                div class=&#34;width-fix&#34;&#xA;                    h1Items./h1&#xA;                    div class=&#34;overflow-width&#34;&#xA;                    /div&#xA;                /div&#xA;            /div&#xA;        /div&#xA;&#xA;CSS에서는 .overflow-width 에만 position: relative 를 적용해 주었습니다. 그리고 자바스크립트에서는 다음과 같은 코드를 적용했어요.&#xA;&#xA;    document.querySelector(&#39;.overflow-width&#39;).style.left=0;&#xA;    // 이게 없으면 후에 style.left 를 불러오지 못합니다.&#xA;    document.querySelector(&#39;#content-scroll&#39;).addEventListener(&#39;wheel&#39;,function(e){&#xA;        if(e.deltaY   0){&#xA;        // 스크롤을 내리고 있으면,&#xA;            if (parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left)   -100) {&#xA;                // 가로스크롤이 충분히 진행되지 않았으면(=스크롤이 필요하면)&#xA;                e.preventDefault();&#xA;                e.stopPropagation();&#xA;                // 기존 세로 스크롤을 막습니다.&#xA;                document.querySelector(&#39;.overflow-width&#39;).style.top=0%;&#xA;                document.querySelector(&#39;.overflow-width&#39;).style.left = ${parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) - 1}%;&#xA;                // 그리고 스크롤 양에 따라 요소의 위치를 옮겨줍니다.&#xA;            }&#xA;        } else if (e.deltaY &lt; 0) {&#xA;        // 스크롤을 올리고 있으면,&#xA;            if (parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) &lt; 0) {&#xA;                // 가로스크롤이 충분히 돌아오지 않았으면(=스크롤이 필요하면)&#xA;                // 아래 내용은 동일합니다.&#xA;                e.preventDefault();&#xA;                e.stopPropagation();&#xA;                document.querySelector(&#39;.overflow-width&#39;).style.top=0%;&#xA;                document.querySelector(&#39;.overflow-width&#39;).style.left = ${parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) + 1}%;&#xA;            }&#xA;        }&#xA;    });&#xA;&#xA;남의코드 복붙해야지 히히 하고 들어갔다가 저한테 맞는 방법, 제가 당장 필요한 방법은 따로 있다는 사실을 깨달았습니다.&#xA;&#xA;물론 제가 당장 필요한 방법을 구현해 놓은 포스트가 있었다면 완전 럭키비키겠지만요.&#xA;&#xA;그래도 최대한 제가 만들어보고 남의 손을 빌리는 게 맞는 것 같습니다.]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:VanillaJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">VanillaJS</span></a></p>

<p>포트폴리오 사이트를 만드는 중 프로젝트를 보여줄 때는 가로스크롤을 하는 게 좋을 것 같아서 만들어 보았습니다.</p>

<p>우선 이렇게 구조를 짰습니다.</p>

<pre><code>        &lt;div id=&#34;content1&#34;&gt;
            &lt;div id=&#34;content-scroll&#34;&gt;
                &lt;div class=&#34;width-fix&#34;&gt;
                    &lt;h1&gt;Items.&lt;/h1&gt;
                    &lt;div class=&#34;overflow-width&#34;&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
</code></pre>

<p>CSS에서는 <code>.overflow-width</code> 에만 <code>position: relative</code> 를 적용해 주었습니다. 그리고 자바스크립트에서는 다음과 같은 코드를 적용했어요.</p>

<pre><code>    document.querySelector(&#39;.overflow-width&#39;).style.left=`0`;
    // 이게 없으면 후에 style.left 를 불러오지 못합니다.
    document.querySelector(&#39;#content-scroll&#39;).addEventListener(&#39;wheel&#39;,function(e){
        if(e.deltaY &gt; 0){
        // 스크롤을 내리고 있으면,
            if (parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) &gt; -100) {
                // 가로스크롤이 충분히 진행되지 않았으면(=스크롤이 필요하면)
                e.preventDefault();
                e.stopPropagation();
                // 기존 세로 스크롤을 막습니다.
                document.querySelector(&#39;.overflow-width&#39;).style.top=`0%`;
                document.querySelector(&#39;.overflow-width&#39;).style.left = `${parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) - 1}%`;
                // 그리고 스크롤 양에 따라 요소의 위치를 옮겨줍니다.
            }
        } else if (e.deltaY &lt; 0) {
        // 스크롤을 올리고 있으면,
            if (parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) &lt; 0) {
                // 가로스크롤이 충분히 돌아오지 않았으면(=스크롤이 필요하면)
                // 아래 내용은 동일합니다.
                e.preventDefault();
                e.stopPropagation();
                document.querySelector(&#39;.overflow-width&#39;).style.top=`0%`;
                document.querySelector(&#39;.overflow-width&#39;).style.left = `${parseInt(document.querySelector(&#39;.overflow-width&#39;).style.left) + 1}%`;
            }
        }
    });
</code></pre>

<p><img src="https://peachtart2.s3.amazonaws.com/tart/79f53cb4-952d-4064-974e-63db759a50ca.webp" alt=""></p>

<p>남의코드 복붙해야지 히히 하고 들어갔다가 저한테 맞는 방법, 제가 당장 필요한 방법은 따로 있다는 사실을 깨달았습니다.</p>

<p>물론 제가 당장 필요한 방법을 구현해 놓은 포스트가 있었다면 완전 럭키비키겠지만요.</p>

<p>그래도 최대한 제가 만들어보고 남의 손을 빌리는 게 맞는 것 같습니다.</p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/js-garoseukeurol-mandeulgi</guid>
      <pubDate>Mon, 23 Sep 2024 07:24:54 +0900</pubDate>
    </item>
    <item>
      <title>Github Pages로 Nuxt 2 프로젝트 배포 시 동적 라우팅 구현하기</title>
      <link>https://blog.daydream.ink/jyhyun1008/nuxt-2reul-github-pageseseo-dongjeog-rautingeuro-bildeuhagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #블로그&#xA;&#xA;Github Pages에서 Nuxt 배포하기&#xA;&#xA;초코스프레드는 별도의 서버 호스팅을 받을 필요 없이 무료로 위키를 올릴 수 있도록, 저예산/소규모 위키를 목표로 하고 있는 프로젝트입니다. (사실상 스프레드시트 파서입니다.)&#xA;&#xA;기존 초코스프레드는 정적 html 페이지를 그대로 Github pages로 deploy했었습니다.&#xA;&#xA;그런데 이렇게 페이지를 만들면 API 키가 전부 노출이 되어 안전하지도 않고, 쿼리스트링 형태로 문서 제목이 붙어 어딘가 너저분한 느낌이 들고 불편했습니다.&#xA;&#xA;그래서 이 두 가지를 개선하고자 Nuxt를 사용하게 된 것입니다.&#xA;&#xA;Nuxt에서는 pages/ 폴더 아래에 vue파일을 집어넣으면 그게 그대로 라우팅이 됩니다. 즉 pages/index.vue 는 / 로, pages/intro.vue 는 /intro/ 로 라우팅이 되는 형태입니다.&#xA;&#xA;그리고 이게 Github 액션을 사용한 빌드에서도 적용됩니다.&#xA;&#xA;그런데 동적 라우팅, 예를 들어서&#xA;&#xA;jyhyun1008.github.io/posts/123&#xA;&#xA;같은 경우는 어떻게 할까요?&#xA;&#xA;pages/posts 폴더 아래에 id.vue 와 같이 밑줄+파라미터 이름 을 넣으면 됩니다.&#xA;&#xA;그리고 당연하게도, 정적 페이지 빌드를 하면 이 페이지는 빠지게 됩니다(...)&#xA;&#xA;위키는 대문 페이지를 제외하고는 거의 대부분의 페이지가 동적 페이지로 되어 있는데, 만들었던 위키 페이지를 정적 페이지로 generate 하니, 대문을 제외한 모든 페이지가 날아가 있었습니다.&#xA;&#xA;그래서 이 문제를 해결하고자 했는데, 공식 문서 를 찾아보는 것으로 의외로 간단하게 해결이 됐습니다.&#xA;&#xA;아래는 공식 문서에서 제시하는 예시입니다.&#xA;&#xA;//nuxt.config.js&#xA;import axios from &#39;axios&#39;&#xA;&#xA;export default {&#xA;  generate: {&#xA;    routes(callback) {&#xA;      axios&#xA;        .get(&#39;https://my-api/users&#39;)&#xA;        .then(res =  {&#xA;          const routes = res.data.map(user =  {&#xA;            return &#39;/users/&#39; + user.id&#xA;          })&#xA;          callback(null, routes)&#xA;        })&#xA;        .catch(callback)&#xA;    }&#xA;  }&#xA;}&#xA;&#xA;generate → routes 아래에 fetch를 통해 어떤 페이지를 라우팅할 지 배열을 불러와서 저장하는 맥락입니다.&#xA;&#xA;그러니 사실은...&#xA;&#xA;동적 라우팅처럼 보이는 것에 가까울 것 같습니다. 유저가 접속할 수 있는 모든 경우의 수의 페이지만큼 정적 파일을 생성하고, 이걸 deploy하는 것이기 때문입니다.&#xA;&#xA;저는 axios는 사용 안하고... 그냥 async + await fetch 로 했습니다. 아래 코드에서 secret이라든지 저에게 맞추어진 id같은 부분은 줄을 그었습니다.&#xA;&#xA;구글 아이디 인증을 하고, 스프레드 시트를 불러와서 시트의 제목마다 라우터를 만들어주는 구조입니다.&#xA;&#xA;//nuxt.config.js&#xA;&#xA;  generate: {&#xA;    async routes(callback) {&#xA;      var secretKey = process.env.PRIVATEKEY.replace(/\\n/gm, &#39;\n&#39;)&#xA;&#xA;      const token = jwt.sign(&#xA;          { &#34;iss&#34;: &#34;-------------&#34;, &#34;scope&#34;: &#34;https://www.googleapis.com/auth/spreadsheets&#34;, &#34;aud&#34;: &#34;https://oauth2.googleapis.com/token&#34; },&#xA;          secretKey,&#xA;          { algorithm: &#39;RS256&#39;, expiresIn: &#34;1h&#34;, keyid: &#34;------------&#34; }&#xA;      );&#xA;&#xA;      const googleAuthUrl = &#39;https://oauth2.googleapis.com/token&#39;&#xA;      const googleAuthParam = {&#xA;              method: &#39;POST&#39;,&#xA;              headers: {&#xA;                  &#39;content-type&#39;: &#34;application/x-www-form-urlencoded&#34;,&#xA;              },&#xA;              body: querystring.stringify({&#xA;                  granttype: &#34;urn:ietf:params:oauth:grant-type:jwt-bearer&#34;,&#xA;                  assertion: token&#xA;              })&#xA;          }&#xA;&#xA;      var authData = await fetch(googleAuthUrl, googleAuthParam)&#xA;      var authRes = await authData.json()&#xA;&#xA;      const googleSheetUrl = https://sheets.googleapis.com/v4/spreadsheets/------------/&#xA;      const googleSheetParam = {&#xA;          method: &#39;GET&#39;,&#xA;          headers: {&#xA;              &#34;content-type&#34;: &#34;application/json&#34;,&#xA;              Authorization: &#34;Bearer &#34; + authRes.accesstoken,&#xA;          },&#xA;      }&#xA;      var sheetData3 = await fetch(googleSheetUrl, googleSheetParam)&#xA;      var sheetRes3 = await sheetData3.json()&#xA;      var wikiListArray = sheetRes3.sheets&#xA;      var wikiList = []&#xA;      for (let i=0; i&lt;wikiListArray.length; i++) {&#xA;          wikiList.push(&#39;/&#39;+encodeURIComponent(wikiListArray[i].properties.title))&#xA;      }&#xA;&#xA;      const routes = wikiList&#xA;      callback(null, routes)&#xA;    }&#xA;  }&#xA;&#xA;`]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B8%94%EB%A1%9C%EA%B7%B8" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">블로그</span></a></p>

<h2 id="github-pages에서-nuxt-배포하기" id="github-pages에서-nuxt-배포하기">Github Pages에서 Nuxt 배포하기</h2>

<p>초코스프레드는 별도의 서버 호스팅을 받을 필요 없이 무료로 위키를 올릴 수 있도록, 저예산/소규모 위키를 목표로 하고 있는 프로젝트입니다. (사실상 스프레드시트 파서입니다.)</p>

<p>기존 초코스프레드는 정적 html 페이지를 그대로 Github pages로 deploy했었습니다.</p>

<p>그런데 이렇게 페이지를 만들면 API 키가 전부 노출이 되어 안전하지도 않고, 쿼리스트링 형태로 문서 제목이 붙어 어딘가 너저분한 느낌이 들고 불편했습니다.</p>

<p>그래서 이 두 가지를 개선하고자 Nuxt를 사용하게 된 것입니다.</p>

<p>Nuxt에서는 pages/ 폴더 아래에 vue파일을 집어넣으면 그게 그대로 라우팅이 됩니다. 즉 <code>pages/index.vue</code> 는 <code>/</code> 로, <code>pages/intro.vue</code> 는 <code>/intro/</code> 로 라우팅이 되는 형태입니다.</p>

<p>그리고 이게 Github 액션을 사용한 빌드에서도 적용됩니다.</p>

<p>그런데 동적 라우팅, 예를 들어서</p>

<pre><code>jyhyun1008.github.io/posts/123
</code></pre>

<p>같은 경우는 어떻게 할까요?</p>

<p><code>pages/posts</code> 폴더 아래에 <code>_id.vue</code> 와 같이 밑줄+파라미터 이름 을 넣으면 됩니다.</p>

<p>그리고 당연하게도, 정적 페이지 빌드를 하면 이 페이지는 빠지게 됩니다(...)</p>

<p>위키는 대문 페이지를 제외하고는 거의 대부분의 페이지가 동적 페이지로 되어 있는데, 만들었던 위키 페이지를 정적 페이지로 generate 하니, 대문을 제외한 모든 페이지가 날아가 있었습니다.</p>

<p>그래서 이 문제를 해결하고자 했는데, <a href="https://v2.nuxt.com/docs/configuration-glossary/configuration-generate/#routes" rel="nofollow">공식 문서</a> 를 찾아보는 것으로 의외로 간단하게 해결이 됐습니다.</p>

<p>아래는 공식 문서에서 제시하는 예시입니다.</p>

<pre><code>//nuxt.config.js
import axios from &#39;axios&#39;

export default {
  generate: {
    routes(callback) {
      axios
        .get(&#39;https://my-api/users&#39;)
        .then(res =&gt; {
          const routes = res.data.map(user =&gt; {
            return &#39;/users/&#39; + user.id
          })
          callback(null, routes)
        })
        .catch(callback)
    }
  }
}
</code></pre>

<p>generate → routes 아래에 fetch를 통해 어떤 페이지를 라우팅할 지 배열을 불러와서 저장하는 맥락입니다.</p>

<h2 id="그러니-사실은" id="그러니-사실은">그러니 사실은...</h2>

<p>동적 라우팅<strong>처럼</strong> 보이는 것에 가까울 것 같습니다. <strong>유저가 접속할 수 있는 모든 경우의 수의 페이지만큼 정적 파일을 생성하고, 이걸 deploy하는 것</strong>이기 때문입니다.</p>

<p>저는 axios는 사용 안하고... 그냥 async + await fetch 로 했습니다. 아래 코드에서 secret이라든지 저에게 맞추어진 id같은 부분은 줄을 그었습니다.</p>

<p>구글 아이디 인증을 하고, 스프레드 시트를 불러와서 시트의 제목마다 라우터를 만들어주는 구조입니다.</p>

<pre><code>//nuxt.config.js

  generate: {
    async routes(callback) {
      var secretKey = process.env.PRIVATE_KEY.replace(/\\n/gm, &#39;\n&#39;)

      const token = jwt.sign(
          { &#34;iss&#34;: &#34;-------------&#34;, &#34;scope&#34;: &#34;https://www.googleapis.com/auth/spreadsheets&#34;, &#34;aud&#34;: &#34;https://oauth2.googleapis.com/token&#34; },
          secretKey,
          { algorithm: &#39;RS256&#39;, expiresIn: &#34;1h&#34;, keyid: &#34;------------&#34; }
      );

      const googleAuthUrl = &#39;https://oauth2.googleapis.com/token&#39;
      const googleAuthParam = {
              method: &#39;POST&#39;,
              headers: {
                  &#39;content-type&#39;: &#34;application/x-www-form-urlencoded&#34;,
              },
              body: querystring.stringify({
                  grant_type: &#34;urn:ietf:params:oauth:grant-type:jwt-bearer&#34;,
                  assertion: token
              })
          }

      var authData = await fetch(googleAuthUrl, googleAuthParam)
      var authRes = await authData.json()

      const googleSheetUrl = `https://sheets.googleapis.com/v4/spreadsheets/------------/`
      const googleSheetParam = {
          method: &#39;GET&#39;,
          headers: {
              &#34;content-type&#34;: &#34;application/json&#34;,
              Authorization: &#34;Bearer &#34; + authRes.access_token,
          },
      }
      var sheetData3 = await fetch(googleSheetUrl, googleSheetParam)
      var sheetRes3 = await sheetData3.json()
      var wikiListArray = sheetRes3.sheets
      var wikiList = []
      for (let i=0; i&lt;wikiListArray.length; i++) {
          wikiList.push(&#39;/&#39;+encodeURIComponent(wikiListArray[i].properties.title))
      }

      const routes = wikiList
      callback(null, routes)
    }
  }

</code></pre>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/nuxt-2reul-github-pageseseo-dongjeog-rautingeuro-bildeuhagi</guid>
      <pubDate>Sat, 07 Sep 2024 20:35:02 +0900</pubDate>
    </item>
    <item>
      <title>Nuxt.js 2에서 마크다운 렌더링하기</title>
      <link>https://blog.daydream.ink/jyhyun1008/nuxt-js-2eseo-makeudaun-rendeoringhagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #블로그&#xA;&#xA;제가 쓰는 Nuxt 버전은 다음과 같습니다.&#xA;&#xA;    &#34;nuxt&#34;: &#34;^2.15.8&#34;,&#xA;    &#34;vue&#34;: &#34;^2.7.10&#34;,&#xA;&#xA;원래 평소와 똑같이 marked 를 임포트해서 처리하려고 했는데... 계속 계속 오류가 뜨길래 Nuxt에서 마크다운을 렌더링하는 기본적인 방법이 없나 찾아보았습니다.&#xA;&#xA;결국 끝장을 보긴 했어요.&#xA;&#xA;일단 @nuxtjs/markdownit 모듈을 깔아줍니다.&#xA;npm i @nuxtjs/markdownit&#xA;&#xA;그다음 nuxt.config.js에서 모듈 부분을 다음과 같이 바꿉니다.&#xA;&#xA;//nuxt.config.js&#xA;  // Modules: https://go.nuxtjs.dev/config-modules&#xA;  modules: [&#39;@nuxtjs/markdownit&#39;],&#xA;  markdownit: {&#xA;    runtime: true // Support $md()&#xA;  },&#xA;&#xA;마크다운 렌더를 원하는 부분에 $md.render() 를 붙이면 끝.&#xA;&#xA;// index.vue&#xA;template&#xA;    div v-html=&#34;$md.render(wikiBody1)&#34;/div&#xA;/template&#xA;&#xA;다만 marked.js보다는 별로라고 생각합니다. 아주 기본적인 마크다운 렌더는 지원하지만, 체크리스트 같은 게 제대로 렌더되지 않았다는 점을 고려해 보면, sub 등의 구문도, 접었다 펼치는 것도 지원하지 않을 거라고 생각해요.&#xA;&#xA;이 부분은 어차피 replace 같은 걸로 따로 구현할 수 있는데, 따로 구현할지 아니면 그냥 둘지는 고민입니다.&#xA;]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B8%94%EB%A1%9C%EA%B7%B8" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">블로그</span></a></p>

<p>제가 쓰는 Nuxt 버전은 다음과 같습니다.</p>

<pre><code>    &#34;nuxt&#34;: &#34;^2.15.8&#34;,
    &#34;vue&#34;: &#34;^2.7.10&#34;,
</code></pre>

<p>원래 평소와 똑같이 <code>marked</code> 를 임포트해서 처리하려고 했는데... 계속 계속 오류가 뜨길래 Nuxt에서 마크다운을 렌더링하는 기본적인 방법이 없나 찾아보았습니다.</p>

<p>결국 끝장을 보긴 했어요.</p>

<p>일단 <code>@nuxtjs/markdownit</code> 모듈을 깔아줍니다.</p>

<pre><code>npm i @nuxtjs/markdownit
</code></pre>

<p>그다음 <code>nuxt.config.js</code>에서 모듈 부분을 다음과 같이 바꿉니다.</p>

<pre><code>//nuxt.config.js
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [&#39;@nuxtjs/markdownit&#39;],
  markdownit: {
    runtime: true // Support `$md()`
  },
</code></pre>

<p>마크다운 렌더를 원하는 부분에 <code>$md.render()</code> 를 붙이면 끝.</p>

<pre><code>// index.vue
&lt;template&gt;
    &lt;div v-html=&#34;$md.render(wikiBody1)&#34;&gt;&lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p><img src="https://for.stella.place/D1/53c30145-b8a1-4926-98db-b3917795bb49.png" alt=""></p>

<p>다만 marked.js보다는 별로라고 생각합니다. 아주 기본적인 마크다운 렌더는 지원하지만, 체크리스트 같은 게 제대로 렌더되지 않았다는 점을 고려해 보면, <code>&lt;sub&gt;</code> 등의 구문도, 접었다 펼치는 것도 지원하지 않을 거라고 생각해요.</p>

<p>이 부분은 어차피 replace 같은 걸로 따로 구현할 수 있는데, 따로 구현할지 아니면 그냥 둘지는 고민입니다.</p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/nuxt-js-2eseo-makeudaun-rendeoringhagi</guid>
      <pubDate>Sat, 07 Sep 2024 12:02:44 +0900</pubDate>
    </item>
    <item>
      <title>[Nuxt.js/Vue.js] JWT를 이용해 구글 서비스 계정 인증하기</title>
      <link>https://blog.daydream.ink/jyhyun1008/nuxt-js-vue-js-jwtreul-iyonghae-gugeul-seobiseu-gyejeong-injeunghagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #블로그&#xA;&#xA;초코스프레드 위키를 Nuxt.js로 옮기게 되었습니다. &#xA;&#xA;왜 Next.js가 아니라 Nuxt.js 였냐 하면....별 이유는 없고 그냥 깃허브 액션에 Next가 있다는 것을 늦게 보았습니다 (...)&#xA;&#xA;하여튼, Nuxt.js는 뷰 기반이니만큼 뷰 기준으로 설명하겠습니다.&#xA;&#xA;//index.vue&#xA;template&#xA;  div{{ wikiBody }}/div&#xA;/template&#xA;&#xA;템플릿에는 가져온 스프레드시트의 컨텐츠를 담을 수 있게 준비했습니다.&#xA;&#xA;//index.vue&#xA;script&#xA;const jwt = require(&#39;jsonwebtoken&#39;);&#xA;const querystring = require(&#34;querystring&#34;);&#xA;&#xA;const secretKey = &#34;(privatekey)&#34;&#xA;&#xA;index.vue가 아니어도 상관없지만, nuxt 기준으로 pages 아래에 있는 뷰 파일에다가 작업을 해 주어야 합니다.&#xA;&#xA;jsonwebtoken 이라는 모듈을 설치하고 불러옵시다. 참고로 이 jsonwebtoken의 경우, 9.0.0이 아니라 8.5.1 버전을 설치해야 합니다. 그렇지 않으면 오류가 납니다.&#xA;querystring 모듈은 나중에 POST 요청을 보내기 위해 필요합니다.&#xA;&#xA;secretKey 변수에 받아온 json 파일의 private key를 집어넣습니다. 일단 로컬에서 실행하기 때문에 그대로 넣어주었습니다. 이후 env 파일로 옮길 예정입니다. 이대로 커밋만 안 하면 됩니다(...)&#xA;&#xA;참고로 이 json 파일은 구글 API에서 새 서비스 계정을 생성해서 받아왔습니다.&#xA;&#xA;//index.vue&#xA;const token = jwt.sign(&#xA;    { &#34;iss&#34;: &#34;�(clientemail)&#34;, &#34;scope&#34;: &#34;https://www.googleapis.com/auth/spreadsheets&#34;, &#34;aud&#34;: &#34;https://oauth2.googleapis.com/token&#34; },&#xA;    secretKey,&#xA;    { algorithm: &#39;RS256&#39;, expiresIn: &#34;1h&#34;, keyid: &#34;(privatekeyid)&#34; }&#xA;    );&#xA;&#xA;jsonwebtoken 모듈을 사용해서 사인을 해봅니다. 간단합니다.&#xA;&#xA;jwt.sign(payload, privateKey, options)&#xA;&#xA;Google에서 요구하는 JWT는 헤더에 alg (RS256), kid가 포함되어야 하고, 페이로드에 iss, scope, aud, iat, exp가 포함되어 있어야 합니다.&#xA;&#xA;이걸 일일이 변수로 만들어줘도 되지만, jwt 모듈에서는 그냥 option에 필요한 값만 넣어주면 알아서 사인을 해줍니다.&#xA;&#xA;예를 들어 alg는 algorithm, kid는 keyid 입니다. exp는 expiresIn 이에요. iat는 자동으로 넣어줍니다.&#xA;&#xA;만들어진 JWT는 https://jwt.io/ 에서 검증할 수 있습니다만, 구글 JWT의 경우 아마 유효하지 않다고 나올 겁니다. 하지만 이게 맞습니다. 구글에서 예시로 던져준 JWT도 유효하지 않다고 나옵니다.&#xA;&#xA;//index.vue&#xA;export default {&#xA;    async asyncData () {&#xA;&#xA;        const googleAuthUrl = &#39;https://oauth2.googleapis.com/token&#39;&#xA;        const googleAuthParam = {&#xA;                                    method: &#39;POST&#39;,&#xA;                                    headers: {&#xA;                                        &#39;content-type&#39;: &#34;application/x-www-form-urlencoded&#34;,&#xA;                                    },&#xA;                                    body: querystring.stringify({&#xA;                                        granttype: &#34;urn:ietf:params:oauth:grant-type:jwt-bearer&#34;,&#xA;                                        assertion: token&#xA;                                    })&#xA;                                }&#xA;&#xA;        var authData = await fetch(googleAuthUrl, googleAuthParam)&#xA;        var authRes = await authData.json()&#xA;&#xA;그 다음에 fetch를 하는 단계 입니다. nuxt이기 때문에 asyncData() 훅을 사용해서 데이터를 받아온 다음 템플릿에 붙일 수 있게 해 보았습니다.&#xA;&#xA;fetch는 다른 사이트로의 fetch들과 똑같이 진행하면 되는데요(그야, 그냥 평범한 POST니까요...), body 에서 granttype 이 자주 오류가 나더라구요.&#xA;&#xA;이유는 잘 모르겠지만 JSON.stringify를 querystring.stringify 로 바꾸었더니 잘 인증이 되었습니다.&#xA;&#xA;//index.vue&#xA;        const googleSheetUrl = &#39;https://sheets.googleapis.com/v4/spreadsheets/(구글 시트 주소)/values/(시트 및 범위)&#39;&#xA;        const googleSheetParam = {&#xA;            method: &#39;GET&#39;,&#xA;            headers: {&#xA;                &#34;content-type&#34;: &#34;application/json&#34;,&#xA;                Authorization: &#34;Bearer &#34; + authRes.access_token,&#xA;            },&#xA;        }&#xA;        var sheetData = await fetch(googleSheetUrl, googleSheetParam)&#xA;        var sheetRes = await sheetData.json()&#xA;        var wikiBody = sheetRes.valuessheetRes.values.length - 1&#xA;        return { wikiBody }&#xA;    }&#xA;}&#xA;/script&#xA;&#xA;내친김에 받은 액세스 토큰으로 구글 시트까지 가져와 보았습니다. &#xA;&#xA;가장 어려운 인증(...) 부분을 뚫었으니 이제는 다른 사이트와 똑같이 제대로 된 엔드포인트에다가 값만 잘 넘겨 주면 됩니다. 특히 시트 값 가져오는 건 GET이니까요.&#xA;&#xA;초코숲을 만들다가 또 막히는 일이 있으면 포스트를 써 보도록 하겠습니다.]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B8%94%EB%A1%9C%EA%B7%B8" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">블로그</span></a></p>

<p>초코스프레드 위키를 Nuxt.js로 옮기게 되었습니다.</p>

<p>왜 Next.js가 아니라 Nuxt.js 였냐 하면....별 이유는 없고 그냥 깃허브 액션에 Next가 있다는 것을 늦게 보았습니다 (...)</p>

<p>하여튼, Nuxt.js는 뷰 기반이니만큼 뷰 기준으로 설명하겠습니다.</p>

<pre><code>//index.vue
&lt;template&gt;
  &lt;div&gt;{{ wikiBody }}&lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p>템플릿에는 가져온 스프레드시트의 컨텐츠를 담을 수 있게 준비했습니다.</p>

<pre><code>//index.vue
&lt;script&gt;
const jwt = require(&#39;jsonwebtoken&#39;);
const querystring = require(&#34;querystring&#34;);

const secretKey = &#34;(private_key)&#34;
</code></pre>

<p><code>index.vue</code>가 아니어도 상관없지만, nuxt 기준으로 <code>pages</code> 아래에 있는 뷰 파일에다가 작업을 해 주어야 합니다.</p>

<p><code>jsonwebtoken</code> 이라는 모듈을 설치하고 불러옵시다. 참고로 이 <code>jsonwebtoken</code>의 경우, <strong>9.0.0이 아니라 8.5.1 버전을 설치해야 합니다.</strong> 그렇지 않으면 오류가 납니다.
<code>querystring</code> 모듈은 나중에 POST 요청을 보내기 위해 필요합니다.</p>

<p><code>secretKey</code> 변수에 받아온 json 파일의 private key를 집어넣습니다. 일단 로컬에서 실행하기 때문에 그대로 넣어주었습니다. 이후 env 파일로 옮길 예정입니다. 이대로 커밋만 안 하면 됩니다(...)</p>

<p>참고로 이 json 파일은 구글 API에서 새 서비스 계정을 생성해서 받아왔습니다.</p>

<pre><code>//index.vue
const token = jwt.sign(
    { &#34;iss&#34;: &#34;(client_email)&#34;, &#34;scope&#34;: &#34;https://www.googleapis.com/auth/spreadsheets&#34;, &#34;aud&#34;: &#34;https://oauth2.googleapis.com/token&#34; },
    secretKey,
    { algorithm: &#39;RS256&#39;, expiresIn: &#34;1h&#34;, keyid: &#34;(private_key_id)&#34; }
    );
</code></pre>

<p><code>jsonwebtoken</code> 모듈을 사용해서 사인을 해봅니다. 간단합니다.</p>

<pre><code>jwt.sign(payload, privateKey, options)
</code></pre>

<p>Google에서 요구하는 JWT는 헤더에 <code>alg</code> (RS256), <code>kid</code>가 포함되어야 하고, 페이로드에 <code>iss</code>, <code>scope</code>, <code>aud</code>, <code>iat</code>, <code>exp</code>가 포함되어 있어야 합니다.</p>

<p>이걸 일일이 변수로 만들어줘도 되지만, jwt 모듈에서는 그냥 option에 필요한 값만 넣어주면 알아서 사인을 해줍니다.</p>

<p>예를 들어 <code>alg</code>는 <code>algorithm</code>, <code>kid</code>는 <code>keyid</code> 입니다. <code>exp</code>는 <code>expiresIn</code> 이에요. <code>iat</code>는 자동으로 넣어줍니다.</p>

<p><img src="https://for.stella.place/D1/a2c36e3f-bfa9-4691-ba0a-69660e4d038d.png" alt=""></p>

<p>만들어진 JWT는 <a href="https://jwt.io/" rel="nofollow">https://jwt.io/</a> 에서 검증할 수 있습니다만, 구글 JWT의 경우 아마 유효하지 않다고 나올 겁니다. 하지만 이게 맞습니다. 구글에서 예시로 던져준 JWT도 유효하지 않다고 나옵니다.</p>

<p><img src="https://for.stella.place/D1/bef69651-2aca-45c0-89fd-57cb61017456.png" alt=""></p>

<pre><code>//index.vue
export default {
    async asyncData () {

        const googleAuthUrl = &#39;https://oauth2.googleapis.com/token&#39;
        const googleAuthParam = {
                                    method: &#39;POST&#39;,
                                    headers: {
                                        &#39;content-type&#39;: &#34;application/x-www-form-urlencoded&#34;,
                                    },
                                    body: querystring.stringify({
                                        grant_type: &#34;urn:ietf:params:oauth:grant-type:jwt-bearer&#34;,
                                        assertion: token
                                    })
                                }

        var authData = await fetch(googleAuthUrl, googleAuthParam)
        var authRes = await authData.json()
</code></pre>

<p>그 다음에 <code>fetch</code>를 하는 단계 입니다. nuxt이기 때문에 <code>asyncData()</code> 훅을 사용해서 데이터를 받아온 다음 템플릿에 붙일 수 있게 해 보았습니다.</p>

<p><code>fetch</code>는 다른 사이트로의 <code>fetch</code>들과 똑같이 진행하면 되는데요(그야, 그냥 평범한 POST니까요...), <code>body</code> 에서 <code>grant_type</code> 이 자주 오류가 나더라구요.</p>

<p>이유는 잘 모르겠지만 <code>JSON.stringify</code>를 <code>querystring.stringify</code> 로 바꾸었더니 잘 인증이 되었습니다.</p>

<pre><code>//index.vue
        const googleSheetUrl = &#39;https://sheets.googleapis.com/v4/spreadsheets/(구글 시트 주소)/values/(시트 및 범위)&#39;
        const googleSheetParam = {
            method: &#39;GET&#39;,
            headers: {
                &#34;content-type&#34;: &#34;application/json&#34;,
                Authorization: &#34;Bearer &#34; + authRes.access_token,
            },
        }
        var sheetData = await fetch(googleSheetUrl, googleSheetParam)
        var sheetRes = await sheetData.json()
        var wikiBody = sheetRes.values[sheetRes.values.length - 1][2]
        return { wikiBody }
    }
}
&lt;/script&gt;
</code></pre>

<p>내친김에 받은 액세스 토큰으로 구글 시트까지 가져와 보았습니다.</p>

<p>가장 어려운 인증(...) 부분을 뚫었으니 이제는 다른 사이트와 똑같이 제대로 된 엔드포인트에다가 값만 잘 넘겨 주면 됩니다. 특히 시트 값 가져오는 건 GET이니까요.</p>

<p>초코숲을 만들다가 또 막히는 일이 있으면 포스트를 써 보도록 하겠습니다.</p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/nuxt-js-vue-js-jwtreul-iyonghae-gugeul-seobiseu-gyejeong-injeunghagi</guid>
      <pubDate>Sat, 07 Sep 2024 08:58:47 +0900</pubDate>
    </item>
    <item>
      <title>BVH 포맷 이해하기</title>
      <link>https://blog.daydream.ink/jyhyun1008/bvh-pomaes-ihaehagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #버튜버&#xA;&#xA;1. 모티베이션&#xA;&#xA;VMC Mixer 까지는 만들었는데, 이 신호를 모션캡쳐 파일로 저장해줄만한 괜찮은 소프트웨어가 없었습니다. BVH 저장이 가능한 일반 소프트웨어들은 Hips 부터 시작해서 Root 본이 누락되어 있었고(그래서 정작 중요한 캐릭터의 위치가 잡히지 않습니다), 블렌더 애드온인 VMC4B는 각 내삽에 오일러 각을 사용하는지 자주 뒤집히며 불완전했습니다.&#xA;&#xA;깃허브에 VMCtoBVH 소프트웨어가 있었지만, .exe 파일이라서 맥에서는 기록이 불가능했습니다.&#xA;&#xA;어쩔 수 없이 BVH 포맷에 대해 찾아보았고, 이정도면 만들기 수월하겠는데? 싶어서 결국 직접 만드는 데까지 이르게 되었습니다.&#xA;&#xA;2. BVH 포맷&#xA;&#xA;BVH 파일의 생김새입니다.&#xA;&#xA;2.1 HIERARCHY&#xA;&#xA;관절 목록이 적혀 있는 부분입니다. &#xA;&#xA;맨 앞의 관절은 ROOT, 그 다음부터는 쭉 JOINT로 이어져 있습니다.&#xA;&#xA;각 JOINT에는 오프셋과 채널 목록이 적혀 있는데, 오프셋은 그 joint의 크기를 결정한다고 봐도 될 것 같고, 채널 목록은 후술할 모션을 받아들이는 채널입니다.&#xA;&#xA;주로 로테이션 값이 지정되어 있으며, 루트 본만 6채널로 해서 따로 포지션을 저장할 수 있게 되어 있습니다.&#xA;&#xA;이 채널 목록의 순서가 매우 중요합니다!&#xA;&#xA;2.2 MOTION&#xA;&#xA;먼저 첫줄에는 프레임 숫자, 두번째 줄에는 프레임당 몇 초를 소요할 것인지(그래서, 0.0333334 라면 초당 30프레임을 뜻합니다) 적혀 있습니다.&#xA;&#xA;세번째 줄부터는 실제 모션이 들어가는데, 앞에서 선언한 채널의 순서대로 값을 지정할 수 있습니다. 한 줄에 채널 전체 숫자만큼 숫자가 들어가며, 다음 프레임으로 넘어갈 때 줄바꿈이 들어갑니다.&#xA;&#xA;제가 받은 파일에서는, 소수점 아래 6자리까지 지정되어 있고, (-) 부호가 붙지 않은 경우 스페이스가 하나 들어가 있어서 그것도 똑같이 구현했습니다.&#xA;&#xA;참고로, 이거 꽤 오래된 포맷이라고 합니다. 그래서인지, 시간 단위는 초, 각도 단위는 degree 입니다.&#xA;&#xA;3. 쿼터니온을 BVH degree로 변환하기&#xA;&#xA;이건, vmc2bvh 리포지토리의 코드 에서 가져왔습니다.&#xA;&#xA;원래는 Quaternion.js의 함수를 사용하려고 했는데, 오일러 변환 중 XYZ의 순서를 어떻게 지정해야 할지 잘 모르기 때문에 기존의 성공적인 코드를 빌려올 수밖에 없었습니다...&#xA;&#xA;function qtodrot(r11, r12, r21, r31, r32) {&#xA;    F = (180.0 / Math.PI);&#xA;&#xA;&#x9;// zyx&#xA;&#x9;var zrot   = Math.atan2(r31, r32)  F;&#xA;&#x9;var yrot = Math.asin(r21)  F;&#xA;&#x9;var xrot  = Math.atan2(r11, r12)  F;&#xA;&#xA;    return [xrot, yrot, zrot]&#xA;}&#xA;&#xA;function qtod(x, y, z, w) {&#xA;&#xA;    var angles = qtodrot(-2  (y  z - w  x), w  w - x  x - y  y + z  z, 2  (x  z + w  y), -2  (x  y - w  z), w  w + x  x - y  y - z  z);&#xA;&#xA;    return angles&#xA;}&#xA;&#xA;원본은 위키백과에 있는 다음 식인 것 같습니다.&#xA;&#xA;(Y값 구하는 데 두번째 공식 사용)&#xA;&#xA;참고로 이 포맷 계산에서는 원본 쿼터니온에서 Qx와 Qy에 -1을 곱한 값을 사용하며, (아마 cw와 ccw의 차이일 것 같습니다.) 루트 계산에서는 이 쿼터니온 값을 제곱한 새로운 쿼터니온을 사용하더군요.&#xA;&#xA;이걸 구한 다음 Xrotation Yrotation Zrotation 순서대로 파일에 저장해 주었습니다.&#xA;&#xA;]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B2%84%ED%8A%9C%EB%B2%84" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">버튜버</span></a></p>

<h1 id="1-모티베이션" id="1-모티베이션">1. 모티베이션</h1>

<p>VMC Mixer 까지는 만들었는데, 이 신호를 모션캡쳐 파일로 저장해줄만한 괜찮은 소프트웨어가 없었습니다. BVH 저장이 가능한 일반 소프트웨어들은 Hips 부터 시작해서 Root 본이 누락되어 있었고(그래서 정작 중요한 캐릭터의 <strong>위치</strong>가 잡히지 않습니다), 블렌더 애드온인 VMC4B는 각 내삽에 오일러 각을 사용하는지 자주 뒤집히며 불완전했습니다.</p>

<p>깃허브에 VMCtoBVH 소프트웨어가 있었지만, .exe 파일이라서 맥에서는 기록이 불가능했습니다.</p>

<p>어쩔 수 없이 BVH 포맷에 대해 찾아보았고, 이정도면 만들기 수월하겠는데? 싶어서 결국 직접 만드는 데까지 이르게 되었습니다.</p>

<h1 id="2-bvh-포맷" id="2-bvh-포맷">2. BVH 포맷</h1>

<p><img src="https://for.stella.place/D1/3f946c86-a6a5-4e60-b268-1178d31c2836.webp" alt=""></p>

<p>BVH 파일의 생김새입니다.</p>

<h2 id="2-1-hierarchy" id="2-1-hierarchy">2.1 HIERARCHY</h2>

<p>관절 목록이 적혀 있는 부분입니다.</p>

<p><img src="https://img1.daumcdn.net/thumb/R800x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVGgLD%2FbtrJZJmYnS3%2Fb2RUa1UnnskrFk3j7CZERK%2Fimg.jpg" alt=""></p>

<p><img src="https://for.stella.place/D1/3f946c86-a6a5-4e60-b268-1178d31c2836.webp" alt=""></p>

<p>맨 앞의 관절은 ROOT, 그 다음부터는 쭉 JOINT로 이어져 있습니다.</p>

<p>각 JOINT에는 오프셋과 채널 목록이 적혀 있는데, 오프셋은 그 joint의 크기를 결정한다고 봐도 될 것 같고, 채널 목록은 후술할 모션을 받아들이는 채널입니다.</p>

<p>주로 로테이션 값이 지정되어 있으며, 루트 본만 6채널로 해서 따로 포지션을 저장할 수 있게 되어 있습니다.</p>

<p>이 채널 목록의 순서가 매우 중요합니다!</p>

<h2 id="2-2-motion" id="2-2-motion">2.2 MOTION</h2>

<p><img src="https://for.stella.place/D1/3e78c086-5498-4263-8469-1b0521655126.webp" alt=""></p>

<p>먼저 첫줄에는 프레임 숫자, 두번째 줄에는 프레임당 몇 초를 소요할 것인지(그래서, 0.0333334 라면 초당 30프레임을 뜻합니다) 적혀 있습니다.</p>

<p>세번째 줄부터는 실제 모션이 들어가는데, 앞에서 선언한 채널의 <strong>순서대로</strong> 값을 지정할 수 있습니다. 한 줄에 채널 전체 숫자만큼 숫자가 들어가며, 다음 프레임으로 넘어갈 때 줄바꿈이 들어갑니다.</p>

<p>제가 받은 파일에서는, 소수점 아래 6자리까지 지정되어 있고, (–) 부호가 붙지 않은 경우 스페이스가 하나 들어가 있어서 그것도 똑같이 구현했습니다.</p>

<p>참고로, 이거 꽤 오래된 포맷이라고 합니다. 그래서인지, 시간 단위는 <strong>초</strong>, 각도 단위는 <strong>degree</strong> 입니다.</p>

<h1 id="3-쿼터니온을-bvh-degree로-변환하기" id="3-쿼터니온을-bvh-degree로-변환하기">3. 쿼터니온을 BVH degree로 변환하기</h1>

<p>이건, <a href="https://github.com/infosia/vmc2bvh/blob/master/code/cgltf_func.inl" rel="nofollow">vmc2bvh 리포지토리의 코드</a> 에서 가져왔습니다.</p>

<p>원래는 Quaternion.js의 함수를 사용하려고 했는데, 오일러 변환 중 XYZ의 순서를 어떻게 지정해야 할지 잘 모르기 때문에 기존의 성공적인 코드를 빌려올 수밖에 없었습니다...</p>

<pre><code>function qtod_rot(r11, r12, r21, r31, r32) {
    F = (180.0 / Math.PI);

	// zyx
	var zrot   = Math.atan2(r31, r32) * F;
	var yrot = Math.asin(r21) * F;
	var xrot  = Math.atan2(r11, r12) * F;

    return [xrot, yrot, zrot]
}

function qtod(x, y, z, w) {

    var angles = qtod_rot(-2 * (y * z - w * x), w * w - x * x - y * y + z * z, 2 * (x * z + w * y), -2 * (x * y - w * z), w * w + x * x - y * y - z * z);

    return angles
}
</code></pre>

<p>원본은 위키백과에 있는 다음 식인 것 같습니다.</p>

<p><img src="https://wikimedia.org/api/rest_v1/media/math/render/svg/071f6cd3340dcf9403b143c2fc5eeebb7222d9ca" alt=""></p>

<p><img src="https://wikimedia.org/api/rest_v1/media/math/render/svg/7f9f6cbdfec1a446396184f851414918249de845" alt=""></p>

<p>(Y값 구하는 데 두번째 공식 사용)</p>

<p>참고로 이 포맷 계산에서는 원본 쿼터니온에서 Qx와 Qy에 -1을 곱한 값을 사용하며, (아마 cw와 ccw의 차이일 것 같습니다.) 루트 계산에서는 이 쿼터니온 값을 제곱한 새로운 쿼터니온을 사용하더군요.</p>

<p>이걸 구한 다음 Xrotation Yrotation Zrotation 순서대로 파일에 저장해 주었습니다.</p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/bvh-pomaes-ihaehagi</guid>
      <pubDate>Tue, 03 Sep 2024 08:27:19 +0900</pubDate>
    </item>
    <item>
      <title>Quaternion.js로 쿼터니온 평균 구하기</title>
      <link>https://blog.daydream.ink/jyhyun1008/quaternion-jsro-kweoteonion-pyeonggyun-guhagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #버튜버&#xA;&#xA;Slerp 개념 이해하기&#xA;&#xA;사실, 지금껏 &#39;평균&#39; 이라고 했지만 &#39;내삽&#39; 이라는 좀더 일반적이고 깔끔한 용어가 있었다는 것을 잊고 있었습니다.&#xA;&#xA;지난 포스트에서 쿼터니온을 최대한 건드리지 않고 오일러 각도로 변환한 뒤 각도의 평균을 내 주는 방법을 사용했다고 했죠.&#xA;&#xA;그런데 이게 부정확했던 겁니다. 계속 값이 튀었어요. 모듈로 계산도 써 보고 머리를 싸매다가, 결국 스택오버플로우에서 검색을 해봤어요.&#xA;&#xA;  Yes, only quaternions are appropriate for inter/extrapolation. &#xA;&#xA;네? 내삽/외삽을 게산하는 데 쿼터니온만이 적합하다고 합니다.&#xA;&#xA;  Quaternion Slerps are commonly used to construct smooth animation curves ...&#xA;&#xA;아... 그랬던 겁니다. 애초에 제가 하는 스무딩 작업은 오일러 각도이든 축각 각도이든 한계가 있었고, 쿼터니온의 Slerp 라는 방법을 써야 각도의 내삽이 가능했던 거였어요.&#xA;&#xA;다음은 위키백과의 Slerp 문서에서 가져온 사진입니다.&#xA;&#xA;네.. 제가 원한 게 바로 이거였거든요. 두 개의 벡터의 단순 평균을 내 주는 게 아니라, 각도의 평균을 내 주는 작업이죠.&#xA;&#xA;쿼터니온의 Slerp은 다음과 같은 계산과정을 거치는 모양입니다.&#xA;&#xA;하지만 여기서는 Slerp의 개념만 이해하고 넘어가기로 했습니다. 계산과정은 모듈에게 맡기기로 합니다.&#xA;&#xA;quaternion.js 모듈에 내삽과 관련된 함수가 있는지 찾아봅시다. (없으면 구현해야 하니까요...--)&#xA;&#xA;아예 대놓고 Slerp 함수가 있었네요.... 지금이라도 찾아서 다행이라고 생각하며 적용해 보았어요.&#xA;&#xA;적용해보기&#xA;&#xA;우선 오일러 각으로 변환하던 기존 코드들을 전부 주석처리한 뒤,&#xA;&#xA;var q1, q2&#xA;q1 = new Quaternion(result1[6], result1[3], result1[4], result1[5])&#xA;q2 = new Quaternion(result2[6], result2[3], result2[4], result2[5])&#xA;&#xA;//평균을 내 줘야 하는 곳에서&#xA;var q3 = q1.slerp(result2[6], result2[3], result2[4], result2[5])(0.5)&#xA;&#xA;//0.5 부분에는 0-1까지의 float가 들어갈 수 있습니다.&#xA;var index = [1,2,3,4,5,6,7,8,9]&#xA;for await (let i of index) {&#xA;    var qResult = qformer.slerp(q3w, q3xyz[0], q3xyz[1], q3xyz[2])(i/9)&#xA;}&#xA;&#xA;쿼터니온의 성분은 기존 코드에서 가져왔습니다.&#xA;&#xA;근데 저렇게 하면 q2 변수는 왜 필요한거냐...? 사실은 오일러각 계산에 필요해서 저장해두고 있습니다.&#xA;&#xA;모든 오일러각을 전부 없앤 게 아니고, 오일러각으로 다루는 게 편한 부분은 오일러각으로, 쿼터니온으로 다루는 게 편한 부분은 쿼터니온으로, 둘 다 비슷하다면 쿼터니온으로 계산하고 있어요.]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B2%84%ED%8A%9C%EB%B2%84" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">버튜버</span></a></p>

<h2 id="slerp-개념-이해하기" id="slerp-개념-이해하기">Slerp 개념 이해하기</h2>

<p>사실, 지금껏 &#39;평균&#39; 이라고 했지만 &#39;내삽&#39; 이라는 좀더 일반적이고 깔끔한 용어가 있었다는 것을 잊고 있었습니다.</p>

<p><a href="https://blog.daydream.ink/jyhyun1008/vmcp-02-vmc-dual-cam-mixer" rel="nofollow">지난 포스트</a>에서 쿼터니온을 최대한 건드리지 않고 오일러 각도로 변환한 뒤 각도의 평균을 내 주는 방법을 사용했다고 했죠.</p>

<p>그런데 이게 부정확했던 겁니다. 계속 값이 튀었어요. 모듈로 계산도 써 보고 머리를 싸매다가, 결국 스택오버플로우에서 검색을 해봤어요.</p>

<blockquote><p>Yes, only quaternions are appropriate for inter/extrapolation.</p></blockquote>

<p>네? 내삽/외삽을 게산하는 데 쿼터니온만이 적합하다고 합니다.</p>

<blockquote><p>Quaternion Slerps are commonly used to construct smooth animation curves ...</p></blockquote>

<p>아... 그랬던 겁니다. 애초에 제가 하는 스무딩 작업은 오일러 각도이든 축각 각도이든 한계가 있었고, 쿼터니온의 Slerp 라는 방법을 써야 각도의 내삽이 가능했던 거였어요.</p>

<p>다음은 위키백과의 Slerp 문서에서 가져온 사진입니다.</p>

<p><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Slerp_factor_explanation.png/440px-Slerp_factor_explanation.png" alt=""></p>

<p>네.. 제가 원한 게 바로 이거였거든요. 두 개의 벡터의 단순 평균을 내 주는 게 아니라, 각도의 평균을 내 주는 작업이죠.</p>

<p>쿼터니온의 Slerp은 다음과 같은 계산과정을 거치는 모양입니다.</p>

<p><img src="https://wikimedia.org/api/rest_v1/media/math/render/svg/c3e28b6824e549921d9bd4403639c1eef992dd9f" alt=""></p>

<p>하지만 여기서는 Slerp의 개념만 이해하고 넘어가기로 했습니다. 계산과정은 모듈에게 맡기기로 합니다.</p>

<p>quaternion.js 모듈에 내삽과 관련된 함수가 있는지 찾아봅시다. (없으면 구현해야 하니까요...-_–)</p>

<p><img src="https://for.stella.place/D1/f3eb8df0-6794-439d-ae97-1318d5da2d57.webp" alt=""></p>

<p>아예 대놓고 Slerp 함수가 있었네요.... 지금이라도 찾아서 다행이라고 생각하며 적용해 보았어요.</p>

<h2 id="적용해보기">적용해보기</h2>

<p>우선 오일러 각으로 변환하던 기존 코드들을 전부 주석처리한 뒤,</p>

<pre><code>var q1, q2
q1 = new Quaternion(result1[6], result1[3], result1[4], result1[5])
q2 = new Quaternion(result2[6], result2[3], result2[4], result2[5])

//평균을 내 줘야 하는 곳에서
var q3 = q1.slerp(result2[6], result2[3], result2[4], result2[5])(0.5)

//0.5 부분에는 0-1까지의 float가 들어갈 수 있습니다.
var index = [1,2,3,4,5,6,7,8,9]
for await (let i of index) {
    var qResult = qformer.slerp(q3_w, q3_xyz[0], q3_xyz[1], q3_xyz[2])(i/9)
}
</code></pre>

<p>쿼터니온의 성분은 기존 코드에서 가져왔습니다.</p>

<p>근데 저렇게 하면 q2 변수는 왜 필요한거냐...? 사실은 오일러각 계산에 필요해서 저장해두고 있습니다.</p>

<p>모든 오일러각을 전부 없앤 게 아니고, 오일러각으로 다루는 게 편한 부분은 오일러각으로, 쿼터니온으로 다루는 게 편한 부분은 쿼터니온으로, 둘 다 비슷하다면 쿼터니온으로 계산하고 있어요.</p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/quaternion-jsro-kweoteonion-pyeonggyun-guhagi</guid>
      <pubDate>Mon, 02 Sep 2024 09:56:15 +0900</pubDate>
    </item>
    <item>
      <title>VMC 프로토콜 신호를 NodeJS에서 주고받기</title>
      <link>https://blog.daydream.ink/jyhyun1008/vmcp-01-vmc-peurotokoleul-nodejseseo-guhyeonhagi</link>
      <description>&lt;![CDATA[#개발과정 #NodeJS #버튜버&#xA;&#xA;1. VMC 프로토콜&#xA;&#xA;대략적으로 설명하자면, VMC프로토콜이란 모션캡쳐 데이터를 여러 소프트웨어 간에 전달할 수 있는 프로토콜입니다. TDPT, Webcam Motion Capture, VSeeFace, 심지어 블렌더까지 다양한 소프트웨어에서 지원을 하는데요.&#xA;&#xA;저 같은 경우에는 아이폰 버전 TDPT의 보다 자연스러운 데이터를 전신 모션캡쳐에 사용했고, Webcam Motion Capture의 표정, 손가락 캡쳐 데이터를 추가로 사용했어요. 두 신호를 믹스하는 것은 VSeeFace에서 처리했습니다. VSeeFace에서 보조 신호를 받을 수 있어서, 부위별로 신호들을 섞을 수 있거든요.&#xA;&#xA;자, 여기까지 간다면 일반 버튜버-적인 설명이겠지요. 이걸로 끝이었으면 제가 이 포스트를 적고 있지도 않았을 겁니다.&#xA;&#xA;따라서 포트 관련 셋팅은 다 되어있다고 가정하고 이야기할 겁니다!!&#xA;&#xA;2. 모티베이션&#xA;&#xA;저는 조금 더 높은 퀄리티의 모션캡쳐를 위해 여러 가지를 알아보고 있었습니다.&#xA;&#xA;일반 모션캡쳐 수트: 가격은 둘째치고 살부터 빼야 합니다...&#xA;저렴이 모션캡쳐 수트: 15분-30분에 한번씩 칼리브레이션을 해줘야 하고 중요한 손가락과 표정 캡쳐를 위해서라면 어차피 광학적 캡쳐를 해야 합니다.&#xA;광학-AI 방식: 제가 지금 하고 있는 것인데 일반적인 모노캠 방식은 당연히 한계가 있습니다.&#xA;듀얼캠-AI 방식: Rokoko 에서 지원하는 것인데, 돈이 드는 건 둘째치고 역시 손가락과 표정 캡쳐가 안되는데다 실시간 지원이 안 됩니다(방송 불가능)&#xA;&#xA;이걸 전부 고려해 보았는데, 역시 Rokoko의 듀얼캠을 VMC내에서 구현할 수는 없을까... 라는 생각이 들었습니다. 무지 수학적이고 어렵겠지만 괜찮아요. 천문학과에서 내내 하는 게 좌표변환이거든요 일단은 도전을 해 보았습니다.&#xA;&#xA;3. OSC 프로토콜&#xA;&#xA;따로 3D 관련 지식이 있어야 하느냐? 아닐 거라고 생각했습니다. VMCP로 수치 데이터만 전송하면 되는 거잖아요? 그래서 VMCP 를 알아보았습니다. 홈페이지에 들어가서 자료를 찾아봤어요. 일단 예제가 있어야 이해하기 쉬우니 예제를 눌러봤습니다.&#xA;&#xA;....? cs...? 처음보는 확장자인데.....?&#xA;&#xA;.....C# 이다 끼야악!!!!!&#xA;&#xA;아무튼 이런 이유로 저는 혹시 다른 언어로 작성된 예제 스크립트가 있는지 알아보았습니다.&#xA;&#xA;파이썬 버전이 있길래 부랴부랴 실행해봤습니다. colab에서는 잘 실행되지만 colab 특성상 포트가 안 열리고, 로컬에서는 왠지 모듈 불러오는 데서부터 에러가 났습니다. 근데 뭘 어떻게 고쳐야 하는건지 잘 몰랐습니다.&#xA;&#xA;저는 제가 python을 나름 할 줄 안다고 생각했는데 아니었나 봅니다. 여러분, 연구-코딩은 개발이 아닙니다. 아셨죠?   .0&#xA;&#xA;익숙한 Node.js 모듈은 없나 찾아봤습니다. 어... VMC와 관련된 노드 모듈은 없었습니다. 절망하던 도중 저는 VMCP 홈페이지의 그림을 찬찬히 살펴보고서야 답을 얻었습니다!&#xA;&#xA;위 이미지들에서 보시다시피 OSC라는 프로토콜 상에서 사용할 수 있습니다. 그러니 OSC 프로토콜을 Node로 구현하면 VMC도 당연히 수신하고 송신할 수 있다!!! 라는 결론에 이르러서 OSC 모듈을 찾아봤습니다. osc-js와 osc.js 두 종류가 있더라구요. 둘 다 깔아봤는데 제가 성공한 버전은 후자 쪽입니다.&#xA;&#xA;4. 단순 토스 서버 만들기&#xA;&#xA;처음이니까, Webcam Motion Capture(39539)의 데이터를 Blender(39538)로 보내주는 코드를 짜보도록 합시다.&#xA;&#xA;npm init&#xA;&#xA;으로 적당히 셋팅해줍니다. 시작은 index.js로 되어있는데 저는 app.js로 변경해서 셋팅했습니다.&#xA;&#xA;//app.js&#xA;&#xA;const osc = require(&#39;osc&#39;)&#xA;&#xA;var getIPAddresses = function () {&#xA;    var os = require(&#34;os&#34;),&#xA;        interfaces = os.networkInterfaces(),&#xA;        ipAddresses = [];&#xA;&#xA;    for (var deviceName in interfaces) {&#xA;        var addresses = interfaces[deviceName];&#xA;        for (var i = 0; i &lt; addresses.length; i++) {&#xA;            var addressInfo = addresses[i];&#xA;            if (addressInfo.family === &#34;IPv4&#34; &amp;&amp; !addressInfo.internal) {&#xA;                ipAddresses.push(addressInfo.address);&#xA;            }&#xA;        }&#xA;    }&#xA;&#xA;    return ipAddresses;&#xA;};&#xA;&#xA;// VMC 데이터를 수신하는 포트&#xA;var udpPort = new osc.UDPPort({&#xA;    localAddress: &#34;127.0.0.1&#34;,&#xA;    localPort: 39539 // ← webcam motion capture&#xA;});&#xA;&#xA;// VMC 데이터를 송신하는 포트. localPort가 아니라 remotePort입니다.&#xA;var sendPort = new osc.UDPPort({&#xA;    localAddress: &#34;127.0.0.1&#34;,&#xA;    remotePort: 39538 // ← blender&#xA;});&#xA;&#xA;// 우선 송신부부터 열어줍니다.&#xA;sendPort.open();&#xA;&#xA;// 수신 포트가 열리면 다음과 같이 콘솔을 찍습니다.&#xA;udpPort.on(&#34;ready&#34;, function () {&#xA;    var ipAddresses = getIPAddresses();&#xA;&#xA;    console.log(&#34;Listening for OSC over UDP.&#34;);&#xA;    ipAddresses.forEach(function (address) {&#xA;        console.log(&#34; Host:&#34;, address + &#34;, Port:&#34;, udpPort.options.localPort);&#xA;    });&#xA;});&#xA;&#xA;// 송신 포트가 열리면 본격적으로 내용을 실행합니다.&#xA;sendPort.on(&#34;ready&#34;, function () {&#xA;    var ipAddresses = getIPAddresses();&#xA;&#xA;    console.log(&#34;Listening for OSC over UDP.&#34;);&#xA;    ipAddresses.forEach(function (address) {&#xA;        console.log(&#34; Host:&#34;, address + &#34;, Port:&#34;, sendPort.options.localPort);&#xA;    });&#xA;&#xA;    udpPort.on(&#34;message&#34;, function (oscMessage) {&#xA;         // 수신한 message를 바탕으로 적당히 가공한 뒤&#xA;         // 송신하는 코드를 여기에 작성합니다. 예를 들면&#xA;        sendPort.send(oscMessage)&#xA;        &#xA;    });&#xA;&#xA;// 에러 처리... 이건 수신부에 대해서만 입니다.&#xA;    udpPort.on(&#34;error&#34;, function (err) {&#xA;        console.log(err);&#xA;    });&#xA;&#xA;// 수신부 포트를 열어줍니다.&#xA;    udpPort.open();&#xA;})&#xA;&#xA;이렇게 한 후, &#xA;&#xA;node app.js&#xA;&#xA;로 실행하면 webcam motion capture의 데이터가 블렌더로 잘 전송됩니다.&#xA;네. 이게 VMC프로토콜의 전부....&#xA;&#xA;는 아니죠, 아직 안을 안 까봤잖아요.&#xA;&#xA;5. 여러 신호의 평균을 내 주는 서버 만들기&#xA;&#xA;우선 저는, Webcam Motion Capture와 TDPT의 신호를 말 그대로 섞을 겁니다. 프레임률은 꽤 떨어지겠지만 특정 시간단위마다 신호들을 모아서 평균을 낸 다음 Blender로 전송하고자 합니다. 그러기 위해서는 VMC 프로토콜 안의 신호가 어떻게 생겼는지 봐야 합니다.&#xA;&#xA;   udpPort.on(&#34;message&#34;, function (oscMessage) {&#xA;         // 수신한 message를 바탕으로 적당히 가공한 뒤&#xA;         // 송신하는 코드를 여기에 작성합니다. 예를 들면&#xA;        sendPort.send(oscMessage)&#xA;        &#xA;    });&#xA;&#xA;여기 코드를&#xA;   udpPort.on(&#34;message&#34;, function (oscMessage) {&#xA;         // 수신한 message를 바탕으로 적당히 가공한 뒤&#xA;         // 송신하는 코드를 여기에 작성합니다. 예를 들면&#xA;        console.log(oscMessage)&#xA;        &#xA;    });&#xA;&#xA;로 변경하면, 어떤 형태의 데이터가 들어오는지 알 수 있게 됩니다. 일단 표정 신호는 섞을 수 없으니, 포즈 데이터에 해당하는 &#39;/VMC/Ext/Bone/Pos&#39; 만을 보여드릴게요.&#xA;&#xA;{&#xA;  address: &#39;/VMC/Ext/Bone/Pos&#39;,&#xA;  args: [&#xA;    &#39;Head&#39;,&#xA;    -4.18985948158479e-8,&#xA;    0.0694720596075058,&#xA;    0.008954105898737907,&#xA;    -4.593485614010968e-22,&#xA;    1.7898570958666765e-22,&#xA;    5.895302718405536e-23,&#xA;    1&#xA;  ]&#xA;}&#xA;&#xA;다행히도 아주 편리한 JSON 데이터입니다. 처리하기 매우 쉽죠.&#xA;&#xA;포즈 데이터는 무조건 address &#39;/VMC/Ext/Bone/Pos&#39; 쪽으로 들어옵니다. 어떤 본에 해당하는 데이터인지는 args에 적혀 있는데, args의 index 0에 그 문자열이 찍힙니다. 나머지 7개의 데이터는, 위치 X, Y, Z, 사원수 X, Y, Z, W 입니다.&#xA;&#xA;이 데이터를 운용하는 방식은 저도 공부를 해 보고 나중에 적어보겠습니다만, 적어도 여기서 해볼 신호의 평균값을 내는 데에는 무리가 없을 것 같습니다. 왜냐? float 데이터 니까요...&#xA;&#xA;저는 일단 시험 삼아, &#39;Spine&#39;, &#39;Chest&#39;, &#39;Neck&#39;, &#39;Head&#39; 네 개의 데이터만을 처리해 보았습니다.&#xA;&#xA;우선 함수를 하나 만들 겁니다. 이 함수는 몇 밀리초간 받아서 축적해온 데이터들의 Array의, 딱 평균값에 해당하는 Array를 계산하여 반환..하지는 않고 바로 블렌더 측에 전송하는 함수입니다.&#xA;&#xA;// 데이터의 평균을 내 주는 함수입니다. &#xA;// part는 문자열(&#39;Head&#39; 같은), &#xA;// receiveArray는 그동안 받아서 축적해온 데이터들의 Array 입니다.&#xA;// 참고로 receiveArray에는 part의 이름이 없습니다. slice(1) 로 뺄겁니다.&#xA;    function averager(part, receiveArray) {&#xA;        if (receiveArray.length   0) {&#xA;&#xA;            var result3 = [part]&#xA;           //result3 는 내보낼 args 데이터 입니다.&#xA;&#xA;            for(var i = 0; i &lt; receiveArray[0].length; i++){&#xA;                var num = 0;&#xA;                for(var j = 0; j &lt; receiveArray.length; j++){ &#xA;                    num += receiveArrayj;&#xA;                }&#xA;                result3.push(num / receiveArray.length)&#xA;             // 앞의 array의 내용물을 index별로 평균내서&#xA;             // result3에 집어넣어줍니다.&#xA;            }&#xA;            //송신부&#xA;            sendPort.send({&#xA;                address: &#39;/VMC/Ext/Bone/Pos&#39;,&#xA;                args: result3&#xA;            })&#xA;// 어떤 데이터가 나가고 있는지 찍고 싶으면 찍으세요..&#xA;         //   console.log({&#xA;         //       address: &#39;/VMC/Ext/Bone/Pos&#39;,&#xA;         //       args: result3&#xA;         //   })&#xA;        }&#xA;    }&#xA;&#xA;그리고 Array들이랑 변수들을 선언했습니다.&#xA;&#xA;    var triggered = false&#xA;    var receive = false&#xA;&#xA;    var receiveSpineArray = []&#xA;    var receiveChestArray = []&#xA;    var receiveNeckArray = []&#xA;    var receiveHeadArray = []&#xA;&#xA;receive가 false가 되면 수신을 중단하고 지금까지 받았던 내용들의 평균을 계산해서 보낼 것입니다. 그리고 receive는 (일단 테스트니까) 450ms째에 false가 되고, 50ms째에 true가 되는 것을 게속 반복할 것입니다.&#xA;&#xA;그리고 그 트리거가 되는 것은 첫번째 데이터의 수신입니다.&#xA;&#xA;    udpPort.on(&#34;message&#34;, function (oscMessage) {&#xA;// triggered가 false일 경우 트리거를 당겨줄겁니다.&#xA;        if (!triggered) {&#xA;            setInterval(() =  {&#xA;                receive = true&#xA;                setTimeout(() =  {&#xA;                    receive = false&#xA;                }, 450);&#xA;            }, 500)&#xA;// 500밀리초 간격으로 true와 false를 반복해서 실행.&#xA;        }&#xA;        triggered = true&#xA;&#xA;        if (oscMessage.address == &#39;/VMC/Ext/Bone/Pos&#39;) {&#xA;            if (oscMessage.args[0] == &#39;Spine&#39;) {&#xA;                if (receive == true) {&#xA;                    receiveSpineArray.push(oscMessage.args.slice(1))&#xA;// receive가 true라면 args에서 앞의 &#39;Spine&#39; 부분을 자르고 push합니다.&#xA;// 계산을 용이하게 하기 위해서 입니다.&#xA;                } else if (receive == false) {&#xA;                    averager(&#39;Spine&#39;, receiveSpineArray)&#xA;                    receiveSpineArray = []&#xA;// receive가 false라면 지금까지 받았던 array의 평균값을 계산하고,&#xA;// 블렌더로 데이터를 보냅니다.&#xA;// 그리고 기존 array를 초기화 합니다.&#xA;                }&#xA;//아래는 부위만 다르고 동일합니다.&#xA;            } else if (oscMessage.args[0] == &#39;Chest&#39;) {&#xA;                if (receive == true) {&#xA;                    receiveChestArray.push(oscMessage.args.slice(1))&#xA;                } else if (receive == false) {&#xA;                    averager(&#39;Chest&#39;, receiveChestArray)&#xA;                    receiveChestArray = []&#xA;                }&#xA;            } else if (oscMessage.args[0] == &#39;Neck&#39;) {&#xA;                if (receive == true) {&#xA;                    receiveNeckArray.push(oscMessage.args.slice(1))&#xA;                } else if (receive == false) {&#xA;                    averager(&#39;Neck&#39;, receiveNeckArray)&#xA;                    receiveNeckArray = []&#xA;                }&#xA;            } else if (oscMessage.args[0] == &#39;Head&#39;) {&#xA;                if (receive == true) {&#xA;                    receiveHeadArray.push(oscMessage.args.slice(1))&#xA;                } else if (receive == false) {&#xA;                    averager(&#39;Head&#39;, receiveHeadArray)&#xA;                    receiveHeadArray = []&#xA;                }&#xA;            }&#xA;        }&#xA;    });&#xA;&#xA;저장하고 실행해 보면 블렌더 상에서 움직이는 아바타를 보실 수 있습니다. 일단 후기 말씀드릴게요.&#xA;&#xA;웹 서버 여는것보다 쉬운것같아요!]]&gt;</description>
      <content:encoded><![CDATA[<p><a href="/jyhyun1008/tag:%EA%B0%9C%EB%B0%9C%EA%B3%BC%EC%A0%95" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">개발과정</span></a> <a href="/jyhyun1008/tag:NodeJS" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">NodeJS</span></a> <a href="/jyhyun1008/tag:%EB%B2%84%ED%8A%9C%EB%B2%84" class="hashtag" rel="nofollow"><span>#</span><span class="p-category">버튜버</span></a></p>

<h2 id="1-vmc-프로토콜" id="1-vmc-프로토콜">1. VMC 프로토콜</h2>

<p>대략적으로 설명하자면, VMC프로토콜이란 모션캡쳐 데이터를 여러 소프트웨어 간에 전달할 수 있는 프로토콜입니다. TDPT, Webcam Motion Capture, VSeeFace, 심지어 블렌더까지 다양한 소프트웨어에서 지원을 하는데요.</p>

<p>저 같은 경우에는 아이폰 버전 TDPT의 보다 자연스러운 데이터를 전신 모션캡쳐에 사용했고, Webcam Motion Capture의 표정, 손가락 캡쳐 데이터를 추가로 사용했어요. 두 신호를 믹스하는 것은 VSeeFace에서 처리했습니다. VSeeFace에서 보조 신호를 받을 수 있어서, 부위별로 신호들을 섞을 수 있거든요.</p>

<p>자, 여기까지 간다면 일반 버튜버-적인 설명이겠지요. 이걸로 끝이었으면 제가 이 포스트를 적고 있지도 않았을 겁니다.</p>

<p><strong>따라서 포트 관련 셋팅은 다 되어있다고 가정하고 이야기할 겁니다!!</strong></p>

<h2 id="2-모티베이션" id="2-모티베이션">2. 모티베이션</h2>

<p>저는 조금 더 높은 퀄리티의 모션캡쳐를 위해 여러 가지를 알아보고 있었습니다.</p>
<ul><li>일반 모션캡쳐 수트: 가격은 둘째치고 살부터 빼야 합니다...</li>
<li>저렴이 모션캡쳐 수트: 15분-30분에 한번씩 칼리브레이션을 해줘야 하고 중요한 손가락과 표정 캡쳐를 위해서라면 어차피 광학적 캡쳐를 해야 합니다.</li>
<li>광학-AI 방식: 제가 지금 하고 있는 것인데 일반적인 모노캠 방식은 당연히 한계가 있습니다.</li>
<li>듀얼캠-AI 방식: Rokoko 에서 지원하는 것인데, 돈이 드는 건 둘째치고 역시 손가락과 표정 캡쳐가 안되는데다 실시간 지원이 안 됩니다(방송 불가능)</li></ul>

<p>이걸 전부 고려해 보았는데, 역시 Rokoko의 듀얼캠을 VMC내에서 구현할 수는 없을까... 라는 생각이 들었습니다. 무지 수학적이고 어렵겠지만 <del>괜찮아요. 천문학과에서 내내 하는 게 좌표변환이거든요</del> 일단은 도전을 해 보았습니다.</p>

<h2 id="3-osc-프로토콜" id="3-osc-프로토콜">3. OSC 프로토콜</h2>

<p>따로 3D 관련 지식이 있어야 하느냐? 아닐 거라고 생각했습니다. VMCP로 수치 데이터만 전송하면 되는 거잖아요? 그래서 VMCP 를 알아보았습니다. 홈페이지에 들어가서 자료를 찾아봤어요. 일단 예제가 있어야 이해하기 쉬우니 예제를 눌러봤습니다.</p>

<p><img src="https://for.stella.place/D1/855f74da-27f3-4cd1-bcd5-b5cf3c9b5d95.webp" alt=""></p>

<p>....? cs...? 처음보는 확장자인데.....?</p>

<p><img src="https://for.stella.place/D1/58303ede-091c-4129-bd50-b26659c3ee8d.png" alt=""></p>

<p>.....C# 이다 끼야악!!!!!</p>

<p>아무튼 이런 이유로 저는 혹시 <strong>다른 언어로 작성된 예제 스크립트가 있는지</strong> 알아보았습니다.</p>

<p>파이썬 버전이 있길래 부랴부랴 실행해봤습니다. colab에서는 잘 실행되지만 colab 특성상 포트가 안 열리고, 로컬에서는 왠지 모듈 불러오는 데서부터 에러가 났습니다. 근데 뭘 어떻게 고쳐야 하는건지 잘 몰랐습니다.</p>

<p>저는 제가 python을 나름 할 줄 안다고 생각했는데 아니었나 봅니다. 여러분, 연구-코딩은 개발이 아닙니다. 아셨죠? &gt;.0</p>

<p>익숙한 Node.js 모듈은 없나 찾아봤습니다. 어... VMC와 관련된 노드 모듈은 없었습니다. 절망하던 도중 저는 VMCP 홈페이지의 그림을 찬찬히 살펴보고서야 답을 얻었습니다!</p>

<p><img src="https://protocol.vmc.info/flow.gif" alt=""></p>

<p><img src="https://protocol.vmc.info/layer.png" alt=""></p>

<p>위 이미지들에서 보시다시피 OSC라는 프로토콜 상에서 사용할 수 있습니다. 그러니 <strong>OSC 프로토콜을 Node로 구현하면 VMC도 당연히 수신하고 송신할 수 있다!!!</strong> 라는 결론에 이르러서 OSC 모듈을 찾아봤습니다. osc-js와 osc.js 두 종류가 있더라구요. 둘 다 깔아봤는데 제가 성공한 버전은 후자 쪽입니다.</p>

<h2 id="4-단순-토스-서버-만들기" id="4-단순-토스-서버-만들기">4. 단순 토스 서버 만들기</h2>

<p>처음이니까, Webcam Motion Capture(39539)의 데이터를 Blender(39538)로 보내주는 코드를 짜보도록 합시다.</p>

<pre><code>npm init
</code></pre>

<p>으로 적당히 셋팅해줍니다. 시작은 index.js로 되어있는데 저는 app.js로 변경해서 셋팅했습니다.</p>

<pre><code class="language-js">//app.js

const osc = require(&#39;osc&#39;)

var getIPAddresses = function () {
    var os = require(&#34;os&#34;),
        interfaces = os.networkInterfaces(),
        ipAddresses = [];

    for (var deviceName in interfaces) {
        var addresses = interfaces[deviceName];
        for (var i = 0; i &lt; addresses.length; i++) {
            var addressInfo = addresses[i];
            if (addressInfo.family === &#34;IPv4&#34; &amp;&amp; !addressInfo.internal) {
                ipAddresses.push(addressInfo.address);
            }
        }
    }

    return ipAddresses;
};

// VMC 데이터를 수신하는 포트
var udpPort = new osc.UDPPort({
    localAddress: &#34;127.0.0.1&#34;,
    localPort: 39539 // ← webcam motion capture
});

// VMC 데이터를 송신하는 포트. localPort가 아니라 remotePort입니다.
var sendPort = new osc.UDPPort({
    localAddress: &#34;127.0.0.1&#34;,
    remotePort: 39538 // ← blender
});

// 우선 송신부부터 열어줍니다.
sendPort.open();

// 수신 포트가 열리면 다음과 같이 콘솔을 찍습니다.
udpPort.on(&#34;ready&#34;, function () {
    var ipAddresses = getIPAddresses();

    console.log(&#34;Listening for OSC over UDP.&#34;);
    ipAddresses.forEach(function (address) {
        console.log(&#34; Host:&#34;, address + &#34;, Port:&#34;, udpPort.options.localPort);
    });
});

// 송신 포트가 열리면 본격적으로 내용을 실행합니다.
sendPort.on(&#34;ready&#34;, function () {
    var ipAddresses = getIPAddresses();

    console.log(&#34;Listening for OSC over UDP.&#34;);
    ipAddresses.forEach(function (address) {
        console.log(&#34; Host:&#34;, address + &#34;, Port:&#34;, sendPort.options.localPort);
    });

    udpPort.on(&#34;message&#34;, function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });

// 에러 처리... 이건 수신부에 대해서만 입니다.
    udpPort.on(&#34;error&#34;, function (err) {
        console.log(err);
    });

// 수신부 포트를 열어줍니다.
    udpPort.open();
})

</code></pre>

<p>이렇게 한 후,</p>

<pre><code>node app.js
</code></pre>

<p>로 실행하면 webcam motion capture의 데이터가 블렌더로 잘 전송됩니다.
네. 이게 VMC프로토콜의 전부....</p>

<p>는 아니죠, 아직 안을 안 까봤잖아요.</p>

<h2 id="5-여러-신호의-평균을-내-주는-서버-만들기" id="5-여러-신호의-평균을-내-주는-서버-만들기">5. 여러 신호의 평균을 내 주는 서버 만들기</h2>

<p>우선 저는, Webcam Motion Capture와 TDPT의 신호를 말 그대로 <strong>섞을</strong> 겁니다. 프레임률은 꽤 떨어지겠지만 특정 시간단위마다 신호들을 모아서 평균을 낸 다음 Blender로 전송하고자 합니다. 그러기 위해서는 VMC 프로토콜 안의 신호가 어떻게 생겼는지 봐야 합니다.</p>

<pre><code class="language-js">   udpPort.on(&#34;message&#34;, function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });
</code></pre>

<p>여기 코드를</p>

<pre><code class="language-js">   udpPort.on(&#34;message&#34;, function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        console.log(oscMessage)
        
    });
</code></pre>

<p>로 변경하면, 어떤 형태의 데이터가 들어오는지 알 수 있게 됩니다. 일단 표정 신호는 섞을 수 없으니, 포즈 데이터에 해당하는 <code>&#39;/VMC/Ext/Bone/Pos&#39;</code> 만을 보여드릴게요.</p>

<pre><code class="language-js">{
  address: &#39;/VMC/Ext/Bone/Pos&#39;,
  args: [
    &#39;Head&#39;,
    -4.18985948158479e-8,
    0.0694720596075058,
    0.008954105898737907,
    -4.593485614010968e-22,
    1.7898570958666765e-22,
    5.895302718405536e-23,
    1
  ]
}
</code></pre>

<p>다행히도 아주 편리한 JSON 데이터입니다. 처리하기 매우 쉽죠.</p>

<p>포즈 데이터는 무조건 address <code>&#39;/VMC/Ext/Bone/Pos&#39;</code> 쪽으로 들어옵니다. 어떤 본에 해당하는 데이터인지는 args에 적혀 있는데, args의 index 0에 그 문자열이 찍힙니다. 나머지 7개의 데이터는, 위치 X, Y, Z, 사원수 X, Y, Z, W 입니다.</p>

<p>이 데이터를 운용하는 방식은 저도 공부를 해 보고 나중에 적어보겠습니다만, 적어도 여기서 해볼 신호의 평균값을 내는 데에는 무리가 없을 것 같습니다. 왜냐? float 데이터 니까요...</p>

<p>저는 일단 시험 삼아, &#39;Spine&#39;, &#39;Chest&#39;, &#39;Neck&#39;, &#39;Head&#39; 네 개의 데이터만을 처리해 보았습니다.</p>

<p>우선 함수를 하나 만들 겁니다. 이 함수는 몇 밀리초간 받아서 축적해온 데이터들의 Array의, 딱 평균값에 해당하는 Array를 계산하여 반환..하지는 않고 바로 블렌더 측에 전송하는 함수입니다.</p>

<pre><code class="language-js">// 데이터의 평균을 내 주는 함수입니다. 
// part는 문자열(&#39;Head&#39; 같은), 
// receiveArray는 그동안 받아서 축적해온 데이터들의 Array 입니다.
// 참고로 receiveArray에는 part의 이름이 없습니다. slice(1) 로 뺄겁니다.
    function averager(part, receiveArray) {
        if (receiveArray.length &gt; 0) {

            var result3 = [part]
           //result3 는 내보낼 args 데이터 입니다.

            for(var i = 0; i &lt; receiveArray[0].length; i++){
                var num = 0;
                for(var j = 0; j &lt; receiveArray.length; j++){ 
                    num += receiveArray[j][i];
                }
                result3.push(num / receiveArray.length)
             // 앞의 array의 내용물을 index별로 평균내서
             // result3에 집어넣어줍니다.
            }
            //송신부
            sendPort.send({
                address: &#39;/VMC/Ext/Bone/Pos&#39;,
                args: result3
            })
// 어떤 데이터가 나가고 있는지 찍고 싶으면 찍으세요..
         //   console.log({
         //       address: &#39;/VMC/Ext/Bone/Pos&#39;,
         //       args: result3
         //   })
        }
    }
</code></pre>

<p>그리고 Array들이랑 변수들을 선언했습니다.</p>

<pre><code class="language-js">    var triggered = false
    var receive = false

    var receiveSpineArray = []
    var receiveChestArray = []
    var receiveNeckArray = []
    var receiveHeadArray = []
</code></pre>

<p>receive가 false가 되면 수신을 중단하고 지금까지 받았던 내용들의 평균을 계산해서 보낼 것입니다. 그리고 receive는 (일단 테스트니까) 450ms째에 false가 되고, 50ms째에 true가 되는 것을 게속 반복할 것입니다.</p>

<p>그리고 그 트리거가 되는 것은 첫번째 데이터의 수신입니다.</p>

<pre><code class="language-js">    udpPort.on(&#34;message&#34;, function (oscMessage) {
// triggered가 false일 경우 트리거를 당겨줄겁니다.
        if (!triggered) {
            setInterval(() =&gt; {
                receive = true
                setTimeout(() =&gt; {
                    receive = false
                }, 450);
            }, 500)
// 500밀리초 간격으로 true와 false를 반복해서 실행.
        }
        triggered = true

        if (oscMessage.address == &#39;/VMC/Ext/Bone/Pos&#39;) {
            if (oscMessage.args[0] == &#39;Spine&#39;) {
                if (receive == true) {
                    receiveSpineArray.push(oscMessage.args.slice(1))
// receive가 true라면 args에서 앞의 &#39;Spine&#39; 부분을 자르고 push합니다.
// 계산을 용이하게 하기 위해서 입니다.
                } else if (receive == false) {
                    averager(&#39;Spine&#39;, receiveSpineArray)
                    receiveSpineArray = []
// receive가 false라면 지금까지 받았던 array의 평균값을 계산하고,
// 블렌더로 데이터를 보냅니다.
// 그리고 기존 array를 초기화 합니다.
                }
//아래는 부위만 다르고 동일합니다.
            } else if (oscMessage.args[0] == &#39;Chest&#39;) {
                if (receive == true) {
                    receiveChestArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager(&#39;Chest&#39;, receiveChestArray)
                    receiveChestArray = []
                }
            } else if (oscMessage.args[0] == &#39;Neck&#39;) {
                if (receive == true) {
                    receiveNeckArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager(&#39;Neck&#39;, receiveNeckArray)
                    receiveNeckArray = []
                }
            } else if (oscMessage.args[0] == &#39;Head&#39;) {
                if (receive == true) {
                    receiveHeadArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager(&#39;Head&#39;, receiveHeadArray)
                    receiveHeadArray = []
                }
            }
        }
    });
</code></pre>

<p><img src="https://for.stella.place/D1/6049ba20-9230-4962-8fc9-ddde94850873.webp" alt=""></p>

<p>저장하고 실행해 보면 블렌더 상에서 움직이는 아바타를 보실 수 있습니다. 일단 후기 말씀드릴게요.</p>

<p><strong>웹 서버 여는것보다 쉬운것같아요!</strong></p>
]]></content:encoded>
      <guid>https://blog.daydream.ink/jyhyun1008/vmcp-01-vmc-peurotokoleul-nodejseseo-guhyeonhagi</guid>
      <pubDate>Tue, 27 Aug 2024 09:23:08 +0900</pubDate>
    </item>
  </channel>
</rss>