<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	
	xmlns:media="http://search.yahoo.com/mrss/">

<channel>
	<title>SNSnote</title>
	<atom:link href="https://snsnote.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://snsnote.com</link>
	<description>SNS 운영 정보 · 무료 AI 도구 · 온라인 업무 도구</description>
	<lastBuildDate>Sat, 10 Oct 2026 17:16:40 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://snsnote.com/wp-content/uploads/2026/06/sns-thumbnail-logo-32x32.png</url>
	<title>SNSnote</title>
	<link>https://snsnote.com</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">254946779</site>	<item>
		<title>화면 녹화 및 동영상 편집 &#8211; 설치 없는 무료 웹툴</title>
		<link>https://snsnote.com/screen-recorder-video-editor/</link>
					<comments>https://snsnote.com/screen-recorder-video-editor/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Sat, 10 Oct 2026 17:11:21 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1792</guid>

					<description><![CDATA[화면 녹화부터 동영상 편집까지, 별도의 프로그램 설치 없이 웹 브라우저에서 무료로 이용해 보세요. SNSNOTE 화면 녹화 및 동영상 편집 웹툴은 컴퓨터 화면을 녹화하고, 영상에서 필요한...]]></description>
										<content:encoded><![CDATA[<!-- SNSNOTE 화면 녹화기 V5: PHP shortcode [snsnote_screen_recorder] -->
<div id="snsnote-screen-recorder" class="snsr">
  <header class="snsr-hero">
    <div class="snsr-hero-content">
      <div class="snsr-eyebrow">SNSNOTE · SCREEN RECORDER</div>
      <h2>화면 녹화 · 영상 크롭 · 워터마크 편집</h2>
      <div class="snsr-hero-pills" aria-label="주요 기능">
        <span class="snsr-pill"><strong>REC</strong><span>화면 녹화</span></span>
        <span class="snsr-pill"><strong>CROP</strong><span>화면 크롭</span></span>
        <span class="snsr-pill"><strong>TRIM</strong><span>길이 자르기</span></span>
        <span class="snsr-pill"><strong>MARK</strong><span>로고 · 텍스트</span></span>
        <span class="snsr-pill"><strong>LOCAL</strong><span>브라우저 처리</span></span>
      </div>
    </div>
  </header>

  <div id="snsr-alert" class="snsr-alert" role="status" aria-live="polite">화면 녹화 또는 영상 파일 불러오기로 시작하세요.</div>

  <section class="snsr-panel" aria-label="화면 녹화">
    <div class="snsr-section-header">
      <h3><span class="snsr-step">01</span> 화면 녹화</h3>
      <span class="snsr-status" id="snsr-status">● 대기 중</span>
    </div>
    <p class="snsr-section-desc">전체 화면, 프로그램 창 또는 브라우저 탭을 선택해 녹화하세요.</p>
    <div class="snsr-options">
      <label class="snsr-check"><input type="checkbox" id="snsr-system" checked><span>화면·시스템 소리 녹음 <small>브라우저에서 공유를 허용한 경우만</small></span></label>
      <label class="snsr-check"><input type="checkbox" id="snsr-mic"><span>마이크 소리 녹음 <small>선택 사항 · 기본 꺼짐</small></span></label>
      <label class="snsr-quality" for="snsr-quality"><span>녹화 화질</span><select id="snsr-quality"><option value="high">고화질 (권장)</option><option value="ultra">최고 화질 (파일 용량 증가)</option><option value="standard">표준 화질 (용량 절약)</option></select><small>원본 화면 크기 유지 · 실제 화질은 공유 대상과 PC 성능에 따라 달라집니다.</small></label>
      <label class="snsr-quality snsr-format-choice" for="snsr-format"><span>저장 형식</span><select id="snsr-format"><option value="mp4" selected>MP4 (권장 · SNS 업로드용)</option><option value="webm">WebM (대체 형식 · 브라우저 호환용)</option></select><small id="snsr-format-help">MP4를 지원하지 않는 브라우저에서는 WebM을 선택해 주세요.</small></label>
    </div>
    <div class="snsr-actions">
      <button type="button" class="snsr-btn snsr-btn-primary" id="snsr-start"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/23fa.png" alt="⏺" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 화면 녹화 시작</button>
      <button type="button" class="snsr-btn snsr-btn-danger" id="snsr-stop" disabled hidden>■ 녹화 중지</button>
      <span class="snsr-counter" id="snsr-counter" aria-label="녹화 시간">00:00</span>
      <span class="snsr-separator">또는</span>
      <label class="snsr-btn snsr-btn-secondary snsr-file-btn" for="snsr-file">영상 파일 불러오기</label>
      <input id="snsr-file" type="file" accept="video/*,.webm,.mp4,.mov,.m4v,.ogv" hidden>
    </div>
    <p class="snsr-help">유튜브·웹사이트 소리를 깨끗하게 녹음하려면 공유 창에서 <b>브라우저 탭</b>과 <b>탭 오디오 공유</b>를 선택하세요. 전체 화면 시스템 소리는 운영체제·오디오 장치에 따라 음질이 달라질 수 있습니다. 블루투스 헤드셋을 사용할 때 마이크를 켜면 통화 모드로 전환되어 소리가 저하될 수 있습니다.</p>
    <p id="snsr-audio-diagnostics" class="snsr-help" aria-live="polite">녹음이 끝나면 화면 선택 종류와 오디오 트랙 정보를 표시합니다.</p>
  </section>

  <section class="snsr-panel" id="snsr-editor" aria-label="영상 편집">
    <div class="snsr-section-header"><h3><span class="snsr-step">02</span> 영상 미리보기 · 화면 크롭</h3><span id="snsr-source-details" class="snsr-muted"></span></div>
    <p class="snsr-section-desc">빈 곳을 드래그해 새 영역을 선택하거나, 녹색 테두리의 모서리·변을 잡아 크기를 조절하세요. 영역 안쪽을 끌면 이동합니다.</p>
    <div id="snsr-editor-placeholder" class="snsr-step-wait"><span class="snsr-wait-symbol">▧</span><div><strong>영상 미리보기 준비</strong><p>화면 녹화 또는 영상 파일 불러오기를 완료하면 크롭 도구가 활성화됩니다.</p></div></div>
    <div id="snsr-editor-content" hidden>
    <div class="snsr-original-row"><a id="snsr-original-download" class="snsr-btn snsr-btn-secondary snsr-btn-small" download="snsnote-original.webm"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2b07.png" alt="⬇" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 녹화 원본 다운로드 (재압축 없음)</a><span class="snsr-muted">편집 후 저장은 영상 재인코딩이 필요합니다.</span></div>
    <div class="snsr-edit-grid">
      <div class="snsr-edit-left">
        <p class="snsr-mini-heading">원본 영상</p>
        <div id="snsr-stage" class="snsr-stage">
          <video id="snsr-video" playsinline preload="auto"></video>
          <div id="snsr-crop-box" class="snsr-crop-box" aria-label="크롭 선택 영역"><span class="snsr-resize-handle" data-dir="nw"></span><span class="snsr-resize-handle" data-dir="n"></span><span class="snsr-resize-handle" data-dir="ne"></span><span class="snsr-resize-handle" data-dir="e"></span><span class="snsr-resize-handle" data-dir="se"></span><span class="snsr-resize-handle" data-dir="s"></span><span class="snsr-resize-handle" data-dir="sw"></span><span class="snsr-resize-handle" data-dir="w"></span></div>
        </div>
        <div class="snsr-crop-tools">
          <label for="snsr-ratio">크롭 비율</label>
          <select id="snsr-ratio"><option value="free">자유 비율</option><option value="16:9">16:9 가로</option><option value="9:16">9:16 세로</option><option value="4:5">4:5 인스타 카드뉴스</option><option value="4:3">4:3 가로 영상</option><option value="1:1">1:1 정사각형</option></select>
          <button type="button" class="snsr-btn snsr-btn-secondary snsr-btn-small" id="snsr-reset-crop">크롭 초기화</button>
          <span class="snsr-muted" id="snsr-crop-details"></span>
        </div>
      </div>
      <div class="snsr-edit-right">
        <p class="snsr-mini-heading">크롭 결과 미리보기</p>
        <div class="snsr-preview-wrap"><canvas id="snsr-crop-preview" width="480" height="270" aria-label="크롭 결과 미리보기"></canvas></div>
        <p class="snsr-help">크롭 결과에 텍스트 또는 로고를 추가한 모습까지 확인할 수 있습니다. 워터마크는 04단계에서 설정하세요. 미리보기 위에서 워터마크를 드래그하면 위치가 이동합니다.</p>
      </div>
    </div>

    </div>

    <div class="snsr-trim-block">
      <div class="snsr-section-header"><h3><span class="snsr-step">03</span> 영상 길이 자르기</h3><span class="snsr-muted" id="snsr-trim-details"></span></div>
      <p class="snsr-section-desc">영상의 시작 시간과 종료 시간을 조절해 필요한 구간만 남겨 보세요.</p>
      <div id="snsr-trim-placeholder" class="snsr-step-wait"><span class="snsr-wait-symbol">◷</span><div><strong>영상 길이 자르기 준비</strong><p>영상을 불러오면 타임라인에서 시작·종료 구간을 조절할 수 있습니다.</p></div></div>
      <div id="snsr-trim-content" hidden>
      <div class="snsr-transport"><button type="button" id="snsr-play" class="snsr-btn snsr-btn-secondary snsr-btn-small"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/25b6.png" alt="▶" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 재생</button><div id="snsr-timeline" class="snsr-timeline"><div id="snsr-trim-band" class="snsr-trim-band" aria-hidden="true"></div><input type="range" id="snsr-playhead" min="0" max="1" step="0.01" value="0" aria-label="영상 재생 위치"></div><span id="snsr-play-time" class="snsr-time">00:00 / 00:00</span></div>
      <div class="snsr-timeline-legend"><span id="snsr-cut-detail" class="snsr-cut-detail">앞뒤 제외 구간: 없음</span><span><i class="snsr-key-cut"></i>제외 구간</span><span><i class="snsr-key-kept"></i>저장 구간</span></div>
      <div class="snsr-trim-grid">
        <div><label for="snsr-trim-start">시작 지점 <strong id="snsr-start-label">00:00</strong></label><input type="range" id="snsr-trim-start" min="0" max="1" step="0.1" value="0"></div>
        <div><label for="snsr-trim-end">종료 지점 <strong id="snsr-end-label">00:00</strong></label><input type="range" id="snsr-trim-end" min="0" max="1" step="0.1" value="1"></div>
      </div>
      </div>
    </div>

    <div class="snsr-watermark-block">
      <div class="snsr-section-header"><h3><span class="snsr-step">04</span> 워터마크 추가 · 텍스트 / 이미지</h3><span class="snsr-muted">원할 때만 사용 · 기본 워터마크 없음</span></div>
      <p class="snsr-section-desc">내 계정명이나 브랜드 로고를 영상에 넣을 수 있습니다. 두 가지를 함께 넣을 수도 있습니다.</p>
      <div id="snsr-wm-placeholder" class="snsr-step-wait"><span class="snsr-wait-symbol">◇</span><div><strong>워터마크 편집 준비</strong><p>영상을 불러오면 텍스트 입력, 로고 이미지 선택과 위치·크기를 조절할 수 있습니다.</p></div></div>
      <div id="snsr-wm-content" class="snsr-wm-grid" hidden>
        <div class="snsr-wm-card">
          <label class="snsr-wm-head"><input type="checkbox" id="snsr-wm-text-on"><strong>텍스트 워터마크 사용</strong></label>
          <label class="snsr-wm-field"><span>표시할 문구</span><input id="snsr-wm-text" type="text" maxlength="100" placeholder="@my_account 또는 브랜드명" autocomplete="off"></label>
          <div class="snsr-wm-inline">
            <label class="snsr-wm-field"><span>글자색</span><input id="snsr-wm-text-color" type="color" value="#ffffff" aria-label="워터마크 글자색"></label>
            <label class="snsr-wm-field"><span>크기 <output id="snsr-wm-text-size-value">5%</output></span><input id="snsr-wm-text-size" type="range" min="2" max="13" step="1" value="5"></label>
          </div>
          <label class="snsr-wm-field"><span>투명도 <output id="snsr-wm-text-opacity-value">100%</output></span><input id="snsr-wm-text-opacity" type="range" min="0" max="100" step="5" value="100"></label>
          <p class="snsr-wm-help">글자를 입력하고 사용을 체크하면 오른쪽 크롭 미리보기에서 확인할 수 있습니다.</p>
        </div>
        <div class="snsr-wm-card">
          <label class="snsr-wm-head"><input type="checkbox" id="snsr-wm-image-on"><strong>이미지 워터마크 사용</strong></label>
          <label class="snsr-wm-field"><span>로고 이미지 (PNG · WebP · JPG)</span><input id="snsr-wm-image-file" type="file" accept="image/png,image/webp,image/jpeg"></label>
          <p id="snsr-wm-image-info" class="snsr-wm-help">투명 배경 PNG나 WebP를 추천합니다. 최대 12MB.</p>
          <label class="snsr-wm-field"><span>로고 너비 <output id="snsr-wm-image-size-value">20%</output></span><input id="snsr-wm-image-size" type="range" min="5" max="50" step="1" value="20"></label>
          <label class="snsr-wm-field"><span>투명도 <output id="snsr-wm-image-opacity-value">100%</output></span><input id="snsr-wm-image-opacity" type="range" min="0" max="100" step="5" value="100"></label>
          <button type="button" id="snsr-wm-image-remove" class="snsr-btn snsr-btn-secondary snsr-btn-small">이미지 제거</button>
        </div>
        <div class="snsr-wm-footer"><span><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 미리보기 위에서 텍스트·이미지를 드래그해 원하는 위치로 옮기세요.</span><button type="button" id="snsr-wm-position-reset" class="snsr-btn snsr-btn-secondary snsr-btn-small">워터마크 위치 초기화</button></div>
      </div>
    </div>

    <div class="snsr-finish-block">
      <div class="snsr-section-header"><h3><span class="snsr-step">05</span> 편집 결과 · 다운로드</h3><span id="snsr-finish-status" class="snsr-muted">편집 대기</span></div>
      <p class="snsr-section-desc">크롭·구간 자르기·워터마크를 적용한 영상을 저장하고 결과를 확인하세요.</p>
      <div id="snsr-finish-placeholder" class="snsr-step-wait"><span class="snsr-wait-symbol">↓</span><div><strong>다운로드 준비</strong><p>녹화나 파일 불러오기를 완료하면 편집 영상을 생성할 수 있습니다.</p></div></div>
      <div id="snsr-export-content" hidden>
      <div class="snsr-export-row">
      <button type="button" id="snsr-export" class="snsr-btn snsr-btn-primary"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2b07.png" alt="⬇" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 편집 영상 저장 (크롭 · 길이 · 워터마크)</button>
      <button type="button" id="snsr-cancel-export" class="snsr-btn snsr-btn-secondary" hidden>내보내기 취소</button>
      <span class="snsr-muted" id="snsr-export-info">화면 크기 유지 · 편집 후 영상은 새로 인코딩됩니다.</span>
    </div>
    <div id="snsr-progress-wrap" hidden><progress id="snsr-progress" max="100" value="0"></progress><p class="snsr-help" id="snsr-progress-label">내보내기 준비 중…</p></div>
    <div id="snsr-output" class="snsr-output" hidden>
      <h3>편집 완료!</h3>
      <video id="snsr-output-video" controls playsinline preload="metadata"></video>
      <a id="snsr-download" class="snsr-btn snsr-btn-primary" download="snsnote-edited.webm">편집한 영상 다운로드</a>
    </div>
      </div>
    </div>
  </section>
  <p class="snsr-footnote">✓ 서버 업로드 없이 기기 안에서 처리합니다. ※ 화면 녹화는 HTTPS 환경과 지원 브라우저가 필요합니다. 탭·시스템 소리 지원은 브라우저와 OS마다 다릅니다. 원본 다운로드는 재인코딩하지 않으며, 크롭·앞뒤 자르기·워터마크 내보내기는 재인코딩으로 품질 저하가 일부 생길 수 있습니다. 고화질 영상은 파일 용량과 CPU 사용량이 증가합니다.</p>
</div>
<style>
#snsnote-screen-recorder.snsr{--sr-fg:#172333;--sr-muted:#6d7d88;--sr-green:#207e6a;--sr-green-dark:#176452;--sr-green-soft:#eff7f4;--sr-border:#dce8e2;--sr-white:#fff;box-sizing:border-box;width:100%;max-width:1450px;margin:20px auto 34px;color:var(--sr-fg);font-family:system-ui,-apple-system,BlinkMacSystemFont,'Noto Sans KR','Apple SD Gothic Neo',Arial,sans-serif;font-size:15px;line-height:1.55}
#snsnote-screen-recorder *,#snsnote-screen-recorder *::before,#snsnote-screen-recorder *::after{box-sizing:border-box}
#snsnote-screen-recorder [hidden]{display:none!important}
#snsnote-screen-recorder p{margin:0}
#snsnote-screen-recorder button,#snsnote-screen-recorder select,#snsnote-screen-recorder input{font:inherit}
#snsnote-screen-recorder .snsr-hero{position:relative;isolation:isolate;overflow:hidden;margin:0 0 26px;background:linear-gradient(112deg,#edf5f1 0%,#f6f8fb 100%);border:1px solid #d6e8df;border-radius:43px;box-shadow:0 15px 40px rgba(32,72,56,.045);padding:46px clamp(24px,5vw,66px) 48px;min-height:282px}
#snsnote-screen-recorder .snsr-hero::before{content:'';position:absolute;left:0;top:17%;height:67%;width:10px;background:#238f77;border-radius:0 12px 12px 0}
#snsnote-screen-recorder .snsr-hero::after{content:'';z-index:-1;position:absolute;right:-60px;top:-170px;width:270px;height:330px;border-radius:50%;background:rgba(255,255,255,.25);pointer-events:none}
#snsnote-screen-recorder .snsr-hero-content{position:relative;z-index:1}
#snsnote-screen-recorder .snsr-eyebrow{display:block;color:#207e6a;font-size:clamp(12px,1.25vw,20px);font-weight:850;letter-spacing:.17em;margin-bottom:20px}
#snsnote-screen-recorder .snsr-hero h2{margin:0 0 30px;font-size:clamp(29px,3.3vw,58px);font-weight:850;letter-spacing:-.055em;line-height:1.24;color:#172333;word-break:keep-all}
#snsnote-screen-recorder .snsr-hero-pills{display:flex;gap:13px;flex-wrap:wrap;align-items:center}
#snsnote-screen-recorder .snsr-pill{display:inline-flex;align-items:center;justify-content:center;gap:13px;min-height:58px;padding:10px 18px;border:1px solid #d4e3db;background:rgba(255,255,255,.94);border-radius:999px;box-shadow:0 6px 17px rgba(22,55,43,.035);color:#35485d;white-space:nowrap;font-size:clamp(13px,1vw,17px);font-weight:800}
#snsnote-screen-recorder .snsr-pill strong{background:#f0f6f3;color:#227865;border-radius:12px;padding:8px 11px;font-size:12px;letter-spacing:.025em;line-height:1.3;font-weight:900}
#snsnote-screen-recorder .snsr-alert{padding:14px 19px;margin:0 0 19px;border-radius:15px;border:1px solid #e0eee8;background:#f3f8f5;color:#426957;font-size:14px;min-height:51px}
#snsnote-screen-recorder .snsr-alert[data-kind=error]{background:#fff4f4;color:#a32323;border-color:#f0cece}
#snsnote-screen-recorder .snsr-alert[data-kind=success]{background:#eef9f3;color:#1d7951;border-color:#cbe9d8}
#snsnote-screen-recorder .snsr-panel{margin:0 0 20px;background:white;border:1px solid var(--sr-border);border-radius:24px;box-shadow:0 9px 32px rgba(26,60,49,.04);padding:clamp(20px,3vw,34px)}
#snsnote-screen-recorder .snsr-section-header{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:14px;margin:0 0 10px}
#snsnote-screen-recorder .snsr-section-header h3,#snsnote-screen-recorder .snsr-output h3{margin:0;font-size:clamp(18px,1.7vw,24px);font-weight:850;line-height:1.4;color:var(--sr-fg)}
#snsnote-screen-recorder .snsr-step{display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;background:#eaf5ef;border-radius:10px;color:#238b71;font-size:12px;font-weight:850;min-width:32px;min-height:29px;margin-right:7px;letter-spacing:.03em}
#snsnote-screen-recorder .snsr-section-desc{color:var(--sr-muted);font-size:14px;margin:0 0 20px!important}
#snsnote-screen-recorder .snsr-status{color:#77878b;font-size:13px;white-space:nowrap;font-weight:750;background:#f2f6f5;border-radius:99px;padding:6px 11px}
#snsnote-screen-recorder .snsr-status.is-recording{color:#b32631;background:#fff1f0}
#snsnote-screen-recorder .snsr-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px;margin:20px 0 19px}
#snsnote-screen-recorder .snsr-check{display:flex;align-items:center;gap:12px;background:#f6faf8;border:1px solid #dbeae2;padding:18px;border-radius:15px;cursor:pointer;color:#233e38;min-width:0}
#snsnote-screen-recorder .snsr-check input{accent-color:#238e75;width:19px;height:19px;flex:none}
#snsnote-screen-recorder .snsr-check span{display:block;font-size:15px;font-weight:800}
#snsnote-screen-recorder .snsr-check small{display:block;font-size:12px;color:#738580;font-weight:450;margin-top:2px}
#snsnote-screen-recorder .snsr-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
#snsnote-screen-recorder .snsr-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:7px;text-align:center;background:#fff;min-height:48px;padding:12px 20px;border-radius:13px;border:1px solid transparent;font-size:14px;font-weight:800;line-height:1.35;white-space:nowrap;cursor:pointer;text-decoration:none;transition:transform .12s ease,background .12s ease,border-color .12s ease;color:var(--sr-fg)}
#snsnote-screen-recorder .snsr-btn:hover{transform:translateY(-1px)}
#snsnote-screen-recorder .snsr-btn-primary{background:#228971;color:#fff;border-color:#228971;box-shadow:0 4px 12px rgba(32,137,113,.13)}
#snsnote-screen-recorder .snsr-btn-primary:hover{background:#176f5b;color:#fff}
#snsnote-screen-recorder .snsr-btn-danger{background:#ce4450;color:white;border-color:#ce4450}
#snsnote-screen-recorder .snsr-btn-danger:hover{background:#b32c39;color:white}
#snsnote-screen-recorder .snsr-btn-secondary{background:white;color:#2d5147;border-color:#d2e1d9}
#snsnote-screen-recorder .snsr-btn-secondary:hover{background:#f5faf7;color:#1d5847}
#snsnote-screen-recorder .snsr-btn-small{min-height:38px;font-size:13px;padding:8px 13px}
#snsnote-screen-recorder .snsr-btn:disabled{opacity:.48;cursor:not-allowed;transform:none}
#snsnote-screen-recorder .snsr-counter{color:#1e4d3f;min-width:72px;text-align:center;font-size:21px;font-weight:850;font-variant-numeric:tabular-nums;letter-spacing:.025em}
#snsnote-screen-recorder .snsr-separator{color:#93a49f;font-size:13px;padding:0 3px}
#snsnote-screen-recorder .snsr-help{font-size:12px;color:#718079;line-height:1.75;margin-top:13px!important}
#snsnote-screen-recorder .snsr-muted{font-size:12px;color:#7a8a83}
#snsnote-screen-recorder .snsr-edit-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,.75fr);gap:20px;align-items:start}
#snsnote-screen-recorder .snsr-mini-heading{font-size:13px;font-weight:800;color:#315c4b;margin:0 0 10px!important}
#snsnote-screen-recorder .snsr-stage{position:relative;overflow:hidden;background:#101e25;border-radius:15px;aspect-ratio:16/9;user-select:none;touch-action:none;cursor:crosshair;max-width:100%}
#snsnote-screen-recorder .snsr-stage video{display:block;width:100%;height:100%;object-fit:fill;pointer-events:none}
#snsnote-screen-recorder .snsr-crop-box{position:absolute;left:0;top:0;width:100%;height:100%;border:2px solid #42d5a0;box-shadow:0 0 0 2500px rgba(0,0,0,.43);pointer-events:none}
#snsnote-screen-recorder .snsr-crop-box i{position:absolute;display:block;width:12px;height:12px;background:#42d5a0;border:2px solid #fff;border-radius:3px}
#snsnote-screen-recorder .snsr-crop-box i:nth-child(1){left:-6px;top:-6px}
#snsnote-screen-recorder .snsr-crop-box i:nth-child(2){right:-6px;top:-6px}
#snsnote-screen-recorder .snsr-crop-box i:nth-child(3){right:-6px;bottom:-6px}
#snsnote-screen-recorder .snsr-crop-box i:nth-child(4){left:-6px;bottom:-6px}
#snsnote-screen-recorder .snsr-crop-tools{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:12px 0 0}
#snsnote-screen-recorder .snsr-crop-tools label{font-size:13px;color:#426454;font-weight:750}
#snsnote-screen-recorder select{border:1px solid #d0e1d8;background:#fff;color:#244c3f;border-radius:11px;padding:9px 12px;min-height:39px;font-size:13px}
#snsnote-screen-recorder .snsr-preview-wrap{display:flex;align-items:center;justify-content:center;min-height:180px;background:#152228;border-radius:15px;overflow:hidden;padding:10px}
#snsnote-screen-recorder #snsr-crop-preview{display:block;max-width:100%;max-height:320px;width:auto;height:auto;object-fit:contain;background:#152228}
#snsnote-screen-recorder .snsr-trim-block{margin:25px 0 0;padding:25px 0 0;border-top:1px solid #e5efe9}
#snsnote-screen-recorder .snsr-transport{display:flex;align-items:center;gap:13px;margin:9px 0 22px}
#snsnote-screen-recorder input[type=range]{display:block;width:100%;accent-color:#248c72;cursor:pointer}
#snsnote-screen-recorder .snsr-transport input{flex:1;min-width:55px}
#snsnote-screen-recorder .snsr-time{color:#426152;font-size:13px;white-space:nowrap;font-variant-numeric:tabular-nums}
#snsnote-screen-recorder .snsr-trim-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}
#snsnote-screen-recorder .snsr-trim-grid label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:14px;font-weight:800;color:#3a5549;margin:0 0 7px}
#snsnote-screen-recorder .snsr-trim-grid strong{color:#227f68;font-variant-numeric:tabular-nums}
#snsnote-screen-recorder .snsr-export-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:26px 0 0}
#snsnote-screen-recorder progress{width:100%;height:13px;margin-top:17px;accent-color:#218b70;border-radius:99px}
#snsnote-screen-recorder .snsr-output{border:1px solid #cae9da;background:#f2faf6;padding:19px;margin-top:22px;border-radius:17px}
#snsnote-screen-recorder .snsr-output video{display:block;width:100%;max-height:430px;background:#14232a;border-radius:12px;object-fit:contain;margin:14px 0}
#snsnote-screen-recorder .snsr-footnote{font-size:12px;color:#738079;line-height:1.75;padding:6px 5px 12px}
@media(max-width:900px){#snsnote-screen-recorder .snsr-hero{padding:35px 32px;border-radius:29px;min-height:auto}#snsnote-screen-recorder .snsr-hero h2{font-size:clamp(27px,3.7vw,41px)}#snsnote-screen-recorder .snsr-edit-grid{grid-template-columns:1fr}#snsnote-screen-recorder .snsr-preview-wrap{min-height:135px}}
@media(max-width:600px){#snsnote-screen-recorder.snsr{margin:14px auto 24px}#snsnote-screen-recorder .snsr-hero{padding:27px 21px 29px;border-radius:24px;margin-bottom:16px}#snsnote-screen-recorder .snsr-hero::before{width:6px}#snsnote-screen-recorder .snsr-eyebrow{font-size:11px;margin-bottom:13px;letter-spacing:.15em}#snsnote-screen-recorder .snsr-hero h2{font-size:clamp(26px,7vw,35px);margin-bottom:21px;line-height:1.32}#snsnote-screen-recorder .snsr-hero-pills{gap:7px}#snsnote-screen-recorder .snsr-pill{min-height:40px;padding:6px 9px;gap:7px;font-size:12px}#snsnote-screen-recorder .snsr-pill strong{font-size:10px;padding:5px 6px;border-radius:7px}#snsnote-screen-recorder .snsr-alert{padding:12px;font-size:13px}#snsnote-screen-recorder .snsr-panel{padding:17px 14px;border-radius:17px}#snsnote-screen-recorder .snsr-options,#snsnote-screen-recorder .snsr-trim-grid{grid-template-columns:1fr;gap:10px}#snsnote-screen-recorder .snsr-check{padding:13px}#snsnote-screen-recorder .snsr-actions{gap:9px}#snsnote-screen-recorder .snsr-btn{min-height:43px;padding:10px 14px;font-size:13px}#snsnote-screen-recorder .snsr-separator{display:none}#snsnote-screen-recorder .snsr-counter{font-size:17px}#snsnote-screen-recorder .snsr-trim-grid{gap:17px}#snsnote-screen-recorder .snsr-section-header{align-items:flex-start}#snsnote-screen-recorder .snsr-export-row .snsr-btn{white-space:normal}}

/* v2: WP 테마 제목 효과 무효화 + 전체 크기 축소 */
#snsnote-screen-recorder.snsr{max-width:1350px!important;margin:10px auto 26px!important;font-size:14px!important}
#snsnote-screen-recorder .snsr-hero{min-height:0!important;margin:0 0 16px!important;padding:28px clamp(22px,3vw,42px) 28px!important;border-radius:28px!important}
#snsnote-screen-recorder .snsr-hero::before{width:7px!important}
#snsnote-screen-recorder .snsr-eyebrow{font-size:14px!important;letter-spacing:.16em!important;margin:0 0 12px!important;line-height:1.35!important}
#snsnote-screen-recorder .snsr-hero h2{display:block!important;background:none!important;background-image:none!important;-webkit-background-clip:border-box!important;background-clip:border-box!important;-webkit-text-fill-color:#172333!important;color:#172333!important;font-size:clamp(24px,2.5vw,39px)!important;line-height:1.3!important;letter-spacing:-.045em!important;max-width:100%!important;margin:0 0 17px!important;padding:0!important;border:0!important;box-shadow:none!important;text-shadow:none!important;border-radius:0!important;font-weight:850!important;text-transform:none!important}
#snsnote-screen-recorder .snsr-hero-pills{gap:8px!important}
#snsnote-screen-recorder .snsr-pill{min-height:38px!important;padding:5px 10px!important;gap:8px!important;font-size:12px!important;box-shadow:none!important}
#snsnote-screen-recorder .snsr-pill strong{padding:5px 8px!important;font-size:11px!important;border-radius:8px!important}
#snsnote-screen-recorder .snsr-panel{padding:20px clamp(17px,2.5vw,26px)!important;border-radius:18px!important;margin-bottom:14px!important}
#snsnote-screen-recorder .snsr-section-header h3,#snsnote-screen-recorder .snsr-output h3{font-size:19px!important;line-height:1.4!important;background:none!important;background-image:none!important;-webkit-text-fill-color:#172333!important;color:#172333!important;padding:0!important;border:0!important;box-shadow:none!important}
#snsnote-screen-recorder .snsr-section-desc{font-size:13px!important;margin-bottom:12px!important}
#snsnote-screen-recorder .snsr-options{margin:10px 0 13px!important;gap:10px!important}
#snsnote-screen-recorder .snsr-check{padding:11px 13px!important}
#snsnote-screen-recorder .snsr-check span{font-size:13px!important}
#snsnote-screen-recorder .snsr-btn{min-height:41px!important;padding:9px 14px!important;font-size:13px!important}
#snsnote-screen-recorder .snsr-btn-small{min-height:35px!important;padding:7px 12px!important}
#snsnote-screen-recorder .snsr-quality{grid-column:1/-1;display:flex;gap:9px;align-items:center;flex-wrap:wrap;border:1px solid #dce9e1;padding:10px 12px;border-radius:12px;color:#2d5749;font-weight:750;font-size:13px}
#snsnote-screen-recorder .snsr-quality select{min-height:32px;padding:5px 8px!important;border-radius:8px;font-size:13px!important}
#snsnote-screen-recorder .snsr-quality small{color:#77877e;font-size:11px;font-weight:450}
#snsnote-screen-recorder .snsr-original-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:-2px 0 12px}
#snsnote-screen-recorder .snsr-trim-block{margin-top:18px!important;padding-top:16px!important}
#snsnote-screen-recorder .snsr-transport{margin:10px 0 4px!important;gap:11px!important}
#snsnote-screen-recorder .snsr-timeline{position:relative;flex:1;min-width:60px;height:36px;display:flex;align-items:center;isolation:isolate;--start-percent:0%;--end-percent:100%}
#snsnote-screen-recorder .snsr-trim-band{position:absolute;z-index:0;inset:13px 0;background:linear-gradient(to right,#c4ccd0 0%,#c4ccd0 var(--start-percent),#239c77 var(--start-percent),#239c77 var(--end-percent),#c4ccd0 var(--end-percent),#c4ccd0 100%);border-radius:999px;pointer-events:none;box-shadow:inset 0 0 0 1px rgba(27,65,56,.06)}
#snsnote-screen-recorder .snsr-trim-band::before,#snsnote-screen-recorder .snsr-trim-band::after{content:'';position:absolute;top:50%;height:25px;width:3px;border-radius:3px;background:#117b63;transform:translate(-50%,-50%);box-shadow:0 0 0 1px white}
#snsnote-screen-recorder .snsr-trim-band::before{left:var(--start-percent)}
#snsnote-screen-recorder .snsr-trim-band::after{left:var(--end-percent)}
#snsnote-screen-recorder .snsr-timeline #snsr-playhead{position:relative;z-index:1;flex:1;width:100%;min-width:0;margin:0;appearance:none!important;-webkit-appearance:none!important;background:transparent!important;background-image:none!important;height:28px!important;outline-offset:2px}
#snsnote-screen-recorder .snsr-timeline #snsr-playhead::-webkit-slider-runnable-track{background:transparent!important;height:12px!important;border:0!important}
#snsnote-screen-recorder .snsr-timeline #snsr-playhead::-moz-range-track{background:transparent!important;height:12px!important;border:0!important}
#snsnote-screen-recorder .snsr-timeline #snsr-playhead::-webkit-slider-thumb{-webkit-appearance:none!important;width:13px!important;height:21px!important;margin-top:-4px!important;border-radius:5px!important;background:#fff!important;border:2px solid #185c4e!important;box-shadow:0 1px 4px rgba(0,0,0,.22)!important}
#snsnote-screen-recorder .snsr-timeline #snsr-playhead::-moz-range-thumb{width:13px!important;height:21px!important;border-radius:5px!important;background:#fff!important;border:2px solid #185c4e!important;box-shadow:0 1px 4px rgba(0,0,0,.22)!important}
#snsnote-screen-recorder .snsr-timeline-legend{display:flex;gap:14px;flex-wrap:wrap;justify-content:flex-end;color:#71817a;font-size:11px;margin:0 0 12px}
#snsnote-screen-recorder .snsr-cut-detail{margin-right:auto;color:#266c58;font-weight:750}
#snsnote-screen-recorder .snsr-timeline-legend span{display:inline-flex;align-items:center;gap:5px}
#snsnote-screen-recorder .snsr-timeline-legend i{width:10px;height:10px;border-radius:3px;display:inline-block}
#snsnote-screen-recorder .snsr-key-cut{background:#c4ccd0}
#snsnote-screen-recorder .snsr-key-kept{background:#239c77}
#snsnote-screen-recorder .snsr-trim-grid{gap:15px!important}
#snsnote-screen-recorder .snsr-export-row{margin-top:17px!important}
@media(max-width:600px){
 #snsnote-screen-recorder .snsr-hero{padding:22px 18px 22px!important;border-radius:18px!important}
 #snsnote-screen-recorder .snsr-hero h2{font-size:clamp(21px,5.7vw,26px)!important;margin-bottom:13px!important}
 #snsnote-screen-recorder .snsr-eyebrow{font-size:11px!important;margin-bottom:11px!important}
 #snsnote-screen-recorder .snsr-pill{font-size:11px!important;min-height:33px!important;padding:4px 7px!important}
 #snsnote-screen-recorder .snsr-pill strong{font-size:10px!important;padding:4px 6px!important}
 #snsnote-screen-recorder .snsr-panel{padding:15px 13px!important}
 #snsnote-screen-recorder .snsr-section-header h3{font-size:17px!important}
 #snsnote-screen-recorder .snsr-transport{gap:6px!important}
 #snsnote-screen-recorder .snsr-transport .snsr-btn{min-width:66px;padding:7px!important}
 #snsnote-screen-recorder .snsr-time{font-size:11px!important}
 #snsnote-screen-recorder .snsr-quality{align-items:flex-start}
}

/* V4: 크롭 모서리/변 8방향 핸들, 모바일 터치, 형식 선택 */
#snsnote-screen-recorder .snsr-crop-box{pointer-events:none;touch-action:none}
#snsnote-screen-recorder .snsr-resize-handle{position:absolute;z-index:3;display:block;width:13px;height:13px;border:2px solid #fff;border-radius:4px;background:#24a77f;box-shadow:0 1px 3px #0005;pointer-events:none;transform:translate(-50%,-50%)}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="nw"]{top:0;left:0}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="n"]{top:0;left:50%}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="ne"]{top:0;left:100%}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="e"]{top:50%;left:100%}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="se"]{top:100%;left:100%}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="s"]{top:100%;left:50%}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="sw"]{top:100%;left:0}
#snsnote-screen-recorder .snsr-resize-handle[data-dir="w"]{top:50%;left:0}
#snsnote-screen-recorder .snsr-format-choice{grid-column:1/-1}
#snsnote-screen-recorder .snsr-stage:focus-visible{outline:3px solid #207e6a;outline-offset:3px}
@media(max-width:600px){#snsnote-screen-recorder .snsr-resize-handle{width:15px;height:15px}}

/* V5: 모든 편집 단계를 처음부터 보여주되, 영상이 없으면 사용 안내 표시 */
#snsnote-screen-recorder .snsr-step-wait{display:flex;align-items:center;gap:13px;min-height:87px;background:linear-gradient(135deg,#f5faf8,#f9fbfa);border:1px dashed #c9ddd4;border-radius:14px;padding:17px 19px;color:#3e6657}
#snsnote-screen-recorder .snsr-step-wait strong{display:block;font-size:13px;font-weight:800;color:#366553}
#snsnote-screen-recorder .snsr-step-wait p{font-size:12px;color:#7b8e85;margin-top:4px}
#snsnote-screen-recorder .snsr-wait-symbol{width:37px;height:37px;flex:0 0 37px;border-radius:11px;background:#e7f3ec;color:#35866e;display:grid;place-items:center;font-weight:900;font-size:22px}
#snsnote-screen-recorder .snsr-watermark-block,#snsnote-screen-recorder .snsr-finish-block{margin:18px 0 0;padding:17px 0 0;border-top:1px solid #e5efe9}
#snsnote-screen-recorder .snsr-wm-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
#snsnote-screen-recorder .snsr-wm-card{min-width:0;border:1px solid #dce9e1;border-radius:13px;background:#f9fcfa;padding:15px;display:flex;flex-direction:column;gap:10px}
#snsnote-screen-recorder .snsr-wm-head{display:flex;gap:9px;align-items:center;font-size:13px;color:#295b4a}
#snsnote-screen-recorder .snsr-wm-head input{width:18px;height:18px;accent-color:#238e75;margin:0}
#snsnote-screen-recorder .snsr-wm-field{display:flex;flex-direction:column;gap:5px;min-width:0;color:#436658;font-size:12px;font-weight:750}
#snsnote-screen-recorder .snsr-wm-field input[type=text],#snsnote-screen-recorder .snsr-wm-field input[type=file]{width:100%;min-height:36px;padding:7px 9px;border:1px solid #cedfd5;border-radius:8px;background:#fff;color:#213f34;font-size:12px}
#snsnote-screen-recorder .snsr-wm-field input[type=file]{font-size:11px}
#snsnote-screen-recorder .snsr-wm-field input[type=range]{width:100%;accent-color:#238e75}
#snsnote-screen-recorder .snsr-wm-field input[type=color]{height:34px;width:57px;padding:3px;border:1px solid #cedfd5;border-radius:8px;background:#fff;cursor:pointer}
#snsnote-screen-recorder .snsr-wm-inline{display:grid;grid-template-columns:90px 1fr;gap:13px;align-items:end}
#snsnote-screen-recorder .snsr-wm-help{font-size:11px!important;color:#7c8b82;line-height:1.5}
#snsnote-screen-recorder .snsr-wm-footer{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;color:#577568;font-size:11px}
#snsnote-screen-recorder #snsr-crop-preview{touch-action:none;cursor:default}
#snsnote-screen-recorder #snsr-crop-preview.is-wm-draggable{cursor:grab}
#snsnote-screen-recorder #snsr-crop-preview.is-wm-dragging{cursor:grabbing}
@media(max-width:730px){#snsnote-screen-recorder .snsr-wm-grid{grid-template-columns:1fr}#snsnote-screen-recorder .snsr-step-wait{padding:13px;min-height:74px}#snsnote-screen-recorder .snsr-wm-inline{grid-template-columns:95px 1fr}}
</style>
<script>
(() => {
  'use strict';
  const root = document.getElementById('snsnote-screen-recorder');
  if (!root || root.dataset.initialized === '1') return;
  root.dataset.initialized = '1';
  const $ = (id) => root.querySelector('#' + id);
  const els = {
    alert:$('snsr-alert'), status:$('snsr-status'), start:$('snsr-start'), stop:$('snsr-stop'), counter:$('snsr-counter'), system:$('snsr-system'), mic:$('snsr-mic'), audioDiagnostics:$('snsr-audio-diagnostics'), quality:$('snsr-quality'), format:$('snsr-format'), formatHelp:$('snsr-format-help'), file:$('snsr-file'), original:$('snsr-original-download'), timeline:$('snsr-timeline'), cutDetail:$('snsr-cut-detail'),
    editor:$('snsr-editor'), editorPlaceholder:$('snsr-editor-placeholder'), editorContent:$('snsr-editor-content'), trimPlaceholder:$('snsr-trim-placeholder'), trimContent:$('snsr-trim-content'), wmPlaceholder:$('snsr-wm-placeholder'), wmContent:$('snsr-wm-content'), finishPlaceholder:$('snsr-finish-placeholder'), exportContent:$('snsr-export-content'), finishStatus:$('snsr-finish-status'), stage:$('snsr-stage'), video:$('snsr-video'), cropBox:$('snsr-crop-box'), ratio:$('snsr-ratio'), resetCrop:$('snsr-reset-crop'), cropDetails:$('snsr-crop-details'), cropPreview:$('snsr-crop-preview'), sourceDetails:$('snsr-source-details'),
    play:$('snsr-play'), playhead:$('snsr-playhead'), playTime:$('snsr-play-time'), trimStart:$('snsr-trim-start'), trimEnd:$('snsr-trim-end'), startLabel:$('snsr-start-label'), endLabel:$('snsr-end-label'), trimDetails:$('snsr-trim-details'),
    wmTextOn:$('snsr-wm-text-on'), wmText:$('snsr-wm-text'), wmTextColor:$('snsr-wm-text-color'), wmTextSize:$('snsr-wm-text-size'), wmTextSizeValue:$('snsr-wm-text-size-value'), wmTextOpacity:$('snsr-wm-text-opacity'), wmTextOpacityValue:$('snsr-wm-text-opacity-value'), wmImageOn:$('snsr-wm-image-on'), wmImageFile:$('snsr-wm-image-file'), wmImageInfo:$('snsr-wm-image-info'), wmImageSize:$('snsr-wm-image-size'), wmImageSizeValue:$('snsr-wm-image-size-value'), wmImageOpacity:$('snsr-wm-image-opacity'), wmImageOpacityValue:$('snsr-wm-image-opacity-value'), wmImageRemove:$('snsr-wm-image-remove'), wmPositionReset:$('snsr-wm-position-reset'),
    export:$('snsr-export'), cancelExport:$('snsr-cancel-export'), exportInfo:$('snsr-export-info'), progressWrap:$('snsr-progress-wrap'), progress:$('snsr-progress'), progressLabel:$('snsr-progress-label'), output:$('snsr-output'), outputVideo:$('snsr-output-video'), download:$('snsr-download')
  };
  let captureStream=null, micStream=null, mixedStream=null, mixContext=null, recorder=null, recordChunks=[], startedAt=0, elapsedTimer=null;
  let lastAudioDetails='';
  let sourceBlob=null, sourceUrl=null, outputUrl=null, duration=0, trimStart=0, trimEnd=0, crop={x:0,y:0,w:1,h:1}, dragging=null, sourceName='snsnote-recording';
  let previewRaf=0, exportSession=null, loadingVersion=0;
  // 화면 크롭 후 결과 영상 좌표계를 기준으로 워터마크 위치를 유지합니다.
  const watermark={text:{on:false,value:'',color:'#ffffff',size:5,opacity:1,x:.035,y:.88},image:{on:false,element:null,url:null,size:20,opacity:1,x:.77,y:.04}};
  let watermarkBounds=[],watermarkDrag=null;
  function setEditorReady(ready){
    for(const item of [els.editorPlaceholder,els.trimPlaceholder,els.wmPlaceholder,els.finishPlaceholder])item.hidden=ready;
    for(const item of [els.editorContent,els.trimContent,els.wmContent,els.exportContent])item.hidden=!ready;
    els.finishStatus.textContent=ready?'편집 준비 완료':'편집 대기';
  }
  setEditorReady(false);
  function resetWatermarkPositions(){watermark.text.x=.035;watermark.text.y=.88;watermark.image.x=.77;watermark.image.y=.04;drawCropPreview();}
  function syncWatermarkUI(){
    watermark.text.on=els.wmTextOn.checked;watermark.text.value=els.wmText.value;
    watermark.text.color=els.wmTextColor.value;watermark.text.size=Number(els.wmTextSize.value);
    watermark.text.opacity=Number(els.wmTextOpacity.value)/100;
    watermark.image.on=els.wmImageOn.checked;watermark.image.size=Number(els.wmImageSize.value);
    watermark.image.opacity=Number(els.wmImageOpacity.value)/100;
    els.wmTextSizeValue.textContent=watermark.text.size+'%';
    els.wmTextOpacityValue.textContent=Math.round(watermark.text.opacity*100)+'%';
    els.wmImageSizeValue.textContent=watermark.image.size+'%';
    els.wmImageOpacityValue.textContent=Math.round(watermark.image.opacity*100)+'%';
    drawCropPreview();
  }
  function paintWatermarks(ctx,w,h,trackBounds=false){
    const bounds=[];const img=watermark.image,tx=watermark.text;
    // 이미지 먼저 그리고, 텍스트를 위에 겹치도록 처리합니다.
    if(img.on&&img.element&&img.opacity>0){
      const ratio=img.element.naturalHeight/img.element.naturalWidth;
      let iw=Math.max(1,w*img.size/100),ih=iw*ratio;
      if(ih>h*.9){const scale=h*.9/ih;iw*=scale;ih*=scale}
      const x=clamp(img.x,0,Math.max(0,1-iw/w))*w;
      const y=clamp(img.y,0,Math.max(0,1-ih/h))*h;
      ctx.save();ctx.globalAlpha=img.opacity;
      ctx.drawImage(img.element,x,y,iw,ih);ctx.restore();
      bounds.push({type:'image',x,y,w:iw,h:ih});
    }
    if(tx.on&&tx.value.trim()&&tx.opacity>0){
      ctx.save();
      const padding=Math.max(1,Math.min(w,h)*.017);
      let fontPx=Math.max(2,Math.min(w,h)*tx.size/100);
      ctx.font='800 '+fontPx+'px system-ui, -apple-system, "Noto Sans KR", sans-serif';
      const label=tx.value.trim();
      const measured=ctx.measureText(label).width;
      if(measured>w-2*padding){fontPx*=Math.max(.01,(w-2*padding)/measured);ctx.font='800 '+fontPx+'px system-ui, -apple-system, "Noto Sans KR", sans-serif'}
      const labelW=ctx.measureText(label).width,labelH=fontPx*1.24;
      const x=clamp(tx.x,0,Math.max(0,(w-padding-labelW)/w))*w;
      const y=clamp(tx.y,0,Math.max(0,(h-labelH-padding)/h))*h;
      ctx.globalAlpha=tx.opacity;ctx.textBaseline='top';ctx.lineJoin='round';
      ctx.lineWidth=Math.max(.7,fontPx*.13);ctx.strokeStyle='rgba(0,0,0,.7)';ctx.strokeText(label,x,y);
      ctx.fillStyle=tx.color;ctx.fillText(label,x,y);ctx.restore();
      bounds.push({type:'text',x,y,w:labelW,h:labelH});
    }
    if(trackBounds)watermarkBounds=bounds;
  }
  function watermarkPoint(e){
    const r=els.cropPreview.getBoundingClientRect();
    return{x:clamp((e.clientX-r.left)/r.width,0,1),y:clamp((e.clientY-r.top)/r.height,0,1)};
  }
  function hoveredWatermark(p){
    const w=els.cropPreview.width,h=els.cropPreview.height;
    for(const b of [...watermarkBounds].reverse())if(p.x*w>=b.x&&p.x*w<=b.x+b.w&&p.y*h>=b.y&&p.y*h<=b.y+b.h)return b;
    return null;
  }
  function wmPointerDown(e){
    if(e.button!==0||exportSession||!sourceBlob)return;
    const p=watermarkPoint(e),b=hoveredWatermark(p);if(!b)return;
    const item=watermark[b.type];
    watermarkDrag={id:e.pointerId,type:b.type,dx:p.x-item.x,dy:p.y-item.y,bw:b.w/els.cropPreview.width,bh:b.h/els.cropPreview.height};
    els.cropPreview.setPointerCapture(e.pointerId);
    els.cropPreview.classList.add('is-wm-dragging');e.preventDefault();
  }
  function wmPointerMove(e){
    const p=watermarkPoint(e);
    if(!watermarkDrag){els.cropPreview.classList.toggle('is-wm-draggable',!!hoveredWatermark(p));return}
    if(watermarkDrag.id!==e.pointerId)return;
    const d=watermarkDrag,item=watermark[d.type];
    item.x=clamp(p.x-d.dx,0,Math.max(0,1-d.bw));
    item.y=clamp(p.y-d.dy,0,Math.max(0,1-d.bh));
    drawCropPreview();e.preventDefault();
  }
  function wmPointerUp(e){if(!watermarkDrag||watermarkDrag.id!==e.pointerId)return;watermarkDrag=null;els.cropPreview.classList.remove('is-wm-dragging');}
  async function importWatermarkImage(file){
    if(!file)return;
    if(!['image/png','image/webp','image/jpeg'].includes(file.type)){notice('PNG, WebP 또는 JPG 이미지를 선택하세요.','error');return}
    if(file.size>12*1024*1024){notice('워터마크 이미지는 12MB 이하만 사용할 수 있습니다.','error');return}
    const url=URL.createObjectURL(file),im=new Image();
    try{
      await new Promise((resolve,reject)=>{im.onload=resolve;im.onerror=()=>reject(new Error('이미지를 읽을 수 없습니다.'));im.src=url});
      if(!im.naturalWidth||!im.naturalHeight||im.naturalWidth>10000||im.naturalHeight>10000)throw new Error('이미지 해상도를 확인하세요 (최대 10000px).');
      if(watermark.image.url)URL.revokeObjectURL(watermark.image.url);
      watermark.image.url=url;watermark.image.element=im;watermark.image.on=true;
      els.wmImageOn.checked=true;els.wmImageInfo.textContent=file.name+' · '+im.naturalWidth+'×'+im.naturalHeight;
      syncWatermarkUI();
    }catch(err){URL.revokeObjectURL(url);notice('로고 이미지 불러오기 실패: '+readableError(err),'error')}
  }
  const clamp=(x,a,b)=>Math.min(Math.max(x,a),b);
  const fmt=(s)=>{s=Math.max(0,Number(s)||0);const m=Math.floor(s/60);const z=Math.floor(s%60);return (m>=60?String(Math.floor(m/60)).padStart(2,'0')+':':'')+String(m%60).padStart(2,'0')+':'+String(z).padStart(2,'0')};
  const fmtCut=(s)=>s<60?Math.max(0,s).toFixed(1)+'초':fmt(s);
  const readableError=(err)=>{
    if (!err) return '알 수 없는 오류';
    const n=err.name||'';
    if(n==='NotAllowedError'||n==='PermissionDeniedError')return '권한이 거부되었거나 화면 선택을 취소했습니다.';
    if(n==='NotFoundError')return '녹화 가능한 화면이나 장치를 찾지 못했습니다.';
    if(n==='NotReadableError')return '선택한 화면을 사용할 수 없습니다. 다른 앱의 사용 여부를 확인하세요.';
    return err.message||String(err);
  };
  function notice(msg,kind='info'){els.alert.textContent=msg;els.alert.dataset.kind=kind;}
  const MP4_MIMES=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=avc1.640028,mp4a.40.2','video/mp4'];
  function supportedMp4(){return !!window.MediaRecorder&&MP4_MIMES.some(m=>MediaRecorder.isTypeSupported(m))}
  function chooseMime(){
    if(!window.MediaRecorder)throw new Error('이 브라우저는 영상 녹화를 지원하지 않습니다.');
    // V3에서 음질이 개선되었던 VP8+Opus 우선 경로를 설정 없이 기본 적용.
    const order=els.format.value==='mp4'?MP4_MIMES:['video/webm;codecs=vp8,opus','video/webm;codecs=vp9,opus','video/webm'];
    for(const m of order)if(MediaRecorder.isTypeSupported(m))return m;
    if(els.format.value==='mp4')throw new Error('이 브라우저에서는 MP4 녹화가 불가능합니다. WebM을 선택해 주세요.');
    return '';
  }
  function videoBitrate(stream,exporting=false){
    const quality=els.quality.value;
    const settings=stream.getVideoTracks()[0]?.getSettings?.()||{};
    const w=settings.width||1920,h=settings.height||1080;
    const pixels=w*h;
    // CSS와 텍스트가 많은 화면을 위해 기본 4.5Mbps에서 대폭 상향.
    // 힌트 값이며 실제 인코더의 비트레이트를 강제하지는 못합니다.
    let base=pixels<=1280*720?8500000:pixels<=1920*1080?13000000:pixels<=2560*1440?19000000:26000000;
    if(quality==='standard')base*=.55;
    if(quality==='ultra')base*=1.55;
    if(exporting)base*=1.15;
    return Math.min(40000000,Math.round(base));
  }
  function newRecorder(stream,exporting=false){
    const mime=chooseMime();
    const options={...(mime?{mimeType:mime}:{}),videoBitsPerSecond:videoBitrate(stream,exporting),audioBitsPerSecond:els.quality.value==='standard'?192000:320000};
    try{return new MediaRecorder(stream,options)}
    catch(e){if(els.format.value==='mp4')throw new Error('MP4 인코더를 시작하지 못했습니다. WebM을 선택해 주세요.');if(mime)return new MediaRecorder(stream,{videoBitsPerSecond:options.videoBitsPerSecond,audioBitsPerSecond:options.audioBitsPerSecond});throw e}
  }
  // 크롬의 MediaRecorder가 만드는 WebM 파일에는 재생 길이(Duration)가 빠지기도 합니다.
  // 다른 서버/라이브러리 없이 EBML Info 메타데이터에 재생 길이를 기록합니다.
  async function withWebMDuration(blob,seconds){
    if(!blob.type.includes('webm')||!(seconds>0))return blob;
    try{
      const bytes=new Uint8Array(await blob.arrayBuffer());
      const id=[0x15,0x49,0xa9,0x66];let pos=-1;
      for(let i=0;i<Math.min(bytes.length-4,4096);i++){
        if(id.every((v,j)=>bytes[i+j]===v)){pos=i;break}
      }
      if(pos<0)return blob;
      // Segment 크기가 "unknown"인 MediaRecorder WebM만 안전하게 수정합니다.
      const segId=[0x18,0x53,0x80,0x67];let seg=-1;
      for(let i=0;i<pos;i++){if(segId.every((v,j)=>bytes[i+j]===v)){seg=i;break}}
      if(seg<0||bytes[seg+4]!==0x01)return blob;
      for(let i=seg+5;i<seg+12;i++)if(bytes[i]!==0xff)return blob;
      function vint(at){
        const first=bytes[at];let len=1,mask=0x80;
        while(len<=8 && !(first&mask)){len++;mask>>=1}
        if(len>8||at+len>bytes.length)throw new Error('유효하지 않은 WebM 길이');
        let value=first&(mask-1);
        for(let j=1;j<len;j++)value=value*256+bytes[at+j];
        return{len,value};
      }
      const sizePos=pos+4,sz=vint(sizePos),contentStart=sizePos+sz.len,contentEnd=contentStart+sz.value;
      if(contentEnd>bytes.length)return blob;
      // 이미 Duration 메타데이터가 있다면 중복 기록하지 않습니다.
      for(let i=contentStart;i<contentEnd-1;i++)if(bytes[i]===0x44&&bytes[i+1]===0x89)return blob;
      let timecodeScale=1000000;
      for(let i=contentStart;i<contentEnd-4;i++){
        if(bytes[i]===0x2a&&bytes[i+1]===0xd7&&bytes[i+2]===0xb1){
          const infoSize=vint(i+3);if(infoSize.value<=8){
            let scale=0;for(let j=0;j<infoSize.value;j++)scale=scale*256+bytes[i+3+infoSize.len+j];
            if(scale>0)timecodeScale=scale;
          }break;
        }
      }
      const durationElement=new Uint8Array(11);durationElement.set([0x44,0x89,0x88]);
      new DataView(durationElement.buffer).setFloat64(3,seconds*1e9/timecodeScale,false);
      const newSize=sz.value+durationElement.length;
      let sizeLen=sz.len;
      while(sizeLen<8&&newSize>=Math.pow(2,7*sizeLen)-1)sizeLen++;
      if(sizeLen>8)return blob;
      const encodedSize=new Uint8Array(sizeLen);
      let val=newSize;
      for(let i=sizeLen-1;i>=0;i--){encodedSize[i]=val%256;val=Math.floor(val/256)}
      encodedSize[0]|=(1<<(8-sizeLen));
      const delta=sizeLen-sz.len+durationElement.length;
      const output=new Uint8Array(bytes.length+delta);
      output.set(bytes.subarray(0,sizePos),0);
      output.set(encodedSize,sizePos);
      const newContentStart=sizePos+sizeLen;
      output.set(bytes.subarray(contentStart,contentEnd),newContentStart);
      output.set(durationElement,newContentStart+sz.value);
      output.set(bytes.subarray(contentEnd),newContentStart+newSize);
      return new Blob([output],{type:blob.type});
    }catch(_){return blob}
  }
  function stopTracks(stream){if(stream)stream.getTracks().forEach(t=>{try{t.stop()}catch(_){}})}
  async function closeMixer(){if(mixContext){try{await mixContext.close()}catch(_){}}mixContext=null;}
  function resetRecordUI(){
    clearInterval(elapsedTimer);elapsedTimer=null;
    els.start.disabled=false;els.start.hidden=false;els.stop.hidden=true;els.stop.disabled=true;
    els.system.disabled=false;els.mic.disabled=false;els.quality.disabled=false;els.format.disabled=false;els.status.textContent='● 대기 중';els.status.classList.remove('is-recording');
  }
  async function cleanupRecording(){stopTracks(captureStream);stopTracks(micStream);if(mixedStream!==captureStream)stopTracks(mixedStream);captureStream=null;micStream=null;mixedStream=null;await closeMixer();}
  async function startRecording(){
    if(exportSession){notice('내보내기가 끝난 후 새 녹화를 시작하세요.','error');return}
    if(!navigator.mediaDevices?.getDisplayMedia){notice('이 환경에서는 화면 녹화를 사용할 수 없습니다. HTTPS와 지원 브라우저가 필요합니다.','error');return}
    els.start.disabled=true;
    try{
      // 화면 선택 창은 사용자 클릭 직후 호출해야 합니다.
      const fps=els.quality.value==='ultra'?60:30;
      // 소리 필터가 음성 외의 음악·게임 사운드를 뭉개지 않게 가능한 경우에는 모두 해제.
      // 이 값은 지원하는 브라우저에서만 적용되며, 실제 소리 품질은 캡처 장치에 달려 있습니다.
      captureStream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:{ideal:fps,max:fps}},audio:els.system.checked?{echoCancellation:false,noiseSuppression:false,autoGainControl:false}:false,systemAudio:els.system.checked?'include':'exclude',surfaceSwitching:'include'});
      if(!captureStream.getVideoTracks().length)throw new Error('화면 영상 트랙을 가져오지 못했습니다.');
      const hasSystemSound=captureStream.getAudioTracks().length>0;
      if(els.mic.checked){
        try{micStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:false,autoGainControl:false}})}
        catch(e){notice('마이크 사용이 허용되지 않아 마이크 없이 녹화합니다. ('+readableError(e)+')','error');}
      }
      if(captureStream.getVideoTracks()[0].readyState==='ended')throw new Error('화면 공유가 종료되었습니다.');
      mixedStream=new MediaStream(captureStream.getVideoTracks());
      const audioTracks=[...(els.system.checked?captureStream.getAudioTracks():[]),...(micStream?micStream.getAudioTracks():[])];
      if(audioTracks.length===1){mixedStream.addTrack(audioTracks[0]);}
      else if(audioTracks.length>1){
        const Ctx=window.AudioContext||window.webkitAudioContext;
        if(!Ctx)throw new Error('오디오 합성을 지원하지 않는 브라우저입니다.');
        mixContext=new Ctx({sampleRate:48000});const destination=mixContext.createMediaStreamDestination();
        // 두 오디오 소스를 합산할 때 오버드라이브(찢어짐)를 줄입니다.
        // 시스템 소리만 녹음할 때는 믹서를 통하지 않습니다. 함께 녹음할 때만 합성합니다.
        // 합산 레벨을 1.0 이내로 조정해 디지털 클리핑(찢어짐)을 예방합니다.
        audioTracks.forEach((track,index)=>{const input=mixContext.createMediaStreamSource(new MediaStream([track]));const gain=mixContext.createGain();gain.gain.value=index===0?.65:.35;input.connect(gain);gain.connect(destination)});
        if(mixContext.state==='suspended')await mixContext.resume();
        mixedStream.addTrack(destination.stream.getAudioTracks()[0]);
      }
      recorder=newRecorder(mixedStream);recordChunks=[];
      const screenKind=({'browser':'브라우저 탭','monitor':'전체 화면','window':'프로그램 창'})[captureStream.getVideoTracks()[0]?.getSettings?.().displaySurface]||'화면';
      const sysTrack=captureStream.getAudioTracks()[0];const sysCfg=sysTrack?.getSettings?.()||{};
      lastAudioDetails='녹음 정보: '+screenKind+' · 시스템 소리 '+(sysTrack?'있음':'없음')+(sysTrack?' · '+(sysCfg.sampleRate||'미확인')+'Hz · '+(sysCfg.channelCount||'?')+'채널':'')+(micStream?' · 마이크 혼합':' · 마이크 꺼짐')+' · '+(recorder.mimeType||'자동 코덱')+' · 오디오 '+Math.round(recorder.audioBitsPerSecond/1000)+'kbps 설정';
      els.audioDiagnostics.textContent=lastAudioDetails;
      const activeRecorder=recorder;
      activeRecorder.ondataavailable=(ev)=>{if(ev.data?.size)recordChunks.push(ev.data)};
      activeRecorder.onerror=(ev)=>notice('녹화 오류: '+readableError(ev.error),'error');
      activeRecorder.onstop=async()=>{
        const elapsed=Math.max(.1,(performance.now()-startedAt)/1000);
        const mime=activeRecorder.mimeType||'video/webm';
        const blob=await withWebMDuration(new Blob(recordChunks,{type:mime}),elapsed);recordChunks=[];
        els.audioDiagnostics.textContent=lastAudioDetails+' · 원본을 재생해 소리가 정상인지 확인하세요.';
        resetRecordUI();await cleanupRecording();
        if(blob.size<100){notice('녹화된 영상 데이터가 없습니다. 다시 시도하세요.','error');return}
        sourceName='snsnote-screen-recording';
        notice('녹화 완료! 아래에서 크롭과 영상 길이를 편집하세요.','success');
        try{await loadEditor(blob,elapsed)}catch(e){notice('영상 미리보기를 열지 못했습니다: '+readableError(e),'error')}
      };
      captureStream.getVideoTracks()[0].addEventListener('ended',()=>{
        if(recorder===activeRecorder&&activeRecorder.state!=='inactive')stopRecording();
      },{once:true});
      // 250ms마다 청크를 생성하면 고해상도 캡처 중 잦은 이벤트가 발생합니다.
      // 2초 단위로 완화. 녹화 프레임 누락을 완전히 막는 설정은 아닙니다.
      activeRecorder.start(2000);
      startedAt=performance.now();els.counter.textContent='00:00';
      elapsedTimer=setInterval(()=>{els.counter.textContent=fmt((performance.now()-startedAt)/1000)},300);
      els.start.hidden=true;els.stop.hidden=false;els.stop.disabled=false;
      els.system.disabled=true;els.mic.disabled=true;els.quality.disabled=true;els.format.disabled=true;
      els.status.textContent='● 녹화 중';els.status.classList.add('is-recording');
      if(els.system.checked&&!hasSystemSound){notice('녹화를 시작했습니다. 브라우저가 화면 소리를 제공하지 않아 영상은 무음일 수 있습니다.','error')}
      else {const s=captureStream.getVideoTracks()[0]?.getSettings?.()||{};notice('고화질 녹화 중 · '+(s.width||'?')+'×'+(s.height||'?')+' · 영상 '+Math.round(activeRecorder.videoBitsPerSecond/1000000)+'Mbps 설정(실제 품질은 환경에 따라 다름).','success');}
    }catch(e){resetRecordUI();await cleanupRecording();notice('녹화 시작 실패: '+readableError(e),'error');}
  }
  function stopRecording(){
    if(recorder&&recorder.state!=='inactive'){
      try{recorder.requestData()}catch(_){}
      try{recorder.stop()}catch(e){notice('녹화 중지 실패: '+readableError(e),'error')}
      els.stop.disabled=true;els.status.textContent='● 영상 처리 중…';
    }
  }
  async function waitForMetadata(video){
    if(video.readyState>=1&&video.videoWidth)return;
    await new Promise((resolve,reject)=>{
      const timer=setTimeout(()=>{done();reject(new Error('영상 메타데이터 읽기 시간 초과'))},15000);
      const good=()=>{done();resolve()},bad=()=>{done();reject(new Error('이 브라우저에서 재생할 수 없는 영상입니다.'))};
      function done(){clearTimeout(timer);video.removeEventListener('loadedmetadata',good);video.removeEventListener('error',bad)}
      video.addEventListener('loadedmetadata',good);video.addEventListener('error',bad);
    });
  }
  async function waitForFirstFrame(video,timeout=4500){
    if(video.readyState>=2)return;
    await new Promise(resolve=>{
      const timer=setTimeout(done,timeout);
      function done(){clearTimeout(timer);video.removeEventListener('loadeddata',done);video.removeEventListener('canplay',done);resolve()}
      video.addEventListener('loadeddata',done,{once:true});video.addEventListener('canplay',done,{once:true});
    });
  }
  async function seekTo(video,time,timeout=5500){
    if(Math.abs(video.currentTime-time)<.04 && video.readyState>=2)return;
    await new Promise((resolve,reject)=>{
      let doneFlag=false;
      const timer=setTimeout(()=>finish(false),timeout);
      function finish(ok){if(doneFlag)return;doneFlag=true;clearTimeout(timer);video.removeEventListener('seeked',onSeek);video.removeEventListener('error',onError);ok?resolve():reject(new Error('영상의 해당 위치를 찾지 못했습니다. 이 파일의 탐색 정보가 불완전할 수 있습니다.'))}
      function onSeek(){finish(true)}function onError(){finish(false)}
      video.addEventListener('seeked',onSeek);video.addEventListener('error',onError);
      try{video.currentTime=time}catch(e){finish(false)}
    });
  }
  async function tryResolveDuration(video,estimate){
    if(Number.isFinite(video.duration)&&video.duration>0)return video.duration;
    // MediaRecorder WebM 파일은 duration 메타데이터가 Infinity인 경우가 있습니다.
    try{
      await seekTo(video,1e9,1800);
      const found=video.currentTime;
      await seekTo(video,0,1800);
      if(Number.isFinite(found)&&found>0)return found;
    }catch(_){try{video.currentTime=0}catch(__){}}
    return estimate>0?estimate:0;
  }
  async function loadEditor(blob,estimate=0){
    const version=++loadingVersion;
    els.video.pause();
    if(sourceUrl)URL.revokeObjectURL(sourceUrl);
    if(outputUrl)URL.revokeObjectURL(outputUrl);
    sourceBlob=blob;sourceUrl=URL.createObjectURL(blob);outputUrl=null;els.output.hidden=true;setEditorReady(true);
    const ext=blob.type.includes('mp4')?'mp4':blob.type.includes('webm')?'webm':'video';
    els.original.href=sourceUrl;els.original.download='snsnote-original.'+ext;
    els.video.src=sourceUrl;els.video.load();
    await waitForMetadata(els.video);
    if(version!==loadingVersion)return;
    duration=await tryResolveDuration(els.video,estimate);
    if(!(duration>0))throw new Error('영상 길이를 확인하지 못했습니다.');
    await waitForFirstFrame(els.video);
    trimStart=0;trimEnd=duration;crop={x:0,y:0,w:1,h:1};els.ratio.value='free';
    els.stage.style.aspectRatio=els.video.videoWidth+'/'+els.video.videoHeight;
    const max=String(duration);
    for(const input of [els.playhead,els.trimStart,els.trimEnd]){input.max=max;input.min='0'}
    els.playhead.value='0';els.trimStart.value='0';els.trimEnd.value=max;
    els.sourceDetails.textContent=els.video.videoWidth+' × '+els.video.videoHeight+' · '+fmt(duration);
    setEditorReady(true);updateCropUI();updateTrimUI();drawCropPreview();
    els.editor.scrollIntoView({behavior:'smooth',block:'start'});
  }
  function updateCropUI(){
    const c=crop;
    Object.assign(els.cropBox.style,{left:(c.x*100)+'%',top:(c.y*100)+'%',width:(c.w*100)+'%',height:(c.h*100)+'%'});
    const w=Math.round(c.w*els.video.videoWidth),h=Math.round(c.h*els.video.videoHeight);
    els.cropDetails.textContent=w+' × '+h+' 선택';drawCropPreview();
  }
  function previewOutputSize(){
    let w=Math.max(2,Math.round(crop.w*els.video.videoWidth)),h=Math.max(2,Math.round(crop.h*els.video.videoHeight));
    // 최대 4K까지 원본 픽셀 수를 유지합니다. 5K 이상은 브라우저 안정성을 위해 축소.
    const scale=Math.min(1,3840/Math.max(w,h));
    w=Math.max(2,Math.floor(w*scale/2)*2);h=Math.max(2,Math.floor(h*scale/2)*2);
    return{w,h};
  }
  function drawCropPreview(){
    if(!els.video.videoWidth||els.video.readyState<2)return;
    const originalW=els.video.videoWidth,originalH=els.video.videoHeight;
    const {w,h}=previewOutputSize();const displayScale=Math.min(1,540/Math.max(w,h));
    const canvas=els.cropPreview;const targetW=Math.max(2,Math.round(w*displayScale)),targetH=Math.max(2,Math.round(h*displayScale));
    if(canvas.width!==targetW)canvas.width=targetW;
    if(canvas.height!==targetH)canvas.height=targetH;
    const ctx=canvas.getContext('2d');
    ctx.clearRect(0,0,canvas.width,canvas.height);
    try{ctx.drawImage(els.video,crop.x*originalW,crop.y*originalH,crop.w*originalW,crop.h*originalH,0,0,canvas.width,canvas.height);paintWatermarks(ctx,canvas.width,canvas.height,true)}catch(_){}
  }
  function cropAspect(){if(els.ratio.value==='free')return null;const [a,b]=els.ratio.value.split(':').map(Number);return a/b}
  function applyRatioPreset(){
    const r=cropAspect();if(!r){updateCropUI();return}
    const sourceAspect=els.video.videoWidth/els.video.videoHeight;
    const w=sourceAspect>=r?r/sourceAspect:1;const h=sourceAspect>=r?1:sourceAspect/r;
    crop={x:(1-w)/2,y:(1-h)/2,w,h};updateCropUI();
  }
  function pointFromEvent(e){const rect=els.stage.getBoundingClientRect();return{x:clamp((e.clientX-rect.left)/rect.width,0,1),y:clamp((e.clientY-rect.top)/rect.height,0,1)}}
  const minCrop=.015;
  function hitCrop(p){
    const c=crop, rect=els.stage.getBoundingClientRect();
    const ax=Math.min(.065,Math.max(.009,11/rect.width)),ay=Math.min(.065,Math.max(.009,11/rect.height));
    const l=c.x,r=c.x+c.w,t=c.y,b=c.y+c.h;
    // 코너와 변의 조절점 판별 (선택 영역 안/밖 모두 가능)
    const nearX=(x)=>Math.abs(p.x-x)<=ax;
    const nearY=(y)=>Math.abs(p.y-y)<=ay;
    if(nearX(l)&&nearY(t))return 'nw';
    if(nearX(r)&&nearY(t))return 'ne';
    if(nearX(r)&&nearY(b))return 'se';
    if(nearX(l)&&nearY(b))return 'sw';
    if(p.x>=l-ax&&p.x<=r+ax&&nearY(t))return 'n';
    if(p.x>=l-ax&&p.x<=r+ax&&nearY(b))return 's';
    if(p.y>=t-ay&&p.y<=b+ay&&nearX(l))return 'w';
    if(p.y>=t-ay&&p.y<=b+ay&&nearX(r))return 'e';
    if(p.x>l&&p.x<r&&p.y>t&&p.y<b)return (c.w>.985&&c.h>.985)?'new':'move';
    return 'new';
  }
  const cropCursor={nw:'nwse-resize',se:'nwse-resize',ne:'nesw-resize',sw:'nesw-resize',n:'ns-resize',s:'ns-resize',e:'ew-resize',w:'ew-resize',move:'move',new:'crosshair'};
  function clampCrop(c){
    let w=clamp(c.w,minCrop,1),h=clamp(c.h,minCrop,1);
    return{x:clamp(c.x,0,1-w),y:clamp(c.y,0,1-h),w,h};
  }
  function resizedFromHandle(mode,p,base){
    const l=base.x,r=base.x+base.w,t=base.y,b=base.y+base.h;
    const fixedRatio=cropAspect();
    if(!fixedRatio){
      let left=l,right=r,top=t,bottom=b;
      if(mode.includes('w'))left=clamp(p.x,0,right-minCrop);
      if(mode.includes('e'))right=clamp(p.x,left+minCrop,1);
      if(mode.includes('n'))top=clamp(p.y,0,bottom-minCrop);
      if(mode.includes('s'))bottom=clamp(p.y,top+minCrop,1);
      return clampCrop({x:left,y:top,w:right-left,h:bottom-top});
    }
    // 비율은 영상의 원본 픽셀 기준. 정사각형 등도 시각적으로 정확함.
    const ratio=fixedRatio*els.video.videoHeight/els.video.videoWidth;
    const horizontal=mode.includes('w')||mode.includes('e');
    const vertical=mode.includes('n')||mode.includes('s');
    let width,height,x,y;
    if(horizontal&&vertical){
      const fx=mode.includes('w')?r:l,fy=mode.includes('n')?b:t;
      const dx=Math.abs(p.x-fx),dy=Math.abs(p.y-fy);
      const maxW=mode.includes('w')?fx:1-fx,maxH=mode.includes('n')?fy:1-fy;
      width=clamp(Math.max(dx,dy*ratio),minCrop,Math.min(maxW,maxH*ratio));
      height=width/ratio;
      x=mode.includes('w')?fx-width:fx;
      y=mode.includes('n')?fy-height:fy;
    }else if(horizontal){
      const fx=mode==='w'?r:l;
      const cy=(t+b)/2;
      const maxH=2*Math.min(cy,1-cy);
      const maxW=mode==='w'?fx:1-fx;
      width=clamp(Math.abs(p.x-fx),minCrop,Math.min(maxW,maxH*ratio));
      height=width/ratio;
      x=mode==='w'?fx-width:fx;
      y=cy-height/2;
    }else{
      const fy=mode==='n'?b:t;
      const cx=(l+r)/2;
      const maxW=2*Math.min(cx,1-cx);
      const maxH=mode==='n'?fy:1-fy;
      height=clamp(Math.abs(p.y-fy),minCrop,Math.min(maxH,maxW/ratio));
      width=height*ratio;
      y=mode==='n'?fy-height:fy;
      x=cx-width/2;
    }
    return clampCrop({x,y,w:width,h:height});
  }
  function newCropFromPoints(a,p){
    let sx=p.x>=a.x?1:-1,sy=p.y>=a.y?1:-1;
    let w=Math.abs(p.x-a.x),h=Math.abs(p.y-a.y);
    const ratio=cropAspect();
    if(ratio){
      const rn=ratio*els.video.videoHeight/els.video.videoWidth;
      const maxW=sx===1?1-a.x:a.x,maxH=sy===1?1-a.y:a.y;
      w=clamp(Math.max(w,h*rn),minCrop,Math.min(maxW,maxH*rn));h=w/rn;
    }
    w=Math.max(.001,w);h=Math.max(.001,h);
    return clampCrop({x:sx===1?a.x:a.x-w,y:sy===1?a.y:a.y-h,w,h});
  }
  function mouseDown(e){
    if(els.editor.hidden||exportSession||!els.video.videoWidth||e.button!==0)return;
    const p=pointFromEvent(e),base={...crop};const mode=hitCrop(p);
    dragging={id:e.pointerId,start:p,base,mode};
    if(mode==='new')crop={x:p.x,y:p.y,w:.001,h:.001};
    els.stage.setPointerCapture(e.pointerId);
    updateCropUI();e.preventDefault();
  }
  function mouseMove(e){
    const p=pointFromEvent(e);
    if(!dragging){els.stage.style.cursor=cropCursor[hitCrop(p)]||'crosshair';return}
    if(e.pointerId!==dragging.id)return;
    const d=dragging;
    if(d.mode==='move'){
      crop={...d.base,x:clamp(d.base.x+p.x-d.start.x,0,1-d.base.w),y:clamp(d.base.y+p.y-d.start.y,0,1-d.base.h)};
    }else if(d.mode==='new'){
      crop=newCropFromPoints(d.start,p);
    }else crop=resizedFromHandle(d.mode,p,d.base);
    updateCropUI();e.preventDefault();
  }
  function mouseUp(e){
    if(!dragging||e.pointerId!==dragging.id)return;
    const d=dragging;
    if((crop.w<.015||crop.h<.015)&&d.mode==='new')crop=d.base;
    dragging=null;updateCropUI();
  }
  function updateTrimUI(){
    const s=duration>0?100*trimStart/duration:0;
    const e=duration>0?100*trimEnd/duration:100;
    els.timeline.style.setProperty('--start-percent',s.toFixed(4)+'%');
    els.timeline.style.setProperty('--end-percent',e.toFixed(4)+'%');
    els.cutDetail.textContent='앞 '+fmtCut(trimStart)+' · 뒤 '+fmtCut(duration-trimEnd)+' 제외';
    els.trimStart.value=String(trimStart);els.trimEnd.value=String(trimEnd);
    els.startLabel.textContent=fmt(trimStart);els.endLabel.textContent=fmt(trimEnd);
    els.trimDetails.textContent='편집 길이 '+fmt(trimEnd-trimStart);
    updatePlayTime();
  }
  function updatePlayTime(){
    if(!els.video.videoWidth)return;
    const t=Number.isFinite(els.video.currentTime)?els.video.currentTime:0;
    els.playTime.textContent=fmt(t)+' / '+fmt(duration);
    els.playhead.value=String(clamp(t,0,duration));
    els.play.textContent=els.video.paused?'&#x25b6; 재생':'❚❚ 일시정지';
  }
  async function playOrPause(){
    try{
      if(!els.video.paused){els.video.pause();return}
      if(els.video.currentTime<trimStart||els.video.currentTime>=trimEnd-.03)await seekTo(els.video,trimStart);
      await els.video.play();previewLoop();
    }catch(e){notice('영상 재생 실패: '+readableError(e),'error')}
  }
  function previewLoop(){cancelAnimationFrame(previewRaf);
    const step=()=>{
      if(els.video.paused)return;
      if(els.video.currentTime>=trimEnd-.03){els.video.pause();updatePlayTime();return}
      drawCropPreview();updatePlayTime();previewRaf=requestAnimationFrame(step);
    };previewRaf=requestAnimationFrame(step);
  }
  function setTrims(type,value){
    const gap=Math.min(.2,Math.max(.03,duration/100));
    if(type==='start'){trimStart=clamp(value,0,trimEnd-gap);if(els.video.currentTime<trimStart)els.video.currentTime=trimStart}
    else {trimEnd=clamp(value,trimStart+gap,duration);if(els.video.currentTime>trimEnd)els.video.currentTime=trimEnd}
    updateTrimUI();
  }
  function cleanupExport(session){
    if(!session)return;
    session.done=true;clearInterval(session.tick);clearInterval(session.frame);
    if(session.video){session.video.pause();session.video.removeAttribute('src');session.video.load();session.video.remove()}
    stopTracks(session.canvasStream);
    if(session.audioCtx)session.audioCtx.close().catch(()=>{});
    els.cancelExport.hidden=true;els.export.disabled=false;els.progressWrap.hidden=true;
    if(exportSession===session)exportSession=null;
  }
  function makeExportVideo(){const v=document.createElement('video');v.preload='auto';v.playsInline=true;v.muted=false;v.volume=1;v.style.cssText='position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;pointer-events:none';v.src=sourceUrl;root.appendChild(v);return v}
  async function exportEdited(){
    if(exportSession)return;
    if(!sourceUrl||!(trimEnd>trimStart)){notice('내보낼 영상 또는 편집 구간을 확인하세요.','error');return}
    els.video.pause();els.export.disabled=true;els.cancelExport.hidden=false;els.finishStatus.textContent='영상 처리 중';
    els.output.hidden=true;els.progressWrap.hidden=false;els.progress.value=0;
    const session={done:false,cancelled:false,video:null,audioCtx:null,canvasStream:null,recorder:null,tick:null,frame:null};exportSession=session;
    let chunks=[];
    try{
      const v=makeExportVideo();session.video=v;
      await waitForMetadata(v);
      await seekTo(v,trimStart);
      if(session.cancelled)return;
      const {w,h}=previewOutputSize();
      const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
      const ctx=canvas.getContext('2d',{alpha:false});
      if(!ctx)throw new Error('Canvas 기능을 사용할 수 없습니다.');
      const cropPixels={sx:crop.x*v.videoWidth,sy:crop.y*v.videoHeight,sw:crop.w*v.videoWidth,sh:crop.h*v.videoHeight};
      const draw=()=>{
        if(session.done||session.cancelled)return;
        try{ctx.drawImage(v,cropPixels.sx,cropPixels.sy,cropPixels.sw,cropPixels.sh,0,0,w,h);paintWatermarks(ctx,w,h,false)}catch(_){}
      };
      draw();
      if(!canvas.captureStream)throw new Error('이 브라우저에서 영상 크롭 내보내기를 지원하지 않습니다.');
      session.canvasStream=canvas.captureStream(els.quality.value==='ultra'?60:30);
      const stream=new MediaStream(session.canvasStream.getVideoTracks());
      // 별도 video를 Web Audio에 연결하여 원본 소리를 출력 파일에 합성합니다.
      const AudioCtx=window.AudioContext||window.webkitAudioContext;
      if(AudioCtx){
        try{
          session.audioCtx=new AudioCtx();
          const source=session.audioCtx.createMediaElementSource(v);
          const dest=session.audioCtx.createMediaStreamDestination();
          source.connect(dest);dest.stream.getAudioTracks().forEach(t=>stream.addTrack(t));
          if(session.audioCtx.state==='suspended')await session.audioCtx.resume();
        }catch(err){
          if(session.audioCtx){try{await session.audioCtx.close()}catch(_){}}
          session.audioCtx=null;
          notice('오디오 처리 기능을 사용할 수 없어 무음으로 저장될 수 있습니다.','error');
        }
      }
      session.recorder=newRecorder(stream,true);
      const active=session.recorder;
      const mime=active.mimeType||'video/webm';
      const completion=new Promise((resolve,reject)=>{
        active.ondataavailable=e=>{if(e.data?.size)chunks.push(e.data)};
        active.onerror=e=>reject(e.error||new Error('영상 인코딩 오류'));
        active.onstop=()=>resolve();
      });
      active.start(250);
      session.frame=setInterval(draw,els.quality.value==='ultra'?16:33);
      session.tick=setInterval(()=>{
        if(session.done||session.cancelled)return;
        const current=Number(v.currentTime)||0;
        const progress=clamp(100*(current-trimStart)/(trimEnd-trimStart),0,100);
        els.progress.value=progress;
        els.progressLabel.textContent='편집 파일 생성 중 '+Math.floor(progress)+'% · 실제 영상 길이만큼 시간이 걸릴 수 있습니다.';
        if(current>=trimEnd-.035 || v.ended){
          clearInterval(session.tick);clearInterval(session.frame);session.tick=null;session.frame=null;
          try{active.requestData()}catch(_){}
          if(active.state!=='inactive')active.stop();
        }
      },60);
      await v.play();
      await completion;
      if(session.cancelled)return;
      const blob=await withWebMDuration(new Blob(chunks,{type:mime}),trimEnd-trimStart);
      if(blob.size<100)throw new Error('편집된 영상 데이터가 비어 있습니다.');
      if(outputUrl)URL.revokeObjectURL(outputUrl);
      outputUrl=URL.createObjectURL(blob);els.outputVideo.src=outputUrl;els.output.hidden=false;
      const ext=mime.includes('mp4')?'mp4':'webm';
      const safeName=sourceName.replace(/\.[^.]+$/,'').replace(/[^a-zA-Z0-9_-]/g,'-').slice(0,45)||'snsnote-video';
      els.download.href=outputUrl;els.download.download=safeName+'-edited.'+ext;
      els.finishStatus.textContent='✓ 편집 완료';
      els.progress.value=100;notice('편집 완료! '+(ext==='mp4'?'MP4 내부 코덱이 SNS에서 지원되는지 업로드 전 확인하세요.':'아래에서 결과 영상을 확인하고 다운로드하세요.'),'success');
      els.output.scrollIntoView({behavior:'smooth',block:'center'});
    }catch(e){if(!session.cancelled)notice('내보내기 실패: '+readableError(e),'error');els.finishStatus.textContent='편집 준비 완료'}
    finally{if(session.recorder&&session.recorder.state!=='inactive'){try{session.recorder.stop()}catch(_){}}cleanupExport(session)}
  }
  function cancelExport(){
    if(!exportSession)return;
    const session=exportSession;session.cancelled=true;
    if(session.recorder&&session.recorder.state!=='inactive'){try{session.recorder.stop()}catch(_){}}
    // exportEdited의 finally가 정리합니다. 직접 제거하면 비동기 작업 중 충돌할 수 있습니다.
    els.finishStatus.textContent='편집 준비 완료';
    notice('영상 내보내기를 취소했습니다.','info');
  }

  els.start.addEventListener('click',startRecording);
  els.stop.addEventListener('click',stopRecording);
  els.file.addEventListener('change',async e=>{
    const file=e.target.files?.[0];if(!file)return;
    if(recorder&&recorder.state!=='inactive'){notice('녹화가 끝난 후 파일을 불러오세요.','error');return}
    if(exportSession){notice('내보내기가 끝난 후 파일을 불러오세요.','error');return}
    sourceName=file.name;
    try{await loadEditor(file);notice('영상 파일을 불러왔습니다. 크롭과 길이를 편집하세요.','success')}
    catch(err){notice('파일 불러오기 실패: '+readableError(err),'error')}
    e.target.value='';
  });
  els.stage.addEventListener('pointerdown',mouseDown);
  els.stage.addEventListener('pointermove',mouseMove);
  els.stage.addEventListener('pointerup',mouseUp);
  els.stage.addEventListener('pointercancel',mouseUp);
  els.ratio.addEventListener('change',applyRatioPreset);
  els.resetCrop.addEventListener('click',()=>{crop={x:0,y:0,w:1,h:1};els.ratio.value='free';updateCropUI()});
  els.play.addEventListener('click',playOrPause);
  els.playhead.addEventListener('input',e=>{els.video.pause();els.video.currentTime=Number(e.target.value);updatePlayTime()});
  els.trimStart.addEventListener('input',e=>setTrims('start',Number(e.target.value)));
  els.trimEnd.addEventListener('input',e=>setTrims('end',Number(e.target.value)));
  els.video.addEventListener('seeked',()=>{drawCropPreview();updatePlayTime()});
  els.video.addEventListener('timeupdate',()=>{drawCropPreview();updatePlayTime();if(!els.video.paused&&els.video.currentTime>=trimEnd-.03)els.video.pause()});
  els.video.addEventListener('pause',updatePlayTime);
  els.video.addEventListener('play',updatePlayTime);
  els.export.addEventListener('click',exportEdited);
  for(const id of ['wmTextOn','wmText','wmTextColor','wmTextSize','wmTextOpacity','wmImageOn','wmImageSize','wmImageOpacity']){
    els[id].addEventListener('input',syncWatermarkUI);
    els[id].addEventListener('change',syncWatermarkUI);
  }
  els.wmImageFile.addEventListener('change',async e=>{const f=e.target.files?.[0];if(f)await importWatermarkImage(f);e.target.value=''});
  els.wmImageRemove.addEventListener('click',()=>{
    if(watermark.image.url)URL.revokeObjectURL(watermark.image.url);
    watermark.image.url=null;watermark.image.element=null;watermark.image.on=false;
    els.wmImageOn.checked=false;els.wmImageInfo.textContent='투명 배경 PNG나 WebP를 추천합니다. 최대 12MB.';
    syncWatermarkUI();
  });
  els.wmPositionReset.addEventListener('click',resetWatermarkPositions);
  els.cropPreview.addEventListener('pointerdown',wmPointerDown);
  els.cropPreview.addEventListener('pointermove',wmPointerMove);
  els.cropPreview.addEventListener('pointerup',wmPointerUp);
  els.cropPreview.addEventListener('pointercancel',wmPointerUp);
  els.cancelExport.addEventListener('click',cancelExport);
  if(!supportedMp4()){
    const mp4Option=els.format.querySelector('option[value="mp4"]');
    if(mp4Option)mp4Option.disabled=true;
    els.format.value='webm';
    els.formatHelp.textContent='현재 브라우저는 MP4 직접 녹화를 지원하지 않아 WebM으로 저장됩니다.';
  }else{
    els.formatHelp.textContent='MP4여도 내부 코덱이 H.264/AAC가 아닐 수 있습니다. SNS 업로드 전 확인하세요.';
  }
  if(!window.isSecureContext||!navigator.mediaDevices?.getDisplayMedia){
    els.start.disabled=true;
    notice('현재 환경에서는 화면 녹화를 시작할 수 없습니다. HTTPS 웹사이트와 지원 브라우저에서 이용하세요. 영상 파일 불러오기와 편집은 별도로 시도할 수 있습니다.','error');
  }else if(!window.MediaRecorder){els.start.disabled=true;els.export.disabled=true;notice('현재 브라우저는 MediaRecorder 녹화 기능을 지원하지 않습니다.','error')}
})();
</script>




<p class="wp-block-paragraph">화면 녹화부터 동영상 편집까지, 별도의 프로그램 설치 없이 웹 브라우저에서 무료로 이용해 보세요.</p>



<p class="wp-block-paragraph">SNSNOTE 화면 녹화 및 동영상 편집 웹툴은 컴퓨터 화면을 녹화하고, 영상에서 필요한 부분만 선택해 크롭하거나 앞뒤 길이를 자를 수 있는 온라인 영상 편집 도구입니다.</p>



<p class="wp-block-paragraph">직접 화면을 녹화하지 않아도 기존에 저장된 동영상 파일을 불러와 편집할 수 있습니다.</p>



<p class="wp-block-paragraph">회원가입이나 별도의 프로그램 설치가 필요하지 않으며, 녹화와 편집 작업은 외부 서버에 영상을 업로드하지 않고 사용자의 브라우저에서 처리됩니다.</p>



<h2 class="wp-block-heading">화면 녹화 및 동영상 편집 웹툴의 주요 기능</h2>



<h3 class="wp-block-heading">1. 설치 없이 컴퓨터 화면 녹화</h3>



<p class="wp-block-paragraph">프로그램을 다운로드하거나 설치하지 않아도 브라우저에서 바로 화면 녹화를 시작할 수 있습니다.</p>



<p class="wp-block-paragraph">크롬이나 엣지 같은 PC 브라우저에서는 전체 화면, 특정 프로그램 창, 브라우저 탭 중 원하는 녹화 대상을 선택할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 웹사이트 사용 방법을 설명하거나, 브라우저에서 재생되는 콘텐츠를 녹화하거나, 컴퓨터 프로그램의 작동 과정을 기록할 때 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">녹화가 끝나면 미리보기 화면에서 결과를 확인하고 원본 영상을 다운로드하거나 추가 편집을 진행할 수 있습니다.</p>



<h3 class="wp-block-heading">2. 시스템 소리와 마이크 녹음 지원</h3>



<p class="wp-block-paragraph">화면 녹화 시 컴퓨터에서 재생되는 소리를 함께 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">브라우저에서 시스템 오디오 공유를 지원하는 경우 영상 소리와 화면을 동시에 녹화할 수 있으며, 마이크 녹음도 선택적으로 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">마이크는 기본적으로 꺼져 있어 사용자가 직접 활성화한 경우에만 녹음을 요청합니다.</p>



<p class="wp-block-paragraph">시스템 소리만 녹음하거나 시스템 소리와 마이크를 함께 녹음하는 방식으로 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 컴퓨터 소리 녹음은 사용 중인 브라우저와 운영체제, 화면 공유 방식에 따라 지원 여부가 달라질 수 있습니다.</p>



<h3 class="wp-block-heading">3. 동영상 화면 크롭 기능</h3>



<p class="wp-block-paragraph">녹화한 영상에서 불필요한 위아래 공간이나 좌우 여백을 제거하고 필요한 화면만 남길 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 웹사이트를 녹화했을 때 브라우저 메뉴나 주변 화면까지 함께 녹화되었다면 영상 크롭 기능으로 원하는 영역만 선택할 수 있습니다.</p>



<p class="wp-block-paragraph">마우스로 새로운 영역을 직접 드래그하거나, 선택된 영역의 모서리와 테두리를 잡아 크기를 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">선택한 크롭 영역을 이동하는 기능도 지원하므로 영상에서 필요한 장면을 더욱 세밀하게 조정할 수 있습니다.</p>



<p class="wp-block-paragraph">크롭 비율은 다음과 같이 선택할 수 있습니다.</p>



<ul class="wp-block-list">
<li>자유 비율: 원하는 가로와 세로 크기로 직접 조절</li>



<li>16:9: 일반적인 가로형 동영상 편집</li>



<li>9:16: 쇼츠, 릴스 등 세로형 영상에 활용</li>



<li>1:1: 정사각형 비율의 SNS 콘텐츠 제작</li>
</ul>



<p class="wp-block-paragraph">각 비율을 선택한 상태에서도 크롭 영역의 크기와 위치를 조절할 수 있습니다.</p>



<h3 class="wp-block-heading">4. 동영상 앞뒤 길이 자르기</h3>



<p class="wp-block-paragraph">동영상의 시작이나 끝에 불필요한 장면이 들어갔다면 영상 길이 자르기 기능을 이용해 원하는 부분만 남길 수 있습니다.</p>



<p class="wp-block-paragraph">별도의 동영상 편집 프로그램을 실행하지 않아도 시작 시간과 종료 시간을 조정할 수 있습니다.</p>



<p class="wp-block-paragraph">영상 타임라인에서는 저장할 구간과 잘라낼 구간이 구분되어 표시되므로 편집 결과를 직관적으로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 1분 길이의 영상에서 10초부터 40초까지만 필요하다면 해당 구간을 선택해 30초 분량으로 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">영상 길이 자르기와 화면 크롭은 함께 적용할 수 있습니다.</p>



<h3 class="wp-block-heading">5. 녹화하지 않은 기존 동영상도 편집 가능</h3>



<p class="wp-block-paragraph">SNSNOTE 웹툴은 화면 녹화 전용 도구가 아닙니다.</p>



<p class="wp-block-paragraph">컴퓨터에 이미 저장된 동영상 파일을 직접 불러와 동일한 편집 기능을 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">기존 영상에서 불필요한 화면을 크롭하거나 앞뒤 시간을 조절해 필요한 장면만 남길 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 가로 영상에서 세로 화면에 필요한 부분만 잘라내거나, 긴 동영상의 일부 장면만 추출하는 작업에 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">동영상 파일 형식에 따른 재생 가능 여부는 브라우저 환경에 따라 달라집니다.</p>



<h3 class="wp-block-heading">6. 고화질 녹화 및 원본 다운로드</h3>



<p class="wp-block-paragraph">화면 녹화 시 화질 설정을 선택할 수 있으며, 브라우저와 컴퓨터가 지원하는 범위에서 고화질 녹화를 진행할 수 있습니다.</p>



<p class="wp-block-paragraph">녹화가 끝난 뒤 별도의 편집이 필요하지 않다면 원본 다운로드 기능으로 녹화된 영상을 재인코딩 없이 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">화면 크롭이나 영상 길이 자르기를 적용한 경우에는 편집한 내용을 반영해 새로운 영상 파일로 저장합니다.</p>



<p class="wp-block-paragraph">단, 고해상도 영상을 녹화하거나 편집할 때는 컴퓨터 성능에 따라 처리 시간이 길어질 수 있습니다.</p>



<h2 class="wp-block-heading">무료 화면 녹화기 사용 방법</h2>



<p class="wp-block-paragraph">SNSNOTE에서 컴퓨터 화면을 녹화하는 방법은 간단합니다.</p>



<ol start="1" class="wp-block-list">
<li>화면 녹화 웹툴에서 화질과 소리 녹음 옵션을 설정합니다.</li>



<li>녹화 시작 버튼을 누릅니다.</li>



<li>브라우저에서 전체 화면, 특정 창 또는 탭을 선택합니다.</li>



<li>필요한 경우 화면 소리 공유를 활성화합니다.</li>



<li>원하는 장면을 녹화한 뒤 녹화 중지 버튼을 누릅니다.</li>



<li>녹화된 영상을 미리보기에서 확인합니다.</li>



<li>필요에 따라 화면 크롭과 영상 길이 자르기를 진행합니다.</li>



<li>편집 결과를 확인한 뒤 동영상을 다운로드합니다.</li>
</ol>



<p class="wp-block-paragraph">녹화한 영상 전체가 필요하다면 편집 없이 원본 파일을 바로 다운로드할 수도 있습니다.</p>



<h2 class="wp-block-heading">기존 동영상 파일 크롭 및 길이 자르기 방법</h2>



<p class="wp-block-paragraph">이미 컴퓨터에 저장된 동영상이 있다면 화면 녹화를 진행할 필요가 없습니다.</p>



<p class="wp-block-paragraph">영상 파일 불러오기 기능으로 편집할 동영상을 선택하면 미리보기 화면에서 편집을 시작할 수 있습니다.</p>



<h3 class="wp-block-heading">동영상 화면 크롭하기</h3>



<p class="wp-block-paragraph">미리보기 화면에서 원하는 부분을 마우스로 드래그해 선택합니다.</p>



<p class="wp-block-paragraph">선택한 영역의 모서리와 테두리를 잡아 크기를 변경하거나 내부를 드래그해 위치를 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">특정 화면 비율이 필요하다면 16:9, 9:16, 1:1 중 원하는 비율을 선택하세요.</p>



<h3 class="wp-block-heading">동영상 길이 자르기</h3>



<p class="wp-block-paragraph">영상 타임라인에서 시작 지점과 종료 지점을 설정합니다.</p>



<p class="wp-block-paragraph">저장할 구간과 제외할 구간이 표시되므로 원하는 길이로 조절하기 편리합니다.</p>



<h3 class="wp-block-heading">편집한 동영상 다운로드</h3>



<p class="wp-block-paragraph">화면 크롭과 길이 자르기를 완료했다면 결과를 확인한 뒤 편집 영상을 다운로드하면 됩니다.</p>



<p class="wp-block-paragraph">영상 편집 과정은 브라우저에서 진행되며, 고해상도 영상이나 긴 파일은 내보내기 시간이 길어질 수 있습니다.</p>



<h2 class="wp-block-heading">화면 녹화 및 동영상 편집 웹툴 활용 방법</h2>



<p class="wp-block-paragraph">SNSNOTE의 무료 웹툴은 복잡한 영상 제작 프로그램 없이 간단한 녹화와 편집 작업이 필요한 경우에 유용합니다.</p>



<h3 class="wp-block-heading">유튜브 쇼츠 및 인스타그램 릴스 영상 준비</h3>



<p class="wp-block-paragraph">가로형 영상에서 필요한 영역만 9:16 비율로 크롭해 세로형 동영상을 만들 수 있습니다.</p>



<p class="wp-block-paragraph">영상 앞뒤의 불필요한 구간을 제거해 필요한 장면만 남길 수도 있습니다.</p>



<p class="wp-block-paragraph">다만 SNS 플랫폼에 업로드하기 전에는 해당 서비스가 지원하는 영상 형식과 코덱을 확인해야 합니다.</p>



<h3 class="wp-block-heading">프로그램 사용법 및 튜토리얼 제작</h3>



<p class="wp-block-paragraph">컴퓨터 프로그램이나 웹사이트 사용 방법을 화면으로 녹화할 수 있습니다.</p>



<p class="wp-block-paragraph">마이크 녹음을 활성화하면 화면을 조작하면서 설명하는 목소리도 함께 담을 수 있습니다.</p>



<p class="wp-block-paragraph">녹화 후 불필요한 부분을 자르면 사용 방법을 설명하는 간단한 튜토리얼 영상을 제작할 수 있습니다.</p>



<h3 class="wp-block-heading">온라인 강의 및 화면 자료 정리</h3>



<p class="wp-block-paragraph">화면 녹화 기능으로 직접 진행하는 설명이나 화면 자료를 기록할 수 있습니다.</p>



<p class="wp-block-paragraph">녹화된 영상에서 필요한 장면만 남기거나 불필요한 화면을 크롭해 자료를 정리할 때 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">녹화 대상의 이용 약관과 저작권을 확인하고 필요한 권한을 갖춘 콘텐츠에 사용하세요.</p>



<h3 class="wp-block-heading">기존 영상에서 특정 장면 추출</h3>



<p class="wp-block-paragraph">이미 가지고 있는 동영상에서 앞부분과 뒷부분을 잘라 필요한 시간대만 남길 수 있습니다.</p>



<p class="wp-block-paragraph">특정 영역만 필요하다면 크롭 기능을 함께 이용할 수 있어 간단한 영상 정리 작업에 적합합니다.</p>



<h2 class="wp-block-heading">프로그램 설치 없이 브라우저에서 영상 편집</h2>



<p class="wp-block-paragraph">일반적인 영상 편집 프로그램은 별도의 설치 과정이 필요하거나 사용 방법이 복잡할 수 있습니다.</p>



<p class="wp-block-paragraph">SNSNOTE는 화면 녹화와 기본적인 동영상 편집을 하나의 웹페이지에서 처리할 수 있도록 제작되었습니다.</p>



<p class="wp-block-paragraph">회원가입 없이 사용할 수 있으며, 사용자가 선택한 화면이나 동영상 파일을 SNSNOTE 서버로 업로드해 처리하는 방식이 아닙니다.</p>



<p class="wp-block-paragraph">녹화와 편집 작업은 사용 중인 브라우저에서 진행됩니다.</p>



<p class="wp-block-paragraph">다만 브라우저 기반 편집 방식은 컴퓨터 메모리와 CPU, 그래픽 처리 성능의 영향을 받을 수 있습니다.</p>



<p class="wp-block-paragraph">특히 장시간 고화질 녹화나 대용량 동영상 편집 시에는 충분한 시스템 자원이 필요합니다.</p>



<h2 class="wp-block-heading">지원 브라우저 및 동영상 파일 형식</h2>



<p class="wp-block-paragraph">화면 녹화 기능은 크롬과 엣지 같은 PC 브라우저에서 사용하는 것을 권장합니다.</p>



<p class="wp-block-paragraph">파이어폭스와 사파리에서도 일부 화면 공유 기능을 이용할 수 있지만, 브라우저별로 녹음과 동영상 인코딩 지원 범위가 다를 수 있습니다.</p>



<p class="wp-block-paragraph">스마트폰과 태블릿의 모바일 브라우저에서는 화면 녹화 기능이 제한될 수 있습니다.</p>



<h3 class="wp-block-heading">WebM 동영상 파일이란?</h3>



<p class="wp-block-paragraph">WebM은 웹 환경에서 사용하는 동영상 파일 형식으로, 브라우저 기반 녹화 기능에서 널리 활용됩니다.</p>



<p class="wp-block-paragraph">SNSNOTE에서도 브라우저가 지원하는 방식에 따라 녹화 원본을 WebM 파일로 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">WebM은 많은 웹 브라우저에서 재생할 수 있지만 일부 동영상 편집 프로그램이나 SNS 업로드 서비스에서는 호환되지 않을 수 있습니다.</p>



<h3 class="wp-block-heading">MP4 파일도 저장할 수 있나요?</h3>



<p class="wp-block-paragraph">MP4 저장 기능은 브라우저의 지원 환경에 따라 사용 가능 여부가 달라질 수 있습니다.</p>



<p class="wp-block-paragraph">또한 MP4 파일로 저장되더라도 내부 영상·오디오 코덱에 따라 일부 프로그램에서 정상적으로 재생되지 않을 수 있습니다.</p>



<p class="wp-block-paragraph">따라서 다른 편집 프로그램이나 SNS에 사용할 계획이라면 다운로드한 영상의 호환성을 먼저 확인하는 것이 좋습니다.</p>



<h2 class="wp-block-heading">SNSNOTE의 다른 무료 편집 웹툴</h2>



<p class="wp-block-paragraph">SNSNOTE에서는 동영상 편집뿐 아니라 이미지 파일 변환과 오디오 편집을 위한 무료 웹툴도 제공하고 있습니다.</p>



<p class="wp-block-paragraph">영상, 이미지, 오디오 파일에 따라 필요한 도구를 선택해 이용할 수 있습니다.</p>



<h3 class="wp-block-heading">무료 이미지 변환기 &#8211; JPG, PNG, WebP 변환</h3>



<p class="wp-block-paragraph">동영상이 아닌 사진이나 이미지 파일의 형식을 변경하고 싶다면 <a href="https://snsnote.com/image-converter/">SNSNOTE 무료 이미지 변환기</a>를 이용해 보세요.</p>



<p class="wp-block-paragraph">이미지 변환기는 다양한 이미지 파일 형식을 웹 브라우저에서 간편하게 변환할 수 있는 무료 도구입니다.</p>



<p class="wp-block-paragraph">JPG, PNG, WebP 등 이미지 형식을 변경해야 하거나 온라인에 업로드할 사진의 파일 형식을 조정할 때 유용합니다.</p>



<p class="wp-block-paragraph">별도의 이미지 편집 프로그램을 설치하지 않고 브라우저에서 바로 사용할 수 있습니다.</p>



<h3 class="wp-block-heading">무료 MP3 자르기 및 오디오 편집기</h3>



<p class="wp-block-paragraph">영상이 아닌 음악이나 음성 파일을 편집하고 싶다면 <a href="https://snsnote.com/mp3-cutter-editor/">SNSNOTE MP3 자르기 및 오디오 편집기</a>를 이용해 보세요.</p>



<p class="wp-block-paragraph">MP3 파일에서 필요한 구간만 선택해 자르거나 음량을 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">페이드 인·아웃 기능을 이용하면 오디오가 자연스럽게 시작하거나 종료되도록 편집할 수도 있습니다.</p>



<p class="wp-block-paragraph">음성 녹음 파일을 정리하거나 음악의 특정 구간을 편집할 때 유용한 웹툴입니다.</p>



<p class="wp-block-paragraph">화면 녹화와 동영상 크롭은 현재 페이지에서, 이미지 변환과 오디오 편집은 각각의 전용 웹툴에서 이용해 보세요.</p>



<h2 class="wp-block-heading">화면 녹화 및 동영상 편집 자주 묻는 질문 (FAQ)</h2>



<h3 class="wp-block-heading">Q. SNSNOTE 화면 녹화기는 무료인가요?</h3>



<p class="wp-block-paragraph">네. SNSNOTE 화면 녹화 및 동영상 편집 웹툴은 무료로 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">별도의 회원가입이나 유료 프로그램 설치 없이 이용할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 화면을 녹화하지 않아도 동영상 편집이 가능한가요?</h3>



<p class="wp-block-paragraph">네. 기존에 저장된 동영상 파일을 불러와 편집할 수 있습니다.</p>



<p class="wp-block-paragraph">화면 크롭과 영상 길이 자르기 기능을 이용해 원하는 부분만 남길 수 있습니다.</p>



<p class="wp-block-paragraph">다만 브라우저에서 지원하지 않는 동영상 형식은 정상적으로 불러오지 못할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 동영상 크롭과 길이 자르기는 어떤 차이가 있나요?</h3>



<p class="wp-block-paragraph">동영상 크롭은 영상의 화면 영역을 잘라내는 기능입니다.</p>



<p class="wp-block-paragraph">예를 들어 영상의 좌우 여백을 제거하거나 필요한 화면만 확대된 형태로 남길 수 있습니다.</p>



<p class="wp-block-paragraph">영상 길이 자르기는 재생 시간에서 불필요한 앞부분과 뒷부분을 제거하는 기능입니다.</p>



<p class="wp-block-paragraph">SNSNOTE에서는 두 기능을 함께 적용할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 크롭 영역을 직접 조절할 수 있나요?</h3>



<p class="wp-block-paragraph">네. 마우스로 새로운 크롭 영역을 선택하거나 기존 영역의 모서리와 테두리를 드래그해 크기를 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">선택한 영역을 이동할 수도 있으며, 지정된 화면 비율을 유지하면서 크기를 변경할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 화면 녹화 시 컴퓨터 소리도 녹음할 수 있나요?</h3>



<p class="wp-block-paragraph">브라우저와 운영체제에서 화면 오디오 공유를 지원하는 경우 컴퓨터에서 재생되는 소리를 함께 녹음할 수 있습니다.</p>



<p class="wp-block-paragraph">마이크 녹음도 선택적으로 지원합니다.</p>



<p class="wp-block-paragraph">단, 화면 공유 창에서 오디오 공유를 허용해야 할 수 있으며 브라우저에 따라 지원 범위가 다릅니다.</p>



<h3 class="wp-block-heading">Q. 녹화한 영상의 화질을 높일 수 있나요?</h3>



<p class="wp-block-paragraph">화질 설정을 통해 녹화 품질을 조정할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 실제 녹화 품질은 원본 화면 해상도, 브라우저 지원 기능, 컴퓨터 성능 등에 영향을 받습니다.</p>



<p class="wp-block-paragraph">고화질로 녹화할수록 파일 용량이 커지고 시스템 부담도 증가할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 영상 녹화가 중간에 끊기는 이유는 무엇인가요?</h3>



<p class="wp-block-paragraph">고해상도 녹화나 장시간 녹화 시 컴퓨터의 CPU·그래픽 처리 성능 및 메모리 사용량에 따라 프레임이 누락되거나 영상이 끊길 수 있습니다.</p>



<p class="wp-block-paragraph">이런 경우 다른 프로그램이나 브라우저 탭을 일부 종료하고 녹화 화질을 한 단계 낮춰 다시 시도하는 것이 도움이 될 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 녹화한 영상이나 업로드한 파일이 서버에 저장되나요?</h3>



<p class="wp-block-paragraph">아니요. SNSNOTE 화면 녹화 및 동영상 편집 웹툴은 사용자의 브라우저에서 파일을 처리하도록 설계되었습니다.</p>



<p class="wp-block-paragraph">선택한 동영상을 SNSNOTE 서버에 업로드해 변환하거나 편집하는 방식이 아닙니다.</p>



<h3 class="wp-block-heading">Q. 휴대폰에서도 화면 녹화와 동영상 편집을 할 수 있나요?</h3>



<p class="wp-block-paragraph">모바일 브라우저에서는 화면 공유 및 녹화 기능이 제한될 수 있어 PC 사용을 권장합니다.</p>



<p class="wp-block-paragraph">기존 동영상 파일을 불러오는 편집 기능 역시 모바일 브라우저의 파일 재생 및 인코딩 지원 여부에 따라 사용 가능 범위가 달라질 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 편집한 동영상은 바로 SNS에 올릴 수 있나요?</h3>



<p class="wp-block-paragraph">다운로드한 영상의 파일 형식과 코덱이 해당 SNS 플랫폼에서 지원된다면 업로드할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 WebM이나 일부 MP4 파일은 서비스에 따라 업로드가 제한될 수 있으므로, 게시 전 파일 호환성을 확인하는 것이 좋습니다.</p>



<h2 class="wp-block-heading">SNSNOTE에서 무료로 화면 녹화와 동영상 편집 시작하기</h2>



<p class="wp-block-paragraph">화면을 녹화하거나 기존 동영상에서 필요한 부분만 편집하고 싶다면 복잡한 프로그램을 설치할 필요가 없습니다.</p>



<p class="wp-block-paragraph">SNSNOTE의 무료 화면 녹화 및 동영상 편집 웹툴에서는 화면 녹화, 시스템 소리 녹음, 동영상 크롭, 영상 길이 자르기, 편집 영상 다운로드 기능을 하나의 페이지에서 이용할 수 있습니다.</p>



<p class="wp-block-paragraph">회원가입 없이 브라우저에서 바로 실행하고, 원하는 영상만 간편하게 편집해 보세요.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/screen-recorder-video-editor/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/10/free-screen-recorder-video-editor.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1792</post-id>	</item>
		<item>
		<title>인스타 수익화 카드뉴스 만들기 &#8211; 무료 템플릿, 설치 없이 웹 제작</title>
		<link>https://snsnote.com/cardnews-maker/</link>
					<comments>https://snsnote.com/cardnews-maker/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Thu, 01 Oct 2026 07:52:34 +0000</pubDate>
				<category><![CDATA[인스타그램]]></category>
		<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1771</guid>

					<description><![CDATA[인스타 수익화 카드뉴스 만들기, 사진 한 장이면 충분합니다 인스타그램에서 정보형 콘텐츠나 수익화 콘텐츠를 운영하다 보면 사진 위에 큰 제목을 넣은 카드뉴스 형식을 자주 사용하게 됩니다....]]></description>
										<content:encoded><![CDATA[<div id="cardnewsCoverMaker" class="cnm-wrap">

  <!-- =========================
       상단 헤더
  ========================== -->
  <div class="cnm-hero">

    <div class="cnm-kicker">
      SNSNOTE · CARD NEWS MAKER
    </div>

    <h2 class="cnm-main-title">
      카드뉴스 표지 만들기
    </h2>

    <p>
      사진과 제목을 넣어 SNS용 카드뉴스 표지를 바로 만들어보세요.
    </p>

  </div>



  <!-- =========================
       메인 영역
  ========================== -->
  <div class="cnm-main-grid">


    <!-- =========================
         왼쪽
    ========================== -->
    <div class="cnm-left-panel">


      <!-- =========================
           템플릿 선택
      ========================== -->
      <section class="cnm-template-section">

        <div class="cnm-panel-heading">

          <h3>
            템플릿 선택
          </h3>

          <p>
            사용할 이미지 구성을 선택하세요.
          </p>

        </div>


        <div class="cnm-template-grid">


          <!-- 이미지 1장 -->
          <button
            type="button"
            class="cnm-template-card active"
            data-template="single"
          >

            <div class="cnm-template-check">
              ✓
            </div>


            <div class="cnm-template-icon cnm-icon-single">
              <span></span>
            </div>


            <div class="cnm-template-name">
              이미지 1장
            </div>

            <div class="cnm-template-desc">
              사진 한 장으로 제작
            </div>

          </button>



          <!-- 이미지 2장 -->
          <button
            type="button"
            class="cnm-template-card"
            data-template="horizontal"
          >

            <div class="cnm-template-check">
              ✓
            </div>


            <div class="cnm-template-icon cnm-icon-double">
              <span></span>
              <span></span>
            </div>


            <div class="cnm-template-name">
              이미지 2장
            </div>

            <div class="cnm-template-desc">
              좌우 반반으로 배치
            </div>

          </button>

        </div>

      </section>



      <!-- =========================
           미리보기
      ========================== -->
      <section class="cnm-preview-section">

        <div class="cnm-panel-heading">

          <h3>
            미리보기
          </h3>

          <p>
            이미지를 직접 드래그해서 위치를 조절할 수 있어요.
          </p>

        </div>


        <div
          class="cnm-preview-wrap"
          id="cnmPreviewWrap"
        >

          <canvas
            id="cnmCanvas"
            width="1080"
            height="1350"
          ></canvas>


          <!-- 이미지 1 업로드 안내 -->
          <div
            class="cnm-slot-hint"
            id="cnmSlot1Hint"
          >

            <div class="cnm-upload-icon">
              +
            </div>

            <strong>
              이미지 선택
            </strong>

            <span>
              클릭해서 이미지를 업로드하세요
            </span>

          </div>


          <!-- 이미지 2 업로드 안내 -->
          <div
            class="cnm-slot-hint"
            id="cnmSlot2Hint"
          >

            <div class="cnm-upload-icon">
              +
            </div>

            <strong>
              이미지 2 선택
            </strong>

            <span>
              클릭해서 이미지를 업로드하세요
            </span>

          </div>

        </div>

      </section>



      <!-- =========================
           이미지 편집
      ========================== -->
      <section class="cnm-image-editor">

        <div class="cnm-panel-heading">

          <h3>
            이미지 편집
          </h3>

          <p>
            이미지 크기와 위치를 조절하세요.
          </p>

        </div>



        <!-- 이미지 1 -->
        <div
          class="cnm-image-control"
          id="cnmImage1Card"
        >

          <div
            class="cnm-image-control-title"
            id="cnmImage1Title"
          >
            이미지
          </div>


          <!-- 이미지 선택 + 초기화 한 줄 -->
          <div class="cnm-image-action-row">

            <label class="cnm-upload-button">

              <span>
                이미지 선택
              </span>

              <input
                type="file"
                id="cnmImageInput1"
                accept="image/*"
              >

            </label>


            <button
              type="button"
              class="cnm-reset-btn"
              id="cnmReset1"
            >
              위치 및 확대 초기화
            </button>

          </div>



          <!-- 확대 -->
          <div class="cnm-range-block">

            <div class="cnm-range-head">

              <span>
                확대 / 축소
              </span>

              <strong id="cnmZoom1Value">
                100%
              </strong>

            </div>


            <input
              type="range"
              id="cnmZoom1"
              min="100"
              max="300"
              value="100"
            >


            <div class="cnm-range-scale">

              <span>
                100%
              </span>

              <span>
                300%
              </span>

            </div>

          </div>

        </div>



        <!-- 이미지 2 -->
        <div
          class="cnm-image-control cnm-hidden"
          id="cnmImage2Card"
        >

          <div class="cnm-image-control-title">
            이미지 2 · 오른쪽
          </div>


          <!-- 이미지 선택 + 초기화 한 줄 -->
          <div class="cnm-image-action-row">

            <label class="cnm-upload-button">

              <span>
                이미지 선택
              </span>

              <input
                type="file"
                id="cnmImageInput2"
                accept="image/*"
              >

            </label>


            <button
              type="button"
              class="cnm-reset-btn"
              id="cnmReset2"
            >
              위치 및 확대 초기화
            </button>

          </div>



          <!-- 확대 -->
          <div class="cnm-range-block">

            <div class="cnm-range-head">

              <span>
                확대 / 축소
              </span>

              <strong id="cnmZoom2Value">
                100%
              </strong>

            </div>


            <input
              type="range"
              id="cnmZoom2"
              min="100"
              max="300"
              value="100"
            >


            <div class="cnm-range-scale">

              <span>
                100%
              </span>

              <span>
                300%
              </span>

            </div>

          </div>

        </div>

      </section>

    </div>



    <!-- =========================
         오른쪽
    ========================== -->
    <div class="cnm-right-panel">


      <!-- =========================
           텍스트 설정
      ========================== -->
      <div class="cnm-text-editor">

        <div class="cnm-panel-heading">

          <h3>
            텍스트 설정
          </h3>

          <p>
            미리보기를 확인하면서 문구와 색상을 조절하세요.
          </p>

        </div>



        <!-- =========================
             상단 핸들
        ========================== -->
        <div class="cnm-field">

          <div class="cnm-field-head">

            <label>
              상단 핸들 / 로고
            </label>


            <label class="cnm-mini-check">

              <input
                type="checkbox"
                id="cnmTopToggle"
                checked
              >

              사용

            </label>

          </div>


          <input
            type="text"
            id="cnmTopText"
            value="@mybrand"
            placeholder="@snsnote"
          >


          <div class="cnm-color-control">

            <input
              type="color"
              id="cnmTopColorPicker"
              value="#ffffff"
              title="색상 선택"
            >


            <input
              type="text"
              class="cnm-hex-input"
              id="cnmTopColor"
              value="#FFFFFF"
              maxlength="7"
              spellcheck="false"
              aria-label="상단 핸들 HEX 색상"
            >

          </div>

        </div>



        <!-- =========================
             중간 라벨
        ========================== -->
        <div class="cnm-field">

          <label>
            중간 라벨
          </label>


          <input
            type="text"
            id="cnmMidLabel"
            value="SNSnote.com"
            placeholder="SNSnote.com"
          >


          <div class="cnm-color-control">

            <input
              type="color"
              id="cnmMidColorPicker"
              value="#ff2b2b"
              title="색상 선택"
            >


            <input
              type="text"
              class="cnm-hex-input"
              id="cnmMidColor"
              value="#FF2B2B"
              maxlength="7"
              spellcheck="false"
              aria-label="중간 라벨 HEX 색상"
            >

          </div>

        </div>



        <!-- =========================
             흰 선 길이
        ========================== -->
        <div class="cnm-field">

          <div class="cnm-range-head">

            <label>
              라벨 앞 흰 선 길이
            </label>

            <strong id="cnmMidLineValue">
              585px
            </strong>

          </div>


          <input
            type="range"
            id="cnmMidLineLength"
            min="80"
            max="720"
            value="585"
          >


          <div class="cnm-range-scale">

            <span>
              80px
            </span>

            <span>
              720px
            </span>

          </div>

        </div>



        <div class="cnm-divider"></div>



        <!-- =========================
             제목 1
        ========================== -->
        <div class="cnm-field">

          <label>
            제목 1줄
          </label>


          <input
            type="text"
            id="cnmLine1"
            value="회전초밥 가게에서"
          >


          <div class="cnm-color-control">

            <input
              type="color"
              id="cnmLine1ColorPicker"
              value="#ffffff"
              title="제목 1줄 색상"
            >


            <input
              type="text"
              class="cnm-hex-input"
              id="cnmLine1Color"
              value="#FFFFFF"
              maxlength="7"
              spellcheck="false"
              aria-label="제목 1줄 HEX 색상"
            >

          </div>

        </div>



        <!-- =========================
             제목 2
        ========================== -->
        <div class="cnm-field">

          <label>
            제목 2줄
          </label>


          <input
            type="text"
            id="cnmLine2"
            value="25접시 먹은 썸남"
          >


          <div class="cnm-color-control">

            <input
              type="color"
              id="cnmLine2ColorPicker"
              value="#ffffff"
              title="제목 2줄 색상"
            >


            <input
              type="text"
              class="cnm-hex-input"
              id="cnmLine2Color"
              value="#FFFFFF"
              maxlength="7"
              spellcheck="false"
              aria-label="제목 2줄 HEX 색상"
            >

          </div>

        </div>



        <!-- =========================
             제목 3
             기본 #58FF42
        ========================== -->
        <div class="cnm-field">

          <label>
            제목 3줄
          </label>


          <input
            type="text"
            id="cnmLine3"
            value="사귈 수 있다? VS 없다?"
          >


          <div class="cnm-color-control">

            <input
              type="color"
              id="cnmLine3ColorPicker"
              value="#58ff42"
              title="제목 3줄 색상"
            >


            <input
              type="text"
              class="cnm-hex-input"
              id="cnmLine3Color"
              value="#58FF42"
              maxlength="7"
              spellcheck="false"
              aria-label="제목 3줄 HEX 색상"
            >

          </div>

        </div>



        <!-- =========================
             크기 / 그라데이션
        ========================== -->
        <div class="cnm-two-col">


          <!-- 제목 크기 -->
          <div class="cnm-field">

            <div class="cnm-range-head">

              <label>
                제목 글자 크기
              </label>

              <strong id="cnmFontSizeValue">
                72px
              </strong>

            </div>


            <input
              type="range"
              id="cnmFontSize"
              min="48"
              max="100"
              value="72"
            >


            <div class="cnm-range-scale">

              <span>
                48px
              </span>

              <span>
                100px
              </span>

            </div>

          </div>



          <!-- 그라데이션 -->
          <div class="cnm-field">

            <div class="cnm-range-head">

              <label>
                하단 그라데이션
              </label>

              <strong id="cnmGradientValue">
                50%
              </strong>

            </div>


            <input
              type="range"
              id="cnmGradientStrength"
              min="0"
              max="100"
              value="50"
            >


            <div class="cnm-range-scale">

              <span>
                0%
              </span>

              <span>
                100%
              </span>

            </div>

          </div>

        </div>

      </div>



      <!-- 다운로드 -->
      <button
        type="button"
        id="cnmDownloadBtn"
        class="cnm-download"
      >
        PNG 다운로드
      </button>


      <p class="cnm-browser-note">
        이미지는 서버에 업로드되지 않고 브라우저에서 처리됩니다.
      </p>

    </div>

  </div>

</div>



<style>

/* =========================================================
   RESET
========================================================= */

#cardnewsCoverMaker *,
#cardnewsCoverMaker *::before,
#cardnewsCoverMaker *::after {
  box-sizing: border-box;
}



#cardnewsCoverMaker {

  --accent: #16836f;
  --accent-dark: #106b5b;
  --accent-soft: #eef7f4;

  --text: #172629;
  --muted: #748488;

  --border: #dce6e3;
  --border-strong: #cbd9d5;

  --soft: #f7f9f8;

  width: 100%;
  max-width: 1380px;

  margin: 24px auto;

  color: var(--text);

  font-family:
    Arial,
    "Apple SD Gothic Neo",
    "Malgun Gothic",
    sans-serif;

}



/* =========================================================
   HEADER
========================================================= */

#cardnewsCoverMaker .cnm-hero {

  position: relative;

  padding: 32px 40px;

  margin-bottom: 26px;

  overflow: hidden;

  border:
    1px solid #dbe7e3;

  border-radius: 22px;

  background:
    linear-gradient(
      135deg,
      #f2f8f6 0%,
      #f7faf9 100%
    );

}



#cardnewsCoverMaker .cnm-hero::before {

  content: "";

  position: absolute;

  left: 0;
  top: 26px;
  bottom: 26px;

  width: 6px;

  border-radius:
    0 6px 6px 0;

  background:
    var(--accent);

}



#cardnewsCoverMaker .cnm-kicker {

  margin: 0 0 8px;

  padding: 0 !important;

  background: none !important;

  color:
    var(--accent) !important;

  font-size: 13px;

  font-weight: 900;

  letter-spacing: .16em;

}



#cardnewsCoverMaker .cnm-main-title,
#cardnewsCoverMaker .cnm-hero h2,
#cardnewsCoverMaker h2.cnm-main-title {

  display: block !important;

  width: auto !important;

  max-width: none !important;

  margin: 0 !important;

  padding: 0 !important;

  border: 0 !important;

  border-radius: 0 !important;

  outline: 0 !important;

  box-shadow: none !important;

  background: none !important;

  background-color:
    transparent !important;

  background-image:
    none !important;

  color:
    #172629 !important;

  -webkit-text-fill-color:
    #172629 !important;

  -webkit-background-clip:
    initial !important;

  background-clip:
    initial !important;

  font-size:
    36px !important;

  font-weight:
    800 !important;

  line-height:
    1.22 !important;

  letter-spacing:
    -.04em !important;

  text-decoration:
    none !important;

}



#cardnewsCoverMaker .cnm-hero p {

  margin:
    13px 0 0;

  padding: 0;

  color:
    var(--muted);

  font-size: 15px;

  line-height: 1.65;

}



/* =========================================================
   MAIN GRID
========================================================= */

#cardnewsCoverMaker .cnm-main-grid {

  display: grid;

  grid-template-columns:
    minmax(360px, 1.08fr)
    minmax(340px, .92fr);

  gap: 24px;

  align-items: start;

}



#cardnewsCoverMaker .cnm-left-panel,
#cardnewsCoverMaker .cnm-right-panel {

  min-width: 0;

}



#cardnewsCoverMaker .cnm-left-panel {

  padding: 24px;

  border:
    1px solid var(--border);

  border-radius: 22px;

  background: #ffffff;

}



#cardnewsCoverMaker .cnm-right-panel {

  display: flex;

  flex-direction: column;

  gap: 16px;

}



/* =========================================================
   SECTION HEADINGS
========================================================= */

#cardnewsCoverMaker .cnm-panel-heading {

  position: relative;

  margin-bottom: 18px;

  padding:
    15px 18px
    15px 22px;

  overflow: hidden;

  border:
    1px solid #e0ebe7;

  border-radius: 13px;

  background:
    linear-gradient(
      90deg,
      #f0f7f5 0%,
      #f7faf9 100%
    );

}



#cardnewsCoverMaker .cnm-panel-heading::before {

  content: "";

  position: absolute;

  left: 0;
  top: 0;
  bottom: 0;

  width: 5px;

  background:
    var(--accent);

}



#cardnewsCoverMaker .cnm-panel-heading h3 {

  margin: 0 !important;

  padding: 0 !important;

  border: 0 !important;

  background: none !important;

  background-image:
    none !important;

  box-shadow:
    none !important;

  color:
    #19292c !important;

  font-size:
    17px !important;

  font-weight:
    800 !important;

  line-height:
    1.3 !important;

}



#cardnewsCoverMaker .cnm-panel-heading p {

  margin: 5px 0 0;

  padding: 0;

  color:
    var(--muted);

  font-size: 12px;

  line-height: 1.5;

}



/* =========================================================
   TEMPLATE
========================================================= */

#cardnewsCoverMaker .cnm-template-section {

  margin-bottom: 26px;

}



#cardnewsCoverMaker .cnm-template-grid {

  display: grid;

  grid-template-columns:
    repeat(2, minmax(0, 1fr));

  gap: 12px;

}



#cardnewsCoverMaker .cnm-template-card {

  position: relative;

  min-height: 145px;

  padding: 18px;

  appearance: none;

  border:
    1px solid var(--border);

  border-radius: 17px;

  background: #ffffff;

  color:
    var(--text);

  cursor: pointer;

  text-align: left;

  font-family: inherit;

  transition:
    border-color .18s ease,
    background .18s ease,
    box-shadow .18s ease;

}



#cardnewsCoverMaker .cnm-template-card:hover {

  border-color:
    #a9c9c0;

  background:
    #fbfdfc;

}



#cardnewsCoverMaker .cnm-template-card.active {

  border:
    2px solid var(--accent);

  background:
    var(--accent-soft);

  box-shadow:
    0 0 0 2px
    rgba(22,131,111,.05);

}



#cardnewsCoverMaker .cnm-template-check {

  position: absolute;

  top: 14px;
  right: 14px;

  display: none;

  align-items: center;
  justify-content: center;

  width: 28px;
  height: 28px;

  border-radius: 50%;

  background:
    var(--accent);

  color: #ffffff;

  font-size: 15px;

  font-weight: 900;

}



#cardnewsCoverMaker
.cnm-template-card.active
.cnm-template-check {

  display: flex;

}



#cardnewsCoverMaker .cnm-template-icon {

  display: flex;

  width: 70px;
  height: 54px;

  margin-bottom: 14px;

  overflow: hidden;

  border:
    1px solid #cbdad6;

  border-radius: 9px;

  background: #ffffff;

}



#cardnewsCoverMaker .cnm-template-icon span {

  display: block;

  background:
    linear-gradient(
      135deg,
      #d9ece6,
      #bddad2
    );

}



#cardnewsCoverMaker .cnm-icon-single span {

  width: 100%;
  height: 100%;

}



#cardnewsCoverMaker .cnm-icon-double {

  gap: 3px;

}



#cardnewsCoverMaker .cnm-icon-double span {

  flex: 1;

  height: 100%;

}



#cardnewsCoverMaker .cnm-template-name {

  color: #19292c;

  font-size: 15px;

  font-weight: 800;

}



#cardnewsCoverMaker .cnm-template-desc {

  margin-top: 5px;

  color:
    var(--muted);

  font-size: 12px;

}



/* =========================================================
   PREVIEW
========================================================= */

#cardnewsCoverMaker .cnm-preview-section {

  padding-top: 23px;

  border-top:
    1px solid #edf1ef;

}



#cardnewsCoverMaker .cnm-preview-wrap {

  position: relative;

  width:
    min(100%, 540px);

  aspect-ratio: 4 / 5;

  margin: 0 auto;

  overflow: hidden;

  border-radius: 16px;

  background: #111111;

  box-shadow:
    0 0 0 1px
    rgba(0,0,0,.07);

  touch-action: none;

  user-select: none;

  cursor: grab;

}



#cardnewsCoverMaker .cnm-preview-wrap:active {

  cursor: grabbing;

}



#cardnewsCoverMaker canvas {

  display: block;

  width: 100%;
  height: 100%;

}



/* 업로드 안내 */

#cardnewsCoverMaker .cnm-slot-hint {

  position: absolute;

  z-index: 5;

  display: flex;

  align-items: center;
  justify-content: center;

  flex-direction: column;

  gap: 8px;

  color: #ffffff;

  text-align: center;

  pointer-events: none;

  background:
    rgba(0,0,0,.16);

}



#cardnewsCoverMaker .cnm-slot-hint strong {

  font-size: 16px;

}



#cardnewsCoverMaker .cnm-slot-hint span {

  color:
    rgba(255,255,255,.76);

  font-size: 12px;

}



#cardnewsCoverMaker .cnm-upload-icon {

  display: flex;

  align-items: center;
  justify-content: center;

  width: 48px;
  height: 48px;

  border:
    1px solid
    rgba(255,255,255,.22);

  border-radius: 15px;

  background:
    rgba(255,255,255,.15);

  font-size: 29px;

  font-weight: 300;

}



/* =========================================================
   IMAGE EDITOR
========================================================= */

#cardnewsCoverMaker .cnm-image-editor {

  margin-top: 28px;

  padding-top: 24px;

  border-top:
    1px solid #edf1ef;

}



#cardnewsCoverMaker .cnm-image-control {

  padding: 16px;

  border:
    1px solid var(--border);

  border-radius: 15px;

  background:
    var(--soft);

}



#cardnewsCoverMaker
.cnm-image-control +
.cnm-image-control {

  margin-top: 12px;

}



#cardnewsCoverMaker .cnm-image-control-title {

  margin-bottom: 11px;

  color:
    #19292c;

  font-size: 14px;

  font-weight: 800;

}



/* 이미지 선택 + 초기화 */

#cardnewsCoverMaker .cnm-image-action-row {

  display: grid;

  grid-template-columns:
    minmax(0, 1fr)
    minmax(0, 1fr);

  gap: 10px;

  margin-bottom: 14px;

}



#cardnewsCoverMaker .cnm-upload-button {

  display: flex;

  align-items: center;
  justify-content: center;

  width: 100%;

  min-height: 44px;

  margin: 0;

  padding: 9px 12px;

  border:
    1px dashed #a7c7be;

  border-radius: 10px;

  background: #ffffff;

  color:
    var(--accent-dark);

  font-size: 12px;

  font-weight: 800;

  cursor: pointer;

}



#cardnewsCoverMaker .cnm-upload-button:hover {

  background:
    var(--accent-soft);

}



#cardnewsCoverMaker .cnm-upload-button input {

  display: none;

}



#cardnewsCoverMaker .cnm-reset-btn {

  display: flex;

  align-items: center;
  justify-content: center;

  width: 100%;

  min-height: 44px;

  padding: 9px 12px;

  border:
    1px solid
    var(--border-strong);

  border-radius: 10px;

  background: #ffffff;

  color: #46575a;

  font-family: inherit;

  font-size: 12px;

  font-weight: 700;

  cursor: pointer;

}



#cardnewsCoverMaker .cnm-reset-btn:hover {

  background:
    #f1f6f4;

}



#cardnewsCoverMaker .cnm-hidden {

  display: none !important;

}



/* =========================================================
   RANGE
========================================================= */

#cardnewsCoverMaker .cnm-range-block {

  margin: 0;

}



#cardnewsCoverMaker .cnm-range-head {

  display: flex;

  align-items: center;
  justify-content: space-between;

  gap: 12px;

  margin-bottom: 7px;

}



#cardnewsCoverMaker .cnm-range-head label,
#cardnewsCoverMaker .cnm-range-head span {

  margin: 0;

  color:
    #263639;

  font-size: 13px;

  font-weight: 700;

}



#cardnewsCoverMaker .cnm-range-head strong {

  color:
    var(--accent);

  font-size: 12px;

  font-weight: 800;

}



#cardnewsCoverMaker input[type="range"] {

  display: block;

  width: 100%;

  margin: 0;

  accent-color:
    var(--accent);

  cursor: pointer;

}



#cardnewsCoverMaker .cnm-range-scale {

  display: flex;

  align-items: center;
  justify-content: space-between;

  margin-top: 4px;

  color: #9aa6a8;

  font-size: 10px;

}



/* =========================================================
   TEXT EDITOR
========================================================= */

#cardnewsCoverMaker .cnm-text-editor {

  padding: 24px;

  border:
    1px solid var(--border);

  border-radius: 22px;

  background: #ffffff;

}



#cardnewsCoverMaker .cnm-field {

  margin-bottom: 19px;

}



#cardnewsCoverMaker .cnm-field:last-child {

  margin-bottom: 0;

}



#cardnewsCoverMaker .cnm-field > label,
#cardnewsCoverMaker .cnm-field-head > label {

  display: block;

  margin:
    0 0 8px;

  color:
    #263639;

  font-size: 13px;

  font-weight: 800;

}



#cardnewsCoverMaker .cnm-field-head {

  display: flex;

  align-items: center;
  justify-content: space-between;

  gap: 10px;

}



#cardnewsCoverMaker .cnm-mini-check {

  display: flex;

  align-items: center;

  gap: 5px;

  margin-bottom: 8px;

  color:
    var(--muted);

  font-size: 12px;

  font-weight: 700;

  cursor: pointer;

}



/* 텍스트 입력 */

#cardnewsCoverMaker input[type="text"] {

  width: 100%;

  min-width: 0;

  height: 44px;

  padding:
    0 13px;

  border:
    1px solid
    var(--border-strong);

  border-radius: 10px;

  outline: none;

  background:
    #ffffff !important;

  background-image:
    none !important;

  color:
    var(--text);

  font-family: inherit;

  font-size: 13px;

}



#cardnewsCoverMaker input[type="text"]:focus {

  border-color:
    var(--accent);

  box-shadow:
    0 0 0 3px
    rgba(22,131,111,.08);

}



/* =========================================================
   HEX COLOR
========================================================= */

#cardnewsCoverMaker .cnm-color-control {

  display: grid;

  grid-template-columns:
    48px 110px;

  justify-content: end;

  gap: 8px;

  margin-top: 8px;

}



#cardnewsCoverMaker
.cnm-color-control
input[type="color"] {

  width: 48px;
  height: 40px;

  padding: 3px;

  border:
    1px solid
    var(--border-strong);

  border-radius: 9px;

  background:
    #ffffff;

  cursor: pointer;

}



#cardnewsCoverMaker
.cnm-color-control
.cnm-hex-input {

  width: 110px;

  height: 40px;

  padding:
    0 10px;

  font-family:
    Consolas,
    "Courier New",
    monospace;

  font-size: 12px;

  text-transform:
    uppercase;

}



/* =========================================================
   TWO COLUMN
========================================================= */

#cardnewsCoverMaker .cnm-two-col {

  display: grid;

  grid-template-columns:
    minmax(0, 1fr)
    minmax(0, 1fr);

  gap: 15px;

}



/* =========================================================
   DIVIDER
========================================================= */

#cardnewsCoverMaker .cnm-divider {

  height: 1px;

  margin:
    23px 0;

  background:
    #edf1ef;

}



/* =========================================================
   DOWNLOAD
========================================================= */

#cardnewsCoverMaker .cnm-download {

  width: 100%;

  min-height: 54px;

  padding:
    12px 18px;

  border: none;

  border-radius: 13px;

  background:
    var(--accent);

  color: #ffffff;

  font-family: inherit;

  font-size: 15px;

  font-weight: 900;

  cursor: pointer;

  transition:
    background .18s ease,
    transform .18s ease;

}



#cardnewsCoverMaker .cnm-download:hover {

  background:
    var(--accent-dark);

}



#cardnewsCoverMaker .cnm-download:active {

  transform:
    translateY(1px);

}



#cardnewsCoverMaker .cnm-browser-note {

  margin:
    -4px 0 0;

  color:
    var(--muted);

  text-align: center;

  font-size: 11px;

}



/* =========================================================
   TABLET
========================================================= */

@media (max-width: 960px) {

  #cardnewsCoverMaker .cnm-main-grid {

    grid-template-columns:
      1fr;

  }


  #cardnewsCoverMaker .cnm-preview-wrap {

    max-width:
      540px;

  }

}



/* =========================================================
   MOBILE
========================================================= */

@media (max-width: 650px) {

  #cardnewsCoverMaker {

    margin:
      16px 0;

  }


  #cardnewsCoverMaker .cnm-hero {

    padding:
      24px 22px;

  }


  #cardnewsCoverMaker .cnm-main-title,
  #cardnewsCoverMaker .cnm-hero h2 {

    font-size:
      28px !important;

  }


  #cardnewsCoverMaker .cnm-left-panel,
  #cardnewsCoverMaker .cnm-text-editor {

    padding:
      17px;

    border-radius:
      17px;

  }


  #cardnewsCoverMaker .cnm-template-card {

    min-height:
      138px;

    padding:
      15px;

  }


  #cardnewsCoverMaker .cnm-image-action-row {

    grid-template-columns:
      1fr;

    gap: 7px;

  }


  #cardnewsCoverMaker .cnm-two-col {

    grid-template-columns:
      1fr;

  }


  #cardnewsCoverMaker .cnm-color-control {

    grid-template-columns:
      48px 105px;

  }


  #cardnewsCoverMaker
  .cnm-color-control
  .cnm-hex-input {

    width: 105px;

  }

}

</style>



<script>

(function () {

  const root =
    document.getElementById(
      'cardnewsCoverMaker'
    );

  if (!root) return;



  /* ======================================================
     DOM
  ====================================================== */

  const canvas =
    root.querySelector(
      '#cnmCanvas'
    );


  const ctx =
    canvas.getContext(
      '2d'
    );


  const previewWrap =
    root.querySelector(
      '#cnmPreviewWrap'
    );


  const templateCards =
    root.querySelectorAll(
      '.cnm-template-card'
    );


  const slot1Hint =
    root.querySelector(
      '#cnmSlot1Hint'
    );


  const slot2Hint =
    root.querySelector(
      '#cnmSlot2Hint'
    );



  /* 이미지 */

  const imageInput1 =
    root.querySelector(
      '#cnmImageInput1'
    );


  const imageInput2 =
    root.querySelector(
      '#cnmImageInput2'
    );


  const image2Card =
    root.querySelector(
      '#cnmImage2Card'
    );


  const image1Title =
    root.querySelector(
      '#cnmImage1Title'
    );



  /* 줌 */

  const zoom1 =
    root.querySelector(
      '#cnmZoom1'
    );


  const zoom2 =
    root.querySelector(
      '#cnmZoom2'
    );


  const zoom1Value =
    root.querySelector(
      '#cnmZoom1Value'
    );


  const zoom2Value =
    root.querySelector(
      '#cnmZoom2Value'
    );


  const reset1 =
    root.querySelector(
      '#cnmReset1'
    );


  const reset2 =
    root.querySelector(
      '#cnmReset2'
    );



  /* 상단 */

  const topToggle =
    root.querySelector(
      '#cnmTopToggle'
    );


  const topText =
    root.querySelector(
      '#cnmTopText'
    );



  /* 라벨 */

  const midLabel =
    root.querySelector(
      '#cnmMidLabel'
    );


  const midLineLength =
    root.querySelector(
      '#cnmMidLineLength'
    );


  const midLineValue =
    root.querySelector(
      '#cnmMidLineValue'
    );



  /* 제목 */

  const line1 =
    root.querySelector(
      '#cnmLine1'
    );


  const line2 =
    root.querySelector(
      '#cnmLine2'
    );


  const line3 =
    root.querySelector(
      '#cnmLine3'
    );



  /* 제목 크기 */

  const fontSizeInput =
    root.querySelector(
      '#cnmFontSize'
    );


  const fontSizeValue =
    root.querySelector(
      '#cnmFontSizeValue'
    );



  /* 그라데이션 */

  const gradientStrength =
    root.querySelector(
      '#cnmGradientStrength'
    );


  const gradientValue =
    root.querySelector(
      '#cnmGradientValue'
    );



  /* 다운로드 */

  const downloadBtn =
    root.querySelector(
      '#cnmDownloadBtn'
    );



  /* ======================================================
     HEX COLOR
  ====================================================== */

  function validHex(
    value
  ) {

    return (
      /^#[0-9A-F]{6}$/i
    ).test(
      value
    );

  }



  function normalizeHex(
    value
  ) {

    let result =
      String(
        value || ''
      ).trim();


    if (
      result &&
      result.charAt(0) !== '#'
    ) {

      result =
        '#' +
        result;

    }


    return result.toUpperCase();

  }



  function setupHexColor(
    pickerId,
    textId
  ) {

    const picker =
      root.querySelector(
        '#' + pickerId
      );


    const text =
      root.querySelector(
        '#' + textId
      );


    if (
      !picker ||
      !text
    ) {
      return;
    }


    picker.addEventListener(
      'input',
      function () {

        text.value =
          picker.value
            .toUpperCase();


        draw();

      }
    );


    text.addEventListener(
      'input',
      function () {

        const value =
          normalizeHex(
            text.value
          );


        if (
          validHex(
            value
          )
        ) {

          text.value =
            value;


          picker.value =
            value
              .toLowerCase();


          draw();

        }

      }
    );


    text.addEventListener(
      'blur',
      function () {

        const value =
          normalizeHex(
            text.value
          );


        if (
          validHex(
            value
          )
        ) {

          text.value =
            value;


          picker.value =
            value
              .toLowerCase();

        } else {

          text.value =
            picker.value
              .toUpperCase();

        }


        draw();

      }
    );

  }



  setupHexColor(
    'cnmTopColorPicker',
    'cnmTopColor'
  );


  setupHexColor(
    'cnmMidColorPicker',
    'cnmMidColor'
  );


  setupHexColor(
    'cnmLine1ColorPicker',
    'cnmLine1Color'
  );


  setupHexColor(
    'cnmLine2ColorPicker',
    'cnmLine2Color'
  );


  setupHexColor(
    'cnmLine3ColorPicker',
    'cnmLine3Color'
  );



  const topColor =
    root.querySelector(
      '#cnmTopColor'
    );


  const midColor =
    root.querySelector(
      '#cnmMidColor'
    );


  const line1Color =
    root.querySelector(
      '#cnmLine1Color'
    );


  const line2Color =
    root.querySelector(
      '#cnmLine2Color'
    );


  const line3Color =
    root.querySelector(
      '#cnmLine3Color'
    );



  /* ======================================================
     STATE
  ====================================================== */

  let template =
    'single';


  const images = [
    null,
    null
  ];


  const transforms = [

    {
      zoom: 1,
      x: 0,
      y: 0
    },

    {
      zoom: 1,
      x: 0,
      y: 0
    }

  ];



  /* ======================================================
     SLOTS
  ====================================================== */

  function getSlots() {

    const W =
      canvas.width;


    const H =
      canvas.height;


    if (
      template ===
      'horizontal'
    ) {

      return [

        {
          index: 0,

          x: 0,
          y: 0,

          w:
            W / 2,

          h:
            H
        },


        {
          index: 1,

          x:
            W / 2,

          y: 0,

          w:
            W / 2,

          h:
            H
        }

      ];

    }


    return [

      {
        index: 0,

        x: 0,
        y: 0,

        w: W,
        h: H
      }

    ];

  }



  /* ======================================================
     EMPTY SLOT
  ====================================================== */

  function drawEmptySlot(
    slot
  ) {

    const grad =
      ctx.createLinearGradient(

        slot.x,
        slot.y,

        slot.x,
        slot.y +
        slot.h

      );


    grad.addColorStop(
      0,
      '#394146'
    );


    grad.addColorStop(
      1,
      '#171b1e'
    );


    ctx.fillStyle =
      grad;


    ctx.fillRect(

      slot.x,
      slot.y,

      slot.w,
      slot.h

    );

  }



  /* ======================================================
     CLAMP IMAGE
  ====================================================== */

  function clampTransform(
    index,
    slot
  ) {

    const img =
      images[index];


    if (!img) return;


    const transform =
      transforms[index];


    const baseScale =
      Math.max(

        slot.w /
        img.width,

        slot.h /
        img.height

      );


    const drawW =
      img.width *
      baseScale *
      transform.zoom;


    const drawH =
      img.height *
      baseScale *
      transform.zoom;


    const maxX =
      Math.max(

        0,

        (
          drawW -
          slot.w
        ) /
        2

      );


    const maxY =
      Math.max(

        0,

        (
          drawH -
          slot.h
        ) /
        2

      );


    transform.x =
      Math.max(

        -maxX,

        Math.min(
          maxX,
          transform.x
        )

      );


    transform.y =
      Math.max(

        -maxY,

        Math.min(
          maxY,
          transform.y
        )

      );

  }



  /* ======================================================
     DRAW IMAGE SLOT
  ====================================================== */

  function drawImageSlot(
    img,
    slot,
    transform
  ) {

    const baseScale =
      Math.max(

        slot.w /
        img.width,

        slot.h /
        img.height

      );


    const scale =
      baseScale *
      transform.zoom;


    const drawW =
      img.width *
      scale;


    const drawH =
      img.height *
      scale;


    const drawX =
      slot.x +
      (
        slot.w -
        drawW
      ) /
      2 +
      transform.x;


    const drawY =
      slot.y +
      (
        slot.h -
        drawH
      ) /
      2 +
      transform.y;


    ctx.save();


    ctx.beginPath();


    ctx.rect(

      slot.x,
      slot.y,

      slot.w,
      slot.h

    );


    ctx.clip();


    ctx.drawImage(

      img,

      drawX,
      drawY,

      drawW,
      drawH

    );


    ctx.restore();

  }



  /* ======================================================
     UPLOAD HINT
  ====================================================== */

  function updateHints() {

    if (
      template ===
      'single'
    ) {

      slot1Hint.style.left =
        '0';


      slot1Hint.style.top =
        '0';


      slot1Hint.style.width =
        '100%';


      slot1Hint.style.height =
        '100%';


      slot1Hint.style.display =
        images[0]
          ? 'none'
          : 'flex';


      slot2Hint.style.display =
        'none';


      return;

    }



    slot1Hint.style.left =
      '0';


    slot1Hint.style.top =
      '0';


    slot1Hint.style.width =
      '50%';


    slot1Hint.style.height =
      '100%';



    slot2Hint.style.left =
      '50%';


    slot2Hint.style.top =
      '0';


    slot2Hint.style.width =
      '50%';


    slot2Hint.style.height =
      '100%';



    slot1Hint.style.display =
      images[0]
        ? 'none'
        : 'flex';


    slot2Hint.style.display =
      images[1]
        ? 'none'
        : 'flex';

  }



  /* ======================================================
     GRADIENT

     50% = 이전 버전의 약 100% 수준

     0%   = 절반 수준
     50%  = 기존 기본 강도
     100% = 더 강함
  ====================================================== */

  function gradientAlpha(
    base,
    value
  ) {

    const multiplier =
      0.5 +
      (
        value /
        100
      );


    return Math.min(

      1,

      base *
      multiplier

    ).toFixed(3);

  }



  /* ======================================================
     DRAW
  ====================================================== */

  function draw() {

    const W =
      canvas.width;


    const H =
      canvas.height;


    /*
      1080 × 1350 안에서
      가운데 1080 × 1080 영역
    */

    const safeTop =
      (
        H -
        W
      ) /
      2;


    const safeBottom =
      H -
      safeTop;


    const pad =
      46;


    ctx.clearRect(
      0,
      0,
      W,
      H
    );


    const slots =
      getSlots();



    /* 이미지 */

    slots.forEach(
      function (
        slot
      ) {

        const img =
          images[
            slot.index
          ];


        if (img) {

          clampTransform(
            slot.index,
            slot
          );


          drawImageSlot(

            img,

            slot,

            transforms[
              slot.index
            ]

          );

        } else {

          drawEmptySlot(
            slot
          );

        }

      }
    );



    /* 2장 중앙 구분선 */

    if (
      template ===
      'horizontal'
    ) {

      ctx.save();


      ctx.strokeStyle =
        'rgba(255,255,255,.35)';


      ctx.lineWidth =
        2;


      ctx.beginPath();


      ctx.moveTo(
        W / 2,
        0
      );


      ctx.lineTo(
        W / 2,
        H
      );


      ctx.stroke();


      ctx.restore();

    }



    /* ==================================================
       하단 그라데이션
    ================================================== */

    const gradientLevel =
      parseInt(
        gradientStrength.value,
        10
      );


    const gradient =
      ctx.createLinearGradient(

        0,
        H * .28,

        0,
        H

      );


    gradient.addColorStop(
      0,
      'rgba(0,0,0,0)'
    );


    gradient.addColorStop(

      .20,

      'rgba(0,0,0,' +

      gradientAlpha(
        .10,
        gradientLevel
      ) +

      ')'

    );


    gradient.addColorStop(

      .42,

      'rgba(0,0,0,' +

      gradientAlpha(
        .32,
        gradientLevel
      ) +

      ')'

    );


    gradient.addColorStop(

      .64,

      'rgba(0,0,0,' +

      gradientAlpha(
        .63,
        gradientLevel
      ) +

      ')'

    );


    gradient.addColorStop(

      .82,

      'rgba(0,0,0,' +

      gradientAlpha(
        .84,
        gradientLevel
      ) +

      ')'

    );


    gradient.addColorStop(

      1,

      'rgba(0,0,0,' +

      gradientAlpha(
        .98,
        gradientLevel
      ) +

      ')'

    );


    ctx.fillStyle =
      gradient;


    ctx.fillRect(
      0,
      0,
      W,
      H
    );



    /* ==================================================
       상단 핸들
    ================================================== */

    if (
      topToggle.checked &&
      topText.value.trim()
    ) {

      ctx.save();


      ctx.font =
        '700 32px Arial, "Apple SD Gothic Neo", sans-serif';


      ctx.fillStyle =
        validHex(
          topColor.value
        )
          ? topColor.value
          : '#FFFFFF';


      ctx.textAlign =
        'center';


      ctx.textBaseline =
        'top';


      ctx.shadowColor =
        'rgba(0,0,0,.50)';


      ctx.shadowBlur =
        10;


      ctx.fillText(

        topText.value.trim(),

        W / 2,

        safeTop +
        18

      );


      ctx.restore();

    }



    /* ==================================================
       제목 데이터
    ================================================== */

    const titleData =
      [];


    if (
      line1.value.trim()
    ) {

      titleData.push({

        text:
          line1.value.trim(),

        color:
          validHex(
            line1Color.value
          )
            ? line1Color.value
            : '#FFFFFF'

      });

    }


    if (
      line2.value.trim()
    ) {

      titleData.push({

        text:
          line2.value.trim(),

        color:
          validHex(
            line2Color.value
          )
            ? line2Color.value
            : '#FFFFFF'

      });

    }


    if (
      line3.value.trim()
    ) {

      titleData.push({

        text:
          line3.value.trim(),

        color:
          validHex(
            line3Color.value
          )
            ? line3Color.value
            : '#58FF42'

      });

    }



    const fontSize =
      parseInt(
        fontSizeInput.value,
        10
      );


    const lineHeight =
      Math.round(
        fontSize *
        1.08
      );


    const titleBottomBaseline =
      safeBottom -
      60;


    const titleCount =
      Math.max(
        titleData.length,
        1
      );


    const firstBaseline =
      titleBottomBaseline -
      (
        lineHeight *
        (
          titleCount -
          1
        )
      );



    /* ==================================================
       중간 라벨 + 흰 선
    ================================================== */

    const labelText =
      midLabel.value.trim() ||
      'SNSnote.com';


    ctx.save();


    ctx.font =
      '800 30px Arial, "Apple SD Gothic Neo", sans-serif';


    const labelWidth =
      ctx.measureText(
        labelText
      ).width;


    const lineY =
      firstBaseline -
      102;


    let lineLength =
      parseInt(
        midLineLength.value,
        10
      );


    const maxLineLength =
      Math.max(

        40,

        W -
        (
          pad *
          2
        ) -
        labelWidth -
        20

      );


    lineLength =
      Math.min(
        lineLength,
        maxLineLength
      );


    const lineEndX =
      pad +
      lineLength;


    const labelX =
      lineEndX +
      16;



    /* 흰 선 */

    ctx.strokeStyle =
      'rgba(255,255,255,.96)';


    ctx.lineWidth =
      2;


    ctx.beginPath();


    ctx.moveTo(
      pad,
      lineY
    );


    ctx.lineTo(
      lineEndX,
      lineY
    );


    ctx.stroke();



    /* 라벨 */

    ctx.fillStyle =
      validHex(
        midColor.value
      )
        ? midColor.value
        : '#FF2B2B';


    ctx.textAlign =
      'left';


    ctx.textBaseline =
      'middle';


    ctx.shadowColor =
      'rgba(0,0,0,.35)';


    ctx.shadowBlur =
      6;


    ctx.fillText(

      labelText,

      labelX,
      lineY

    );


    ctx.restore();



    /* ==================================================
       제목 출력
    ================================================== */

    ctx.save();


    ctx.font =
      '900 ' +
      fontSize +
      'px Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif';


    ctx.textAlign =
      'left';


    ctx.textBaseline =
      'alphabetic';


    ctx.shadowColor =
      'rgba(0,0,0,.62)';


    ctx.shadowBlur =
      14;


    titleData.forEach(
      function (
        item,
        index
      ) {

        ctx.fillStyle =
          item.color;


        ctx.fillText(

          item.text,

          pad,

          firstBaseline +
          (
            lineHeight *
            index
          )

        );

      }
    );


    ctx.restore();



    updateHints();

  }



  /* ======================================================
     LOAD IMAGE
  ====================================================== */

  function loadImage(
    file,
    index
  ) {

    if (!file) return;


    const reader =
      new FileReader();


    reader.onload =
      function (
        event
      ) {

        const img =
          new Image();


        img.onload =
          function () {

            images[index] =
              img;


            transforms[index] = {

              zoom: 1,

              x: 0,

              y: 0

            };


            if (
              index ===
              0
            ) {

              zoom1.value =
                100;


              zoom1Value.textContent =
                '100%';

            } else {

              zoom2.value =
                100;


              zoom2Value.textContent =
                '100%';

            }


            draw();

          };


        img.src =
          event.target.result;

      };


    reader.readAsDataURL(
      file
    );

  }



  imageInput1.addEventListener(
    'change',
    function () {

      loadImage(
        this.files &&
        this.files[0],
        0
      );

    }
  );



  imageInput2.addEventListener(
    'change',
    function () {

      loadImage(
        this.files &&
        this.files[0],
        1
      );

    }
  );



  /* ======================================================
     TEMPLATE
  ====================================================== */

  templateCards.forEach(
    function (
      card
    ) {

      card.addEventListener(
        'click',
        function () {

          template =
            card.dataset.template;


          templateCards.forEach(
            function (
              item
            ) {

              item.classList.remove(
                'active'
              );

            }
          );


          card.classList.add(
            'active'
          );


          if (
            template ===
            'horizontal'
          ) {

            image2Card.classList.remove(
              'cnm-hidden'
            );


            image1Title.textContent =
              '이미지 1 · 왼쪽';

          } else {

            image2Card.classList.add(
              'cnm-hidden'
            );


            image1Title.textContent =
              '이미지';

          }


          draw();

        }
      );

    }
  );



  /* ======================================================
     ZOOM
  ====================================================== */

  zoom1.addEventListener(
    'input',
    function () {

      const value =
        parseInt(
          this.value,
          10
        );


      transforms[0].zoom =
        value /
        100;


      zoom1Value.textContent =
        value +
        '%';


      draw();

    }
  );



  zoom2.addEventListener(
    'input',
    function () {

      const value =
        parseInt(
          this.value,
          10
        );


      transforms[1].zoom =
        value /
        100;


      zoom2Value.textContent =
        value +
        '%';


      draw();

    }
  );



  /* ======================================================
     RESET
  ====================================================== */

  reset1.addEventListener(
    'click',
    function () {

      transforms[0] = {

        zoom: 1,

        x: 0,

        y: 0

      };


      zoom1.value =
        100;


      zoom1Value.textContent =
        '100%';


      draw();

    }
  );



  reset2.addEventListener(
    'click',
    function () {

      transforms[1] = {

        zoom: 1,

        x: 0,

        y: 0

      };


      zoom2.value =
        100;


      zoom2Value.textContent =
        '100%';


      draw();

    }
  );



  /* ======================================================
     POINTER -> CANVAS
  ====================================================== */

  function pointerToCanvas(
    clientX,
    clientY
  ) {

    const rect =
      canvas.getBoundingClientRect();


    return {

      x:
        (
          clientX -
          rect.left
        ) *
        (
          canvas.width /
          rect.width
        ),


      y:
        (
          clientY -
          rect.top
        ) *
        (
          canvas.height /
          rect.height
        )

    };

  }



  function getSlotAtPoint(
    x,
    y
  ) {

    const slots =
      getSlots();


    for (
      let i = 0;
      i < slots.length;
      i++
    ) {

      const slot =
        slots[i];


      if (

        x >=
        slot.x &&

        x <=
        slot.x +
        slot.w &&

        y >=
        slot.y &&

        y <=
        slot.y +
        slot.h

      ) {

        return slot;

      }

    }


    return null;

  }



  /* ======================================================
     DRAG
  ====================================================== */

  let dragState =
    null;



  previewWrap.addEventListener(
    'pointerdown',
    function (
      event
    ) {

      const point =
        pointerToCanvas(

          event.clientX,

          event.clientY

        );


      const slot =
        getSlotAtPoint(

          point.x,

          point.y

        );


      if (!slot) return;


      dragState = {

        pointerId:
          event.pointerId,

        slot:
          slot,

        startX:
          point.x,

        startY:
          point.y,

        originalX:
          transforms[
            slot.index
          ].x,

        originalY:
          transforms[
            slot.index
          ].y,

        moved:
          false

      };


      try {

        previewWrap.setPointerCapture(
          event.pointerId
        );

      } catch (e) {}


      event.preventDefault();

    }
  );



  previewWrap.addEventListener(
    'pointermove',
    function (
      event
    ) {

      if (!dragState) return;


      if (
        dragState.pointerId !==
        event.pointerId
      ) {
        return;
      }


      const index =
        dragState.slot.index;


      if (
        !images[index]
      ) {
        return;
      }


      const point =
        pointerToCanvas(

          event.clientX,

          event.clientY

        );


      const dx =
        point.x -
        dragState.startX;


      const dy =
        point.y -
        dragState.startY;


      if (

        Math.abs(dx) >
        3 ||

        Math.abs(dy) >
        3

      ) {

        dragState.moved =
          true;

      }


      transforms[index].x =
        dragState.originalX +
        dx;


      transforms[index].y =
        dragState.originalY +
        dy;


      clampTransform(
        index,
        dragState.slot
      );


      draw();


      event.preventDefault();

    }
  );



  function finishPointer(
    event
  ) {

    if (!dragState) return;


    if (
      dragState.pointerId !==
      event.pointerId
    ) {
      return;
    }


    const index =
      dragState.slot.index;



    /*
      빈 슬롯 클릭 시
      해당 이미지 파일 선택창
    */

    if (

      !dragState.moved &&

      !images[index]

    ) {

      if (
        index ===
        0
      ) {

        imageInput1.click();

      } else {

        imageInput2.click();

      }

    }


    try {

      previewWrap.releasePointerCapture(
        event.pointerId
      );

    } catch (e) {}


    dragState =
      null;

  }



  previewWrap.addEventListener(
    'pointerup',
    finishPointer
  );



  previewWrap.addEventListener(
    'pointercancel',
    function () {

      dragState =
        null;

    }
  );



  /* ======================================================
     RANGE VALUE
  ====================================================== */

  midLineLength.addEventListener(
    'input',
    function () {

      midLineValue.textContent =
        this.value +
        'px';


      draw();

    }
  );



  fontSizeInput.addEventListener(
    'input',
    function () {

      fontSizeValue.textContent =
        this.value +
        'px';


      draw();

    }
  );



  gradientStrength.addEventListener(
    'input',
    function () {

      gradientValue.textContent =
        this.value +
        '%';


      draw();

    }
  );



  /* ======================================================
     TEXT REALTIME
  ====================================================== */

  [

    topToggle,
    topText,

    midLabel,

    line1,
    line2,
    line3

  ].forEach(
    function (
      element
    ) {

      element.addEventListener(
        'input',
        draw
      );


      element.addEventListener(
        'change',
        draw
      );

    }
  );



  /* ======================================================
     DOWNLOAD
  ====================================================== */

  downloadBtn.addEventListener(
    'click',
    function () {

      draw();


      const link =
        document.createElement(
          'a'
        );


      link.download =
        'cardnews-cover-1080x1350.png';


      link.href =
        canvas.toDataURL(
          'image/png'
        );


      link.click();

    }
  );



  /* 최초 실행 */

  draw();

})();

</script>



<h2 class="wp-block-heading">인스타 수익화 카드뉴스 만들기, 사진 한 장이면 충분합니다</h2>



<p class="wp-block-paragraph">인스타그램에서 정보형 콘텐츠나 수익화 콘텐츠를 운영하다 보면 사진 위에 큰 제목을 넣은 <strong>카드뉴스 형식</strong>을 자주 사용하게 됩니다. 하지만 카드뉴스 한 장을 만들기 위해 매번 디자인 프로그램이나 앱을 실행하고, 글자 위치와 그라데이션을 직접 맞추는 과정은 생각보다 번거롭습니다.</p>



<p class="wp-block-paragraph">이 페이지의 <strong>인스타 카드뉴스 만들기 무료 도구</strong>를 이용하면 사진 한 장을 업로드한 뒤 제목과 핸들만 입력해 카드뉴스 이미지를 빠르게 제작할 수 있습니다.</p>



<p class="wp-block-paragraph">별도의 프로그램을 설치하거나 회원가입할 필요가 없으며, 브라우저에서 바로 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">현재 기본 템플릿은 인스타그램 피드에서 많이 사용하는 4:5 세로 비율(1080×1350)을 기준으로 제작되며, 공유 화면이나 썸네일에서 중요한 내용이 잘리지 않도록 <strong>1:1 안전영역 가이드</strong>도 함께 확인할 수 있습니다.</p>



<h2 class="wp-block-heading">인스타 카드뉴스 무료 템플릿 주요 기능</h2>



<p class="wp-block-paragraph">사진을 업로드하면 하단에 자연스러운 검정 그라데이션이 적용되어 밝은 사진에서도 제목을 선명하게 표시할 수 있습니다.</p>



<p class="wp-block-paragraph">제목은 최대 3줄까지 직접 입력할 수 있으며, 글자 크기와 색상도 조절할 수 있습니다. 카드뉴스 상단에는 계정 이름이나 인스타 핸들을 넣을 수 있고, 제목 위에는 흰색 구분선과 함께 브랜드명이나 채널명을 표시할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 카드뉴스를 인스타그램에 올린 뒤 프로필이나 공유 화면에서 1:1 비율로 잘려 보이는 상황을 고려해 주요 문구가 안전영역 안에 배치되도록 구성했습니다.</p>



<p class="wp-block-paragraph">완성된 이미지는 <strong>PNG 파일로 바로 저장</strong>할 수 있습니다.</p>



<h2 class="wp-block-heading">카드뉴스 만드는 방법</h2>



<ol start="1" class="wp-block-list">
<li>왼쪽 카드뉴스 미리보기 영역을 클릭해 사용할 사진을 선택합니다.</li>



<li>상단 핸들 또는 로고 문구에 자신의 인스타 계정명이나 브랜드명을 입력합니다.</li>



<li>제목 1줄, 2줄, 3줄에 카드뉴스에서 보여줄 문구를 입력합니다.</li>



<li>제목 위에 표시되는 중간 라벨에는 채널명이나 브랜드명을 입력합니다.</li>



<li>글자 색상과 크기, 하단 그라데이션 강도를 원하는 스타일로 조절합니다.</li>



<li>점선으로 표시된 <strong>1:1 안전영역</strong> 안에 중요한 제목이 들어오는지 확인합니다.</li>



<li>작업이 끝나면 <strong>PNG 다운로드</strong> 버튼을 눌러 이미지를 저장합니다.</li>
</ol>



<p class="wp-block-paragraph">별도의 디자인 프로그램을 사용할 필요 없이 사진과 문구만 준비하면 바로 카드뉴스를 만들 수 있습니다.</p>



<h2 class="wp-block-heading">왜 4:5 비율로 제작되나요?</h2>



<p class="wp-block-paragraph">인스타그램 피드에서는 세로 공간을 넓게 사용할 수 있는 <strong>4:5 비율의 이미지</strong>가 카드뉴스 제작에 많이 사용됩니다.</p>



<p class="wp-block-paragraph">이 도구는 기본적으로 <strong>1080×1350 크기</strong>로 이미지를 제작합니다.</p>



<p class="wp-block-paragraph">다만 카드뉴스가 다른 화면에서 표시되거나 정사각형 형태로 잘릴 경우 이미지의 위아래 일부가 보이지 않을 수 있습니다. 그래서 편집 화면에 <strong>1:1 안전영역</strong>을 별도로 표시해 제목과 계정명 같은 중요한 내용을 안전한 범위 안에 배치할 수 있도록 했습니다.</p>



<p class="wp-block-paragraph">안전영역 점선은 편집할 때만 보이며 다운로드한 최종 이미지에는 포함되지 않습니다.</p>



<h2 class="wp-block-heading">밝은 사진에서도 제목이 잘 보이도록</h2>



<p class="wp-block-paragraph">카드뉴스에 흰색 제목을 사용하는 경우 배경 사진이 밝으면 글자가 잘 보이지 않을 수 있습니다.</p>



<p class="wp-block-paragraph">이 도구는 이미지 하단에 검정색 그라데이션을 자동으로 추가하며, 사용자가 직접 <strong>그라데이션 강도</strong>를 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">인물 사진이나 풍경 사진처럼 배경 색상이 복잡한 이미지에서도 제목 영역을 어둡게 만들어 문구의 가독성을 높일 수 있습니다.</p>



<h2 class="wp-block-heading">인스타 수익화 콘텐츠뿐 아니라 다양하게 활용할 수 있습니다</h2>



<p class="wp-block-paragraph">이 도구는 인스타 수익화 콘텐츠를 빠르게 제작하기 위해 만들었지만 반드시 인스타그램에서만 사용할 필요는 없습니다.</p>



<p class="wp-block-paragraph">완성한 카드뉴스는 <strong>Threads, X, 블로그, 커뮤니티 등 이미지 게시물을 지원하는 다양한 플랫폼</strong>에서도 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">뉴스 요약, 후기, 정보성 콘텐츠, 제품 소개, 사건 정리, 연예·스포츠 콘텐츠처럼 사진 위에 강한 제목이 필요한 게시물을 만들 때 활용하기 좋습니다.</p>



<p class="wp-block-paragraph">특히 여러 게시물을 같은 디자인으로 제작하면 계정 전체의 카드뉴스 스타일을 일정하게 유지하는 데도 도움이 됩니다.</p>



<h2 class="wp-block-heading">설치와 회원가입 없이 사용할 수 있습니다</h2>



<p class="wp-block-paragraph">이 카드뉴스 제작 도구는 별도의 프로그램이나 앱을 설치할 필요가 없습니다.</p>



<p class="wp-block-paragraph">페이지에서 바로 이미지를 선택하고 문구를 입력한 뒤 결과물을 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">또한 현재 버전은 이미지 편집이 사용자의 브라우저에서 처리되기 때문에 선택한 사진을 카드뉴스 제작을 위해 별도 서버에 업로드하거나 저장하지 않습니다.</p>



<p class="wp-block-paragraph">개인 사진이나 작업용 이미지를 간단하게 편집하고 싶을 때도 부담 없이 사용할 수 있습니다.</p>



<h2 class="wp-block-heading">자주 묻는 질문 FAQ</h2>



<p class="wp-block-paragraph"><strong>Q. 카드뉴스 만들기는 무료인가요?</strong><br>네. 현재 제공되는 카드뉴스 제작 기능은 무료로 사용할 수 있으며 회원가입도 필요하지 않습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 프로그램이나 앱을 설치해야 하나요?</strong><br>아니요. 웹브라우저에서 바로 사용할 수 있어 별도의 프로그램이나 앱 설치가 필요하지 않습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 카드뉴스 이미지 크기는 얼마인가요?</strong><br>기본 출력 크기는 <strong>1080×1350 픽셀, 4:5 비율</strong>입니다. 인스타그램 세로 피드용 이미지에 사용하기 좋은 크기입니다.</p>



<p class="wp-block-paragraph"><strong>Q. 점선으로 표시되는 영역은 무엇인가요?</strong><br>점선은 <strong>1:1 안전영역</strong>입니다. 카드뉴스가 정사각형 형태로 표시되거나 일부가 잘릴 때도 중요한 제목이 최대한 보이도록 위치를 확인하기 위한 가이드입니다. 다운로드 이미지에는 점선이 포함되지 않습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 제목은 몇 줄까지 넣을 수 있나요?</strong><br>현재 기본 템플릿에서는 제목을 3줄까지 각각 입력할 수 있습니다. 글자 크기도 직접 조절할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 인스타 계정명이나 로고를 넣을 수 있나요?</strong><br>네. 상단에 계정 핸들 또는 로고 문구를 넣을 수 있으며, 제목 위에도 별도의 브랜드명이나 채널명을 표시할 수 있습니다. 색상도 각각 변경할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 밝은 사진을 사용하면 글씨가 안 보이지 않나요?</strong><br>하단 그라데이션 강도를 조절할 수 있어 밝은 사진에서도 제목 부분을 어둡게 만들어 글씨를 보다 선명하게 표시할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>Q. Threads나 X에서도 사용할 수 있나요?</strong><br>네. 완성된 이미지는 일반 PNG 파일이기 때문에 인스타그램뿐 아니라 Threads, X, 블로그 등 이미지 업로드가 가능한 서비스에서도 사용할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>Q. 업로드한 사진이 서버에 저장되나요?</strong><br>현재 카드뉴스 제작 과정은 브라우저에서 처리되며 이미지 제작을 위해 사진을 별도 서버에 저장하지 않습니다.</p>



<h2 class="wp-block-heading">빠르게 카드뉴스를 만들어보세요</h2>



<p class="wp-block-paragraph">복잡한 디자인 작업 없이 <strong>사진 한 장과 제목만으로 인스타 카드뉴스를 제작</strong>할 수 있습니다.</p>



<p class="wp-block-paragraph">사진을 업로드하고 제목, 계정 핸들, 브랜드명과 그라데이션을 설정한 뒤 PNG로 저장하면 끝입니다.</p>



<p class="wp-block-paragraph">인스타 수익화 콘텐츠, 정보형 게시물, 뉴스형 콘텐츠처럼 카드뉴스를 자주 제작한다면 매번 디자인을 새로 만드는 대신 이 무료 템플릿을 활용해 빠르게 이미지를 만들어보세요.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/cardnews-maker/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/10/instagram-monetization-cardnews-maker-free-web-tool.webp" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1771</post-id>	</item>
		<item>
		<title>사진 모자이크 온라인 무료 &#8211; AI 얼굴·번호판 자동 블러</title>
		<link>https://snsnote.com/photo-mosaic-ai/</link>
					<comments>https://snsnote.com/photo-mosaic-ai/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Sun, 27 Sep 2026 13:35:54 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<category><![CDATA[인스타그램]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1758</guid>

					<description><![CDATA[사진 모자이크, AI로 얼굴과 번호판을 자동으로 가려보세요 사진을 SNS나 커뮤니티에 올리기 전에 다른 사람의 얼굴이나 자동차 번호판이 함께 찍혀 신경 쓰일 때가 있습니다. 하나씩 편집...]]></description>
										<content:encoded><![CDATA[        <div id="snpb-1" class="snpb-app">
            <style>
                #snpb-1 {
                    --snpb-blue:#4f63ff;
                    --snpb-blue2:#6f7cff;
                    --snpb-purple:#7457f5;
                    --snpb-ink:#20283a;
                    --snpb-muted:#6d7890;
                    --snpb-line:#e4e8f1;
                    --snpb-soft:#f7f8fc;
                    --snpb-green:#15a36d;
                    --snpb-red:#e44963;
                    --snpb-orange:#e88c24;
                    font-family:inherit;
                    color:var(--snpb-ink);
                    max-width:100%;
                }
                #snpb-1 *{box-sizing:border-box}
                #snpb-1 button,
                #snpb-1 input,
                #snpb-1 select{font:inherit}

                #snpb-1 .snpb-section-title{
                    display:flex;align-items:center;gap:9px;
                    margin:0 0 14px;font-size:22px;font-weight:800;letter-spacing:-.03em;
                }
                #snpb-1 .snpb-section-title:before{
                    content:"";width:9px;height:9px;border-radius:99px;background:var(--snpb-blue);flex:0 0 auto;
                }
                #snpb-1 .snpb-panel{
                    border:1px solid var(--snpb-line);background:#fff;border-radius:20px;
                    padding:22px;box-shadow:0 8px 26px rgba(37,48,78,.045);margin-bottom:22px;
                }
                #snpb-1 .snpb-upload{
                    position:relative;border:2px dashed #cfd6e7;border-radius:18px;background:#fafbff;
                    min-height:170px;display:flex;align-items:center;justify-content:center;text-align:center;
                    padding:24px;cursor:pointer;transition:.2s ease;outline:none;
                }
                #snpb-1 .snpb-upload:hover,
                #snpb-1 .snpb-upload.is-drag{
                    border-color:#7d8bff;background:#f5f6ff;transform:translateY(-1px);
                }
                #snpb-1 .snpb-upload-icon{
                    width:54px;height:54px;border-radius:16px;background:#eef1ff;color:var(--snpb-blue);
                    display:flex;align-items:center;justify-content:center;margin:0 auto 10px;font-size:26px;
                }
                #snpb-1 .snpb-upload strong{display:block;font-size:16px;margin-bottom:5px}
                #snpb-1 .snpb-upload small{color:var(--snpb-muted);font-size:13px}
                #snpb-1 .snpb-file-info{
                    margin-top:10px;color:#536079;font-size:13px;word-break:break-all;
                }
                #snpb-1 .snpb-hidden{display:none!important}

                #snpb-1 .snpb-grid{
                    display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:16px;
                }
                #snpb-1 .snpb-field{
                    border:1px solid #e5e8f0;border-radius:14px;padding:13px 14px;background:#fff;
                }
                #snpb-1 .snpb-field-label{
                    display:flex;justify-content:space-between;align-items:center;gap:10px;
                    font-size:13px;font-weight:800;color:#364158;margin-bottom:8px;
                }
                #snpb-1 .snpb-field-note{font-size:11px;color:#8a94a8;font-weight:600}
                #snpb-1 select,
                #snpb-1 .snpb-select{
                    width:100%;height:42px;border:1px solid #dfe4ee;border-radius:10px;background:#fff;
                    padding:0 11px;color:#2d374d;outline:none;
                }
                #snpb-1 input[type="range"]{width:100%;accent-color:var(--snpb-blue)}
                #snpb-1 .snpb-checks{
                    display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:14px;
                }
                #snpb-1 .snpb-check{
                    display:flex;align-items:flex-start;gap:9px;border:1px solid #e5e8f0;border-radius:12px;
                    padding:11px 12px;background:#fafbfe;cursor:pointer;font-size:13px;line-height:1.45;
                }
                #snpb-1 .snpb-check input{margin-top:2px;accent-color:var(--snpb-blue)}
                #snpb-1 .snpb-check b{display:block;color:#30394f}
                #snpb-1 .snpb-check span{color:#7c879c;font-size:12px}

                #snpb-1 .snpb-actions{
                    display:flex;flex-wrap:wrap;gap:9px;margin-top:16px;
                }
                #snpb-1 .snpb-btn{
                    appearance:none;border:1px solid #dfe4ef;background:#fff;color:#30394e;
                    border-radius:11px;min-height:44px;padding:0 16px;font-weight:800;font-size:14px;
                    cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:7px;
                    transition:.18s ease;line-height:1.2;
                }
                #snpb-1 .snpb-btn:hover{transform:translateY(-1px);border-color:#c9d0df}
                #snpb-1 .snpb-btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
                #snpb-1 .snpb-btn-primary{
                    background:linear-gradient(135deg,var(--snpb-blue),var(--snpb-purple));
                    color:#fff;border-color:transparent;box-shadow:0 8px 20px rgba(82,92,238,.18);
                }
                #snpb-1 .snpb-btn-soft{background:#f3f5ff;color:#4659e7;border-color:#dce1ff}
                #snpb-1 .snpb-btn-danger{color:#c43e53;background:#fff7f8;border-color:#f4d7dc}
                #snpb-1 .snpb-status{
                    margin-top:14px;border-radius:13px;padding:12px 14px;background:#f7f8fc;
                    border:1px solid #e8eaf1;color:#566078;font-size:13px;line-height:1.55;
                }
                #snpb-1 .snpb-status.is-working{background:#f4f6ff;border-color:#dfe3ff;color:#4253c9}
                #snpb-1 .snpb-status.is-ok{background:#f2fbf7;border-color:#d6f0e4;color:#19734f}
                #snpb-1 .snpb-status.is-error{background:#fff5f6;border-color:#f3d8dd;color:#a63849}
                #snpb-1 .snpb-progress{
                    height:6px;background:#e8ebf4;border-radius:99px;overflow:hidden;margin-top:9px;display:none;
                }
                #snpb-1 .snpb-progress.is-on{display:block}
                #snpb-1 .snpb-progress > i{
                    display:block;height:100%;width:0;background:linear-gradient(90deg,var(--snpb-blue),var(--snpb-purple));
                    border-radius:99px;transition:width .2s ease;
                }
                #snpb-1 .snpb-privacy-note{
                    margin-top:12px;color:#7b869b;font-size:12px;line-height:1.6;
                }

                #snpb-1 .snpb-preview-grid{
                    display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px;
                }
                #snpb-1 .snpb-preview-card{
                    border:1px solid #e4e8f0;border-radius:17px;background:#fafbfe;overflow:hidden;
                }
                #snpb-1 .snpb-preview-head{
                    display:flex;align-items:center;justify-content:space-between;gap:10px;
                    padding:12px 14px;border-bottom:1px solid #e8ebf2;background:#fff;
                    font-size:13px;font-weight:800;color:#354057;
                }
                #snpb-1 .snpb-canvas-wrap{
                    position:relative;min-height:270px;display:flex;align-items:center;justify-content:center;
                    padding:12px;background:
                    linear-gradient(45deg,#f1f3f8 25%,transparent 25%),
                    linear-gradient(-45deg,#f1f3f8 25%,transparent 25%),
                    linear-gradient(45deg,transparent 75%,#f1f3f8 75%),
                    linear-gradient(-45deg,transparent 75%,#f1f3f8 75%);
                    background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;
                }
                #snpb-1 .snpb-placeholder{color:#929caf;text-align:center;font-size:13px;line-height:1.7}
                #snpb-1 canvas.snpb-canvas{
                    display:block;max-width:100%;height:auto;max-height:620px;border-radius:10px;
                    box-shadow:0 5px 18px rgba(20,31,55,.08);background:#fff;touch-action:none;
                }
                #snpb-1 .snpb-edit-stage{
                    position:relative;display:inline-block;max-width:100%;line-height:0;
                }
                #snpb-1 .snpb-edit-stage .snpb-original-canvas{
                    position:relative;z-index:1;
                }
                #snpb-1 .snpb-edit-stage .snpb-result-canvas{
                    position:absolute;z-index:2;inset:0;width:100%;height:100%;max-height:none;
                    box-shadow:none;background:transparent;
                }
                #snpb-1 .snpb-result-canvas.tool-rect{cursor:crosshair}
                #snpb-1 .snpb-result-canvas.tool-brush{cursor:crosshair}
                #snpb-1 .snpb-result-canvas.tool-restore{cursor:crosshair}

                #snpb-1 .snpb-summary{
                    display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:15px 0;
                }
                #snpb-1 .snpb-stat{
                    border:1px solid #e4e8f0;border-radius:14px;background:#fafbfe;padding:12px;text-align:center;
                }
                #snpb-1 .snpb-stat strong{display:block;font-size:22px;line-height:1.1;color:#2e3850}
                #snpb-1 .snpb-stat span{display:block;margin-top:5px;color:#7b869a;font-size:11px;font-weight:700}

                #snpb-1 .snpb-toolbox{
                    border:1px solid #e3e7f0;border-radius:16px;padding:14px;background:#fbfcff;margin-top:14px;
                }
                #snpb-1 .snpb-tool-title{font-weight:850;font-size:14px;margin-bottom:10px;color:#303b52}
                #snpb-1 .snpb-tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
                #snpb-1 .snpb-tool-group{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
                #snpb-1 .snpb-tool-divider{width:1px;height:38px;background:#dfe5f0;margin:0 4px;border-radius:999px;flex:0 0 auto}
                #snpb-1 .snpb-tool.is-active{
                    background:#eef1ff;color:#4155e6;border-color:#cbd3ff;box-shadow:inset 0 0 0 1px #d7dcff;
                }
                #snpb-1 .snpb-tool-help{margin:9px 0 0;color:#7b8699;font-size:12px;line-height:1.55}

                #snpb-1 .snpb-list-head{
                    display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;margin-bottom:10px;
                }
                #snpb-1 .snpb-list-head b{font-size:15px}
                #snpb-1 .snpb-list-head span{font-size:12px;color:#7b8699}
                #snpb-1 .snpb-detect-list{
                    display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;
                }
                #snpb-1 .snpb-item{
                    display:grid;grid-template-columns:54px minmax(0,1fr) auto;gap:10px;align-items:center;
                    border:1px solid #e3e7ef;border-radius:13px;background:#fff;padding:9px;
                    transition:.15s ease;
                }
                #snpb-1 .snpb-item:hover{border-color:#cbd2e3;box-shadow:0 5px 14px rgba(32,45,72,.05)}
                #snpb-1 .snpb-thumb{
                    width:54px;height:54px;border-radius:10px;background:#eef1f6;display:block;object-fit:cover;
                }
                #snpb-1 .snpb-item-main{min-width:0}
                #snpb-1 .snpb-item-title{display:flex;align-items:center;flex-wrap:wrap;gap:5px;font-size:13px;font-weight:800;color:#303b52}
                #snpb-1 .snpb-badge{
                    display:inline-flex;align-items:center;border-radius:99px;padding:2px 6px;font-size:10px;font-weight:800;
                    background:#eef1ff;color:#4c5ee1;
                }
                #snpb-1 .snpb-badge.green{background:#ebf8f2;color:#16845a}
                #snpb-1 .snpb-badge.orange{background:#fff4e9;color:#b76519}
                #snpb-1 .snpb-item-sub{font-size:11px;color:#8490a4;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
                #snpb-1 .snpb-toggle{
                    position:relative;width:42px;height:24px;display:inline-block;flex:0 0 auto;
                }
                #snpb-1 .snpb-toggle input{opacity:0;width:0;height:0}
                #snpb-1 .snpb-toggle i{
                    position:absolute;inset:0;border-radius:99px;background:#dfe3eb;transition:.18s;cursor:pointer;
                }
                #snpb-1 .snpb-toggle i:before{
                    content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;border-radius:50%;background:#fff;
                    box-shadow:0 2px 5px rgba(0,0,0,.15);transition:.18s;
                }
                #snpb-1 .snpb-toggle input:checked + i{background:var(--snpb-blue)}
                #snpb-1 .snpb-toggle input:checked + i:before{transform:translateX(18px)}
                #snpb-1 .snpb-empty-list{
                    grid-column:1/-1;border:1px dashed #d8deea;border-radius:13px;padding:18px;text-align:center;
                    color:#8993a6;font-size:12px;background:#fafbfe;
                }
                #snpb-1 .snpb-download-row{
                    display:grid;grid-template-columns:180px 1fr;gap:10px;margin-top:15px;align-items:center;
                }
                #snpb-1 .snpb-download-row .snpb-btn{width:100%}

                @media(max-width:760px){
                    #snpb-1 .snpb-panel{padding:16px;border-radius:16px}
                    #snpb-1 .snpb-grid,
                    #snpb-1 .snpb-checks,
                    #snpb-1 .snpb-preview-grid,
                    #snpb-1 .snpb-detect-list{grid-template-columns:1fr}
                    #snpb-1 .snpb-summary{grid-template-columns:repeat(2,minmax(0,1fr))}
                    #snpb-1 .snpb-download-row{grid-template-columns:1fr}
                    #snpb-1 .snpb-canvas-wrap{min-height:220px;padding:8px}
                    #snpb-1 .snpb-actions .snpb-btn{flex:1 1 calc(50% - 8px);padding-left:10px;padding-right:10px}
                    #snpb-1 .snpb-tool-divider{display:none}
                    #snpb-1 .snpb-tool-group{width:100%}
                }
            </style>

            <h2 class="snpb-section-title">이미지 업로드</h2>
            <div class="snpb-panel">
                <input class="snpb-file snpb-hidden" type="file" accept="image/jpeg,image/png,image/webp">
                <div class="snpb-upload" tabindex="0" role="button" aria-label="이미지 업로드">
                    <div>
                        <div class="snpb-upload-icon"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f5bc.png" alt="🖼" class="wp-smiley" style="height: 1em; max-height: 1em;" /></div>
                        <strong>사진을 여기 놓으세요 또는 클릭해서 파일을 선택하세요</strong>
                        <small>JPG, PNG, WebP 지원 · 권장 25MB 이하</small>
                        <div class="snpb-file-info">선택된 파일 없음</div>
                    </div>
                </div>

                <div class="snpb-checks">
                    <label class="snpb-check">
                        <input class="snpb-detect-face" type="checkbox" checked>
                        <span><b>얼굴 자동 감지</b><span>배경 인물까지 찾아 부드럽게 블러합니다.</span></span>
                    </label>
                    <label class="snpb-check">
                        <input class="snpb-detect-plate" type="checkbox" checked>
                        <span><b>자동차 번호판 감지</b><span>브라우저에서 번호판 AI 모델을 실행합니다.</span></span>
                    </label>
                    <label class="snpb-check">
                        <input class="snpb-detect-text" type="checkbox">
                        <span><b>개인정보 텍스트 검사</b><span>전화번호·이메일·긴 번호 등을 OCR로 찾습니다.</span></span>
                    </label>
                    <label class="snpb-check">
                        <input class="snpb-keep-main" type="checkbox" checked>
                        <span><b>내 얼굴 자동 유지</b><span>가장 크고 중앙에 가까운 얼굴 1개를 우선 보존합니다.</span></span>
                    </label>
                </div>


                <div class="snpb-actions">
                    <button class="snpb-btn snpb-btn-primary snpb-scan" type="button" disabled><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2728.png" alt="✨" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 다시 감지</button>
                    <button class="snpb-btn snpb-reset" type="button" disabled>↺ 초기화</button>
                </div>

                <div class="snpb-status">
                    사진을 선택하면 AI 개인정보 감지가 자동으로 시작됩니다.
                    <div class="snpb-progress"><i></i></div>
                </div>
                <div class="snpb-privacy-note">
                    ※ 사진 자체는 SNSnote 서버로 업로드하지 않고 브라우저에서 처리합니다.<br>
                    ※ 첫 실행 시 얼굴·번호판·OCR 모델 파일을 외부 CDN에서 내려받을 수 있습니다.<br>
                    ※ AI가 작은 얼굴이나 가려진 번호판을 놓칠 수 있으므로 놓친 부분은 블러 브러시로 직접 보완해 주세요.
                </div>
            </div>

            <h2 class="snpb-section-title">미리보기 · 세부 조정</h2>
            <div class="snpb-panel">
                <div class="snpb-preview-grid">
                    <div class="snpb-preview-card">
                        <div class="snpb-preview-head">
                            <span>편집 이미지</span>
                            <span class="snpb-original-size">-</span>
                        </div>
                        <div class="snpb-canvas-wrap snpb-edit-wrap">
                            <div class="snpb-placeholder">업로드한 사진이<br>여기에 표시됩니다.</div>
                            <div class="snpb-edit-stage snpb-hidden">
                                <canvas class="snpb-canvas snpb-original-canvas"></canvas>
                                <canvas class="snpb-canvas snpb-result-canvas"></canvas>
                            </div>
                        </div>
                        <div style="padding:9px 13px;border-top:1px solid #e8ebf2;background:#fff;color:#7b8698;font-size:11px;line-height:1.45">
                            AI 감지 영역 확인 · 블러 브러시 · 복구 브러시는 왼쪽에서 조정합니다.
                        </div>
                    </div>
                    <div class="snpb-preview-card">
                        <div class="snpb-preview-head">
                            <span>최종 결과</span>
                            <span style="font-size:11px;color:#7b8698;font-weight:700">원본 해상도로 저장</span>
                        </div>
                        <div class="snpb-canvas-wrap snpb-final-wrap">
                            <div class="snpb-placeholder">다운로드될 최종 결과가<br>실시간으로 표시됩니다.</div>
                            <canvas class="snpb-canvas snpb-final-canvas snpb-hidden"></canvas>
                        </div>
                    </div>
                </div>

                <div class="snpb-summary">
                    <div class="snpb-stat"><strong class="snpb-stat-ai">0</strong><span>AI 감지</span></div>
                    <div class="snpb-stat"><strong class="snpb-stat-active">0</strong><span>블러 적용</span></div>
                    <div class="snpb-stat"><strong class="snpb-stat-kept">0</strong><span>유지·복구</span></div>
                    <div class="snpb-stat"><strong class="snpb-stat-manual">0</strong><span>수동 보정</span></div>
                </div>

                <div class="snpb-toolbox">
                    <div class="snpb-tool-title">세밀한 수동 보정</div>
                    <div class="snpb-tools">
                        <div class="snpb-tool-group">
                            <button class="snpb-btn snpb-tool" data-tool="restore" type="button" disabled><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/21a9.png" alt="↩" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 복구 브러시</button>
                            <button class="snpb-btn snpb-tool" data-tool="brush" type="button" disabled><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f58c.png" alt="🖌" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 블러 브러시</button>
                        </div>
                        <div class="snpb-tool-divider" aria-hidden="true"></div>
                        <div class="snpb-tool-group">
                            <button class="snpb-btn snpb-undo" type="button" disabled>실행 취소</button>
                            <button class="snpb-btn snpb-redo" type="button" disabled>다시 실행</button>
                            <button class="snpb-btn snpb-ai-reset" type="button" disabled>AI 결과로 되돌리기</button>
                        </div>
                    </div>
                    <p class="snpb-tool-help">AI가 놓친 부분은 왼쪽 편집 이미지에서 블러 브러시로 직접 가리고, 잘못 가려진 부분은 복구 브러시로 문질러 원본으로 되돌릴 수 있습니다.</p>
                </div>

                <div class="snpb-grid" style="margin-top:14px">
                    <div class="snpb-field">
                        <div class="snpb-field-label"><span>블러 강도</span><span class="snpb-blur-value">24 px</span></div>
                        <input class="snpb-blur" type="range" min="8" max="48" step="1" value="24">
                    </div>
                    <div class="snpb-field">
                        <div class="snpb-field-label"><span>경계 부드러움</span><span class="snpb-feather-value">14 px</span></div>
                        <input class="snpb-feather" type="range" min="0" max="32" step="1" value="14">
                    </div>
                    <div class="snpb-field">
                        <div class="snpb-field-label"><span>수동 브러시 크기</span><span class="snpb-brush-value">42 px</span></div>
                        <input class="snpb-brush-size" type="range" min="12" max="160" step="2" value="42">
                    </div>
                </div>
                <div class="snpb-privacy-note" style="margin-top:10px">※ 블러 강도와 경계 부드러움은 즉시 반영됩니다. 다운로드 파일은 업로드한 원본 해상도를 그대로 유지합니다.</div>

                <div class="snpb-list-head">
                    <b>감지·블러 목록</b>
                    <span>스위치 ON = 블러 적용 · OFF = 원본 유지</span>
                </div>
                <div class="snpb-detect-list">
                    <div class="snpb-empty-list">AI 감지 결과와 브러시 기록이 여기에 표시됩니다.</div>
                </div>

                <div class="snpb-download-row">
                    <select class="snpb-format">
                        <option value="image/png">PNG</option>
                        <option value="image/jpeg">JPG</option>
                        <option value="image/webp">WebP</option>
                    </select>
                    <button class="snpb-btn snpb-btn-primary snpb-download" type="button" disabled><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2b07.png" alt="⬇" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 결과 다운로드</button>
                </div>
            </div>
        </div>

        <script>
        (function(){
            'use strict';

            const root = document.getElementById("snpb-1");
            if (!root || root.dataset.ready === '1') return;
            root.dataset.ready = '1';

            const $ = (s) => root.querySelector(s);
            const $$ = (s) => Array.from(root.querySelectorAll(s));
            const els = {
                file: $('.snpb-file'), upload: $('.snpb-upload'), fileInfo: $('.snpb-file-info'),
                detectFace: $('.snpb-detect-face'), detectPlate: $('.snpb-detect-plate'), detectText: $('.snpb-detect-text'), keepMain: $('.snpb-keep-main'),
                blur: $('.snpb-blur'), feather: $('.snpb-feather'), brushSize: $('.snpb-brush-size'),
                blurValue: $('.snpb-blur-value'), featherValue: $('.snpb-feather-value'), brushValue: $('.snpb-brush-value'),
                scan: $('.snpb-scan'), reset: $('.snpb-reset'), status: $('.snpb-status'), progress: $('.snpb-progress'), progressBar: $('.snpb-progress > i'),
                originalCanvas: $('.snpb-original-canvas'), resultCanvas: $('.snpb-result-canvas'), finalCanvas: $('.snpb-final-canvas'), editWrap: $('.snpb-edit-wrap'), finalWrap: $('.snpb-final-wrap'), editStage: $('.snpb-edit-stage'),
                originalSize: $('.snpb-original-size'),
                statAI: $('.snpb-stat-ai'), statActive: $('.snpb-stat-active'), statKept: $('.snpb-stat-kept'), statManual: $('.snpb-stat-manual'),
                tools: $$('.snpb-tool'), guidesToggle: $('.snpb-guides-toggle'), undo: $('.snpb-undo'), redo: $('.snpb-redo'), aiReset: $('.snpb-ai-reset'),
                list: $('.snpb-detect-list'), format: $('.snpb-format'), download: $('.snpb-download')
            };

            const state = {
                file: null,
                img: null,
                width: 0,
                height: 0,
                regions: [],
                strokes: [],
                restoreStrokes: [],
                seq: 1,
                tool: null,
                drawing: false,
                draftRect: null,
                activeStroke: null,
                hoverId: null,
                showGuides: true,
                history: [],
                future: [],
                aiBaseline: null,
                busy: false,
                faceDetector: null,
                plateSession: null,
                blurCanvas: document.createElement('canvas'),
                maskCanvas: document.createElement('canvas'),
                tempCanvas: document.createElement('canvas'),
                exportCanvas: document.createElement('canvas'),
                raf: 0,
                autoScanTimer: 0
            };

            const FACE_VERSION = '0.4.1646425229';
            const ORT_VERSION = '1.30.0';
            const TESS_VERSION = '7.0.0';
            const PLATE_MODEL = 'https://huggingface.co/ml-debi/yolov8-license-plate-detection/resolve/main/best.onnx?download=true';

            function setStatus(message, kind, progress) {
                els.status.classList.remove('is-working','is-ok','is-error');
                if (kind) els.status.classList.add('is-' + kind);
                const p = Math.max(0, Math.min(100, Number(progress || 0)));
                const textNode = Array.from(els.status.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
                if (textNode) textNode.textContent = message + ' ';
                else els.status.insertAdjacentText('afterbegin', message + ' ');
                if (kind === 'working') {
                    els.progress.classList.add('is-on');
                    els.progressBar.style.width = p + '%';
                } else {
                    els.progress.classList.remove('is-on');
                    els.progressBar.style.width = '0%';
                }
            }

            function humanBytes(bytes) {
                if (!Number.isFinite(bytes)) return '';
                const u = ['B','KB','MB','GB']; let i = 0, v = bytes;
                while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
                return (i === 0 ? Math.round(v) : v.toFixed(v >= 10 ? 1 : 2)) + ' ' + u[i];
            }

            function clamp(v, min, max){ return Math.max(min, Math.min(max, v)); }
            function uid(prefix){ return prefix + '-' + (state.seq++); }
            function deepState(){ return JSON.stringify({regions: state.regions, strokes: state.strokes, restoreStrokes: state.restoreStrokes}); }
            function restoreState(json){
                const v = JSON.parse(json);
                state.regions = v.regions || [];
                state.strokes = v.strokes || [];
                state.restoreStrokes = v.restoreStrokes || [];
                renderList(); scheduleRender(); updateCounts(); updateUndoRedo();
            }
            function pushHistory(){
                const snap = deepState();
                if (state.history[state.history.length - 1] !== snap) state.history.push(snap);
                if (state.history.length > 40) state.history.shift();
                state.future = [];
                updateUndoRedo();
            }
            function resetHistory(){ state.history = [deepState()]; state.future = []; updateUndoRedo(); }
            function updateUndoRedo(){
                els.undo.disabled = state.history.length <= 1 || state.busy;
                els.redo.disabled = state.future.length === 0 || state.busy;
            }

            function setBusy(on){
                state.busy = !!on;
                els.scan.disabled = on || !state.img;
                els.reset.disabled = on || !state.img;
                els.download.disabled = on || !state.img;
                els.tools.forEach(b => b.disabled = on || !state.img);
                if (els.guidesToggle) els.guidesToggle.disabled = on || !state.img;
                els.aiReset.disabled = on || !state.aiBaseline;
                updateUndoRedo();
            }

            function loadScript(src, globalName) {
                window.__SNSPB_LIBS = window.__SNSPB_LIBS || {};
                if (globalName && window[globalName]) return Promise.resolve(window[globalName]);
                if (window.__SNSPB_LIBS[src]) return window.__SNSPB_LIBS[src];
                window.__SNSPB_LIBS[src] = new Promise((resolve, reject) => {
                    const s = document.createElement('script');
                    s.src = src; s.async = true; s.crossOrigin = 'anonymous';
                    s.onload = () => resolve(globalName ? window[globalName] : true);
                    s.onerror = () => reject(new Error('외부 라이브러리를 불러오지 못했습니다.'));
                    document.head.appendChild(s);
                });
                return window.__SNSPB_LIBS[src];
            }

            function clearCanvas(c){
                c.width = Math.max(1, state.width || 1); c.height = Math.max(1, state.height || 1);
                c.getContext('2d').clearRect(0,0,c.width,c.height);
            }

            async function loadImage(file){
                if (!file || !/^image\/(jpeg|png|webp)$/i.test(file.type)) {
                    setStatus('JPG, PNG, WebP 이미지 파일을 선택해 주세요.', 'error');
                    return;
                }
                if (file.size > 25 * 1024 * 1024) {
                    setStatus('25MB 이하 이미지를 권장합니다. 큰 이미지는 브라우저 메모리가 부족할 수 있습니다.', 'error');
                }

                const url = URL.createObjectURL(file);
                const img = new Image();
                img.decoding = 'async';
                img.onload = () => {
                    const naturalW = img.naturalWidth, naturalH = img.naturalHeight;
                    const w = naturalW, h = naturalH;

                    state.file = file; state.img = img; state.width = w; state.height = h;
                    state.regions = []; state.strokes = []; state.restoreStrokes = []; state.seq = 1; state.tool = null;
                    state.aiBaseline = null; state.hoverId = null; state.showGuides = true; window.clearTimeout(state.autoScanTimer);

                    [els.originalCanvas, els.resultCanvas, els.finalCanvas, state.blurCanvas, state.maskCanvas, state.tempCanvas, state.exportCanvas].forEach(c => { c.width = w; c.height = h; });

                    const octx = els.originalCanvas.getContext('2d');
                    octx.clearRect(0,0,w,h); octx.drawImage(img,0,0,w,h);
                    rebuildBlurCache();

                    els.editStage.classList.remove('snpb-hidden');
                    els.finalCanvas.classList.remove('snpb-hidden');
                    els.editWrap.querySelector('.snpb-placeholder')?.classList.add('snpb-hidden');
                    els.finalWrap.querySelector('.snpb-placeholder')?.classList.add('snpb-hidden');
                    if (els.guidesToggle) { els.guidesToggle.classList.add('is-active'); els.guidesToggle.textContent='◎ 블러 영역'; }
                    els.originalSize.textContent = naturalW + '×' + naturalH;
                    els.fileInfo.textContent = file.name + ' · ' + humanBytes(file.size);
                    setBusy(false); setTool(null); resetHistory(); renderList(); updateCounts(); scheduleRender();
                    setStatus('이미지를 불러왔습니다. AI 개인정보를 자동으로 감지하고 있습니다…', 'working', 4);
                    window.clearTimeout(state.autoScanTimer);
                    state.autoScanTimer = window.setTimeout(()=>{ runScan(true); }, 80);
                    URL.revokeObjectURL(url);
                };
                img.onerror = () => { URL.revokeObjectURL(url); setStatus('이미지를 읽지 못했습니다.', 'error'); };
                img.src = url;
            }

            function rebuildBlurCache(){
                if (!state.img) return;
                const c = state.blurCanvas, ctx = c.getContext('2d');
                ctx.save(); ctx.clearRect(0,0,c.width,c.height);
                ctx.filter = 'blur(' + Number(els.blur.value) + 'px)';
                ctx.drawImage(els.originalCanvas, 0, 0);
                ctx.restore();
            }

            function roundRectPath(ctx,x,y,w,h,r){
                r = Math.min(r, w/2, h/2);
                ctx.beginPath();
                if (ctx.roundRect) ctx.roundRect(x,y,w,h,r);
                else {
                    ctx.moveTo(x+r,y); ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
                    ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
                    ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
                    ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
                }
                ctx.closePath();
            }

            function drawMask(){
                const c = state.maskCanvas, ctx = c.getContext('2d');
                ctx.save(); ctx.clearRect(0,0,c.width,c.height);
                ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff';
                ctx.filter = 'blur(' + Number(els.feather.value) + 'px)';

                state.regions.filter(r => r.active).forEach(r => {
                    if (r.shape === 'ellipse') {
                        ctx.beginPath(); ctx.ellipse(r.x+r.w/2, r.y+r.h/2, r.w/2, r.h/2, 0, 0, Math.PI*2); ctx.fill();
                    } else {
                        roundRectPath(ctx,r.x,r.y,r.w,r.h,Math.min(18,Math.min(r.w,r.h)*.22)); ctx.fill();
                    }
                });

                state.strokes.filter(s => s.active && s.points.length).forEach(s => {
                    ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = s.radius*2;
                    ctx.beginPath();
                    s.points.forEach((p,i) => { if(i===0) ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y); });
                    if (s.points.length === 1) { const p=s.points[0]; ctx.moveTo(p.x+0.01,p.y); ctx.lineTo(p.x,p.y); }
                    ctx.stroke();
                });

                ctx.globalCompositeOperation = 'destination-out';
                state.restoreStrokes.filter(s => s.active && s.points.length).forEach(s => {
                    ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = s.radius*2;
                    ctx.beginPath();
                    s.points.forEach((p,i) => { if(i===0) ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y); });
                    if (s.points.length === 1) { const p=s.points[0]; ctx.moveTo(p.x+0.01,p.y); ctx.lineTo(p.x,p.y); }
                    ctx.stroke();
                });
                ctx.restore();
            }

            function renderComposite(target){
                if (!state.img) return;
                drawMask();
                const tctx = state.tempCanvas.getContext('2d');
                tctx.save(); tctx.clearRect(0,0,state.width,state.height); tctx.globalCompositeOperation='source-over';
                tctx.drawImage(state.blurCanvas,0,0);
                tctx.globalCompositeOperation='destination-in'; tctx.drawImage(state.maskCanvas,0,0);
                tctx.restore();

                const ctx = target.getContext('2d');
                ctx.save(); ctx.clearRect(0,0,state.width,state.height); ctx.globalCompositeOperation='source-over';
                ctx.drawImage(els.originalCanvas,0,0); ctx.drawImage(state.tempCanvas,0,0); ctx.restore();
            }

            function drawGuides(ctx){
                if (!state.showGuides) return;
                ctx.save();
                state.regions.forEach(r => {
                    const isHover = state.hoverId === r.id;
                    ctx.lineWidth = isHover ? 4 : 2;
                    ctx.strokeStyle = r.active ? (r.source === 'manual' ? '#e88c24' : '#e44963') : '#18a36f';
                    ctx.setLineDash(r.active ? [] : [8,6]);
                    if(r.shape === 'ellipse'){
                        ctx.beginPath();ctx.ellipse(r.x+r.w/2,r.y+r.h/2,r.w/2,r.h/2,0,0,Math.PI*2);ctx.stroke();
                    }else{ roundRectPath(ctx,r.x,r.y,r.w,r.h,10);ctx.stroke(); }
                });
                state.strokes.forEach(s => {
                    if(!s.points.length) return;
                    ctx.lineWidth = Math.max(2, s.radius*2 + 3); ctx.lineCap='round';ctx.lineJoin='round';
                    ctx.strokeStyle = s.active ? '#e88c24' : '#18a36f'; ctx.globalAlpha=.45;
                    ctx.beginPath(); s.points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.stroke();ctx.globalAlpha=1;
                });
                state.restoreStrokes.forEach(s => {
                    if(!s.points.length) return;
                    ctx.lineWidth = Math.max(2, s.radius*2 + 3); ctx.lineCap='round';ctx.lineJoin='round';
                    ctx.strokeStyle = s.active ? '#18a36f' : '#a1acbf'; ctx.globalAlpha=.5;
                    ctx.beginPath(); s.points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.stroke();ctx.globalAlpha=1;
                });
                ctx.restore();
            }

            function render(){
                state.raf = 0;
                if (!state.img) return;
                // 왼쪽은 편집용: 블러 결과 + 빨강/초록/주황 가이드
                renderComposite(els.resultCanvas);
                drawGuides(els.resultCanvas.getContext('2d'));
                // 오른쪽은 다운로드될 깨끗한 최종 결과
                renderComposite(els.finalCanvas);
            }
            function scheduleRender(){ if(!state.raf) state.raf = requestAnimationFrame(render); }

            function iou(a,b){
                const x1=Math.max(a.x,b.x), y1=Math.max(a.y,b.y), x2=Math.min(a.x+a.w,b.x+b.w), y2=Math.min(a.y+a.h,b.y+b.h);
                const inter=Math.max(0,x2-x1)*Math.max(0,y2-y1); const union=a.w*a.h+b.w*b.h-inter;
                return union>0?inter/union:0;
            }
            function dedupe(boxes, threshold){
                const out=[];
                boxes.sort((a,b)=>(b.score||0)-(a.score||0));
                boxes.forEach(b=>{ if(!out.some(o=>iou(o,b)>threshold)) out.push(b); });
                return out;
            }

            function parseFaceBox(d){
                const b = d?.boundingBox || d?.locationData?.relativeBoundingBox || d?.locationData?.relative_bounding_box;
                if (!b) return null;
                let x,y,w,h;
                if (Number.isFinite(b.xCenter) && Number.isFinite(b.yCenter)) {
                    w=b.width*state.width; h=b.height*state.height; x=(b.xCenter-b.width/2)*state.width; y=(b.yCenter-b.height/2)*state.height;
                } else if (Number.isFinite(b.xmin)) {
                    x=b.xmin*state.width; y=b.ymin*state.height; w=b.width*state.width; h=b.height*state.height;
                } else if (Number.isFinite(b.left)) {
                    const normalized = Math.abs(b.left)<=1 && Math.abs(b.width)<=1;
                    x=b.left*(normalized?state.width:1); y=b.top*(normalized?state.height:1); w=b.width*(normalized?state.width:1); h=b.height*(normalized?state.height:1);
                } else return null;
                const growX=w*.20, growY=h*.25;
                x=clamp(x-growX/2,0,state.width); y=clamp(y-growY*.58,0,state.height);
                w=clamp(w+growX,8,state.width-x); h=clamp(h+growY,8,state.height-y);
                const score = d?.score?.[0] ?? d?.score ?? d?.categories?.[0]?.score ?? .5;
                return {x,y,w,h,score:Number(score)||.5};
            }

            async function detectFaces(){
                setStatus('얼굴 AI 모델을 준비하고 있습니다…','working',12);
                await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@'+FACE_VERSION+'/face_detection.js','FaceDetection');
                if (!state.faceDetector) {
                    state.faceDetector = new window.FaceDetection({
                        locateFile: (file) => 'https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@'+FACE_VERSION+'/'+file
                    });
                    state.faceDetector.setOptions({model:'full', minDetectionConfidence:.35});
                }
                setStatus('사진 속 얼굴을 찾고 있습니다…','working',25);
                const results = await new Promise((resolve,reject)=>{
                    let settled=false;
                    const timer=setTimeout(()=>{ if(!settled){settled=true;reject(new Error('얼굴 감지 시간이 초과되었습니다.'));}},30000);
                    state.faceDetector.onResults((res)=>{ if(!settled){settled=true;clearTimeout(timer);resolve(res);}});
                    Promise.resolve(state.faceDetector.send({image:els.originalCanvas})).catch(err=>{if(!settled){settled=true;clearTimeout(timer);reject(err);}});
                });
                let boxes=(results?.detections||[]).map(parseFaceBox).filter(Boolean);
                boxes=dedupe(boxes,.48);

                let mainIndex=-1;
                if (els.keepMain.checked && boxes.length) {
                    let best=-Infinity;
                    boxes.forEach((b,i)=>{
                        const area=(b.w*b.h)/(state.width*state.height);
                        const cx=(b.x+b.w/2)/state.width, cy=(b.y+b.h/2)/state.height;
                        const dist=Math.hypot(cx-.5,cy-.48);
                        const score=area*5 + (1-Math.min(1,dist))*0.35;
                        if(score>best){best=score;mainIndex=i;}
                    });
                }
                return boxes.map((b,i)=>({
                    id:uid('face'), type:'face', source:'ai', shape:'ellipse', x:b.x,y:b.y,w:b.w,h:b.h,
                    active:i!==mainIndex, score:b.score, main:i===mainIndex
                }));
            }

            async function getPlateSession(){
                if (state.plateSession) return state.plateSession;
                setStatus('번호판 AI 엔진을 불러오고 있습니다…','working',40);
                await loadScript('https://cdn.jsdelivr.net/npm/onnxruntime-web@'+ORT_VERSION+'/dist/ort.min.js','ort');
                window.ort.env.wasm.wasmPaths='https://cdn.jsdelivr.net/npm/onnxruntime-web@'+ORT_VERSION+'/dist/';
                window.ort.env.wasm.numThreads = Math.max(1, Math.min(4, navigator.hardwareConcurrency || 2));
                state.plateSession = await window.ort.InferenceSession.create(PLATE_MODEL,{executionProviders:['wasm'],graphOptimizationLevel:'all'});
                return state.plateSession;
            }

            function letterbox640(){
                const size=640, c=document.createElement('canvas'); c.width=size;c.height=size;
                const ctx=c.getContext('2d',{willReadFrequently:true}); ctx.fillStyle='rgb(114,114,114)';ctx.fillRect(0,0,size,size);
                const ratio=Math.min(size/state.width,size/state.height);
                const nw=Math.round(state.width*ratio), nh=Math.round(state.height*ratio);
                const dx=Math.floor((size-nw)/2), dy=Math.floor((size-nh)/2);
                ctx.drawImage(els.originalCanvas,0,0,state.width,state.height,dx,dy,nw,nh);
                return {canvas:c,ratio,dx,dy,size};
            }

            function nms(boxes,threshold=.45){
                const sorted=boxes.slice().sort((a,b)=>b.score-a.score), keep=[];
                while(sorted.length){
                    const a=sorted.shift();keep.push(a);
                    for(let i=sorted.length-1;i>=0;i--) if(iou(a,sorted[i])>threshold) sorted.splice(i,1);
                }
                return keep;
            }

            async function detectPlates(){
                const session=await getPlateSession();
                setStatus('자동차 번호판을 찾고 있습니다…','working',55);
                const lb=letterbox640(), ctx=lb.canvas.getContext('2d',{willReadFrequently:true});
                const rgba=ctx.getImageData(0,0,lb.size,lb.size).data;
                const n=lb.size*lb.size, input=new Float32Array(3*n);
                for(let i=0;i<n;i++){
                    input[i]=rgba[i*4]/255; input[n+i]=rgba[i*4+1]/255; input[2*n+i]=rgba[i*4+2]/255;
                }
                const tensor=new window.ort.Tensor('float32',input,[1,3,lb.size,lb.size]);
                const feeds={}; feeds[session.inputNames[0]]=tensor;
                const result=await session.run(feeds); const out=result[session.outputNames[0]];
                const d=out.data, dims=out.dims||[]; let boxes=[];

                if (dims.length===3 && dims[2]===6 && dims[1] < 1000) {
                    for(let i=0;i<dims[1];i++){
                        const base=i*6, score=Number(d[base+4]); if(score<.25) continue;
                        const x1=d[base],y1=d[base+1],x2=d[base+2],y2=d[base+3];
                        boxes.push({cx:(x1+x2)/2,cy:(y1+y2)/2,w:x2-x1,h:y2-y1,score});
                    }
                } else if (dims.length===3 && dims[1] <= 16 && dims[2] > 100) {
                    const count=dims[2];
                    for(let i=0;i<count;i++){
                        const score=Number(d[4*count+i]); if(score<.25) continue;
                        const cx=d[i], cy=d[count+i], w=d[2*count+i], h=d[3*count+i];
                        boxes.push({cx,cy,w,h,score});
                    }
                } else if (dims.length===3 && dims[2] <= 16 && dims[1] > 100) {
                    const count=dims[1], ch=dims[2];
                    for(let i=0;i<count;i++){
                        const base=i*ch, score=Number(d[base+4]); if(score<.25) continue;
                        boxes.push({cx:d[base],cy:d[base+1],w:d[base+2],h:d[base+3],score});
                    }
                } else {
                    throw new Error('번호판 모델 출력 형식을 해석하지 못했습니다.');
                }

                boxes=boxes.map(b=>{
                    let x=(b.cx-b.w/2-lb.dx)/lb.ratio, y=(b.cy-b.h/2-lb.dy)/lb.ratio;
                    let w=b.w/lb.ratio, h=b.h/lb.ratio;
                    const gx=w*.10, gy=h*.24; x-=gx/2;y-=gy/2;w+=gx;h+=gy;
                    x=clamp(x,0,state.width);y=clamp(y,0,state.height);w=clamp(w,4,state.width-x);h=clamp(h,4,state.height-y);
                    return {x,y,w,h,score:b.score};
                }).filter(b=>b.w>8&&b.h>5&&b.x<state.width&&b.y<state.height);

                return nms(boxes,.42).slice(0,30).map(b=>({id:uid('plate'),type:'plate',source:'ai',shape:'rect',x:b.x,y:b.y,w:b.w,h:b.h,active:true,score:b.score}));
            }

            function isSensitiveText(text){
                const s=(text||'').replace(/\s+/g,' ').trim(); if(!s) return false;
                const patterns=[
                    /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
                    /01[016789][\s.\-]?\d{3,4}[\s.\-]?\d{4}/,
                    /0\d{1,2}[\s.\-]?\d{3,4}[\s.\-]?\d{4}/,
                    /\b\d{6}[\s\-]?[1-4]\d{6}\b/,
                    /\b(?:\d[\s\-]?){12,19}\b/,
                    /(?:서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)[^\n]{0,40}(?:로|길|동|읍|면)\s*\d*/
                ];
                return patterns.some(re=>re.test(s));
            }

            async function detectSensitiveText(){
                setStatus('OCR 모델을 준비하고 있습니다…','working',68);
                await loadScript('https://cdn.jsdelivr.net/npm/tesseract.js@'+TESS_VERSION+'/dist/tesseract.min.js','Tesseract');
                const OEM = window.Tesseract.OEM?.LSTM_ONLY ?? 1;
                const worker=await window.Tesseract.createWorker('kor+eng',OEM,{
                    logger:m=>{
                        if(m?.status==='recognizing text') setStatus('사진 속 개인정보 텍스트를 읽고 있습니다…','working',70+Math.round((m.progress||0)*20));
                    }
                });
                try{
                    const ret=await worker.recognize(els.originalCanvas,{}, {blocks:true});
                    const out=[];
                    (ret.data.blocks||[]).forEach(block=>(block.paragraphs||[]).forEach(p=>(p.lines||[]).forEach(line=>{
                        if(!isSensitiveText(line.text)) return;
                        const b=line.bbox; if(!b) return;
                        const pad=Math.max(4,Math.min(16,(b.y1-b.y0)*.18));
                        const x=clamp(b.x0-pad,0,state.width), y=clamp(b.y0-pad,0,state.height);
                        const w=clamp((b.x1-b.x0)+pad*2,4,state.width-x), h=clamp((b.y1-b.y0)+pad*2,4,state.height-y);
                        out.push({id:uid('text'),type:'text',source:'ai',shape:'rect',x,y,w,h,active:true,score:line.confidence||0,label:(line.text||'').trim().slice(0,44)});
                    })));
                    return dedupe(out,.55).slice(0,40);
                } finally { try{await worker.terminate();}catch(e){} }
            }

            async function runScan(isAuto){
                if(!state.img || state.busy) return;
                if(!els.detectFace.checked && !els.detectPlate.checked && !els.detectText.checked){
                    setStatus('감지할 항목을 하나 이상 선택해 주세요.','error'); return;
                }
                setTool(null);
                setBusy(true); const warnings=[]; let found=[];
                try{
                    let faceFound = [];
                    if(els.detectFace.checked){
                        let retried = false;
                        try { faceFound = await detectFaces(); } catch(e){ console.error(e); }
                        if(!faceFound.length){
                            retried = true;
                            setStatus('얼굴 감지를 다시 시도하고 있습니다…','working',32);
                            try { faceFound = await detectFaces(); } catch(e){ console.error(e); faceFound = []; }
                        }
                        if(!faceFound.length){ warnings.push('얼굴 감지 실패'); }
                        found = found.concat(faceFound);
                    }
                    if(els.detectPlate.checked){ try{found=found.concat(await detectPlates());}catch(e){warnings.push('번호판 감지 실패'); console.error(e);} }
                    if(els.detectText.checked){ try{found=found.concat(await detectSensitiveText());}catch(e){warnings.push('텍스트 감지 실패'); console.error(e);} }

                    state.regions = state.regions.filter(r=>r.source==='manual').concat(found);
                    state.aiBaseline = deepState(); resetHistory(); renderList();updateCounts();scheduleRender();
                    const preserved=found.filter(x=>!x.active).length;
                    let msg='';
                    let kind='ok';
                    if(!found.length){
                        kind='error';
                        if(warnings.includes('얼굴 감지 실패')) msg='얼굴 감지에 실패했습니다. 다시 감지하거나 블러 브러시로 직접 가릴 수 있습니다.';
                        else msg='가릴 개인정보를 찾지 못했습니다. 다시 감지하거나 블러 브러시로 직접 가릴 수 있습니다.';
                    } else {
                        msg='AI 감지 완료: '+found.length+'개 영역을 찾았습니다.';
                        if(preserved) msg+=' 내 얼굴 추정 '+preserved+'개는 우선 유지했습니다.';
                        if(warnings.length) msg+=' ('+warnings.join(' · ')+') 다시 감지하거나 블러 브러시로 보완할 수 있습니다.';
                    }
                    setStatus(msg, kind);
                } catch(e){ console.error(e); setStatus('AI 감지 중 오류가 발생했습니다. 다시 감지하거나 블러 브러시로 직접 가려 주세요.','error'); }
                finally{ setBusy(false); els.aiReset.disabled=!state.aiBaseline; }
            }

            function typeName(item){
                return {face:'얼굴',plate:'번호판',text:'개인정보',manual:'수동 영역',brush:'블러 브러시',restore:'복구 브러시'}[item.type] || '영역';
            }
            function itemBBox(item){
                if(item.kind==='region') return item.data;
                const s=item.data; if(!s.points.length) return {x:0,y:0,w:1,h:1};
                const xs=s.points.map(p=>p.x),ys=s.points.map(p=>p.y),r=s.radius;
                const x=clamp(Math.min(...xs)-r,0,state.width),y=clamp(Math.min(...ys)-r,0,state.height);
                const x2=clamp(Math.max(...xs)+r,0,state.width),y2=clamp(Math.max(...ys)+r,0,state.height);
                return {x,y,w:Math.max(1,x2-x),h:Math.max(1,y2-y)};
            }
            function allItems(){
                return state.regions.map(r=>({kind:'region',id:r.id,type:r.type,source:r.source,active:r.active,data:r}))
                    .concat(state.strokes.map(s=>({kind:'stroke',id:s.id,type:'brush',source:'manual',active:s.active,data:s})))
                    .concat(state.restoreStrokes.map(s=>({kind:'stroke',id:s.id,type:'restore',source:'manual',active:s.active,data:s})));
            }

            function makeThumb(item,canvas){
                const b=itemBBox(item), ctx=canvas.getContext('2d'); const size=54;
                canvas.width=size;canvas.height=size;ctx.clearRect(0,0,size,size);ctx.fillStyle='#eef1f6';ctx.fillRect(0,0,size,size);
                const pad=Math.max(b.w,b.h)*.35; const sx=clamp(b.x-pad,0,state.width), sy=clamp(b.y-pad,0,state.height);
                const sw=clamp(b.w+pad*2,1,state.width-sx), sh=clamp(b.h+pad*2,1,state.height-sy);
                const scale=Math.max(size/sw,size/sh), dw=sw*scale,dh=sh*scale;
                ctx.drawImage(els.originalCanvas,sx,sy,sw,sh,(size-dw)/2,(size-dh)/2,dw,dh);
            }

            function renderList(){
                const items=allItems();
                if(!items.length){els.list.innerHTML='<div class="snpb-empty-list">AI 감지 결과와 브러시 기록이 여기에 표시됩니다.</div>';return;}
                els.list.innerHTML='';
                items.forEach((item,index)=>{
                    const data=item.data, wrap=document.createElement('div');wrap.className='snpb-item';wrap.dataset.id=item.id;
                    const thumb=document.createElement('canvas');thumb.className='snpb-thumb';
                    const main=document.createElement('div');main.className='snpb-item-main';
                    const title=document.createElement('div');title.className='snpb-item-title';
                    title.append(document.createTextNode(typeName(item)+' '+(index+1)));
                    if(data.main){const b=document.createElement('span');b.className='snpb-badge green';b.textContent='내 얼굴 추정';title.appendChild(b);}
                    if(item.source==='manual'){const b=document.createElement('span');b.className='snpb-badge orange';b.textContent='수동';title.appendChild(b);}
                    const sub=document.createElement('div');sub.className='snpb-item-sub';
                    sub.textContent = data.label ? data.label : (item.type==='restore' ? (item.active ? '원본 복구 적용 중' : '복구 해제됨') : (item.active ? '블러 적용 중' : '원본 유지 · 복구됨'));
                    main.append(title,sub);
                    const label=document.createElement('label');label.className='snpb-toggle';
                    const input=document.createElement('input');input.type='checkbox';input.checked=!!item.active;input.setAttribute('aria-label',typeName(item)+(item.type==='restore' ? ' 적용' : ' 블러 적용'));
                    const sw=document.createElement('i'); label.append(input,sw);
                    input.addEventListener('change',()=>{
                        data.active=input.checked; pushHistory(); renderList();updateCounts();scheduleRender();
                    });
                    wrap.addEventListener('mouseenter',()=>{state.hoverId=item.id;scheduleRender();});
                    wrap.addEventListener('mouseleave',()=>{state.hoverId=null;scheduleRender();});
                    wrap.append(thumb,main,label);els.list.appendChild(wrap);makeThumb(item,thumb);
                });
            }

            function updateCounts(){
                const ai=state.regions.filter(r=>r.source==='ai').length;
                const manual=state.regions.filter(r=>r.source==='manual').length + state.strokes.length + state.restoreStrokes.length;
                const active=state.regions.filter(r=>r.active).length + state.strokes.filter(s=>s.active).length;
                const kept=state.regions.filter(r=>!r.active).length + state.restoreStrokes.filter(s=>s.active).length;
                els.statAI.textContent=ai;els.statActive.textContent=active;els.statKept.textContent=kept;els.statManual.textContent=manual;
            }

            function setTool(tool){
                state.tool = state.tool===tool ? null : tool;
                els.tools.forEach(b=>b.classList.toggle('is-active',b.dataset.tool===state.tool));
                els.resultCanvas.classList.remove('tool-brush','tool-restore');
                if(state.tool) els.resultCanvas.classList.add('tool-'+state.tool);
                scheduleRender();
            }

            function pointFromEvent(e){
                const r=els.resultCanvas.getBoundingClientRect();
                return {x:clamp((e.clientX-r.left)*state.width/r.width,0,state.width),y:clamp((e.clientY-r.top)*state.height/r.height,0,state.height)};
            }
            function distToSegment(p,a,b){
                const l2=(b.x-a.x)**2+(b.y-a.y)**2;if(!l2)return Math.hypot(p.x-a.x,p.y-a.y);
                let t=((p.x-a.x)*(b.x-a.x)+(p.y-a.y)*(b.y-a.y))/l2;t=clamp(t,0,1);
                return Math.hypot(p.x-(a.x+t*(b.x-a.x)),p.y-(a.y+t*(b.y-a.y)));
            }

            els.resultCanvas.addEventListener('pointerdown',e=>{
                if(!state.img||state.busy||!state.tool)return;
                const p=pointFromEvent(e); els.resultCanvas.setPointerCapture?.(e.pointerId); state.drawing=true;
                if(state.tool==='brush'){
                    state.activeStroke={id:uid('brush'),type:'brush',source:'manual',active:true,radius:Number(els.brushSize.value),points:[p]};
                    state.strokes.push(state.activeStroke);scheduleRender();
                } else if(state.tool==='restore'){
                    state.activeStroke={id:uid('restore'),type:'restore',source:'manual',active:true,radius:Number(els.brushSize.value),points:[p]};
                    state.restoreStrokes.push(state.activeStroke);scheduleRender();
                }
                e.preventDefault();
            });
            els.resultCanvas.addEventListener('pointermove',e=>{
                if(!state.drawing||!state.tool||!state.activeStroke)return;const p=pointFromEvent(e);
                const last=state.activeStroke.points[state.activeStroke.points.length-1];
                if(Math.hypot(p.x-last.x,p.y-last.y)>Math.max(2,state.activeStroke.radius*.12)){state.activeStroke.points.push(p);scheduleRender();}
                e.preventDefault();
            });
            function finishPointer(e){
                if(!state.drawing)return;state.drawing=false;
                if((state.tool==='brush'||state.tool==='restore') && state.activeStroke){state.activeStroke=null;pushHistory();renderList();updateCounts();scheduleRender();}
                try{els.resultCanvas.releasePointerCapture?.(e.pointerId);}catch(err){}
            }
            els.resultCanvas.addEventListener('pointerup',finishPointer);els.resultCanvas.addEventListener('pointercancel',finishPointer);

            els.tools.forEach(b=>b.addEventListener('click',()=>setTool(b.dataset.tool)));
            els.undo.addEventListener('click',()=>{
                if(state.history.length<=1)return;state.future.push(state.history.pop());restoreState(state.history[state.history.length-1]);
            });
            els.redo.addEventListener('click',()=>{
                if(!state.future.length)return;const snap=state.future.pop();state.history.push(snap);restoreState(snap);
            });
            els.aiReset.addEventListener('click',()=>{
                if(!state.aiBaseline)return;restoreState(state.aiBaseline);resetHistory();setStatus('AI 감지 직후 상태로 되돌렸습니다.','ok');
            });

            els.blur.addEventListener('input',()=>{els.blurValue.textContent=els.blur.value+' px';rebuildBlurCache();scheduleRender();});
            els.feather.addEventListener('input',()=>{els.featherValue.textContent=els.feather.value+' px';scheduleRender();});
            els.brushSize.addEventListener('input',()=>{els.brushValue.textContent=els.brushSize.value+' px';});

            els.scan.addEventListener('click',()=>runScan(false));
            els.reset.addEventListener('click',()=>{if(state.file)loadImage(state.file);});

            els.upload.addEventListener('click',()=>els.file.click());
            els.upload.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();els.file.click();}});
            els.file.addEventListener('change',()=>{if(els.file.files?.[0])loadImage(els.file.files[0]);});
            ['dragenter','dragover'].forEach(name=>els.upload.addEventListener(name,e=>{e.preventDefault();els.upload.classList.add('is-drag');}));
            ['dragleave','drop'].forEach(name=>els.upload.addEventListener(name,e=>{e.preventDefault();els.upload.classList.remove('is-drag');}));
            els.upload.addEventListener('drop',e=>{const f=e.dataTransfer?.files?.[0];if(f)loadImage(f);});

            els.download.addEventListener('click',()=>{
                if(!state.img)return;
                renderComposite(state.exportCanvas);
                const type=els.format.value;const ext=type==='image/jpeg'?'jpg':type==='image/webp'?'webp':'png';
                state.exportCanvas.toBlob(blob=>{
                    if(!blob){setStatus('이미지 저장에 실패했습니다.','error');return;}
                    const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='snsnote-privacy-blur.'+ext;
                    document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),1500);
                    setStatus('결과 이미지를 저장했습니다. 다운로드 전에 가려진 영역을 한 번 더 확인해 주세요.','ok');
                },type,(type==='image/jpeg'||type==='image/webp') ? .94 : undefined);
            });

            setBusy(false);
        })();
        </script>
        



<h2 class="wp-block-heading">사진 모자이크, AI로 얼굴과 번호판을 자동으로 가려보세요</h2>



<p class="wp-block-paragraph">사진을 SNS나 커뮤니티에 올리기 전에 다른 사람의 얼굴이나 자동차 번호판이 함께 찍혀 신경 쓰일 때가 있습니다. 하나씩 편집 프로그램으로 가리려면 번거롭지만, 이 <strong>사진 모자이크 무료 도구</strong>를 이용하면 AI가 사진 속 얼굴과 차량 번호판을 자동으로 찾아 부드럽게 블러 처리합니다.</p>



<p class="wp-block-paragraph">이미지를 선택하면 별도의 프로그램 설치 없이 브라우저에서 바로 분석이 시작됩니다. AI가 감지한 영역은 자동으로 블러 처리되며, 필요한 경우 사용자가 직접 결과를 수정할 수도 있습니다.</p>



<h2 class="wp-block-heading">AI 얼굴 모자이크 자동 감지</h2>



<p class="wp-block-paragraph">사진 속 여러 사람의 얼굴을 AI가 자동으로 감지해 블러 처리합니다.</p>



<p class="wp-block-paragraph">셀카처럼 본인이 중심에 있는 사진에서는 <strong>내 얼굴 자동 유지</strong> 기능을 사용할 수 있습니다. 가장 크고 중앙에 가까운 얼굴을 우선 보존하고, 주변 사람들의 얼굴을 가리는 방식입니다.</p>



<p class="wp-block-paragraph">단체 사진이나 여행 사진처럼 여러 사람이 함께 찍힌 이미지에서도 사용할 수 있으며, AI가 놓친 작은 얼굴은 <strong>블러 브러시</strong>를 이용해 직접 가릴 수 있습니다.</p>



<h2 class="wp-block-heading">자동차 번호판도 자동 블러 처리</h2>



<p class="wp-block-paragraph">거리 사진이나 주차장에서 찍은 사진에는 차량 번호판이 함께 노출되는 경우가 많습니다.</p>



<p class="wp-block-paragraph">이 도구는 사진 속 자동차 번호판을 AI로 감지해 자동으로 블러 처리합니다. 번호판이 작거나 각도가 심해 자동 감지가 되지 않는 경우에는 블러 브러시로 필요한 부분만 직접 가릴 수 있습니다.</p>



<p class="wp-block-paragraph">얼굴과 번호판을 한 번에 처리할 수 있어 SNS 업로드 전 개인정보를 확인할 때 편리합니다.</p>



<h2 class="wp-block-heading">잘못 가린 부분은 복구 브러시로 되돌리기</h2>



<p class="wp-block-paragraph">AI가 원하지 않는 부분까지 블러 처리했더라도 처음부터 다시 작업할 필요가 없습니다.</p>



<p class="wp-block-paragraph"><strong>복구 브러시</strong>를 선택한 뒤 잘못 가려진 부분을 문지르면 해당 영역을 다시 원본 이미지로 되돌릴 수 있습니다.</p>



<p class="wp-block-paragraph">반대로 AI가 놓친 부분은 블러 브러시를 이용해 추가로 가릴 수 있기 때문에,</p>



<p class="wp-block-paragraph"><strong>AI 자동 감지 → 직접 보정 → 최종 결과 확인</strong></p>



<p class="wp-block-paragraph">순서로 간단하게 작업할 수 있습니다.</p>



<h2 class="wp-block-heading">블러 강도와 경계를 직접 조절</h2>



<p class="wp-block-paragraph">사진에 따라 적당한 블러 강도가 다를 수 있습니다.</p>



<p class="wp-block-paragraph">블러 강도를 조절하면 얼굴이나 번호판이 얼마나 흐리게 보일지 변경할 수 있으며, <strong>경계 부드러움</strong>을 조절하면 블러 영역의 가장자리가 네모 형태로 딱 끊기지 않고 자연스럽게 퍼지도록 만들 수 있습니다.</p>



<p class="wp-block-paragraph">설정을 움직이면 결과가 바로 반영되어 원하는 정도를 확인하면서 조절할 수 있습니다.</p>



<h2 class="wp-block-heading">원본 해상도를 유지해 저장</h2>



<p class="wp-block-paragraph">AI 분석 과정에서 사진의 화질이 떨어질까 걱정할 필요가 없습니다.</p>



<p class="wp-block-paragraph">최종 이미지는 업로드한 사진의 <strong>원본 해상도를 유지해 저장</strong>하도록 만들었습니다. 블러 처리에 필요한 부분만 변경되며, 사진 전체 크기를 임의로 줄이지 않습니다.</p>



<p class="wp-block-paragraph">완성된 이미지는 최종 결과 화면에서 확인한 뒤 저장할 수 있습니다.</p>



<h2 class="wp-block-heading">설치 없이 브라우저에서 바로 사용</h2>



<p class="wp-block-paragraph">이 사진 모자이크 도구는 별도의 이미지 편집 프로그램을 설치할 필요가 없습니다.</p>



<p class="wp-block-paragraph">사진을 선택하면 웹브라우저에서 바로 사용할 수 있으며, 얼굴 감지와 번호판 감지를 비롯한 주요 이미지 처리가 브라우저에서 진행됩니다.</p>



<p class="wp-block-paragraph">사진 자체를 SNSnote 서버에 업로드해 처리하는 방식이 아니기 때문에, 개인정보가 포함된 사진을 다룰 때도 부담을 줄였습니다.</p>



<h2 class="wp-block-heading">사진 모자이크 사용 방법</h2>



<ol start="1" class="wp-block-list">
<li>모자이크 처리할 사진을 선택합니다.</li>



<li>AI가 얼굴과 차량 번호판을 자동으로 감지합니다.</li>



<li>왼쪽 편집 화면에서 감지된 영역을 확인합니다.</li>



<li>놓친 부분은 <strong>블러 브러시</strong>로 직접 가립니다.</li>



<li>잘못 가려진 부분은 <strong>복구 브러시</strong>로 되돌립니다.</li>



<li>블러 강도와 경계 부드러움을 원하는 정도로 조절합니다.</li>



<li>오른쪽 최종 결과를 확인한 뒤 이미지를 저장합니다.</li>
</ol>



<h2 class="wp-block-heading">어떤 사진에 사용할 수 있나요?</h2>



<p class="wp-block-paragraph">이 도구는 단체 사진, 여행 사진, 길거리 사진, 차량 사진, SNS 업로드용 사진 등 다양한 이미지에 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 다른 사람의 얼굴이 배경에 함께 찍혔거나 자동차 번호판이 노출된 사진을 공유하기 전에 빠르게 개인정보를 가리는 용도로 활용하기 좋습니다.</p>



<p class="wp-block-paragraph">다만 AI 자동 감지는 사진의 밝기, 얼굴 크기, 촬영 각도, 가려짐 정도에 따라 일부 영역을 놓칠 수 있습니다. 저장하기 전에는 최종 결과를 한 번 확인하고, 필요한 부분은 블러 브러시로 직접 보완하는 것을 권장합니다.</p>



<h2 class="wp-block-heading">자주 묻는 질문</h2>



<h3 class="wp-block-heading">얼굴이 자동으로 감지되지 않으면 어떻게 하나요?</h3>



<p class="wp-block-paragraph">AI 감지가 실패하거나 일부 얼굴을 놓쳤다면 <strong>다시 감지</strong>를 실행할 수 있습니다. 그래도 감지되지 않는 영역은 블러 브러시로 직접 가릴 수 있습니다.</p>



<h3 class="wp-block-heading">내 얼굴까지 블러 처리되면 어떻게 하나요?</h3>



<p class="wp-block-paragraph">내 얼굴 자동 유지 기능을 사용할 수 있으며, 잘못 블러 처리된 부분은 <strong>복구 브러시</strong>를 이용해 원본으로 되돌릴 수 있습니다.</p>



<h3 class="wp-block-heading">사진 화질이 줄어드나요?</h3>



<p class="wp-block-paragraph">최종 결과는 업로드한 이미지의 원본 해상도를 유지해 저장합니다.</p>



<h3 class="wp-block-heading">번호판도 자동으로 찾을 수 있나요?</h3>



<p class="wp-block-paragraph">네. 자동차 번호판 AI 감지를 활성화하면 사진 속 번호판을 찾아 자동으로 블러 처리합니다. 다만 작거나 가려진 번호판은 감지하지 못할 수 있으므로 최종 결과를 확인하는 것이 좋습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/photo-mosaic-ai/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/photo-mosaic-ai.webp" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1758</post-id>	</item>
		<item>
		<title>설치 없이 MP3 자르기 &#8211; 볼륨 조절·파형 편집·페이드 인아웃</title>
		<link>https://snsnote.com/mp3-cutter-editor/</link>
					<comments>https://snsnote.com/mp3-cutter-editor/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Thu, 24 Sep 2026 07:16:39 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1746</guid>

					<description><![CDATA[MP3 자르기와 간단한 편집을 한 번에 MP3 파일에서 필요한 구간만 남기고 싶을 때 별도의 오디오 편집 프로그램을 설치할 필요 없이 브라우저에서 바로 편집할 수 있습니다....]]></description>
										<content:encoded><![CDATA[
<style>
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1{
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
  color:#162132;
  width:100%;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 *{box-sizing:border-box}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 button,
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 input{font:inherit}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 button{
  appearance:none;
  -webkit-appearance:none;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero{
  position:relative;
  overflow:hidden;
  padding:30px 34px;
  border:1px solid #d9e8e2;
  border-radius:28px;
  background:linear-gradient(135deg,#eef4f1,#f8fbfa 62%,#f2f6fb);
  box-shadow:0 12px 32px rgba(31,51,74,.05);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero:before{
  content:"";
  position:absolute;
  left:0;
  top:32px;
  bottom:32px;
  width:6px;
  border-radius:0 99px 99px 0;
  background:#1f8a72;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero:after{
  content:"";
  position:absolute;
  right:-45px;
  top:-55px;
  width:155px;
  height:155px;
  border-radius:50%;
  background:rgba(255,255,255,.34);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero-inner{
  position:relative;
  z-index:2;
  padding-left:15px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .kicker{
  display:block;
  margin-bottom:14px;
  color:#1f7666;
  font-size:12px;
  font-weight:900;
  letter-spacing:.17em;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 h2{
  margin:0!important;
  padding:0!important;
  border:0!important;
  background:none!important;
  background-color:transparent!important;
  background-image:none!important;
  box-shadow:none!important;
  text-shadow:none!important;
  color:#162132!important;
  font-size:clamp(28px,3vw,36px)!important;
  font-weight:900!important;
  line-height:1.13!important;
  letter-spacing:-.045em!important;
  width:auto!important;
  max-width:none!important;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .badges{
  display:flex;
  flex-wrap:wrap;
  gap:11px;
  margin-top:24px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .badge{
  display:inline-flex;
  align-items:center;
  gap:8px;
  min-height:40px;
  padding:0 14px;
  border:1px solid #d6e0dc;
  border-radius:999px;
  background:rgba(255,255,255,.9);
  color:#314255;
  font-size:11px;
  font-weight:850;
  box-shadow:0 4px 12px rgba(36,58,84,.04);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .badge-mark{
  display:grid;
  place-items:center;
  min-width:31px;
  height:22px;
  padding:0 6px;
  border-radius:7px;
  background:#f1f6f4;
  color:#1f7666;
  font-size:8px;
  font-weight:950;
  letter-spacing:.04em;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .card{
  margin-top:18px;
  padding:18px;
  border:1px solid #e3e8f0;
  border-radius:24px;
  background:#fff;
  box-shadow:0 12px 34px rgba(32,52,89,.06);
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop{
  min-height:220px;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  padding:26px;
  border:1.5px dashed #9fc9ff;
  border-radius:20px;
  background:linear-gradient(180deg,#fbfdff,#f7fbff);
  text-align:center;
  cursor:pointer;
  transition:.18s;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop:hover,
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop.drag{
  border-color:#5b9dff;
  background:#f3f9ff;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .music{
  width:56px;
  height:56px;
  display:grid;
  place-items:center;
  margin-bottom:11px;
  border-radius:17px;
  background:linear-gradient(145deg,#8fc1ff,#5b9af3);
  color:#fff;
  font-size:27px;
  font-weight:900;
  box-shadow:0 9px 20px rgba(65,128,222,.18);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop strong{font-size:14px}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop span{
  margin-top:7px;
  color:#73849a;
  font-size:10.5px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .drop small{
  margin-top:8px;
  color:#9aa8ba;
  font-size:9.5px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 input[type=file]{display:none}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .msg{
  display:none;
  margin-bottom:12px;
  padding:11px 13px;
  border-radius:12px;
  font-size:10.5px;
  font-weight:800;
  line-height:1.5;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .msg.show{display:block}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .msg.err{
  background:#fff1f2;
  color:#b42331;
  border:1px solid #ffd5d9;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .msg.ok{
  background:#effaf5;
  color:#176b57;
  border:1px solid #cfeee2;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .editor{display:none}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .editor.show{display:block}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .filebar{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:14px;
  margin-bottom:12px;
  padding:11px 12px;
  border:1px solid #e0e7f0;
  border-radius:14px;
  background:#f8fafc;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .fileinfo{min-width:0}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .fileinfo strong{
  display:block;
  overflow:hidden;
  text-overflow:ellipsis;
  white-space:nowrap;
  font-size:11px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .fileinfo span{
  display:block;
  margin-top:3px;
  color:#8998ab;
  font-size:9px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .change{
  flex:0 0 auto;
  min-height:34px;
  padding:0 11px;
  border:1px solid #d9e3ef;
  border-radius:10px;
  background:#fff;
  color:#53657b;
  font-size:9.5px;
  font-weight:900;
  cursor:pointer;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-card{
  padding:14px;
  border:1px solid #e0e7f0;
  border-radius:18px;
  background:#fbfcfe;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-head{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:12px;
  margin-bottom:9px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-head strong{font-size:10.5px}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-note{
  color:#718198;
  font-size:9px;
  font-weight:800;
  text-align:right;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-note.warn{color:#d1434b}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-shell{
  position:relative;
  height:190px;
  overflow:hidden;
  padding:0 24px;
  border:1px solid #dfe7f2;
  border-radius:14px;
  background:linear-gradient(180deg,#fff,#f7faff);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-wrap{
  position:relative;
  width:100%;
  height:100%;
  touch-action:none;
  user-select:none;
  cursor:crosshair;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 canvas{
  display:block;
  width:100%;
  height:100%;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .handle{
  position:absolute;
  top:0;
  bottom:0;
  width:3px;
  background:#2f6df6;
  z-index:4;
  pointer-events:none;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .handle:before{
  content:"";
  position:absolute;
  top:9px;
  left:50%;
  width:22px;
  height:22px;
  transform:translateX(-50%);
  border-radius:50%;
  background:#2f6df6;
  border:4px solid #fff;
  box-shadow:0 2px 9px rgba(31,73,170,.22);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .playhead{
  display:none;
  position:absolute;
  top:0;
  bottom:0;
  width:2px;
  background:#ff6f7d;
  z-index:5;
  pointer-events:none;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .times{
  display:grid;
  grid-template-columns:1fr auto 1fr;
  gap:9px;
  align-items:center;
  margin-top:10px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .time{
  padding:10px 11px;
  border:1px solid #e2e8f0;
  border-radius:11px;
  background:#fff;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .time:last-child{text-align:right}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .time small{
  display:block;
  color:#94a3b8;
  font-size:8.5px;
  font-weight:800;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .time strong{
  display:block;
  margin-top:3px;
  font-size:12px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .duration{
  color:#59708d;
  font-size:9px;
  font-weight:850;
  white-space:nowrap;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .controls{
  display:grid;
  grid-template-columns:repeat(3,minmax(0,1fr));
  gap:10px;
  margin-top:12px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .ctrl{
  padding:14px;
  border:1px solid #e1e8f1;
  border-radius:16px;
  background:#fff;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .ctrl-head{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:10px;
  margin-bottom:13px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .ctrl-title{
  color:#334155;
  font-size:10.5px;
  font-weight:900;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .value{
  min-width:54px;
  padding:6px 8px;
  border-radius:9px;
  background:#f7f9fc;
  color:#27364b;
  text-align:center;
  font-size:9.5px;
  font-weight:900;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .range{
  width:100%;
  height:5px;
  margin:4px 0 0;
  accent-color:#2f6df6;
  cursor:pointer;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .scale{
  display:flex;
  justify-content:space-between;
  margin-top:7px;
  color:#a0adbd;
  font-size:8px;
  font-weight:750;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .actions{
  display:grid;
  grid-template-columns:180px 180px minmax(220px,1fr);
  gap:9px;
  margin-top:12px;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .btn{
  min-height:48px;
  border-radius:13px;
  font-size:10.5px;
  font-weight:900;
  cursor:pointer;
  transition:.16s;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .preview{
  border:1px solid #cfe0fb;
  background:#f2f7ff;
  color:#245f99;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .reset{
  border:1px solid #e1e7ef;
  background:#fff;
  color:#607086;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .save{
  border:0;
  background:linear-gradient(105deg,#3c82ff,#2d63e9);
  color:#fff;
  box-shadow:0 9px 18px rgba(47,109,246,.16);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .btn:disabled{
  opacity:.45;
  cursor:not-allowed;
}

#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .progress{
  display:none;
  margin-top:12px;
  padding:12px;
  border:1px solid #e1e8f1;
  border-radius:14px;
  background:#f8fafc;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .progress.show{display:block}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .phead{
  display:flex;
  justify-content:space-between;
  gap:10px;
  margin-bottom:8px;
  font-size:9.5px;
  font-weight:900;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .track{
  height:7px;
  overflow:hidden;
  border-radius:999px;
  background:#e8eef6;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .bar{
  width:0;
  height:100%;
  border-radius:999px;
  background:linear-gradient(90deg,#68a3ff,#2f6df6);
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .tip{
  margin-top:12px;
  padding:11px 12px;
  border-radius:12px;
  background:#f7f9fc;
  color:#74859a;
  font-size:9px;
  line-height:1.55;
}
#snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .tip b{color:#42556d}

@media(max-width:760px){
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero{
    padding:24px 22px 22px;
    border-radius:24px;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .hero-inner{padding-left:10px}
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 h2{font-size:26px!important}
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .badges{
    gap:8px;
    margin-top:20px;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .badge{
    min-height:36px;
    padding:0 11px;
    font-size:10px;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .card{
    padding:13px;
    border-radius:18px;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-shell{
    height:150px;
    padding:0 18px;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .controls{
    grid-template-columns:1fr;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .actions{
    grid-template-columns:1fr 1fr;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .save{
    grid-column:1/-1;
  }
}
@media(max-width:480px){
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .times{
    grid-template-columns:1fr 1fr;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .duration{
    grid-column:1/-1;
    text-align:center;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .actions{
    grid-template-columns:1fr;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .save{
    grid-column:auto;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-head{
    align-items:flex-start;
    flex-direction:column;
  }
  #snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1 .wave-note{text-align:left}
}
</style>

<div id="snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1">

  <section class="hero">
    <div class="hero-inner">

      <span class="kicker">
        SNSNOTE · MP3 EDITOR
      </span>

      <h2>
        MP3 자르기 · 페이드 인·아웃 · 볼륨 조절
      </h2>

      <div class="badges">

        <span class="badge">
          <span class="badge-mark">CUT</span>
          구간 자르기
        </span>

        <span class="badge">
          <span class="badge-mark">VOL</span>
          볼륨 조절
        </span>

        <span class="badge">
          <span class="badge-mark">FADE</span>
          페이드 인·아웃
        </span>

        <span class="badge">
          <span class="badge-mark">LOCAL</span>
          브라우저 처리
        </span>

      </div>

    </div>
  </section>

  <section class="card">

    <div class="drop" data-drop>

      <div class="music">♪</div>

      <strong>
        편집할 MP3 파일을 선택하세요
      </strong>

      <span>
        클릭하거나 MP3 파일을 여기에 끌어 놓으세요
      </span>

      <small>
        파일을 선택하면 파형 편집기가 열립니다
      </small>

      <input
        data-file
        type="file"
        accept=".mp3,audio/mpeg"
      >

    </div>

    <div class="msg" data-msg></div>

    <div class="editor" data-editor>

      <div class="filebar">

        <div class="fileinfo">
          <strong data-name></strong>
          <span data-meta></span>
        </div>

        <button
          type="button"
          class="change"
          data-change
        >
          다른 MP3
        </button>

      </div>

      <div class="wave-card">

        <div class="wave-head">

          <strong>
            양쪽 손잡이를 드래그해 사용할 구간을 선택하세요
          </strong>

          <span
            class="wave-note"
            data-wave-note
          >
            볼륨·페이드가 파형에 실시간 반영됩니다
          </span>

        </div>

        <div class="wave-shell">

          <div
            class="wave-wrap"
            data-wave-wrap
          >

            <canvas data-wave></canvas>

            <div
              class="handle"
              data-h1
            ></div>

            <div
              class="handle"
              data-h2
            ></div>

            <div
              class="playhead"
              data-playhead
            ></div>

          </div>

        </div>

        <div class="times">

          <div class="time">
            <small>시작</small>
            <strong data-start>
              0:00.0
            </strong>
          </div>

          <div
            class="duration"
            data-duration
          >
            선택 0:00.0
          </div>

          <div class="time">
            <small>끝</small>
            <strong data-end>
              0:00.0
            </strong>
          </div>

        </div>

      </div>

      <div class="controls">

        <div class="ctrl">

          <div class="ctrl-head">
            <span class="ctrl-title">
              볼륨
            </span>

            <span
              class="value"
              data-vol-value
            >
              0.0 dB
            </span>
          </div>

          <input
            class="range"
            data-vol
            type="range"
            min="-24"
            max="6"
            step="0.5"
            value="0"
          >

          <div class="scale">
            <span>-24dB</span>
            <span>0dB</span>
            <span>+6dB</span>
          </div>

        </div>

        <div class="ctrl">

          <div class="ctrl-head">

            <span class="ctrl-title">
              페이드 인
            </span>

            <span
              class="value"
              data-fin-value
            >
              0.0초
            </span>

          </div>

          <input
            class="range"
            data-fin
            type="range"
            min="0"
            max="10"
            step="0.1"
            value="0"
          >

          <div class="scale">
            <span>0초</span>
            <span>부드럽게 시작</span>
            <span>10초</span>
          </div>

        </div>

        <div class="ctrl">

          <div class="ctrl-head">

            <span class="ctrl-title">
              페이드 아웃
            </span>

            <span
              class="value"
              data-fout-value
            >
              0.0초
            </span>

          </div>

          <input
            class="range"
            data-fout
            type="range"
            min="0"
            max="10"
            step="0.1"
            value="0"
          >

          <div class="scale">
            <span>0초</span>
            <span>부드럽게 종료</span>
            <span>10초</span>
          </div>

        </div>

      </div>

      <div class="actions">

        <button
          type="button"
          class="btn preview"
          data-preview
        >
          <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/25b6.png" alt="▶" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 선택 구간 미리 듣기
        </button>

        <button
          type="button"
          class="btn reset"
          data-reset
        >
          ↺ 편집값 초기화
        </button>

        <button
          type="button"
          class="btn save"
          data-save
        >
          편집한 MP3 저장 →
        </button>

      </div>

      <div
        class="progress"
        data-progress
      >

        <div class="phead">

          <span data-ptext>
            처리 중
          </span>

          <span data-pval>
            0%
          </span>

        </div>

        <div class="track">
          <div
            class="bar"
            data-bar
          ></div>
        </div>

      </div>

      <div class="tip">
        <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <b>파일은 서버에 업로드되지 않습니다.</b>
        편집과 MP3 생성은 현재 브라우저에서 처리됩니다.
        <br>
        5분 미만의 MP3 파일만 사용할 수 있으며,
        기기 성능에 따라 저장까지 잠시 시간이 걸릴 수 있습니다.
      </div>

    </div>

  </section>

</div>

<script>
(function(){

const root = document.getElementById(
  'snmp3-fa98d43a-a6ec-4da7-a9b0-e35a1e67eaa1'
);

if (!root) return;

const $ = s => root.querySelector(s);

const drop = $('[data-drop]');
const file = $('[data-file]');
const editor = $('[data-editor]');
const msg = $('[data-msg]');
const nameEl = $('[data-name]');
const metaEl = $('[data-meta]');
const change = $('[data-change]');

const wrap = $('[data-wave-wrap]');
const canvas = $('[data-wave]');
const h1 = $('[data-h1]');
const h2 = $('[data-h2]');
const playhead = $('[data-playhead]');
const waveNote = $('[data-wave-note]');

const startEl = $('[data-start]');
const endEl = $('[data-end]');
const durationEl = $('[data-duration]');

const vol = $('[data-vol]');
const fin = $('[data-fin]');
const fout = $('[data-fout]');

const volValue = $('[data-vol-value]');
const finValue = $('[data-fin-value]');
const foutValue = $('[data-fout-value]');

const preview = $('[data-preview]');
const reset = $('[data-reset]');
const save = $('[data-save]');

const progress = $('[data-progress]');
const ptext = $('[data-ptext]');
const pval = $('[data-pval]');
const bar = $('[data-bar]');

const AC =
  window.AudioContext ||
  window.webkitAudioContext;

const OC =
  window.OfflineAudioContext ||
  window.webkitOfflineAudioContext;

let ctx = null;
let buf = null;
let current = null;

let s = 0;
let e = 0;
let drag = null;

let src = null;
let raf = null;

let lamePromise = null;
let busy = false;

function fmt(t){

  t = Math.max(
    0,
    Number(t) || 0
  );

  const m = Math.floor(
    t / 60
  );

  const sec =
    t - m * 60;

  return (
    m +
    ':' +
    sec
      .toFixed(1)
      .padStart(4,'0')
  );

}

function bytes(n){

  return n < 1048576
    ? (n / 1024).toFixed(1) + ' KB'
    : (n / 1048576).toFixed(1) + ' MB';

}

function dbGain(db){

  return Math.pow(
    10,
    Number(db) / 20
  );

}

function show(
  text,
  type = 'err'
){

  msg.textContent = text;

  msg.className =
    'msg show ' +
    type;

}

function clearMsg(){

  msg.textContent = '';

  msg.className =
    'msg';

}

function setProgress(
  n,
  text
){

  n = Math.max(
    0,
    Math.min(
      100,
      Math.round(n)
    )
  );

  progress.classList.add(
    'show'
  );

  bar.style.width =
    n + '%';

  pval.textContent =
    n + '%';

  if (text) {
    ptext.textContent =
      text;
  }

}

function stop(){

  if (src) {

    try {

      src.onended = null;
      src.stop();

    } catch (_) {}

    src = null;

  }

  if (raf) {

    cancelAnimationFrame(raf);

    raf = null;

  }

  playhead.style.display =
    'none';

  preview.textContent =
    '&#x25b6; 선택 구간 미리 듣기';

}

async function audio(){

  if (!AC) {

    throw new Error(
      '이 브라우저에서는 오디오 편집을 지원하지 않습니다.'
    );

  }

  if (!ctx) {
    ctx = new AC();
  }

  if (
    ctx.state ===
    'suspended'
  ) {
    await ctx.resume();
  }

  return ctx;

}

function clampFades(){

  const d =
    Math.max(
      0,
      e - s
    );

  const max =
    Math.max(
      0,
      Math.min(
        10,
        d / 2
      )
    );

  fin.max =
    max.toFixed(1);

  fout.max =
    max.toFixed(1);

  if (
    Number(fin.value) >
    max
  ) {

    fin.value =
      max.toFixed(1);

  }

  if (
    Number(fout.value) >
    max
  ) {

    fout.value =
      max.toFixed(1);

  }

  finValue.textContent =
    Number(fin.value)
      .toFixed(1) +
    '초';

  foutValue.textContent =
    Number(fout.value)
      .toFixed(1) +
    '초';

}

function ui(){

  if (!buf) return;

  s = Math.max(
    0,
    Math.min(
      s,
      buf.duration
    )
  );

  e = Math.max(
    s + .1,
    Math.min(
      e,
      buf.duration
    )
  );

  h1.style.left =
    (
      s /
      buf.duration *
      100
    ) + '%';

  h2.style.left =
    (
      e /
      buf.duration *
      100
    ) + '%';

  startEl.textContent =
    fmt(s);

  endEl.textContent =
    fmt(e);

  durationEl.textContent =
    '선택 ' +
    fmt(e - s);

  clampFades();

  draw();

}

function draw(){

  if (!buf) return;

  const r =
    wrap.getBoundingClientRect();

  const w =
    Math.max(
      1,
      Math.floor(
        r.width
      )
    );

  const h =
    Math.max(
      1,
      Math.floor(
        r.height
      )
    );

  const dpr =
    Math.min(
      2,
      window.devicePixelRatio ||
      1
    );

  canvas.width =
    Math.floor(
      w * dpr
    );

  canvas.height =
    Math.floor(
      h * dpr
    );

  const c =
    canvas.getContext(
      '2d'
    );

  c.setTransform(
    dpr,
    0,
    0,
    dpr,
    0,
    0
  );

  c.clearRect(
    0,
    0,
    w,
    h
  );

  c.fillStyle =
    '#fbfcfe';

  c.fillRect(
    0,
    0,
    w,
    h
  );

  const sx =
    s /
    buf.duration *
    w;

  const ex =
    e /
    buf.duration *
    w;

  const sel =
    Math.max(
      .001,
      e - s
    );

  const fi =
    Math.min(
      Number(fin.value),
      sel / 2
    );

  const fo =
    Math.min(
      Number(fout.value),
      sel / 2
    );

  const base =
    dbGain(
      vol.value
    );

  c.fillStyle =
    'rgba(47,109,246,.055)';

  c.fillRect(
    sx,
    0,
    Math.max(
      0,
      ex - sx
    ),
    h
  );

  c.fillStyle =
    'rgba(148,163,184,.09)';

  c.fillRect(
    0,
    0,
    sx,
    h
  );

  c.fillRect(
    ex,
    0,
    Math.max(
      0,
      w - ex
    ),
    h
  );

  if (fi > 0) {

    const fw =
      fi /
      buf.duration *
      w;

    const g =
      c.createLinearGradient(
        sx,
        0,
        sx + fw,
        0
      );

    g.addColorStop(
      0,
      'rgba(124,92,246,.17)'
    );

    g.addColorStop(
      1,
      'rgba(124,92,246,.02)'
    );

    c.fillStyle = g;

    c.fillRect(
      sx,
      0,
      Math.min(
        fw,
        ex - sx
      ),
      h
    );

  }

  if (fo > 0) {

    const fw =
      fo /
      buf.duration *
      w;

    const x0 =
      Math.max(
        sx,
        ex - fw
      );

    const g =
      c.createLinearGradient(
        x0,
        0,
        ex,
        0
      );

    g.addColorStop(
      0,
      'rgba(124,92,246,.02)'
    );

    g.addColorStop(
      1,
      'rgba(124,92,246,.17)'
    );

    c.fillStyle = g;

    c.fillRect(
      x0,
      0,
      ex - x0,
      h
    );

  }

  const l =
    buf.getChannelData(0);

  const rr =
    buf.numberOfChannels > 1
      ? buf.getChannelData(1)
      : null;

  const total =
    l.length;

  const mid =
    h / 2;

  const amp =
    h * .40;

  const step =
    Math.max(
      1,
      Math.floor(
        total / w
      )
    );

  const env =
    time => {

      if (
        time < s ||
        time > e
      ) {
        return 1;
      }

      const rel =
        time - s;

      const rem =
        e - time;

      let v = 1;

      if (
        fi > 0 &&
        rel < fi
      ) {

        v = Math.min(
          v,
          rel / fi
        );

      }

      if (
        fo > 0 &&
        rem < fo
      ) {

        v = Math.min(
          v,
          rem / fo
        );

      }

      return Math.max(
        0,
        Math.min(
          1,
          v
        )
      );

    };

  c.beginPath();

  c.strokeStyle =
    '#cbd6e4';

  c.lineWidth = 1;

  for (
    let x = 0;
    x < w;
    x++
  ) {

    const from =
      x * step;

    const to =
      Math.min(
        total,
        from + step
      );

    let mn = 1;
    let mx = -1;

    for (
      let i = from;
      i < to;
      i++
    ) {

      const v =
        rr
        ? (
            l[i] +
            rr[i]
          ) * .5
        : l[i];

      if (v < mn) mn = v;
      if (v > mx) mx = v;

    }

    c.moveTo(
      x + .5,
      mid +
      mn * amp
    );

    c.lineTo(
      x + .5,
      mid +
      mx * amp
    );

  }

  c.stroke();

  let clipped = false;

  for (
    let x =
      Math.max(
        0,
        Math.floor(sx)
      );
    x <=
      Math.min(
        w - 1,
        Math.ceil(ex)
      );
    x++
  ) {

    const from =
      x * step;

    const to =
      Math.min(
        total,
        from + step
      );

    if (from >= to) {
      continue;
    }

    let mn = 1;
    let mx = -1;
    let peak = 0;

    for (
      let i = from;
      i < to;
      i++
    ) {

      const v =
        rr
        ? (
            l[i] +
            rr[i]
          ) * .5
        : l[i];

      if (v < mn) mn = v;
      if (v > mx) mx = v;

      if (
        Math.abs(v) >
        peak
      ) {
        peak =
          Math.abs(v);
      }

    }

    const time =
      (
        x + .5
      ) /
      w *
      buf.duration;

    const gain =
      base *
      env(time);

    const clip =
      peak *
      gain >
      1;

    clipped =
      clipped ||
      clip;

    const a =
      Math.max(
        -1,
        Math.min(
          1,
          mn * gain
        )
      );

    const b =
      Math.max(
        -1,
        Math.min(
          1,
          mx * gain
        )
      );

    c.beginPath();

    c.strokeStyle =
      clip
      ? '#e5484d'
      : '#4d8ff4';

    c.lineWidth =
      1.25;

    c.moveTo(
      x + .5,
      mid +
      a * amp
    );

    c.lineTo(
      x + .5,
      mid +
      b * amp
    );

    c.stroke();

  }

  if (
    fi > 0 ||
    fo > 0
  ) {

    c.save();

    c.setLineDash(
      [5,5]
    );

    c.strokeStyle =
      'rgba(111,78,230,.6)';

    c.lineWidth = 1;

    c.beginPath();

    let first = true;

    for (
      let x =
        Math.max(
          0,
          Math.floor(sx)
        );
      x <=
        Math.min(
          w - 1,
          Math.ceil(ex)
        );
      x++
    ) {

      const time =
        (
          x + .5
        ) /
        w *
        buf.duration;

      const y =
        mid -
        Math.min(
          1,
          base *
          env(time)
        ) *
        amp;

      if (first) {

        c.moveTo(
          x,
          y
        );

        first = false;

      } else {

        c.lineTo(
          x,
          y
        );

      }

    }

    c.stroke();

    c.restore();

  }

  c.beginPath();

  c.strokeStyle =
    'rgba(100,116,139,.18)';

  c.moveTo(
    0,
    mid + .5
  );

  c.lineTo(
    w,
    mid + .5
  );

  c.stroke();

  waveNote.textContent =
    clipped
      ? '빨간 파형은 볼륨 과다로 왜곡될 수 있습니다'
      : '볼륨·페이드가 파형에 실시간 반영됩니다';

  waveNote.classList.toggle(
    'warn',
    clipped
  );

}

function timeFrom(ev){

  const r =
    wrap.getBoundingClientRect();

  const x =
    Math.max(
      0,
      Math.min(
        r.width,
        ev.clientX -
        r.left
      )
    );

  return (
    x /
    r.width *
    buf.duration
  );

}

function down(ev){

  if (!buf) return;

  stop();

  const r =
    wrap.getBoundingClientRect();

  const x =
    ev.clientX -
    r.left;

  const sx =
    s /
    buf.duration *
    r.width;

  const ex =
    e /
    buf.duration *
    r.width;

  const t =
    timeFrom(ev);

  drag =
    Math.abs(x - sx) <= 24
      ? 's'
      : Math.abs(x - ex) <= 24
        ? 'e'
        : Math.abs(t - s) <= Math.abs(t - e)
          ? 's'
          : 'e';

  wrap.setPointerCapture?.(
    ev.pointerId
  );

  move(ev);

}

function move(ev){

  if (
    !buf ||
    !drag
  ) {
    return;
  }

  const t =
    timeFrom(ev);

  if (
    drag === 's'
  ) {

    s =
      Math.max(
        0,
        Math.min(
          t,
          e - .1
        )
      );

  } else {

    e =
      Math.min(
        buf.duration,
        Math.max(
          t,
          s + .1
        )
      );

  }

  ui();

}

function up(ev){

  drag = null;

  try {

    wrap.releasePointerCapture?.(
      ev.pointerId
    );

  } catch (_) {}

}

async function load(f){

  clearMsg();
  stop();

  if (!f) return;

  const ok =
    /\.mp3$/i.test(
      f.name
    ) ||
    /audio\/(mpeg|mp3)/.test(
      f.type
    );

  if (!ok) {

    show(
      'MP3 파일만 사용할 수 있습니다.'
    );

    file.value = '';

    return;

  }

  drop.style.opacity =
    '.55';

  try {

    const ac =
      await audio();

    const arr =
      await f.arrayBuffer();

    const decoded =
      await ac.decodeAudioData(
        arr.slice(0)
      );

    if (
      decoded.duration >=
      300
    ) {

      throw new Error(
        '5분 미만의 MP3 파일만 사용할 수 있습니다. 현재 파일은 ' +
        fmt(decoded.duration) +
        '입니다.'
      );

    }

    current = f;
    buf = decoded;

    s = 0;
    e = decoded.duration;

    vol.value = '0';
    fin.value = '0';
    fout.value = '0';

    volValue.textContent =
      '0.0 dB';

    finValue.textContent =
      '0.0초';

    foutValue.textContent =
      '0.0초';

    nameEl.textContent =
      f.name;

    metaEl.textContent =
      bytes(f.size) +
      ' · ' +
      fmt(decoded.duration) +
      ' · ' +
      (
        decoded.sampleRate /
        1000
      ).toFixed(1) +
      'kHz · ' +
      (
        decoded.numberOfChannels >
        1
          ? '스테레오'
          : '모노'
      );

    editor.classList.add(
      'show'
    );

    drop.style.display =
      'none';

    requestAnimationFrame(
      () => {

        ui();

        setTimeout(
          draw,
          60
        );

      }
    );

  } catch (err) {

    buf = null;
    current = null;

    editor.classList.remove(
      'show'
    );

    drop.style.display =
      'flex';

    show(
      err.message ||
      'MP3 파일을 불러오지 못했습니다.'
    );

    file.value = '';

  } finally {

    drop.style.opacity =
      '1';

  }

}

async function listen(){

  if (!buf) return;

  if (src) {

    stop();

    return;

  }

  try {

    const ac =
      await audio();

    const d =
      e - s;

    const base =
      dbGain(
        vol.value
      );

    const fi =
      Math.min(
        Number(fin.value),
        d / 2
      );

    const fo =
      Math.min(
        Number(fout.value),
        d / 2
      );

    const source =
      ac.createBufferSource();

    const g =
      ac.createGain();

    const now =
      ac.currentTime +
      .03;

    source.buffer =
      buf;

    source.connect(g);

    g.connect(
      ac.destination
    );

    if (fi > 0) {

      g.gain.setValueAtTime(
        0,
        now
      );

      g.gain.linearRampToValueAtTime(
        base,
        now + fi
      );

    } else {

      g.gain.setValueAtTime(
        base,
        now
      );

    }

    if (fo > 0) {

      g.gain.setValueAtTime(
        base,
        now +
        d -
        fo
      );

      g.gain.linearRampToValueAtTime(
        0,
        now + d
      );

    }

    src = source;

    preview.textContent =
      '■ 미리 듣기 정지';

    const started =
      ac.currentTime;

    source.onended =
      () => {

        if (
          src ===
          source
        ) {
          stop();
        }

      };

    source.start(
      now,
      s,
      d
    );

    source.stop(
      now +
      d +
      .05
    );

    function tick(){

      if (!src) return;

      const t =
        Math.min(
          e,
          s +
          Math.max(
            0,
            ac.currentTime -
            started -
            .03
          )
        );

      playhead.style.display =
        'block';

      playhead.style.left =
        (
          t /
          buf.duration *
          100
        ) + '%';

      if (t < e) {

        raf =
          requestAnimationFrame(
            tick
          );

      }

    }

    raf =
      requestAnimationFrame(
        tick
      );

  } catch (err) {

    stop();

    show(
      err.message ||
      '미리 듣기를 시작하지 못했습니다.'
    );

  }

}

function loadScript(url){

  return new Promise(
    (
      resolve,
      reject
    ) => {

      const el =
        document.createElement(
          'script'
        );

      el.src = url;
      el.async = true;

      el.onload =
        resolve;

      el.onerror =
        reject;

      document.head.appendChild(
        el
      );

    }
  );

}

function lame(){

  if (
    window.lamejs &&
    window.lamejs.Mp3Encoder
  ) {

    return Promise.resolve();

  }

  if (lamePromise) {

    return lamePromise;

  }

  lamePromise =
    loadScript(
      'https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.all.js'
    )
    .catch(
      () =>
        loadScript(
          'https://unpkg.com/lamejs@1.2.1/lame.all.js'
        )
    )
    .then(
      () => {

        if (
          !window.lamejs ||
          !window.lamejs.Mp3Encoder
        ) {

          throw new Error(
            'MP3 인코더를 불러오지 못했습니다.'
          );

        }

      }
    );

  return lamePromise;

}

function to16(
  input,
  from,
  to
){

  const out =
    new Int16Array(
      to - from
    );

  for (
    let i = from,
        j = 0;
    i < to;
    i++,
    j++
  ) {

    const v =
      Math.max(
        -1,
        Math.min(
          1,
          input[i]
        )
      );

    out[j] =
      v < 0
        ? v * 32768
        : v * 32767;

  }

  return out;

}

async function render(){

  if (!OC) {

    throw new Error(
      '이 브라우저에서는 MP3 저장을 지원하지 않습니다.'
    );

  }

  const d =
    e - s;

  const sr =
    44100;

  const ch =
    Math.min(
      2,
      buf.numberOfChannels
    );

  const offline =
    new OC(
      ch,
      Math.max(
        1,
        Math.ceil(
          d * sr
        )
      ),
      sr
    );

  const source =
    offline.createBufferSource();

  const g =
    offline.createGain();

  const base =
    dbGain(
      vol.value
    );

  const fi =
    Math.min(
      Number(fin.value),
      d / 2
    );

  const fo =
    Math.min(
      Number(fout.value),
      d / 2
    );

  source.buffer =
    buf;

  source.connect(g);

  g.connect(
    offline.destination
  );

  if (fi > 0) {

    g.gain.setValueAtTime(
      0,
      0
    );

    g.gain.linearRampToValueAtTime(
      base,
      fi
    );

  } else {

    g.gain.setValueAtTime(
      base,
      0
    );

  }

  if (fo > 0) {

    g.gain.setValueAtTime(
      base,
      d - fo
    );

    g.gain.linearRampToValueAtTime(
      0,
      d
    );

  }

  source.start(
    0,
    s,
    d
  );

  source.stop(d);

  return await offline.startRendering();

}

async function exportMp3(){

  if (
    !buf ||
    busy
  ) {
    return;
  }

  busy = true;

  stop();
  clearMsg();

  save.disabled =
    true;

  preview.disabled =
    true;

  reset.disabled =
    true;

  try {

    setProgress(
      5,
      'MP3 인코더 준비 중'
    );

    await lame();

    setProgress(
      20,
      '효과 적용 중'
    );

    const rendered =
      await render();

    const ch =
      Math.min(
        2,
        rendered.numberOfChannels
      );

    const encoder =
      new lamejs.Mp3Encoder(
        ch,
        rendered.sampleRate,
        192
      );

    const left =
      rendered.getChannelData(0);

    const right =
      ch > 1
        ? rendered.getChannelData(1)
        : null;

    const out = [];

    const block =
      1152;

    for (
      let i = 0;
      i < left.length;
      i += block
    ) {

      const to =
        Math.min(
          left.length,
          i + block
        );

      const l =
        to16(
          left,
          i,
          to
        );

      const encoded =
        ch > 1
          ? encoder.encodeBuffer(
              l,
              to16(
                right,
                i,
                to
              )
            )
          : encoder.encodeBuffer(
              l
            );

      if (
        encoded &&
        encoded.length
      ) {

        out.push(
          new Int8Array(
            encoded
          )
        );

      }

      if (
        (
          i /
          block
        ) %
        24 ===
        0
      ) {

        setProgress(
          45 +
          i /
          left.length *
          50,
          'MP3 변환 중'
        );

        await new Promise(
          r =>
            setTimeout(
              r,
              0
            )
        );

      }

    }

    const last =
      encoder.flush();

    if (
      last &&
      last.length
    ) {

      out.push(
        new Int8Array(
          last
        )
      );

    }

    setProgress(
      98,
      '파일 준비 중'
    );

    const blob =
      new Blob(
        out,
        {
          type:
            'audio/mpeg'
        }
      );

    const url =
      URL.createObjectURL(
        blob
      );

    const a =
      document.createElement(
        'a'
      );

    const base =
      (
        current?.name ||
        'audio'
      )
      .replace(
        /\.mp3$/i,
        ''
      ) ||
      'audio';

    a.href = url;

    a.download =
      base +
      '-edited.mp3';

    document.body.appendChild(
      a
    );

    a.click();

    a.remove();

    setTimeout(
      () =>
        URL.revokeObjectURL(
          url
        ),
      1500
    );

    setProgress(
      100,
      '완료'
    );

    show(
      '편집한 MP3를 저장했습니다. (' +
      fmt(e - s) +
      ' · 192kbps)',
      'ok'
    );

  } catch (err) {

    show(
      err.message ||
      'MP3 저장 중 오류가 발생했습니다.'
    );

  } finally {

    busy = false;

    save.disabled =
      false;

    preview.disabled =
      false;

    reset.disabled =
      false;

  }

}

function resetAll(){

  if (!buf) return;

  stop();

  s = 0;
  e = buf.duration;

  vol.value = '0';
  fin.value = '0';
  fout.value = '0';

  volValue.textContent =
    '0.0 dB';

  finValue.textContent =
    '0.0초';

  foutValue.textContent =
    '0.0초';

  clearMsg();

  progress.classList.remove(
    'show'
  );

  ui();

}

drop.onclick =
  () => file.click();

drop.ondragover =
  ev => {

    ev.preventDefault();

    drop.classList.add(
      'drag'
    );

  };

drop.ondragleave =
  () =>
    drop.classList.remove(
      'drag'
    );

drop.ondrop =
  ev => {

    ev.preventDefault();

    drop.classList.remove(
      'drag'
    );

    load(
      ev.dataTransfer.files?.[0]
    );

  };

file.onchange =
  () =>
    load(
      file.files?.[0]
    );

change.onclick =
  () => {

    stop();

    file.value = '';

    file.click();

  };

wrap.addEventListener(
  'pointerdown',
  down
);

wrap.addEventListener(
  'pointermove',
  move
);

wrap.addEventListener(
  'pointerup',
  up
);

wrap.addEventListener(
  'pointercancel',
  up
);

vol.oninput =
  () => {

    volValue.textContent =
      (
        Number(vol.value) >
        0
          ? '+'
          : ''
      ) +
      Number(vol.value)
        .toFixed(1) +
      ' dB';

    stop();
    draw();

  };

fin.oninput =
  () => {

    finValue.textContent =
      Number(fin.value)
        .toFixed(1) +
      '초';

    stop();
    draw();

  };

fout.oninput =
  () => {

    foutValue.textContent =
      Number(fout.value)
        .toFixed(1) +
      '초';

    stop();
    draw();

  };

preview.onclick =
  listen;

reset.onclick =
  resetAll;

save.onclick =
  exportMp3;

let timer;

window.addEventListener(
  'resize',
  () => {

    clearTimeout(
      timer
    );

    timer =
      setTimeout(
        draw,
        120
      );

  }
);

})();
</script>





<h2 class="wp-block-heading">MP3 자르기와 간단한 편집을 한 번에</h2>



<p class="wp-block-paragraph">MP3 파일에서 필요한 구간만 남기고 싶을 때 별도의 오디오 편집 프로그램을 설치할 필요 없이 브라우저에서 바로 편집할 수 있습니다.</p>



<p class="wp-block-paragraph">위 MP3 편집기를 이용하면 <strong>파형을 보면서 시작·끝 구간을 직접 선택</strong>할 수 있으며, 단순히 MP3를 자르는 것뿐만 아니라 <strong>볼륨 조절, 페이드 인, 페이드 아웃</strong>까지 한 번에 적용할 수 있습니다.</p>



<p class="wp-block-paragraph">편집 결과는 미리 들어본 뒤 새로운 MP3 파일로 저장할 수 있습니다.</p>



<h2 class="wp-block-heading">MP3 자르는 방법</h2>



<p class="wp-block-paragraph">편집할 MP3 파일을 선택하면 전체 음원의 파형이 표시됩니다.</p>



<p class="wp-block-paragraph">파형 양쪽의 손잡이를 움직여 원하는 시작 지점과 끝 지점을 선택하세요. 손잡이를 움직일 때마다 현재 시작 시간, 종료 시간, 선택한 구간의 길이를 바로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">원하는 구간을 선택한 뒤 <strong>편집한 MP3 저장</strong> 버튼을 누르면 선택된 부분만 새로운 MP3 파일로 저장됩니다.</p>



<h2 class="wp-block-heading">MP3 볼륨 조절</h2>



<p class="wp-block-paragraph">음원이 너무 크거나 작다면 <strong>-24dB부터 +6dB까지 볼륨을 조절</strong>할 수 있습니다.</p>



<p class="wp-block-paragraph">볼륨 값을 변경하면 파형의 크기도 실시간으로 변하기 때문에 음량 변화를 쉽게 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">볼륨을 지나치게 높여 소리가 왜곡될 가능성이 있는 구간은 파형에 빨간색으로 표시됩니다. 빨간색 구간이 많이 나타난다면 볼륨을 조금 낮춰 사용하는 것이 좋습니다.</p>



<h2 class="wp-block-heading">페이드 인·페이드 아웃 적용</h2>



<p class="wp-block-paragraph">MP3를 중간 구간에서 자르면 소리가 갑자기 시작하거나 끝나는 느낌이 날 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>페이드 인</strong>을 적용하면 음악이 작은 소리에서 서서히 커지도록 만들 수 있고, <strong>페이드 아웃</strong>을 적용하면 마지막 부분의 소리가 점점 작아지면서 자연스럽게 끝나도록 만들 수 있습니다.</p>



<p class="wp-block-paragraph">각 효과는 최대 10초까지 설정할 수 있으며, 설정한 페이드 효과 역시 파형에 실시간으로 반영됩니다.</p>



<h2 class="wp-block-heading">저장하기 전에 미리 듣기</h2>



<p class="wp-block-paragraph"><strong>선택 구간 미리 듣기</strong>를 누르면 현재 설정한 자르기 구간과 볼륨, 페이드 인·아웃이 적용된 상태로 먼저 들어볼 수 있습니다.</p>



<p class="wp-block-paragraph">결과가 마음에 들지 않는다면 값을 다시 조절한 후 재생해보고, 원하는 결과가 만들어졌을 때 MP3로 저장하면 됩니다.</p>



<h2 class="wp-block-heading">MP3 파일은 서버에 업로드되나요?</h2>



<p class="wp-block-paragraph">아닙니다.</p>



<p class="wp-block-paragraph">선택한 MP3 파일은 SNSnote 서버에 업로드하지 않고 <strong>현재 사용 중인 브라우저에서 직접 처리</strong>됩니다.</p>



<p class="wp-block-paragraph">파형 생성과 구간 자르기, 볼륨 및 페이드 효과 적용, 최종 MP3 생성까지 브라우저에서 진행됩니다.</p>



<p class="wp-block-paragraph">현재는 안정적인 브라우저 처리를 위해 <strong>5분 미만의 MP3 파일</strong>을 지원합니다.</p>



<h2 class="wp-block-heading">이런 경우에 사용할 수 있습니다</h2>



<ul class="wp-block-list">
<li>MP3 앞부분이나 뒷부분 자르기</li>



<li>노래에서 원하는 구간만 추출하기</li>



<li>영상·쇼츠·릴스에 사용할 배경음악 만들기</li>



<li>MP3 볼륨 크게 또는 작게 조절하기</li>



<li>음악 시작 부분에 페이드 인 넣기</li>



<li>끝부분에 페이드 아웃 적용하기</li>



<li>프로그램 설치 없이 간단한 MP3 편집하기</li>
</ul>



<h2 class="wp-block-heading">MP3 음성을 자막으로 변환하려면?</h2>



<p class="wp-block-paragraph">MP3 파일을 자르는 것이 아니라 <strong>음성 내용을 텍스트 또는 SRT 자막으로 변환</strong>하고 싶다면 SNSnote의 AI 음성 자막 변환기를 이용할 수 있습니다.</p>



<p class="wp-block-paragraph"><a href="https://snsnote.com/ai-audio-subtitle-converter/">AI 음성 자막 변환기</a></p>



<h2 class="wp-block-heading">자주 묻는 질문</h2>



<h3 class="wp-block-heading">MP3를 무료로 자를 수 있나요?</h3>



<p class="wp-block-paragraph">네. MP3 파일을 선택한 뒤 파형에서 필요한 부분을 지정하고 새로운 MP3로 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">볼륨과 페이드 효과를 동시에 적용할 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. 구간을 선택한 뒤 볼륨, 페이드 인, 페이드 아웃을 각각 설정하고 하나의 MP3로 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">원본 MP3 파일도 변경되나요?</h3>



<p class="wp-block-paragraph">아닙니다. 원본 파일은 그대로 유지되며 편집 결과가 새로운 MP3 파일로 저장됩니다.</p>



<h3 class="wp-block-heading">5분이 넘는 MP3도 편집할 수 있나요?</h3>



<p class="wp-block-paragraph">현재 버전은 브라우저의 메모리 사용량과 안정성을 고려해 <strong>5분 미만 MP3 파일만 지원</strong>합니다.</p>



<h3 class="wp-block-heading">휴대폰에서도 사용할 수 있나요?</h3>



<p class="wp-block-paragraph">브라우저의 오디오 처리 기능을 지원하는 최신 모바일 브라우저에서도 사용할 수 있습니다. 다만 파일 크기와 기기 성능에 따라 처리 속도에는 차이가 있을 수 있습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/mp3-cutter-editor/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/mp3-cutter-editor-free-online.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1746</post-id>	</item>
		<item>
		<title>무료 PDF 합치기·나누기 &#8211; 순서 변경·페이지 추출·분할</title>
		<link>https://snsnote.com/pdf-merge-split/</link>
					<comments>https://snsnote.com/pdf-merge-split/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Wed, 23 Sep 2026 20:08:10 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1731</guid>

					<description><![CDATA[PDF를 JPG 이미지로 변환하거나 JPG를 PDF로 만들고 싶다면 아래 도구를 이용하세요.무료 PDF·JPG 변환기에서 PDF → JPG, JPG → PDF 변환을 브라우저에서 바로 사용할 수 있습니다....]]></description>
										<content:encoded><![CDATA[<style>
.snpdf{--ink:#0f172a;--sub:#68758a;--line:#dfe7f2;--blue:#2f6df6;--blue2:#2457d7;--sky:#eef6ff;--red:#ff6b73;--redsoft:#fff0f1;--yellow:#ffca5f;width:100%;max-width:none;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;color:var(--ink)}
.snpdf *{box-sizing:border-box}.snpdf button,.snpdf input{font:inherit}

/* HERO - 업스케일러 느낌의 깔끔한 소개 카드 스타일 */
.snpdf-hero{position:relative;overflow:hidden;padding:28px 34px;border:1px solid #d9e8e2;border-radius:30px;background:linear-gradient(135deg,#eef4f1 0%,#f8fbfa 62%,#f2f6fb 100%);box-shadow:0 14px 36px rgba(31,51,74,.05)}
.snpdf-hero:before{content:"";position:absolute;left:0;top:32px;bottom:32px;width:6px;border-radius:0 999px 999px 0;background:#1f8a72}
.snpdf-hero:after{content:"";position:absolute;right:-42px;top:-52px;width:150px;height:150px;border-radius:50%;background:rgba(255,255,255,.35);pointer-events:none}
.snpdf-hero-copy{position:relative;z-index:2;padding-left:14px}
.snpdf-kicker{display:inline-block;margin-bottom:14px;color:#1f7666;font-size:12px;font-weight:900;letter-spacing:.18em;text-transform:uppercase}
.snpdf-titleline{display:block}
.snpdf-titleline .snpdf-title-pdf{display:none}
.snpdf h2{margin:0!important;padding:0!important;border:0!important;border-radius:0!important;background:none!important;background-color:transparent!important;background-image:none!important;box-shadow:none!important;color:#162132!important;font-size:clamp(28px,3vw,36px)!important;font-weight:900!important;line-height:1.12!important;letter-spacing:-.045em!important;width:auto!important;max-width:none!important;text-shadow:none!important}
.snpdf-title-dot{display:inline-block;width:10px;height:10px;margin:0 6px 5px;border-radius:50%;background:#6d79f8;vertical-align:middle}
.snpdf-hero p{max-width:860px;margin:14px 0 0;color:#647481;font-size:13.5px;line-height:1.72}
.snpdf-badges{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}.snpdf-badges span{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 16px;border:1px solid #d4ddd8;border-radius:999px;background:rgba(255,255,255,.78);backdrop-filter:blur(2px);color:#324454;font-size:12px;font-weight:800;box-shadow:0 3px 10px rgba(36,58,84,.03)}
.snpdf-badges span:nth-child(1):before{content:"&#x1f4ce;";margin-right:7px}.snpdf-badges span:nth-child(2):before{content:"&#x2702;";margin-right:7px;color:#f3626d}.snpdf-badges span:nth-child(3):before{content:"⇅";margin-right:7px;color:#4f86f7}.snpdf-badges span:nth-child(4):before{content:"&#x1f512;";margin-right:7px}


/* TOOL CARD */
.snpdf-card{margin-top:18px;padding:18px;border:1px solid #e3e8f0;border-radius:24px;background:#fff;box-shadow:0 12px 34px rgba(32,52,89,.06)}
.snpdf-tabs{display:grid;grid-template-columns:1fr 1fr;gap:7px;padding:5px;border:1px solid #e1e8f1;border-radius:15px;background:#f7f9fc;margin-bottom:15px}
.snpdf-tab{min-height:45px;border:0;border-radius:11px;background:transparent;color:#7a879a;font-size:11px;font-weight:900;cursor:pointer;transition:.18s}
.snpdf-tab:before{display:inline-block;margin-right:7px;font-size:15px;vertical-align:-1px}.snpdf-tab[data-tab="merge"]:before{content:"▱"}.snpdf-tab[data-tab="split"]:before{content:"&#x2702;"}
.snpdf-tab.active{background:#fff;color:#255ddf;box-shadow:0 5px 16px rgba(55,88,143,.09),inset 0 0 0 1px #d8e6ff}
.snpdf-panel{display:none}.snpdf-panel.active{display:block}

/* DROP AREA */
.snpdf-drop{min-height:215px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:22px;border:1.5px dashed #a9d1ff;border-radius:20px;background:linear-gradient(180deg,#fbfdff,#f7fbff);text-align:center;cursor:pointer;transition:.18s}
.snpdf-drop:hover,.snpdf-drop.drag{border-color:#5b9dff;background:#f3f9ff}
.snpdf-upload-scene{position:relative;width:112px;height:68px;margin-bottom:12px}
.snpdf-cloud{position:absolute;left:27px;top:12px;width:58px;height:38px;border-radius:22px;background:linear-gradient(145deg,#9bc8ff,#6faeff);box-shadow:0 8px 18px rgba(78,143,225,.18)}
.snpdf-cloud:before,.snpdf-cloud:after{content:"";position:absolute;border-radius:50%;background:inherit}.snpdf-cloud:before{width:30px;height:30px;left:7px;top:-12px}.snpdf-cloud:after{width:24px;height:24px;right:6px;top:-6px}
.snpdf-up{position:absolute;z-index:2;left:50%;top:15px;transform:translateX(-50%);color:#fff;font-size:26px;font-weight:900}
.snpdf-mini-paper{position:absolute;width:18px;height:24px;border-radius:4px;background:#ffc9cd;box-shadow:0 3px 8px rgba(100,100,120,.08)}.snpdf-mini-paper.p1{left:4px;bottom:1px;transform:rotate(-13deg)}.snpdf-mini-paper.p2{right:3px;bottom:3px;background:#d7e9ff;transform:rotate(12deg)}
.snpdf-drop strong{font-size:13.5px}.snpdf-drop span{margin-top:6px;color:#697a91;font-size:10.5px}.snpdf-drop small{margin-top:8px;color:#9aa8ba;font-size:9px}

/* FILE LIST */
.snpdf-list-wrap{margin-top:13px;padding:11px;border-radius:17px;background:#f7f9fc}
.snpdf-list-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}
.snpdf-list-title{font-size:10.5px;font-weight:900;color:#27354c}.snpdf-list-title b{color:#2f6df6}
.snpdf-order-hint{padding:6px 9px;border:1px solid #d9e8ff;border-radius:999px;background:#eef6ff;color:#4d79bc;font-size:8.8px;font-weight:800}
.snpdf-list{display:flex;flex-direction:column;gap:8px}
.snpdf-row{display:grid;grid-template-columns:30px 40px minmax(0,1fr) auto;gap:10px;align-items:center;padding:11px 12px;border:1px solid #e0e7f0;border-radius:14px;background:#fff;box-shadow:0 3px 10px rgba(42,61,92,.035);transition:.15s}
.snpdf-row.dragging{opacity:.45;transform:scale(.995)}.snpdf-row.drag-over{border-color:#8db9ff;background:#f8fbff}
.snpdf-handle{display:grid;place-items:center;width:28px;height:36px;border-radius:9px;color:#a2afc0;font-size:19px;cursor:grab;user-select:none}.snpdf-handle:active{cursor:grabbing}
.snpdf-fileicon{position:relative;display:grid;place-items:end center;width:36px;height:43px;padding-bottom:7px;border-radius:8px;background:linear-gradient(155deg,#ff989e,#ff6c75);color:#fff;font-size:8px;font-weight:900;box-shadow:0 5px 12px rgba(255,103,113,.16)}.snpdf-fileicon:before{content:"";position:absolute;right:0;top:0;border-style:solid;border-width:0 0 9px 9px;border-color:transparent transparent rgba(255,255,255,.8) transparent}
.snpdf-row-copy{min-width:0}.snpdf-row strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px}.snpdf-row-copy span{display:block;margin-top:3px;color:#98a5b8;font-size:9px}
.snpdf-row-actions{display:flex;align-items:center;gap:6px}
.snpdf-move{display:flex;gap:5px;padding:4px;border:1px solid #dce7f6;border-radius:12px;background:#f6f9fd}
.snpdf-mini{display:flex;align-items:center;justify-content:center;gap:5px;height:36px;border:1px solid transparent;border-radius:9px;font-size:9.5px;font-weight:900;cursor:pointer;transition:.15s;white-space:nowrap}
.snpdf-mini[data-up],.snpdf-mini[data-down]{min-width:66px;padding:0 9px;background:#fff;color:#2f6df6;border-color:#d7e5fb}
.snpdf-mini[data-up]:before{content:"↑";font-size:15px;line-height:1}.snpdf-mini[data-down]:before{content:"↓";font-size:15px;line-height:1}
.snpdf-mini[data-up]:hover,.snpdf-mini[data-down]:hover{background:#edf5ff;border-color:#a9c9ff;transform:translateY(-1px)}
.snpdf-mini[data-del]{width:38px;padding:0;border-color:#ffd9dd;background:#fff1f2;color:#ff626d;font-size:16px}
.snpdf-mini[data-del]:hover{background:#ffe8ea;transform:translateY(-1px)}
.snpdf-mini:disabled{opacity:1;cursor:not-allowed;transform:none!important;background:#f4f6f9!important;border-color:#e7ebf0!important;color:#b3bdc9!important}
.snpdf-mini:disabled:before{color:#b3bdc9}

/* BIG ACTIONS */
.snpdf-actions{display:grid;grid-template-columns:190px 1fr;gap:10px;margin-top:13px}
.snpdf-primary,.snpdf-secondary{min-height:50px;border-radius:14px;padding:0 16px;font-size:11.5px;font-weight:900;cursor:pointer}
.snpdf-primary{border:0;background:linear-gradient(105deg,#3c82ff,#2d63e9);color:#fff;box-shadow:0 9px 18px rgba(47,109,246,.16)}.snpdf-primary:after{content:"  →";font-size:16px}
.snpdf-primary:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
.snpdf-secondary{display:flex;align-items:center;justify-content:center;gap:8px;border:1.5px solid #76a7ff;background:#fff;color:#2e65de}.snpdf-secondary:before{content:"＋";display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f6df6;color:#fff;font-size:17px}
.snpdf-note{position:relative;margin-top:12px;padding:11px 13px 11px 37px;border-radius:12px;background:#f7f9fc;color:#7b899d;font-size:9.2px;line-height:1.55}.snpdf-note:before{content:"&#x1f4a1;";position:absolute;left:12px;top:9px;font-size:16px}

/* SPLIT */
.snpdf-filebox{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:11px;padding:12px 13px;border:1px solid #e2e8f0;border-radius:14px;background:#f8fafc}.snpdf-filebox strong{display:block;max-width:650px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10.5px}.snpdf-filebox span{display:block;margin-top:3px;color:#94a3b8;font-size:9px}.snpdf-filebox button{border:1px solid #dbe3e8;border-radius:9px;background:#fff;padding:8px 10px;color:#64748b;font-size:9.5px;font-weight:800;cursor:pointer}
.snpdf-rangebox{margin-top:12px;padding:15px;border:1px solid #e2e8f0;border-radius:15px;background:#fcfdff}.snpdf-label{display:block;margin-bottom:7px;color:#334155;font-size:10px;font-weight:900}.snpdf-range{width:100%;height:44px;padding:0 12px;border:1px solid #dbe3e8;border-radius:10px;outline:none;font-size:11px}.snpdf-range:focus{border-color:#8fb8ff;box-shadow:0 0 0 3px rgba(47,109,246,.08)}
.snpdf-hint{margin:7px 0 0!important;color:#94a3b8;font-size:9px;line-height:1.5}.snpdf-split-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.snpdf-split-actions button{min-height:44px;border-radius:11px;font-size:10px;font-weight:900;cursor:pointer}.snpdf-extract{border:0;background:#2f6df6;color:#fff}.snpdf-zip{border:1px solid #bfd5ff;background:#eef5ff;color:#2d62d9}.snpdf-split-actions button:disabled{opacity:.45;cursor:not-allowed}

/* STATE */
.snpdf-progress-wrap{margin-top:13px;padding:12px;border:1px solid #e2e8f0;border-radius:12px;background:#fbfcfd}.snpdf-progress-top{display:flex;justify-content:space-between}.snpdf-progress-top strong{font-size:10px}.snpdf-progress-top span{color:#2563eb;font-size:10px;font-weight:900}.snpdf-progress{height:7px;margin-top:8px;border-radius:999px;background:#e8edf3;overflow:hidden}.snpdf-progress span{display:block;width:0;height:100%;background:linear-gradient(90deg,#6aa6ff,#2f6df6)}.snpdf-progress-wrap p{margin:7px 0 0!important;color:#94a3b8;font-size:9px}
.snpdf-error{margin-top:12px;padding:11px 12px;border:1px solid #fecaca;border-radius:10px;background:#fff7f7;color:#b91c1c;font-size:10px;line-height:1.55}
.snpdf [hidden]{display:none!important}

@media(max-width:760px){
 .snpdf-hero{padding:24px 22px 22px;border-radius:24px}.snpdf-hero:before{top:24px;bottom:24px}.snpdf-hero-copy{padding-left:10px}.snpdf-hero:after{right:-58px;top:-68px;width:130px;height:130px}.snpdf h2{font-size:26px!important}.snpdf-hero p{font-size:12.5px}.snpdf-badges{gap:7px;margin-top:15px}.snpdf-badges span{min-height:36px;padding:0 11px;font-size:10px}.snpdf-card{padding:13px;border-radius:18px}
 .snpdf-drop{min-height:195px}.snpdf-actions{grid-template-columns:1fr}.snpdf-secondary{order:2}.snpdf-primary{order:1}
 .snpdf-list-head{align-items:flex-start;flex-direction:column}.snpdf-order-hint{align-self:flex-start}
 .snpdf-row{grid-template-columns:24px 34px minmax(0,1fr)}.snpdf-row-actions{grid-column:1/-1;justify-content:flex-end}.snpdf-move{flex:1}.snpdf-mini[data-up],.snpdf-mini[data-down]{flex:1;min-width:0}.snpdf-fileicon{width:32px;height:39px}
 .snpdf-split-actions{grid-template-columns:1fr}
}
</style>

<div class="snpdf" id="snpdf-8a660b40-27b3-409e-a4c0-e01e8955df19" data-snpdf>
  <section class="snpdf-hero">
    <div class="snpdf-hero-copy">
      <span class="snpdf-kicker">SNSNOTE · PDF TOOL</span>
      <div class="snpdf-titleline">
        <h2>PDF 합치기 <i class="snpdf-title-dot"></i> 나누기</h2>
      </div>
      <p>여러 PDF를 원하는 순서로 합치고, 필요한 페이지만 추출하거나 페이지별로 나눌 수 있습니다. </p>
      <div class="snpdf-badges">
        <span>PDF 합치기</span><span>페이지 추출</span><span>순서 변경</span><span>브라우저 처리</span>
      </div>
    </div>
  </section>

  <section class="snpdf-card">
    <div class="snpdf-tabs">
      <button type="button" class="snpdf-tab active" data-tab="merge">PDF 합치기</button>
      <button type="button" class="snpdf-tab" data-tab="split">PDF 나누기 · 추출</button>
    </div>

    <div class="snpdf-panel active" data-panel="merge">
      <div class="snpdf-drop" data-merge-drop tabindex="0">
        <input type="file" data-merge-input accept="application/pdf,.pdf" multiple hidden>
        <div class="snpdf-upload-scene" aria-hidden="true">
          <i class="snpdf-mini-paper p1"></i><i class="snpdf-mini-paper p2"></i>
          <i class="snpdf-cloud"></i><b class="snpdf-up">↑</b>
        </div>
        <strong>합칠 PDF 파일을 선택하세요</strong>
        <span>여러 파일을 한 번에 선택하거나 끌어 놓으세요</span>
        <small>업로드 후 드래그 또는 ↑ ↓ 버튼으로 순서를 바꿀 수 있어요</small>
      </div>

      <div class="snpdf-list-wrap" data-list-wrap hidden>
        <div class="snpdf-list-head">
          <div class="snpdf-list-title">업로드된 파일 <b data-count>0개</b></div>
          <div class="snpdf-order-hint">☷ 드래그하거나 위로·아래로 버튼으로 순서 변경</div>
        </div>
        <div class="snpdf-list" data-list></div>
      </div>

      <div class="snpdf-actions">
        <button type="button" class="snpdf-secondary" data-add hidden>PDF 추가</button>
        <button type="button" class="snpdf-primary" data-merge disabled>PDF 합치기</button>
      </div>
      <div class="snpdf-note">위에서 보이는 순서대로 PDF가 합쳐집니다. 암호가 걸린 PDF나 특수 보안 PDF는 처리되지 않을 수 있습니다.</div>
    </div>

    <div class="snpdf-panel" data-panel="split">
      <div class="snpdf-drop" data-split-drop tabindex="0">
        <input type="file" data-split-input accept="application/pdf,.pdf" hidden>
        <div class="snpdf-upload-scene" aria-hidden="true">
          <i class="snpdf-mini-paper p1"></i><i class="snpdf-mini-paper p2"></i>
          <i class="snpdf-cloud"></i><b class="snpdf-up">↑</b>
        </div>
        <strong>나눌 PDF 파일을 선택하세요</strong>
        <span>특정 페이지만 추출하거나 전체 페이지를 각각 분리할 수 있습니다</span>
        <small>PDF 1개 선택</small>
      </div>
      <div class="snpdf-filebox" data-filebox hidden>
        <div><strong data-split-name></strong><span data-split-meta></span></div>
        <button type="button" data-change>다른 PDF</button>
      </div>
      <div class="snpdf-rangebox" data-rangebox hidden>
        <label class="snpdf-label">추출할 페이지</label>
        <input type="text" class="snpdf-range" data-range placeholder="예: 1-3, 5, 8-10">
        <p class="snpdf-hint">예: <b>1-3, 5, 8-10</b> → 해당 페이지만 하나의 새 PDF로 저장</p>
        <div class="snpdf-split-actions">
          <button type="button" class="snpdf-extract" data-extract disabled>선택 페이지 PDF 저장</button>
          <button type="button" class="snpdf-zip" data-zip disabled>전체 페이지 각각 나누기 ZIP</button>
        </div>
      </div>
    </div>

    <div class="snpdf-progress-wrap" data-progress-wrap hidden>
      <div class="snpdf-progress-top"><strong data-status>처리 중...</strong><span data-percent>0%</span></div>
      <div class="snpdf-progress"><span data-progress></span></div>
      <p data-detail>PDF는 현재 브라우저에서 처리됩니다.</p>
    </div>
    <div class="snpdf-error" data-error hidden></div>
  </section>
</div>

<script>
(() => {
'use strict';

const PDFLIB_URLS=[
'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js',
'https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js'
];
const JSZIP_URLS=[
'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js',
'https://unpkg.com/jszip@3.10.1/dist/jszip.min.js'
];

const state=window.__snsnotePdfToolV2||(window.__snsnotePdfToolV2={loads:new Map()});

function loadScript(urls,globalName){
 if(window[globalName])return Promise.resolve(window[globalName]);
 const key=globalName+urls.join('|');
 if(state.loads.has(key))return state.loads.get(key);
 const p=new Promise((resolve,reject)=>{
  let i=0;
  const next=()=>{
   if(i>=urls.length)return reject(new Error(globalName+' 파일을 불러오지 못했습니다.'));
   const s=document.createElement('script');s.src=urls[i++];s.async=true;s.crossOrigin='anonymous';
   s.onload=()=>window[globalName]?resolve(window[globalName]):(s.remove(),next());
   s.onerror=()=>{s.remove();next()};
   document.head.appendChild(s);
  };next();
 });
 state.loads.set(key,p);return p;
}
const fmt=b=>b<1048576?Math.round(b/1024)+' KB':(b/1048576).toFixed(1)+' MB';
const safe=n=>(n||'document').replace(/\.pdf$/i,'').replace(/[\\/:*?"<>|]+/g,'_')||'document';
function dl(blob,name){const u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1800)}

function init(root){
 if(root.dataset.ready==='1')return;root.dataset.ready='1';
 const q=s=>root.querySelector(s),qa=s=>[...root.querySelectorAll(s)];
 const tabs=qa('[data-tab]'),panels=qa('[data-panel]');
 const mergeDrop=q('[data-merge-drop]'),mergeInput=q('[data-merge-input]'),list=q('[data-list]'),listWrap=q('[data-list-wrap]'),count=q('[data-count]'),add=q('[data-add]'),mergeBtn=q('[data-merge]');
 const splitDrop=q('[data-split-drop]'),splitInput=q('[data-split-input]'),filebox=q('[data-filebox]'),splitName=q('[data-split-name]'),splitMeta=q('[data-split-meta]'),change=q('[data-change]');
 const rangebox=q('[data-rangebox]'),range=q('[data-range]'),extract=q('[data-extract]'),zipBtn=q('[data-zip]');
 const pw=q('[data-progress-wrap]'),status=q('[data-status]'),percent=q('[data-percent]'),bar=q('[data-progress]'),detail=q('[data-detail]'),err=q('[data-error]');
 let files=[],splitFile=null,pageCount=0,busy=false,dragIndex=-1;

 function error(m){err.textContent=m;err.hidden=false}
 function clear(){err.hidden=true;err.textContent=''}
 function prog(p,t,d=''){pw.hidden=false;p=Math.max(0,Math.min(100,Math.round(p)));bar.style.width=p+'%';percent.textContent=p+'%';status.textContent=t;if(d)detail.textContent=d}
 function hideProg(){pw.hidden=true}
 const isPdf=f=>f&&(f.type==='application/pdf'||/\.pdf$/i.test(f.name));

 tabs.forEach(b=>b.onclick=()=>{tabs.forEach(x=>x.classList.toggle('active',x===b));panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===b.dataset.tab));clear();hideProg()});

 function openMerge(){if(!busy)mergeInput.click()}
 mergeDrop.onclick=e=>{if(e.target!==mergeInput)openMerge()};
 mergeDrop.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openMerge()}};
 add.onclick=openMerge;
 mergeInput.onchange=()=>{addFiles([...mergeInput.files]);mergeInput.value=''};
 ['dragenter','dragover'].forEach(ev=>mergeDrop.addEventListener(ev,e=>{e.preventDefault();mergeDrop.classList.add('drag')}));
 ['dragleave','drop'].forEach(ev=>mergeDrop.addEventListener(ev,e=>{e.preventDefault();mergeDrop.classList.remove('drag')}));
 mergeDrop.addEventListener('drop',e=>addFiles([...e.dataTransfer.files]));

 function addFiles(arr){
  clear();
  const v=arr.filter(isPdf);
  if(!v.length)return error('PDF 파일을 선택해 주세요.');
  files.push(...v);
  render();
 }
 function render(){
  list.innerHTML='';
  listWrap.hidden=!files.length;
  add.hidden=!files.length;
  count.textContent=files.length+'개';
  mergeBtn.disabled=files.length<2||busy;

  files.forEach((f,i)=>{
   const r=document.createElement('div');
   r.className='snpdf-row';
   r.draggable=true;
   r.dataset.index=i;
   r.innerHTML=`
    <div class="snpdf-handle" title="드래그해서 순서 변경">⠿</div>
    <div class="snpdf-fileicon">PDF</div>
    <div class="snpdf-row-copy"><strong></strong><span></span></div>
    <div class="snpdf-row-actions">
      <div class="snpdf-move" aria-label="파일 순서 이동">
        <button type="button" class="snpdf-mini" data-up title="한 칸 위로 이동">위로</button>
        <button type="button" class="snpdf-mini" data-down title="한 칸 아래로 이동">아래로</button>
      </div>
      <button type="button" class="snpdf-mini" data-del title="삭제">×</button>
    </div>`;
   r.querySelector('strong').textContent=f.name;
   r.querySelector('.snpdf-row-copy span').textContent=fmt(f.size);

   const up=r.querySelector('[data-up]'),down=r.querySelector('[data-down]');
   up.disabled=i===0;down.disabled=i===files.length-1;
   up.onclick=()=>move(i,i-1);
   down.onclick=()=>move(i,i+1);
   r.querySelector('[data-del]').onclick=()=>{files.splice(i,1);render()};

   r.addEventListener('dragstart',e=>{
     if(busy){e.preventDefault();return}
     dragIndex=i;r.classList.add('dragging');
     try{e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',String(i))}catch(_){}
   });
   r.addEventListener('dragend',()=>{dragIndex=-1;qa('.snpdf-row').forEach(x=>x.classList.remove('dragging','drag-over'))});
   r.addEventListener('dragover',e=>{e.preventDefault();if(dragIndex!==i)r.classList.add('drag-over')});
   r.addEventListener('dragleave',()=>r.classList.remove('drag-over'));
   r.addEventListener('drop',e=>{
     e.preventDefault();r.classList.remove('drag-over');
     if(dragIndex<0||dragIndex===i)return;
     const [m]=files.splice(dragIndex,1);
     files.splice(i,0,m);
     dragIndex=-1;
     render();
   });
   list.appendChild(r);
  });
 }
 function move(a,b){
  if(b<0||b>=files.length)return;
  const [m]=files.splice(a,1);files.splice(b,0,m);render();
 }

 mergeBtn.onclick=async()=>{
  if(files.length<2||busy)return;
  busy=true;render();clear();
  try{
   prog(3,'PDF 엔진 준비 중...');
   const P=await loadScript(PDFLIB_URLS,'PDFLib'),out=await P.PDFDocument.create();
   let total=0;
   for(let i=0;i<files.length;i++){
    prog(8+Math.round(i/files.length*78),'PDF 합치는 중...',`${i+1} / ${files.length} · ${files[i].name}`);
    let src;
    try{src=await P.PDFDocument.load(await files[i].arrayBuffer())}
    catch{throw new Error(`"${files[i].name}" 파일을 열지 못했습니다.`)}
    const ps=await out.copyPages(src,src.getPageIndices());
    ps.forEach(p=>out.addPage(p));total+=ps.length;
   }
   prog(92,'새 PDF 만드는 중...',`총 ${total}페이지`);
   const bytes=await out.save({useObjectStreams:true});
   dl(new Blob([bytes],{type:'application/pdf'}),'merged.pdf');
   prog(100,'완료',`${files.length}개 PDF · ${total}페이지를 합쳤습니다.`);
  }catch(e){error(e.message||String(e));hideProg()}
  finally{busy=false;render()}
 };

 function openSplit(){if(!busy)splitInput.click()}
 splitDrop.onclick=e=>{if(e.target!==splitInput)openSplit()};
 splitDrop.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openSplit()}};
 splitInput.onchange=()=>{chooseSplit(splitInput.files&&splitInput.files[0]);splitInput.value=''};
 change.onclick=()=>{splitDrop.hidden=false;filebox.hidden=true;rangebox.hidden=true;splitFile=null;pageCount=0;openSplit()};
 ['dragenter','dragover'].forEach(ev=>splitDrop.addEventListener(ev,e=>{e.preventDefault();splitDrop.classList.add('drag')}));
 ['dragleave','drop'].forEach(ev=>splitDrop.addEventListener(ev,e=>{e.preventDefault();splitDrop.classList.remove('drag')}));
 splitDrop.addEventListener('drop',e=>chooseSplit(e.dataTransfer.files&&e.dataTransfer.files[0]));

 async function chooseSplit(f){
  if(!isPdf(f)||busy)return f&&error('PDF 파일을 선택해 주세요.');
  clear();
  try{
   prog(5,'PDF 확인 중...',f.name);
   const P=await loadScript(PDFLIB_URLS,'PDFLib');
   const pdf=await P.PDFDocument.load(await f.arrayBuffer());
   splitFile=f;pageCount=pdf.getPageCount();
   splitName.textContent=f.name;splitMeta.textContent=`${fmt(f.size)} · ${pageCount}페이지`;
   splitDrop.hidden=true;filebox.hidden=false;rangebox.hidden=false;
   range.value=`1-${pageCount}`;extract.disabled=false;zipBtn.disabled=false;
   prog(100,'준비 완료',`${pageCount}페이지 PDF입니다.`);
   setTimeout(hideProg,700);
  }catch{
   error('이 PDF를 열지 못했습니다. 암호화 또는 손상된 파일인지 확인해 주세요.');
   hideProg();
  }
 }

 function parsePages(text,max){
  text=String(text||'').replace(/\s+/g,'');
  if(!text)throw new Error('추출할 페이지를 입력해 주세요.');
  const out=[],seen=new Set();
  for(const tok of text.split(',')){
   if(/^\d+$/.test(tok)){
    const n=+tok;
    if(n<1||n>max)throw new Error(`${n}페이지는 존재하지 않습니다.`);
    if(!seen.has(n)){seen.add(n);out.push(n)}
   }else if(/^\d+-\d+$/.test(tok)){
    let[a,b]=tok.split('-').map(Number);
    if(a<1||b<1||a>max||b>max)throw new Error(`${tok} 범위가 페이지 수를 벗어납니다.`);
    const s=a<=b?1:-1;
    for(let n=a;;n+=s){if(!seen.has(n)){seen.add(n);out.push(n)}if(n===b)break}
   }else throw new Error(`"${tok}" 형식을 확인해 주세요. 예: 1-3, 5, 8-10`);
  }
  return out;
 }

 extract.onclick=async()=>{
  if(!splitFile||busy)return;
  busy=true;extract.disabled=zipBtn.disabled=true;clear();
  try{
   const sel=parsePages(range.value,pageCount);
   prog(5,'페이지 추출 준비 중...',`${sel.length}페이지`);
   const P=await loadScript(PDFLIB_URLS,'PDFLib');
   const src=await P.PDFDocument.load(await splitFile.arrayBuffer()),out=await P.PDFDocument.create();
   const ps=await out.copyPages(src,sel.map(n=>n-1));ps.forEach(p=>out.addPage(p));
   prog(85,'새 PDF 만드는 중...');
   const bytes=await out.save({useObjectStreams:true});
   dl(new Blob([bytes],{type:'application/pdf'}),safe(splitFile.name)+'-pages.pdf');
   prog(100,'완료',`${sel.length}페이지를 저장했습니다.`);
  }catch(e){error(e.message||String(e));hideProg()}
  finally{busy=false;extract.disabled=!splitFile;zipBtn.disabled=!splitFile}
 };

 zipBtn.onclick=async()=>{
  if(!splitFile||busy)return;
  busy=true;extract.disabled=zipBtn.disabled=true;clear();
  try{
   prog(3,'페이지 분할 준비 중...',`${pageCount}페이지`);
   const [P,JSZip]=await Promise.all([loadScript(PDFLIB_URLS,'PDFLib'),loadScript(JSZIP_URLS,'JSZip')]);
   const src=await P.PDFDocument.load(await splitFile.arrayBuffer()),zip=new JSZip(),base=safe(splitFile.name),digits=String(pageCount).length;
   for(let i=0;i<pageCount;i++){
    prog(8+Math.round(i/pageCount*76),'페이지별 PDF 만드는 중...',`${i+1} / ${pageCount}`);
    const out=await P.PDFDocument.create(),[p]=await out.copyPages(src,[i]);
    out.addPage(p);
    zip.file(`${base}-page-${String(i+1).padStart(digits,'0')}.pdf`,await out.save({useObjectStreams:true}));
   }
   prog(88,'ZIP 파일 만드는 중...');
   const blob=await zip.generateAsync({type:'blob'});
   dl(blob,base+'-split-pages.zip');
   prog(100,'완료',`${pageCount}개 PDF를 ZIP으로 저장했습니다.`);
  }catch(e){error(e.message||String(e));hideProg()}
  finally{busy=false;extract.disabled=!splitFile;zipBtn.disabled=!splitFile}
 };
}

const boot=()=>document.querySelectorAll('[data-snpdf]').forEach(init);
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();
</script>




<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>PDF를 JPG 이미지로 변환하거나 JPG를 PDF로 만들고 싶다면 아래 도구를 이용하세요.</strong><br><a href="https://snsnote.com/pdf-jpg-converter/">무료 PDF·JPG 변환기</a>에서 PDF → JPG, JPG → PDF 변환을 브라우저에서 바로 사용할 수 있습니다.</p>
</blockquote>



<h2 class="wp-block-heading">PDF 합치기·나누기는 언제 필요한가요?</h2>



<p class="wp-block-paragraph">여러 개로 나뉜 PDF 파일을 하나로 제출해야 하거나, 긴 PDF 문서에서 필요한 페이지만 따로 저장하고 싶을 때 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 신청서, 통장 사본, 증빙 서류처럼 각각 따로 저장된 PDF를 하나의 파일로 제출해야 하는 경우가 있습니다. 이럴 때 PDF 파일을 원하는 순서대로 배치한 뒤 하나의 문서로 합칠 수 있습니다.</p>



<p class="wp-block-paragraph">반대로 수십 페이지로 된 PDF에서 일부 페이지만 보내야 한다면 전체 파일을 전달할 필요 없이 원하는 페이지만 선택해 새로운 PDF로 저장할 수 있습니다.</p>



<h2 class="wp-block-heading">여러 PDF 파일 하나로 합치기</h2>



<p class="wp-block-paragraph">상단의 <strong>PDF 합치기</strong> 메뉴에서 여러 PDF 파일을 선택하면 업로드한 파일 목록이 표시됩니다.</p>



<p class="wp-block-paragraph">파일은 화면에 표시된 위에서 아래 순서대로 합쳐집니다. 순서가 마음에 들지 않는다면 파일을 직접 드래그하거나 <strong>위로·아래로 버튼</strong>을 이용해 PDF 순서를 변경할 수 있습니다.</p>



<p class="wp-block-paragraph">원하는 순서로 정리한 후 <strong>PDF 합치기</strong> 버튼을 누르면 하나의 PDF 파일로 저장됩니다.</p>



<h2 class="wp-block-heading">PDF 순서 변경하는 방법</h2>



<p class="wp-block-paragraph">PDF를 합칠 때는 파일 순서가 중요할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 신청서 → 신분증 사본 → 통장 사본 → 추가 증빙자료처럼 제출 순서를 맞춰야 하는 경우가 있습니다.</p>



<p class="wp-block-paragraph">이 도구에서는 파일을 다시 선택할 필요 없이 업로드 후 바로 순서를 변경할 수 있습니다. 잘못 추가한 PDF는 삭제한 뒤 새로운 파일을 추가할 수도 있습니다.</p>



<h2 class="wp-block-heading">PDF에서 원하는 페이지만 추출하기</h2>



<p class="wp-block-paragraph"><strong>PDF 나누기·추출</strong> 메뉴에서는 하나의 PDF 파일에서 필요한 페이지만 선택해 별도의 PDF로 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">페이지 입력창에 다음과 같이 입력하면 됩니다.</p>



<p class="wp-block-paragraph"><code>1-3, 5, 8-10</code></p>



<p class="wp-block-paragraph">위와 같이 입력하면 1, 2, 3, 5, 8, 9, 10페이지만 모아서 하나의 새로운 PDF로 저장됩니다.</p>



<p class="wp-block-paragraph">계약서나 안내문, 보고서처럼 페이지가 많은 문서에서 일부 페이지만 전달해야 할 때 유용합니다.</p>



<h2 class="wp-block-heading">PDF 전체 페이지를 각각 분할하기</h2>



<p class="wp-block-paragraph">PDF의 모든 페이지를 각각 별도의 파일로 나누는 기능도 지원합니다.</p>



<p class="wp-block-paragraph">예를 들어 10페이지짜리 PDF라면 각각 1페이지씩 들어 있는 PDF 파일 10개로 분리하고, 결과 파일을 ZIP으로 한 번에 저장할 수 있습니다.</p>



<p class="wp-block-paragraph">스캔한 문서를 페이지별로 나누거나 여러 사람에게 서로 다른 페이지만 전달해야 할 때 사용할 수 있습니다.</p>



<h2 class="wp-block-heading">파일은 서버에 업로드되나요?</h2>



<p class="wp-block-paragraph">아니요. 이 페이지의 PDF 합치기와 PDF 나누기 기능은 파일을 외부 서버에 업로드한 뒤 처리하는 방식이 아니라 <strong>현재 사용 중인 브라우저 안에서 직접 처리</strong>합니다.</p>



<p class="wp-block-paragraph">개인정보가 포함된 서류나 업무 문서를 다룰 때도 파일 자체가 SNSnote 서버로 전송되지 않습니다.</p>



<p class="wp-block-paragraph">다만 암호가 설정된 PDF, 복사나 편집이 제한된 특수 보안 PDF, 손상된 PDF 파일은 정상적으로 처리되지 않을 수 있습니다.</p>



<h2 class="wp-block-heading">이런 경우에 사용하기 좋습니다</h2>



<ul class="wp-block-list">
<li>여러 PDF 서류를 하나로 합쳐 제출해야 할 때</li>



<li>PDF 파일의 순서를 변경한 뒤 합치고 싶을 때</li>



<li>긴 PDF에서 특정 페이지만 따로 저장하고 싶을 때</li>



<li>PDF 전체를 페이지별 파일로 분리하고 싶을 때</li>



<li>별도의 프로그램을 설치하지 않고 브라우저에서 바로 처리하고 싶을 때</li>
</ul>



<h2 class="wp-block-heading">자주 묻는 질문</h2>



<h3 class="wp-block-heading">PDF 합치기 전에 순서를 바꿀 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. 업로드된 파일을 드래그하거나 위로·아래로 버튼을 사용해 원하는 순서로 변경한 후 PDF를 합칠 수 있습니다.</p>



<h3 class="wp-block-heading">PDF의 일부 페이지만 따로 저장할 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. <code>1-3, 6, 9-12</code>처럼 원하는 페이지 번호를 입력하면 선택한 페이지만 새로운 PDF 파일로 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">PDF 파일을 페이지별로 전부 나눌 수도 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. <strong>전체 페이지 각각 나누기</strong> 기능을 사용하면 PDF를 1페이지 단위로 분리한 뒤 ZIP 파일로 한 번에 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">PDF를 JPG로 변환할 수도 있나요?</h3>



<p class="wp-block-paragraph">이 도구는 PDF 합치기와 나누기에 특화되어 있습니다. <strong>PDF를 JPG로 변환하거나 JPG 이미지를 PDF로 만들려면 <a href="https://snsnote.com/pdf-jpg-converter/">무료 PDF·JPG 변환기</a>를 이용하면 됩니다.</strong></p>



<h3 class="wp-block-heading">파일이 서버에 저장되나요?</h3>



<p class="wp-block-paragraph">아니요. PDF 처리는 브라우저 내부에서 진행되며, 선택한 PDF 파일은 SNSnote 서버에 업로드되지 않습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/pdf-merge-split/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/pdf-merge-split-page-extract.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1731</post-id>	</item>
		<item>
		<title>무료 AI 업스케일러 &#8211; 사진·영상 화질 개선 2배·4배</title>
		<link>https://snsnote.com/ai-upscaler/</link>
					<comments>https://snsnote.com/ai-upscaler/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Mon, 21 Sep 2026 10:10:35 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1682</guid>

					<description><![CDATA[AI 업스케일러란? 해상도가 낮거나 오래된 사진을 확대하면 보통 픽셀이 그대로 커지면서 이미지가 흐릿하게 보입니다. 단순히 가로·세로 크기만 늘리는 방식과 달리 AI 업스케일러는 이미지의 윤곽과 디테일을...]]></description>
										<content:encoded><![CDATA[<p>[snsnote_ai_upscaler]</p>



<h2 class="wp-block-heading">AI 업스케일러란?</h2>



<p class="wp-block-paragraph">해상도가 낮거나 오래된 사진을 확대하면 보통 픽셀이 그대로 커지면서 이미지가 흐릿하게 보입니다. 단순히 가로·세로 크기만 늘리는 방식과 달리 AI 업스케일러는 이미지의 윤곽과 디테일을 분석해 더 자연스럽게 확대하는 방식입니다.</p>



<p class="wp-block-paragraph">위 도구에서는 사진뿐 아니라 짧은 영상도 2배 또는 4배 크기로 확대할 수 있습니다. 결과가 만들어지면 원본과 AI 처리 결과를 슬라이더로 직접 비교할 수 있어 어느 정도 화질이 개선됐는지 바로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">별도의 프로그램이나 앱을 설치할 필요 없이 브라우저에서 바로 사용할 수 있으며, 선택한 사진과 영상은 업스케일 처리를 위해 SNSnote 서버에 업로드되지 않습니다.</p>



<h2 class="wp-block-heading">AI 사진 화질 개선 사용 방법</h2>



<p class="wp-block-paragraph">사용 방법은 간단합니다.</p>



<h3 class="wp-block-heading">1. 사진 또는 영상 선택</h3>



<p class="wp-block-paragraph">상단의 <strong>사진 또는 영상 선택</strong> 영역을 클릭해 파일을 불러옵니다.</p>



<p class="wp-block-paragraph">지원 형식은 다음과 같습니다.</p>



<p class="wp-block-paragraph">JPG, PNG, WebP 이미지와 MP4, WebM, MOV 영상을 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">PC에서는 파일을 업로드 영역으로 직접 끌어다 놓아도 됩니다.</p>



<h3 class="wp-block-heading">2. 확대 배율 선택</h3>



<p class="wp-block-paragraph">파일을 선택한 뒤 <strong>2배 확대</strong> 또는 <strong>4배 확대</strong>를 선택합니다.</p>



<p class="wp-block-paragraph">일반적인 사진은 먼저 2배 확대를 사용해보는 것을 권장합니다. 원본 해상도가 많이 낮거나 더 큰 이미지가 필요한 경우에는 4배 확대를 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 800 × 600px 사진을 4배로 처리하면 최종 결과는 3,200 × 2,400px 크기로 만들어집니다.</p>



<p class="wp-block-paragraph">단, 배율이 높아질수록 처리해야 할 이미지 크기도 크게 증가하기 때문에 작업 시간이 더 길어질 수 있습니다.</p>



<h3 class="wp-block-heading">3. AI 업스케일 시작</h3>



<p class="wp-block-paragraph">배율을 선택한 뒤 <strong>AI 업스케일 시작</strong> 버튼을 누릅니다.</p>



<p class="wp-block-paragraph">처음 사용할 때는 브라우저에서 AI 처리에 필요한 파일을 한 번 불러오기 때문에 평소보다 조금 더 시간이 걸릴 수 있습니다.</p>



<p class="wp-block-paragraph">이후에는 사용 중인 PC와 브라우저 환경에 따라 GPU 가속을 이용해 사진의 디테일을 복원하면서 확대합니다.</p>



<h3 class="wp-block-heading">4. 원본과 결과 비교</h3>



<p class="wp-block-paragraph">처리가 완료되면 원본과 AI 업스케일 결과가 하나의 화면에 표시됩니다.</p>



<p class="wp-block-paragraph">가운데 있는 슬라이더를 좌우로 움직이면 같은 위치에서 원본과 결과를 비교할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 글자 주변, 머리카락, 얼굴 윤곽, 사물의 경계처럼 흐릿했던 부분을 비교해 보면 차이를 확인하기 쉽습니다.</p>



<h3 class="wp-block-heading">5. 결과 파일 저장</h3>



<p class="wp-block-paragraph">결과가 마음에 들면 우측 상단의 저장 버튼을 눌러 파일을 저장하면 됩니다.</p>



<p class="wp-block-paragraph">사진은 고해상도 이미지로 저장되며, 영상은 처리 후 MP4 형식으로 저장할 수 있습니다.</p>



<h2 class="wp-block-heading">2배와 4배 중 어떤 것을 선택해야 할까?</h2>



<p class="wp-block-paragraph">무조건 4배가 좋은 것은 아닙니다.</p>



<p class="wp-block-paragraph">원본 사진의 화질이 어느 정도 괜찮다면 <strong>2배 확대만으로도 충분한 경우가 많습니다.</strong> 처리 속도도 빠르고 결과 파일 크기도 상대적으로 작습니다.</p>



<p class="wp-block-paragraph">반대로 작은 썸네일이나 오래된 저해상도 사진처럼 원본 크기가 많이 작은 경우에는 4배 확대가 더 유용할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 AI 업스케일링은 원본에 존재하지 않는 정보를 완벽하게 되살리는 기술은 아닙니다. 심하게 흔들렸거나 초점 자체가 맞지 않은 사진은 크기는 커져도 기대만큼 선명해지지 않을 수 있습니다.</p>



<h2 class="wp-block-heading">영상 화질 개선은 사진보다 오래 걸리는 이유</h2>



<p class="wp-block-paragraph">사진은 이미지 한 장만 처리하면 되지만 영상은 다릅니다.</p>



<p class="wp-block-paragraph">예를 들어 30fps 영상은 1초에 약 30장의 이미지로 구성되어 있습니다. 10초짜리 영상이라면 약 300개의 프레임을 순서대로 처리해야 합니다.</p>



<p class="wp-block-paragraph">따라서 동일한 해상도라도 영상 업스케일은 사진보다 훨씬 많은 연산이 필요합니다.</p>



<p class="wp-block-paragraph">영상 처리 중에는 가능하면 해당 브라우저 탭을 닫지 않는 것이 좋습니다. PC의 그래픽 성능과 영상의 길이, 해상도에 따라 처리 시간은 달라질 수 있습니다.</p>



<p class="wp-block-paragraph">현재는 안정적인 브라우저 처리를 위해 긴 고해상도 영상보다는 <strong>짧은 저해상도 영상의 화질 개선 용도</strong>에 적합합니다.</p>



<h2 class="wp-block-heading">AI 업스케일러 FAQ</h2>



<h3 class="wp-block-heading">AI 업스케일러는 무료인가요?</h3>



<p class="wp-block-paragraph">네. 현재 SNSnote AI 업스케일러는 별도의 결제 없이 사용할 수 있습니다. 결과 이미지에 워터마크도 추가되지 않습니다.</p>



<h3 class="wp-block-heading">업로드한 사진이 서버에 저장되나요?</h3>



<p class="wp-block-paragraph">아니요. 사진과 영상의 AI 처리는 사용 중인 브라우저에서 진행됩니다. 선택한 원본 파일 자체를 업스케일 목적으로 SNSnote 서버에 전송하는 방식이 아닙니다.</p>



<h3 class="wp-block-heading">스마트폰에서도 사용할 수 있나요?</h3>



<p class="wp-block-paragraph">브라우저가 필요한 기능을 지원한다면 사용할 수 있지만, AI 업스케일링은 연산량이 큰 작업입니다. 큰 사진이나 영상은 스마트폰보다 GPU가 있는 PC의 Chrome 또는 Edge에서 사용하는 것이 더 안정적일 수 있습니다.</p>



<h3 class="wp-block-heading">사진을 4배로 확대하면 화질도 정확히 4배 좋아지나요?</h3>



<p class="wp-block-paragraph">아닙니다. 4배는 이미지의 가로와 세로 해상도를 확대한다는 의미입니다.</p>



<p class="wp-block-paragraph">AI가 윤곽과 디테일을 보완하기 때문에 단순 확대보다 자연스럽게 보일 수 있지만, 원본 사진에 없던 디테일을 완벽하게 복원한다는 의미는 아닙니다.</p>



<h3 class="wp-block-heading">흐릿한 사진도 선명하게 만들 수 있나요?</h3>



<p class="wp-block-paragraph">가벼운 저화질이나 압축으로 인해 디테일이 부족한 사진은 개선 효과를 볼 수 있습니다.</p>



<p class="wp-block-paragraph">하지만 심한 모션 블러, 초점이 크게 벗어난 사진, 얼굴이 몇 픽셀밖에 남지 않은 극단적인 저해상도 이미지는 복원에 한계가 있습니다.</p>



<h3 class="wp-block-heading">JPG를 업스케일하면 용량이 커지나요?</h3>



<p class="wp-block-paragraph">대부분 커집니다. 이미지의 가로와 세로 픽셀 수가 늘어나기 때문에 결과 파일의 용량 역시 증가할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 4배 확대는 원본보다 훨씬 많은 픽셀을 포함하게 됩니다.</p>



<h3 class="wp-block-heading">영상도 4배 확대할 수 있나요?</h3>



<p class="wp-block-paragraph">가능하지만 영상의 길이와 원본 해상도에 따라 제한될 수 있습니다.</p>



<p class="wp-block-paragraph">영상은 모든 프레임을 개별적으로 AI 처리해야 하기 때문에 사진보다 훨씬 많은 GPU 연산과 메모리가 필요합니다. 짧은 480p급 영상처럼 비교적 작은 영상에서 사용하는 것을 권장합니다.</p>



<h3 class="wp-block-heading">처리 도중 브라우저를 닫아도 되나요?</h3>



<p class="wp-block-paragraph">아니요. AI 처리가 브라우저 안에서 진행되기 때문에 완료되기 전에 페이지를 닫거나 새로고침하면 현재 작업이 중단됩니다.</p>



<h3 class="wp-block-heading">결과가 원본과 크게 차이나지 않는 경우도 있나요?</h3>



<p class="wp-block-paragraph">있습니다. 원본 사진 자체가 이미 고해상도이고 선명하다면 AI 업스케일 후에도 화면에서 보이는 차이가 크지 않을 수 있습니다.</p>



<p class="wp-block-paragraph">반대로 낮은 해상도의 이미지나 확대했을 때 픽셀이 눈에 띄는 사진은 차이를 확인하기 더 쉽습니다.</p>



<h2 class="wp-block-heading">이런 경우에 활용하기 좋습니다</h2>



<p class="wp-block-paragraph">작은 웹 이미지를 더 큰 크기로 사용해야 할 때, 오래된 사진을 확대해서 보관할 때, 저해상도 썸네일을 조금 더 선명하게 만들고 싶을 때, 짧은 저화질 영상을 더 높은 해상도로 변환하고 싶을 때 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 결과를 바로 저장하기 전에 <strong>원본과 AI 결과를 슬라이더로 비교할 수 있기 때문에</strong>, 실제로 개선 효과가 있는지 확인한 뒤 파일을 저장할 수 있다는 점이 편리합니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/ai-upscaler/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/ai-upscaler-photo-video-quality-enhancement.webp" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1682</post-id>	</item>
		<item>
		<title>AI 음성 자막 변환기 무료 &#8211; 회의·강의 SRT·TXT 자동 생성</title>
		<link>https://snsnote.com/ai-audio-subtitle-converter/</link>
					<comments>https://snsnote.com/ai-audio-subtitle-converter/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Thu, 17 Sep 2026 05:02:34 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1672</guid>

					<description><![CDATA[회의나 강의를 녹음해두고 나중에 내용을 정리하려고 하면 생각보다 시간이 많이 걸립니다. 1시간짜리 MP3 파일을 처음부터 끝까지 다시 들으면서 직접 받아 적는 것은 상당히 번거로운 작업입니다....]]></description>
										<content:encoded><![CDATA[<div id="sns-ai-subtitle">
  <style>
    #sns-ai-subtitle{max-width:980px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",Arial,sans-serif;color:#0f172a}
    #sns-ai-subtitle *{box-sizing:border-box}
    #sns-ai-subtitle .sns-card{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:24px;box-shadow:0 8px 28px rgba(15,23,42,.06)}
    #sns-ai-subtitle h2{font-size:28px;line-height:1.25;margin:0 0 8px;font-weight:800;letter-spacing:-.04em}
    #sns-ai-subtitle .sns-sub{font-size:14px;line-height:1.65;color:#64748b;margin:0 0 20px}
    #sns-ai-subtitle .sns-drop{display:flex;min-height:220px;border:2px dashed #cbd5e1;border-radius:16px;background:#f8fafc;align-items:center;justify-content:center;text-align:center;padding:28px;cursor:pointer;transition:.18s ease}
    #sns-ai-subtitle .sns-drop:hover,#sns-ai-subtitle .sns-drop.is-drag{border-color:#2563eb;background:#eff6ff}
    #sns-ai-subtitle .sns-plus{width:54px;height:54px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 12px;background:#2563eb;color:#fff;font-size:30px;font-weight:400;line-height:1}
    #sns-ai-subtitle .sns-drop strong{display:block;font-size:18px;margin-bottom:7px}
    #sns-ai-subtitle .sns-drop span{font-size:13px;color:#64748b}
    #sns-ai-subtitle input[type=file]{display:none}
    #sns-ai-subtitle .sns-file{display:none;margin-top:16px;border:1px solid #e2e8f0;border-radius:14px;padding:16px;background:#fff}
    #sns-ai-subtitle .sns-file.show{display:block}
    #sns-ai-subtitle .sns-file-top{display:flex;align-items:center;gap:12px}
    #sns-ai-subtitle .sns-file-icon{width:42px;height:42px;border-radius:12px;background:#eff6ff;color:#2563eb;display:flex;align-items:center;justify-content:center;font-size:21px;flex:0 0 auto}
    #sns-ai-subtitle .sns-file-name{font-weight:700;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    #sns-ai-subtitle .sns-file-meta{font-size:12px;color:#64748b;margin-top:3px}
    #sns-ai-subtitle .sns-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}
    #sns-ai-subtitle .sns-field label{display:block;font-size:13px;font-weight:700;margin:0 0 7px}
    #sns-ai-subtitle select{width:100%;height:46px;border:1px solid #cbd5e1;border-radius:12px;background:#fff;padding:0 13px;font-size:14px;color:#0f172a;outline:none}
    #sns-ai-subtitle select:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.09)}
    #sns-ai-subtitle .sns-helper{margin-top:7px;font-size:12px;line-height:1.45;color:#64748b}
    #sns-ai-subtitle .sns-btn{width:100%;height:54px;border:0;border-radius:14px;margin-top:16px;background:#0f172a;color:#fff;font-size:17px;font-weight:800;cursor:pointer;transition:.16s ease}
    #sns-ai-subtitle .sns-btn:hover{transform:translateY(-1px)}
    #sns-ai-subtitle .sns-btn:disabled{opacity:.55;cursor:not-allowed;transform:none}
    #sns-ai-subtitle .sns-status{display:none;margin-top:16px;padding:14px 15px;border-radius:13px;border:1px solid #dbeafe;background:#eff6ff;color:#1e40af;font-size:13px;line-height:1.55}
    #sns-ai-subtitle .sns-status.show{display:block}
    #sns-ai-subtitle .sns-status.err{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
    #sns-ai-subtitle .sns-progress-wrap{display:none;margin-top:12px}
    #sns-ai-subtitle .sns-progress-wrap.show{display:block}
    #sns-ai-subtitle .sns-progress-head{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:#64748b;margin-bottom:7px}
    #sns-ai-subtitle .sns-progress{height:9px;border-radius:999px;background:#e2e8f0;overflow:hidden}
    #sns-ai-subtitle .sns-progress i{display:block;width:0;height:100%;background:#2563eb;border-radius:999px;transition:width .2s ease}
    #sns-ai-subtitle .sns-result{display:none;margin-top:22px}
    #sns-ai-subtitle .sns-result.show{display:block}
    #sns-ai-subtitle .sns-result h3{font-size:20px;margin:0 0 12px;font-weight:800}
    #sns-ai-subtitle .sns-result-box{border:1px solid #e2e8f0;border-radius:15px;overflow:hidden;background:#fff}
    #sns-ai-subtitle .sns-result-toolbar{display:flex;flex-wrap:wrap;gap:8px;padding:12px;border-bottom:1px solid #e2e8f0;background:#f8fafc}
    #sns-ai-subtitle .sns-mini{border:1px solid #cbd5e1;background:#fff;color:#0f172a;border-radius:10px;height:38px;padding:0 13px;font-size:13px;font-weight:700;cursor:pointer}
    #sns-ai-subtitle .sns-mini.primary{background:#2563eb;border-color:#2563eb;color:#fff}
    #sns-ai-subtitle textarea{display:block;width:100%;min-height:270px;border:0;outline:0;resize:vertical;padding:17px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.65;color:#0f172a;background:#fff}
    #sns-ai-subtitle .sns-note{font-size:12px;line-height:1.6;color:#64748b;margin:14px 2px 0}
    #sns-ai-subtitle .sns-badge{display:inline-flex;align-items:center;gap:6px;margin-top:10px;padding:7px 10px;border-radius:999px;background:#eef2ff;color:#4338ca;font-size:12px;font-weight:700}
    #sns-ai-subtitle .sns-reset{margin-left:auto}
    @media(max-width:700px){
      #sns-ai-subtitle .sns-card{padding:18px}
      #sns-ai-subtitle h2{font-size:23px}
      #sns-ai-subtitle .sns-grid{grid-template-columns:1fr}
      #sns-ai-subtitle .sns-drop{min-height:190px;padding:20px}
    }
  </style>

  <div class="sns-card">
    <h2>AI 음성 → 자막 변환</h2>
    <p class="sns-sub">음성이나 영상 파일을 넣으면 AI가 브라우저에서 음성을 분석해 자막으로 변환합니다. 변환 후 SRT 또는 TXT로 바로 저장할 수 있습니다.</p>

    <label class="sns-drop" id="snsAsrDrop" for="snsAsrFile">
      <div>
        <div class="sns-plus">+</div>
        <strong>음성·영상 파일을 선택하거나 여기로 끌어오세요</strong>
        <span>MP3 · WAV · M4A · MP4 · WEBM · OGG 등 / 최대 300MB</span>
      </div>
    </label>
    <input id="snsAsrFile" type="file" accept="audio/*,video/mp4,video/webm,.mp3,.wav,.m4a,.mp4,.webm,.ogg,.aac,.flac">

    <div class="sns-file" id="snsAsrFileBox">
      <div class="sns-file-top">
        <div class="sns-file-icon">♪</div>
        <div style="min-width:0;flex:1">
          <div class="sns-file-name" id="snsAsrFileName"></div>
          <div class="sns-file-meta" id="snsAsrFileMeta"></div>
        </div>
        <button type="button" class="sns-mini sns-reset" id="snsAsrReset">다른 파일</button>
      </div>
    </div>

    <div class="sns-grid">
      <div class="sns-field">
        <label for="snsAsrLang">음성 언어</label>
        <select id="snsAsrLang">
          <option value="korean">한국어</option>
          <option value="japanese">일본어</option>
          <option value="english">영어</option>
          <option value="chinese">중국어</option>
          <option value="spanish">스페인어</option>
          <option value="french">프랑스어</option>
          <option value="german">독일어</option>
        </select>
      </div>
      <div class="sns-field">
        <label for="snsAsrMode">AI 모드</label>
        <select id="snsAsrMode">
          <option value="fast">빠른 변환</option>
          <option value="accurate">정확도 우선</option>
        </select>
        <div class="sns-helper" id="snsAsrModeHelp">회의·강의·일반 음성에 추천</div>
      </div>
    </div>

    <button type="button" class="sns-btn" id="snsAsrStart" disabled>AI 자막 만들기</button>
    <div class="sns-badge"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f512.png" alt="🔒" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 파일은 자막 변환을 위해 사이트 서버에 업로드하지 않습니다</div>

    <div class="sns-status" id="snsAsrStatus"></div>
    <div class="sns-progress-wrap" id="snsAsrProgressWrap">
      <div class="sns-progress-head"><span id="snsAsrProgressText">준비 중</span><span id="snsAsrProgressPct">0%</span></div>
      <div class="sns-progress"><i id="snsAsrProgressBar"></i></div>
    </div>

    <div class="sns-result" id="snsAsrResult">
      <h3>변환된 자막</h3>
      <div class="sns-result-box">
        <div class="sns-result-toolbar">
          <button type="button" class="sns-mini primary" id="snsAsrSrt">SRT 다운로드</button>
          <button type="button" class="sns-mini" id="snsAsrTxt">TXT 다운로드</button>
          <button type="button" class="sns-mini" id="snsAsrCopy">전체 복사</button>
        </div>
        <textarea id="snsAsrOutput" spellcheck="false"></textarea>
      </div>
      <p class="sns-note">※ 자동 자막은 발음, 배경음, 음질에 따라 오차가 생길 수 있습니다. 다운로드 전에 자막 내용을 한 번 확인하는 것을 권장합니다.</p>
    </div>
  </div>

  <script type="module">
  (() => {
    const root = document.getElementById('sns-ai-subtitle');
    if (!root || root.dataset.ready === '1') return;
    root.dataset.ready = '1';

    const $ = (s) => root.querySelector(s);
    const fileInput = $('#snsAsrFile');
    const drop = $('#snsAsrDrop');
    const fileBox = $('#snsAsrFileBox');
    const fileName = $('#snsAsrFileName');
    const fileMeta = $('#snsAsrFileMeta');
    const resetBtn = $('#snsAsrReset');
    const startBtn = $('#snsAsrStart');
    const langSel = $('#snsAsrLang');
    const modeSel = $('#snsAsrMode');
    const modeHelp = $('#snsAsrModeHelp');
    const statusEl = $('#snsAsrStatus');
    const progressWrap = $('#snsAsrProgressWrap');
    const progressText = $('#snsAsrProgressText');
    const progressPct = $('#snsAsrProgressPct');
    const progressBar = $('#snsAsrProgressBar');
    const resultBox = $('#snsAsrResult');
    const outputEl = $('#snsAsrOutput');
    const srtBtn = $('#snsAsrSrt');
    const txtBtn = $('#snsAsrTxt');
    const copyBtn = $('#snsAsrCopy');

    let selectedFile = null;
    let currentDuration = 0;
    let lastChunks = [];
    let lastText = '';
    let running = false;

    const pipes = new Map();
    let transformersPromise = null;
    let musicClassifierPromise = null;
    let demucsProcessorPromise = null;

    const WHISPER_FAST = 'onnx-community/whisper-tiny';
    const WHISPER_ACCURATE = 'onnx-community/whisper-base';
    const MUSIC_MODEL = 'Xenova/ast-finetuned-audioset-10-10-0.4593';
    const DEMUCS_MAX_SECONDS = 30 * 60; // 브라우저에서 지나치게 긴 음악 분리는 방지
    const DEMUCS_CHUNK_SECONDS = 120;   // 메모리 폭주 방지를 위해 2분씩 분리

    function humanBytes(bytes) {
      if (!Number.isFinite(bytes)) return '';
      const units = ['B','KB','MB','GB'];
      let i = 0, n = bytes;
      while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
      return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
    }

    function fmtDuration(sec) {
      sec = Math.max(0, Math.round(sec || 0));
      const h = Math.floor(sec / 3600);
      const m = Math.floor((sec % 3600) / 60);
      const s = sec % 60;
      return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
    }

    function setStatus(msg, error = false) {
      statusEl.textContent = msg;
      statusEl.classList.add('show');
      statusEl.classList.toggle('err', !!error);
    }

    function hideStatus() {
      statusEl.classList.remove('show','err');
      statusEl.textContent = '';
    }

    function setProgress(pct, text) {
      const p = Math.max(0, Math.min(100, Number(pct) || 0));
      progressWrap.classList.add('show');
      progressBar.style.width = `${p}%`;
      progressPct.textContent = `${Math.round(p)}%`;
      progressText.textContent = text || '처리 중';
    }

    function resetProgress() {
      progressWrap.classList.remove('show');
      progressBar.style.width = '0%';
      progressPct.textContent = '0%';
      progressText.textContent = '준비 중';
    }

    function updateModeHelp() {
      modeHelp.textContent = modeSel.value === 'accurate'
        ? '음악을 자동 분석하고 필요할 때만 보컬을 분리합니다'
        : '회의·강의·일반 음성에 추천';
    }
    modeSel.addEventListener('change', updateModeHelp);
    updateModeHelp();

    async function readDuration(file) {
      return new Promise((resolve) => {
        const tag = document.createElement(file.type.startsWith('video/') ? 'video' : 'audio');
        const url = URL.createObjectURL(file);
        tag.preload = 'metadata';
        tag.onloadedmetadata = () => {
          const d = Number.isFinite(tag.duration) ? tag.duration : 0;
          URL.revokeObjectURL(url);
          resolve(d);
        };
        tag.onerror = () => { URL.revokeObjectURL(url); resolve(0); };
        tag.src = url;
      });
    }

    async function chooseFile(file) {
      if (!file) return;
      const max = 300 * 1024 * 1024;
      if (file.size > max) {
        setStatus('파일이 너무 큽니다. 300MB 이하 파일을 사용해주세요.', true);
        return;
      }
      selectedFile = file;
      currentDuration = await readDuration(file);
      fileName.textContent = file.name;
      fileMeta.textContent = `${humanBytes(file.size)}${currentDuration ? ` · ${fmtDuration(currentDuration)}` : ''}`;
      fileBox.classList.add('show');
      startBtn.disabled = false;
      resultBox.classList.remove('show');
      outputEl.value = '';
      lastChunks = [];
      lastText = '';
      hideStatus();
      resetProgress();
    }

    fileInput.addEventListener('change', e => chooseFile(e.target.files?.[0]));
    ['dragenter','dragover'].forEach(evt => drop.addEventListener(evt, e => { e.preventDefault(); drop.classList.add('is-drag'); }));
    ['dragleave','drop'].forEach(evt => drop.addEventListener(evt, e => { e.preventDefault(); drop.classList.remove('is-drag'); }));
    drop.addEventListener('drop', e => chooseFile(e.dataTransfer?.files?.[0]));

    resetBtn.addEventListener('click', () => {
      if (running) return;
      selectedFile = null;
      currentDuration = 0;
      fileInput.value = '';
      fileBox.classList.remove('show');
      resultBox.classList.remove('show');
      startBtn.disabled = true;
      hideStatus();
      resetProgress();
      fileInput.click();
    });

    async function decodeFile(file) {
      setProgress(3, '음성 파일 읽는 중');
      const arrayBuffer = await file.arrayBuffer();
      const AudioCtx = window.AudioContext || window.webkitAudioContext;
      if (!AudioCtx) throw new Error('이 브라우저는 오디오 처리를 지원하지 않습니다. Chrome 또는 Edge 최신 버전을 사용해주세요.');
      const ctx = new AudioCtx();
      try {
        const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0));
        currentDuration = decoded.duration || currentDuration;
        return { ctx, decoded };
      } catch (e) {
        await ctx.close().catch(()=>{});
        throw new Error('이 파일의 오디오 코덱을 브라우저에서 읽지 못했습니다. MP3, WAV, M4A 또는 MP4 파일로 다시 시도해주세요.');
      }
    }

    async function audioBufferTo16kMono(decoded) {
      setProgress(7, 'AI가 읽을 수 있게 음성 변환 중');
      const targetRate = 16000;
      const frameCount = Math.max(1, Math.ceil(decoded.duration * targetRate));
      const offline = new OfflineAudioContext(1, frameCount, targetRate);
      const source = offline.createBufferSource();
      source.buffer = decoded;
      source.connect(offline.destination);
      source.start(0);
      const rendered = await offline.startRendering();
      return new Float32Array(rendered.getChannelData(0));
    }

    async function getTransformers() {
      if (!transformersPromise) {
        transformersPromise = import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0/+esm')
          .catch(err => { transformersPromise = null; throw err; });
      }
      return transformersPromise;
    }

    async function getWhisper(modelId, progressFrom = 60, progressTo = 75) {
      if (pipes.has(modelId)) return pipes.get(modelId);
      setStatus('AI 모델을 처음 불러오는 중입니다. 첫 실행만 시간이 조금 걸리고 이후에는 브라우저 캐시를 사용합니다.');
      const mod = await getTransformers();
      const opts = {
        progress_callback: (p) => {
          if (p?.status === 'progress' && Number.isFinite(p.progress)) {
            const ratio = Math.max(0, Math.min(1, p.progress / 100));
            setProgress(progressFrom + (progressTo - progressFrom) * ratio, '음성 인식 AI 다운로드 중');
          }
        }
      };
      if ('gpu' in navigator) opts.device = 'webgpu';

      let pipe;
      try {
        pipe = await mod.pipeline('automatic-speech-recognition', modelId, opts);
      } catch (gpuErr) {
        if (opts.device) {
          delete opts.device;
          setStatus('GPU 모드 대신 호환 모드로 AI를 실행합니다. 변환 시간이 조금 더 걸릴 수 있습니다.');
          pipe = await mod.pipeline('automatic-speech-recognition', modelId, opts);
        } else {
          throw gpuErr;
        }
      }
      pipes.set(modelId, pipe);
      return pipe;
    }

    function makeMusicProbes(audio, sampleRate = 16000) {
      const seconds = audio.length / sampleRate;
      const probeSec = Math.min(10, Math.max(4, seconds));
      const probeLen = Math.max(1, Math.floor(probeSec * sampleRate));
      if (audio.length <= probeLen) return [audio];

      const centers = seconds < 35 ? [0.5] : [0.15, 0.5, 0.85];
      const seen = new Set();
      const probes = [];
      for (const c of centers) {
        let start = Math.round((seconds * c - probeSec / 2) * sampleRate);
        start = Math.max(0, Math.min(audio.length - probeLen, start));
        if (seen.has(start)) continue;
        seen.add(start);
        probes.push(audio.slice(start, start + probeLen));
      }
      return probes;
    }

    function classifyMusicLabels(items) {
      const musicPattern = /(music|singing|song|musical|instrument|guitar|piano|keyboard|drum|bass|violin|orchestra|choir|vocal music|background music|rock|pop music|hip hop|electronic music|jingle|melody)/i;
      const strongPattern = /(music|singing|song|vocal music|background music|orchestra|choir)/i;
      const speechPattern = /(speech|conversation|narration|monologue|spoken word|male speech|female speech)/i;
      let music = 0, speech = 0, strongestMusic = 0, strongestLabel = '';
      for (const item of items || []) {
        const label = String(item?.label || '');
        const score = Number(item?.score) || 0;
        if (musicPattern.test(label)) {
          music += score;
          if (score > strongestMusic) { strongestMusic = score; strongestLabel = label; }
        }
        if (speechPattern.test(label)) speech += score;
      }
      return {
        music,
        speech,
        strongestMusic,
        strongestLabel,
        strongMusic: strongPattern.test(strongestLabel) && strongestMusic >= 0.10
      };
    }

    async function getMusicClassifier() {
      if (musicClassifierPromise) return musicClassifierPromise;
      musicClassifierPromise = (async () => {
        const mod = await getTransformers();
        setStatus('정확도 우선 모드: 음악 여부를 먼저 분석합니다. 첫 실행에는 분석 모델을 한 번 다운로드합니다.');
        return mod.pipeline('audio-classification', MUSIC_MODEL, {
          dtype: 'q8',
          progress_callback: (p) => {
            if (p?.status === 'progress' && Number.isFinite(p.progress)) {
              const ratio = Math.max(0, Math.min(1, p.progress / 100));
              setProgress(10 + 10 * ratio, '음악 감지 AI 준비 중');
            }
          }
        });
      })().catch(err => { musicClassifierPromise = null; throw err; });
      return musicClassifierPromise;
    }

    async function detectMusic(audio16k) {
      setProgress(10, '음악·배경음 자동 분석 중');
      const classifier = await getMusicClassifier();
      const probes = makeMusicProbes(audio16k);
      let musicVotes = 0;
      let best = { strongestMusic: 0, strongestLabel: '', music: 0, speech: 0 };

      for (let i = 0; i < probes.length; i++) {
        setProgress(20 + (i / Math.max(1, probes.length)) * 7, `음악 여부 분석 중 (${i + 1}/${probes.length})`);
        const out = await classifier(probes[i], { top_k: 12 });
        const score = classifyMusicLabels(out);
        const isMusic = score.strongMusic || score.music >= 0.18 || (score.music >= 0.12 && score.music > score.speech * 0.8);
        if (isMusic) musicVotes++;
        if (score.strongestMusic > best.strongestMusic) best = score;
      }

      const requiredVotes = probes.length <= 1 ? 1 : Math.ceil(probes.length * 0.66);
      return {
        hasMusic: musicVotes >= requiredVotes,
        musicVotes,
        totalVotes: probes.length,
        label: best.strongestLabel,
        score: best.strongestMusic
      };
    }

    async function getDemucsProcessor() {
      if (demucsProcessorPromise) return demucsProcessorPromise;
      demucsProcessorPromise = (async () => {
        setStatus('음악이 감지되어 AI가 보컬 분리 모델을 준비합니다. 첫 실행에는 약 172MB 모델 다운로드가 필요합니다.');
        setProgress(28, '보컬 분리 AI 준비 중');

        const [ort, demucsMod] = await Promise.all([
          import('https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/+esm'),
          import('https://cdn.jsdelivr.net/npm/demucs-web@1.0.2/+esm')
        ]);

        if (ort?.env?.wasm) {
          ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/';
          ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 2) : 1;
        }

        const providers = [];
        if ('gpu' in navigator) providers.push('webgpu');
        providers.push('wasm');

        const processor = new demucsMod.DemucsProcessor({
          ort,
          sessionOptions: {
            enableCpuMemArena: false,
            enableMemPattern: false,
            executionProviders: providers,
            graphOptimizationLevel: 'basic'
          },
          onDownloadProgress: (loaded, total) => {
            if (total > 0) {
              const r = Math.max(0, Math.min(1, loaded / total));
              setProgress(28 + r * 10, `보컬 분리 AI 다운로드 중 · ${Math.round(r * 100)}%`);
            }
          }
        });

        await processor.loadModel(demucsMod.CONSTANTS.DEFAULT_MODEL_URL);
        return { processor, constants: demucsMod.CONSTANTS };
      })().catch(err => { demucsProcessorPromise = null; throw err; });
      return demucsProcessorPromise;
    }

    async function renderStereoChunk(decoded, startSec, durationSec, targetRate = 44100) {
      const frames = Math.max(1, Math.ceil(durationSec * targetRate));
      const offline = new OfflineAudioContext(2, frames, targetRate);
      const source = offline.createBufferSource();
      source.buffer = decoded;
      source.connect(offline.destination);
      source.start(0, startSec, durationSec);
      return offline.startRendering();
    }

    function stereoTo16k(left, right, sourceRate = 44100) {
      const sourceLen = Math.min(left.length, right.length);
      const outLen = Math.max(1, Math.floor(sourceLen * 16000 / sourceRate));
      const out = new Float32Array(outLen);
      const ratio = sourceRate / 16000;
      for (let i = 0; i < outLen; i++) {
        const pos = i * ratio;
        const idx = Math.floor(pos);
        const frac = pos - idx;
        const j = Math.min(idx + 1, sourceLen - 1);
        const a = (left[idx] + right[idx]) * 0.5;
        const b = (left[j] + right[j]) * 0.5;
        out[i] = a + (b - a) * frac;
      }
      return out;
    }

    function lightlyNormalize(audio) {
      let peak = 0;
      for (let i = 0; i < audio.length; i += 16) peak = Math.max(peak, Math.abs(audio[i]));
      if (peak < 0.005 || peak >= 0.85) return audio;
      const gain = Math.min(3, 0.85 / peak);
      if (gain <= 1.05) return audio;
      const out = new Float32Array(audio.length);
      for (let i = 0; i < audio.length; i++) out[i] = Math.max(-1, Math.min(1, audio[i] * gain));
      return out;
    }

    async function extractVocalsTo16k(decoded) {
      if ((decoded.duration || 0) > DEMUCS_MAX_SECONDS) {
        throw new Error('DEMUCS_TOO_LONG');
      }

      const { processor, constants } = await getDemucsProcessor();
      const totalSec = decoded.duration;
      const totalOutLen = Math.max(1, Math.ceil(totalSec * 16000));
      const vocal16k = new Float32Array(totalOutLen);
      const chunkCount = Math.ceil(totalSec / DEMUCS_CHUNK_SECONDS);
      let outOffset = 0;

      for (let n = 0; n < chunkCount; n++) {
        const start = n * DEMUCS_CHUNK_SECONDS;
        const duration = Math.min(DEMUCS_CHUNK_SECONDS, totalSec - start);
        const basePct = 40 + (n / chunkCount) * 20;
        setProgress(basePct, `보컬 분리 중 (${n + 1}/${chunkCount})`);

        const rendered = await renderStereoChunk(decoded, start, duration, constants.SAMPLE_RATE || 44100);
        const left = rendered.getChannelData(0);
        const right = rendered.numberOfChannels > 1 ? rendered.getChannelData(1) : left;
        const separated = await processor.separate(left, right);
        const chunk16k = stereoTo16k(separated.vocals.left, separated.vocals.right, constants.SAMPLE_RATE || 44100);
        const room = vocal16k.length - outOffset;
        const copyLen = Math.min(room, chunk16k.length);
        vocal16k.set(chunk16k.subarray(0, copyLen), outOffset);
        outOffset += copyLen;
      }

      return lightlyNormalize(outOffset === vocal16k.length ? vocal16k : vocal16k.slice(0, outOffset));
    }

    function cleanChunks(chunks, duration) {
      if (!Array.isArray(chunks)) return [];
      return chunks.map((c, i) => {
        const ts = Array.isArray(c.timestamp) ? c.timestamp : [0, null];
        const start = Math.max(0, Number(ts[0]) || 0);
        let end = ts[1] == null ? null : Number(ts[1]);
        if (!Number.isFinite(end) || end <= start) {
          const next = chunks[i + 1];
          const nextStart = next && Array.isArray(next.timestamp) ? Number(next.timestamp[0]) : NaN;
          end = Number.isFinite(nextStart) && nextStart > start ? nextStart : Math.min(duration || (start + 5), start + 8);
        }
        return { start, end, text: String(c.text || '').trim() };
      }).filter(c => c.text);
    }

    function srtTime(sec) {
      const ms = Math.max(0, Math.round((Number(sec) || 0) * 1000));
      const h = Math.floor(ms / 3600000);
      const m = Math.floor((ms % 3600000) / 60000);
      const s = Math.floor((ms % 60000) / 1000);
      const x = ms % 1000;
      return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')},${String(x).padStart(3,'0')}`;
    }

    function toSrt(chunks) {
      return chunks.map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}\n`).join('\n');
    }

    function safeBaseName(name) {
      return (name || 'subtitle').replace(/\.[^.]+$/, '').replace(/[\\/:*?"<>|]+/g, '_');
    }

    function download(text, ext, type) {
      const blob = new Blob(['\ufeff' + text], { type: `${type};charset=utf-8` });
      const a = document.createElement('a');
      const url = URL.createObjectURL(blob);
      a.href = url;
      a.download = `${safeBaseName(selectedFile?.name)}.${ext}`;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1200);
    }

    startBtn.addEventListener('click', async () => {
      if (!selectedFile || running) return;
      running = true;
      startBtn.disabled = true;
      startBtn.textContent = 'AI가 자막을 만들고 있습니다...';
      resultBox.classList.remove('show');
      outputEl.value = '';
      hideStatus();

      let ctx = null;
      try {
        const decodedPack = await decodeFile(selectedFile);
        ctx = decodedPack.ctx;
        const decoded = decodedPack.decoded;
        let audio16k = await audioBufferTo16kMono(decoded);
        const accurate = modeSel.value === 'accurate';
        let usedDemucs = false;
        let skippedLongMusic = false;

        if (accurate) {
          const music = await detectMusic(audio16k);
          if (music.hasMusic) {
            if ((decoded.duration || 0) <= DEMUCS_MAX_SECONDS) {
              setStatus('음악이 감지되었습니다. 반주를 줄이고 보컬을 먼저 분리한 뒤 자막을 만듭니다.');
              try {
                audio16k = await extractVocalsTo16k(decoded);
                usedDemucs = true;
              } catch (demucsErr) {
                if (demucsErr?.message === 'DEMUCS_TOO_LONG') {
                  skippedLongMusic = true;
                } else {
                  console.warn('Demucs failed; continuing with original audio.', demucsErr);
                  setStatus('보컬 분리를 사용할 수 없어 원본 음성으로 계속 변환합니다. 자막 생성은 그대로 진행됩니다.');
                }
              }
            } else {
              skippedLongMusic = true;
              setStatus('음악이 감지됐지만 30분이 넘는 파일이라 브라우저 메모리를 보호하기 위해 보컬 분리는 생략하고 정확도 우선 음성 인식으로 진행합니다.');
            }
          } else {
            setStatus('일반 음성으로 판단되어 보컬 분리는 건너뛰고 정확도 우선 음성 인식으로 진행합니다.');
          }
        }

        const modelId = accurate ? WHISPER_ACCURATE : WHISPER_FAST;
        const pipe = await getWhisper(modelId, accurate ? 62 : 15, accurate ? 76 : 42);
        setProgress(accurate ? 78 : 45, '음성을 듣고 자막으로 변환 중');

        const result = await pipe(audio16k, {
          return_timestamps: true,
          chunk_length_s: 30,
          stride_length_s: 5,
          task: 'transcribe',
          language: langSel.value
        });

        setProgress(96, '자막 파일 정리 중');
        lastText = String(result?.text || '').trim();
        lastChunks = cleanChunks(result?.chunks, currentDuration);
        if (!lastChunks.length && lastText) {
          lastChunks = [{ start: 0, end: currentDuration || 5, text: lastText }];
        }
        if (!lastText && !lastChunks.length) throw new Error('음성을 인식하지 못했습니다. 음량이 너무 작거나 음성이 없는 파일인지 확인해주세요.');

        outputEl.value = lastChunks.length
          ? lastChunks.map(c => `[${fmtDuration(c.start)}] ${c.text}`).join('\n')
          : lastText;
        resultBox.classList.add('show');
        setProgress(100, '완료');

        if (usedDemucs) {
          setStatus(`완료 · 음악을 감지해 보컬을 분리한 뒤 ${lastChunks.length}개 자막 구간을 만들었습니다.`);
        } else if (skippedLongMusic) {
          setStatus(`완료 · 긴 파일이라 보컬 분리는 생략하고 ${lastChunks.length}개 자막 구간을 만들었습니다.`);
        } else {
          setStatus(`자막 변환이 완료됐습니다. ${lastChunks.length ? `${lastChunks.length}개 구간을 만들었습니다.` : ''}`);
        }
        setTimeout(() => resultBox.scrollIntoView({ behavior:'smooth', block:'start' }), 100);
      } catch (err) {
        console.error(err);
        setStatus(err?.message || '변환 중 오류가 발생했습니다. 다른 파일로 다시 시도해주세요.', true);
        resetProgress();
      } finally {
        if (ctx) await ctx.close().catch(()=>{});
        running = false;
        startBtn.disabled = !selectedFile;
        startBtn.textContent = 'AI 자막 만들기';
      }
    });

    srtBtn.addEventListener('click', () => {
      if (!lastChunks.length) return;
      download(toSrt(lastChunks), 'srt', 'application/x-subrip');
    });

    function toReadableTxt(chunks, fallbackText = '') {
      if (chunks && chunks.length) {
        return chunks
          .map(c => `[${fmtDuration(c.start)}] ${String(c.text || '').trim()}`)
          .filter(Boolean)
          .join('\r\n');
      }
      return String(fallbackText || '').trim().replace(/([.!?。！？])\s+/g, '$1\r\n');
    }

    txtBtn.addEventListener('click', () => {
      const text = toReadableTxt(lastChunks, lastText);
      if (!text) return;
      download(text, 'txt', 'text/plain;charset=utf-8');
    });

    copyBtn.addEventListener('click', async () => {
      const text = toReadableTxt(lastChunks, lastText);
      if (!text) return;
      try {
        await navigator.clipboard.writeText(text);
        const old = copyBtn.textContent;
        copyBtn.textContent = '복사 완료';
        setTimeout(() => copyBtn.textContent = old, 1400);
      } catch {
        outputEl.focus();
        outputEl.select();
        document.execCommand('copy');
      }
    });
  })();
  </script>
</div>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">회의나 강의를 녹음해두고 나중에 내용을 정리하려고 하면 생각보다 시간이 많이 걸립니다. 1시간짜리 MP3 파일을 처음부터 끝까지 다시 들으면서 직접 받아 적는 것은 상당히 번거로운 작업입니다.</p>



<p class="wp-block-paragraph">이럴 때 사용할 수 있도록 만든 것이 <strong>AI 음성 자막 변환기</strong>입니다.</p>



<p class="wp-block-paragraph">회의 녹음, 강의, 인터뷰 등 음성이 들어 있는 파일을 업로드하면 AI가 음성을 분석해 텍스트로 변환하고, 영상 편집에서 바로 사용할 수 있는 <strong>SRT 자막 파일</strong>도 함께 만들 수 있습니다.</p>



<p class="wp-block-paragraph">별도의 프로그램을 설치하지 않아도 브라우저에서 바로 사용할 수 있으며, 간단한 회의 녹음부터 긴 음성 파일까지 활용할 수 있습니다.</p>



<h2 class="wp-block-heading">회의 녹음 MP3를 AI로 텍스트 변환</h2>



<p class="wp-block-paragraph">제가 이 도구를 만들면서 가장 중요하게 생각한 활용 방법은 <strong>회의 녹음 파일 변환</strong>입니다.</p>



<p class="wp-block-paragraph">회의 중에는 내용을 전부 받아 적기 어렵기 때문에 스마트폰이나 녹음기로 회의 내용을 MP3 또는 M4A 형식으로 저장해두는 경우가 많습니다.</p>



<p class="wp-block-paragraph">문제는 회의가 끝난 뒤입니다.</p>



<p class="wp-block-paragraph">30분이나 1시간 정도 되는 녹음 파일을 다시 처음부터 들으면서 필요한 내용을 찾으려면 시간이 상당히 오래 걸립니다.</p>



<p class="wp-block-paragraph">AI 음성 자막 변환기를 이용하면 녹음 파일을 업로드한 뒤 <strong>AI 자막 만들기</strong> 버튼을 누르는 것만으로 음성 내용을 텍스트로 변환할 수 있습니다.</p>



<p class="wp-block-paragraph">변환된 내용에는 시간 정보도 함께 표시되기 때문에,</p>



<p class="wp-block-paragraph"><code>[12:35] 이번 프로젝트 일정은 다음 주까지 확정하겠습니다.</code></p>



<p class="wp-block-paragraph">와 같이 어느 시점에서 어떤 내용이 나왔는지 확인하기도 편합니다.</p>



<p class="wp-block-paragraph">회의 내용을 다시 확인하거나 회의록 초안을 작성해야 할 때 특히 유용합니다.</p>



<h2 class="wp-block-heading">SRT 자막 파일을 바로 다운로드할 수 있습니다</h2>



<p class="wp-block-paragraph">단순히 음성을 글자로 변환하는 것만 필요하다면 TXT 파일을 이용하면 됩니다.</p>



<p class="wp-block-paragraph">하지만 영상을 편집할 예정이라면 <strong>SRT 파일</strong>이 훨씬 편합니다.</p>



<p class="wp-block-paragraph">SRT는 자막 내용뿐 아니라 각각의 문장이 등장해야 하는 <strong>시간 정보까지 함께 저장하는 자막 파일 형식</strong>입니다.</p>



<p class="wp-block-paragraph">예를 들어 AI가 아래와 같이 음성을 인식했다면,</p>



<pre class="wp-block-code"><code>00:00:05 → 00:00:09
오늘 회의를 시작하겠습니다.

00:00:10 → 00:00:15
먼저 지난주 진행 상황부터 확인하겠습니다.</code></pre>



<p class="wp-block-paragraph">이 시간 정보가 SRT 파일 안에 자동으로 저장됩니다.</p>



<p class="wp-block-paragraph">따라서 SRT 파일을 영상 편집 프로그램에 넣으면 자막을 하나씩 직접 시간에 맞춰 배치할 필요가 없습니다.</p>



<h2 class="wp-block-heading">영상 편집기에 SRT를 넣으면 자막이 시간에 맞게 자동 배치</h2>



<p class="wp-block-paragraph">개인적으로 AI 음성 자막 변환기에서 가장 편리한 기능이 바로 이것입니다.</p>



<p class="wp-block-paragraph">일반적인 영상 자막 작업은 영상을 재생하면서 문장을 입력하고, 자막이 나타나는 시작 시간과 사라지는 시간을 하나씩 맞춰야 합니다.</p>



<p class="wp-block-paragraph">영상이 길어질수록 이 작업에 상당한 시간이 필요합니다.</p>



<p class="wp-block-paragraph">하지만 이 도구에서 만든 <strong>SRT 파일을 다운로드한 뒤 영상 편집기에 불러오기만 하면</strong>, AI가 분석한 시간 정보를 기준으로 자막이 영상의 해당 시점에 자동으로 배치됩니다.</p>



<p class="wp-block-paragraph">Premiere Pro, DaVinci Resolve, CapCut 등 SRT 불러오기를 지원하는 영상 편집 프로그램에서 활용할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 30분짜리 회의 영상을 편집한다고 가정하면,</p>



<p class="wp-block-paragraph">직접 자막을 작성할 경우에는 음성을 들으면서 문장을 입력하고 자막 시간까지 맞춰야 하지만, AI로 먼저 SRT를 만들어두면 <strong>불러온 자막을 검토하고 틀린 부분만 수정하는 방식</strong>으로 작업할 수 있습니다.</p>



<p class="wp-block-paragraph">영상 편집 시간을 줄이는 데 상당히 도움이 됩니다.</p>



<h2 class="wp-block-heading">TXT 파일은 회의록 정리에 활용하기 좋습니다</h2>



<p class="wp-block-paragraph">영상 편집이 목적이 아니라면 TXT 파일을 다운로드하면 됩니다.</p>



<p class="wp-block-paragraph">AI가 인식한 문장은 시간 순서대로 줄바꿈되어 저장되기 때문에 긴 회의 내용도 비교적 쉽게 읽을 수 있습니다.</p>



<p class="wp-block-paragraph">TXT 파일은 다음과 같은 용도로 활용하기 좋습니다.</p>



<ul class="wp-block-list">
<li>회의록 초안 작성</li>



<li>강의 내용 정리</li>



<li>인터뷰 내용 확인</li>



<li>전화 또는 상담 녹음 정리</li>



<li>유튜브 영상 대본 초안</li>



<li>녹음 내용 중 특정 문장 검색</li>
</ul>



<p class="wp-block-paragraph">특히 긴 녹음 파일에서 특정 내용을 다시 찾아야 할 때는 음성을 직접 탐색하는 것보다 TXT에서 검색하는 편이 훨씬 빠릅니다.</p>



<h2 class="wp-block-heading">AI 음성 자막 변환기 사용 방법</h2>



<p class="wp-block-paragraph">사용 방법은 어렵지 않습니다.</p>



<h3 class="wp-block-heading">1. 음성 또는 영상 파일 업로드</h3>



<p class="wp-block-paragraph">먼저 자막으로 변환할 파일을 선택합니다.</p>



<p class="wp-block-paragraph">회의를 녹음한 MP3 파일이나 M4A 음성 파일은 물론, 음성이 포함된 영상 파일도 사용할 수 있습니다.</p>



<h3 class="wp-block-heading">2. 음성 언어 선택</h3>



<p class="wp-block-paragraph">파일에서 사용된 언어를 선택합니다.</p>



<p class="wp-block-paragraph">한국어 회의 파일이라면 <strong>한국어</strong>를 선택하면 됩니다.</p>



<p class="wp-block-paragraph">언어를 직접 지정하면 AI가 해당 언어를 기준으로 음성을 인식하기 때문에 자동 언어 감지보다 안정적으로 사용할 수 있습니다.</p>



<h3 class="wp-block-heading">3. 빠른 변환 또는 정확도 우선 선택</h3>



<p class="wp-block-paragraph">변환 방식도 선택할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>빠른 변환</strong>은 회의, 강의 등 일반적인 음성 파일을 빠르게 확인하고 싶을 때 적합합니다.</p>



<p class="wp-block-paragraph"><strong>정확도 우선</strong>은 처리 시간이 조금 더 걸리더라도 더 정확한 음성 인식이 필요한 경우에 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">PC 성능과 파일 길이에 따라 변환 시간에는 차이가 있을 수 있습니다.</p>



<h3 class="wp-block-heading">4. AI 자막 만들기</h3>



<p class="wp-block-paragraph">설정을 마친 후 AI 자막 만들기 버튼을 누르면 음성 분석이 시작됩니다.</p>



<p class="wp-block-paragraph">파일이 길수록 처리 시간도 길어질 수 있으므로 변환이 끝날 때까지 브라우저 창을 닫지 않는 것이 좋습니다.</p>



<h3 class="wp-block-heading">5. SRT 또는 TXT 다운로드</h3>



<p class="wp-block-paragraph">변환이 끝나면 원하는 형식으로 저장하면 됩니다.</p>



<p class="wp-block-paragraph">영상에 자막을 넣을 예정이라면 <strong>SRT</strong>, 내용을 읽거나 회의록으로 정리하려면 <strong>TXT</strong>를 선택하면 됩니다.</p>



<h2 class="wp-block-heading">MP3 회의 녹음은 어떤 설정이 좋을까?</h2>



<p class="wp-block-paragraph">일반적인 회의실에서 스마트폰으로 녹음한 MP3 파일이라면 우선 <strong>빠른 변환</strong>으로 테스트해보는 것을 추천합니다.</p>



<p class="wp-block-paragraph">참석자의 목소리가 비교적 깨끗하게 녹음되어 있다면 빠른 변환만으로도 내용을 확인하는 데 충분할 수 있습니다.</p>



<p class="wp-block-paragraph">반대로 다음과 같은 환경이라면 정확도 우선을 이용하는 것이 좋습니다.</p>



<p class="wp-block-paragraph">회의실이 넓거나 마이크와 참석자의 거리가 먼 경우, 여러 사람이 번갈아 빠르게 말하는 경우, 주변 소음이 많은 경우, 녹음 음질이 낮은 경우 등입니다.</p>



<p class="wp-block-paragraph">다만 음성 인식 AI 역시 모든 말을 100% 정확하게 받아 적을 수 있는 것은 아닙니다.</p>



<p class="wp-block-paragraph">중요한 회의록이나 외부에 공개할 자막이라면 <strong>AI가 만든 결과를 한 번 검토한 뒤 사용하는 것</strong>을 권장합니다.</p>



<h2 class="wp-block-heading">자막 인식 정확도를 높이는 방법</h2>



<p class="wp-block-paragraph">AI 음성 인식은 원본 녹음 품질의 영향을 많이 받습니다.</p>



<p class="wp-block-paragraph">가능하다면 마이크를 말하는 사람과 가까운 곳에 두고 녹음하는 것이 좋습니다.</p>



<p class="wp-block-paragraph">회의 참가자가 동시에 이야기하거나 주변에서 음악이 크게 재생되는 환경에서는 인식 정확도가 낮아질 수 있습니다.</p>



<p class="wp-block-paragraph">특히 배경음악이 큰 영상이나 노래처럼 음성과 음악이 강하게 섞인 파일보다는 <strong>회의, 강의, 인터뷰처럼 사람의 말소리가 중심인 파일</strong>에서 더 좋은 결과를 얻을 수 있습니다.</p>



<h2 class="wp-block-heading">AI 음성 자막 변환기를 활용하기 좋은 경우</h2>



<p class="wp-block-paragraph">회의 녹음을 정리하는 용도 외에도 활용 방법은 다양합니다.</p>



<p class="wp-block-paragraph">회사 회의 영상을 SRT 자막으로 만들거나, 온라인 강의를 텍스트로 정리하거나, 인터뷰 녹음을 글로 옮길 때 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">유튜브나 쇼츠 영상을 편집하는 경우에도 먼저 SRT 파일을 만든 뒤 영상 편집 프로그램으로 가져오면 자막을 처음부터 직접 입력하는 작업을 크게 줄일 수 있습니다.</p>



<p class="wp-block-paragraph">특히 영상 길이가 길수록 <strong>자동으로 시간 정보까지 만들어주는 SRT 파일의 장점</strong>이 커집니다.</p>



<h2 class="wp-block-heading">자주 묻는 질문 FAQ</h2>



<h3 class="wp-block-heading">MP3 파일도 자막으로 변환할 수 있나요?</h3>



<p class="wp-block-paragraph">네. 회의를 녹음한 MP3 파일을 업로드하면 AI가 음성을 분석해 텍스트와 SRT 자막으로 변환할 수 있습니다.</p>



<h3 class="wp-block-heading">SRT 파일은 무엇인가요?</h3>



<p class="wp-block-paragraph">SRT는 자막 문장과 해당 문장이 표시되는 시간이 함께 저장된 파일입니다. SRT 불러오기를 지원하는 영상 편집 프로그램에 넣으면 자막이 시간에 맞춰 자동으로 배치됩니다.</p>



<h3 class="wp-block-heading">TXT와 SRT 중 어떤 파일을 받아야 하나요?</h3>



<p class="wp-block-paragraph">회의 내용을 읽거나 문서로 정리할 목적이라면 TXT가 편합니다. 영상을 편집하거나 자막을 넣을 예정이라면 SRT를 사용하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">긴 회의 녹음 파일도 변환할 수 있나요?</h3>



<p class="wp-block-paragraph">가능하지만 파일 길이와 PC 성능에 따라 처리 시간이 길어질 수 있습니다. 긴 파일을 변환하는 동안에는 브라우저 창을 닫지 않는 것이 좋습니다.</p>



<h3 class="wp-block-heading">음성 인식이 틀릴 수도 있나요?</h3>



<p class="wp-block-paragraph">네. 말하는 속도, 발음, 마이크 거리, 주변 소음 등에 따라 일부 단어가 다르게 인식될 수 있습니다. 중요한 자막이나 회의록은 변환 후 한 번 확인하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">여러 사람이 말하는 회의도 사용할 수 있나요?</h3>



<p class="wp-block-paragraph">여러 사람의 음성을 텍스트로 변환하는 것은 가능하지만, 현재 기능은 사람마다 이름을 자동으로 구분해 표시하는 화자 분리 기능과는 다릅니다. 동시에 여러 명이 이야기하면 인식률이 낮아질 수 있습니다.</p>



<h3 class="wp-block-heading">배경음악이 있는 영상도 변환할 수 있나요?</h3>



<p class="wp-block-paragraph">변환 자체는 가능하지만 배경음악의 볼륨이 크면 사람의 음성을 정확하게 인식하기 어려울 수 있습니다. 사람의 말소리가 중심인 회의, 강의, 인터뷰 파일에서 사용하는 것을 권장합니다.</p>



<h2 class="wp-block-heading">회의 녹음을 다시 들으며 받아 적지 않아도 됩니다</h2>



<p class="wp-block-paragraph">회의가 끝난 뒤 긴 MP3 파일을 처음부터 다시 들으며 내용을 받아 적는 작업은 생각보다 많은 시간이 필요합니다.</p>



<p class="wp-block-paragraph"><strong>AI 음성 자막 변환기</strong>를 이용하면 녹음 파일을 먼저 텍스트로 변환한 뒤 필요한 부분만 확인할 수 있고, 영상으로 사용할 경우에는 SRT 파일을 내려받아 영상 편집기에 바로 넣을 수도 있습니다.</p>



<p class="wp-block-paragraph">특히 <strong>SRT의 시간 정보 덕분에 자막이 영상의 해당 시간에 자동으로 배치된다는 점</strong>은 긴 영상이나 회의 영상을 편집할 때 상당히 편리합니다.</p>



<p class="wp-block-paragraph">회의 녹음 정리, 강의 기록, 인터뷰 텍스트 변환, 영상 자막 제작처럼 음성을 다시 글로 옮겨야 하는 일이 있다면 직접 받아 적기 전에 AI 음성 자막 변환기를 활용해보세요.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/ai-audio-subtitle-converter/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/ai-audio-subtitle-converter-meeting-srt-txt.webp" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1672</post-id>	</item>
		<item>
		<title>SNS 게시물 위험도 검사기｜Instagram·Threads·X 이미지 사전 점검</title>
		<link>https://snsnote.com/sns-post-risk-checker/</link>
					<comments>https://snsnote.com/sns-post-risk-checker/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Fri, 11 Sep 2026 14:45:03 +0000</pubDate>
				<category><![CDATA[인스타그램]]></category>
		<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1617</guid>

					<description><![CDATA[SNS 게시 전, 이미지 위험요소를 미리 확인하세요 SNSNOTE의 SNS 게시물 위험도 검사기는 Instagram, Threads, X 등에 이미지를 게시하기 전 민감 콘텐츠로 판단될 가능성을 사전에 확인할...]]></description>
										<content:encoded><![CDATA[<div id="sns-risk-v8">

<style>
#sns-risk-v8,
#sns-risk-v8 * {
  box-sizing: border-box;
}

#sns-risk-v8 {
  width: 100%;
  max-width: 1100px;
  margin: 0 auto;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
    Roboto, "Noto Sans KR", Arial, sans-serif;
  color: #172033;
}

#sns-risk-v8 .sr-card {
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 6px 28px rgba(15,23,42,.06);
}

#sns-risk-v8 .sr-head {
  padding: 24px;
  background: linear-gradient(135deg,#f8faff,#fffaf4);
  border-bottom: 1px solid #eef2f7;
}

#sns-risk-v8 .sr-title-box {
  position: relative;
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 17px 20px 17px 25px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 14px;
  box-shadow: 0 4px 16px rgba(15,23,42,.06);
  overflow: hidden;
}

#sns-risk-v8 .sr-title-box::before {
  content: "";
  position: absolute;
  left: 0;
  top: 12px;
  bottom: 12px;
  width: 6px;
  border-radius: 0 6px 6px 0;
  background: linear-gradient(180deg,#3b82f6,#6366f1,#8b5cf6);
}

#sns-risk-v8 .sr-title-icon {
  width: 40px;
  height: 40px;
  flex: 0 0 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 11px;
  background: #eef2ff;
  color: #4f46e5;
  font-size: 20px;
  font-weight: 900;
}

#sns-risk-v8 .sr-title {
  margin: 0 !important;
  padding: 0 !important;
  background: none !important;
  border: 0 !important;
  box-shadow: none !important;
  color: #172033 !important;
  font-size: 25px !important;
  font-weight: 800 !important;
  line-height: 1.35 !important;
  letter-spacing: -.5px;
}

#sns-risk-v8 .sr-desc {
  margin: 16px 0 0;
  color: #64748b;
  font-size: 14px;
  line-height: 1.7;
}

#sns-risk-v8 .sr-badges {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 16px;
}

#sns-risk-v8 .sr-badge {
  padding: 7px 10px;
  border: 1px solid #e2e8f0;
  border-radius: 999px;
  background: #fff;
  color: #475569;
  font-size: 11px;
  font-weight: 700;
}

#sns-risk-v8 .sr-body {
  padding: 24px;
}

#sns-risk-v8 .sr-drop {
  min-height: 190px;
  border: 2px dashed #cbd5e1;
  border-radius: 16px;
  background: #f8fafc;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 24px;
  cursor: pointer;
  transition: .2s;
}

#sns-risk-v8 .sr-drop:hover {
  border-color: #6366f1;
  background: #f5f3ff;
}

#sns-risk-v8 .sr-drop strong {
  display: block;
  font-size: 16px;
  color: #1e293b;
}

#sns-risk-v8 .sr-drop span {
  display: block;
  margin-top: 7px;
  color: #64748b;
  font-size: 12px;
  line-height: 1.65;
}

#sns-risk-v8 #sr8-file {
  display: none;
}

#sns-risk-v8 .sr-count {
  display: none;
  margin-top: 14px;
  padding: 11px 13px;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #f8fafc;
  color: #334155;
  font-size: 13px;
  font-weight: 700;
}

#sns-risk-v8 .sr-preview {
  display: grid;
  grid-template-columns: repeat(5,minmax(0,1fr));
  gap: 10px;
  margin-top: 16px;
}

#sns-risk-v8 .sr-thumb {
  position: relative !important;
  aspect-ratio: 1/1;
  overflow: hidden;
  border-radius: 12px;
  background: #f1f5f9;
  isolation: isolate;
}

#sns-risk-v8 .sr-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

#sns-risk-v8 .sr-num {
  position: absolute !important;
  top: 8px !important;
  left: 8px !important;
  min-width: 28px !important;
  height: 28px !important;
  padding: 0 7px !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
  border-radius: 8px !important;
  background: rgba(15,23,42,.9) !important;
  color: #fff !important;
  font-size: 11px !important;
  font-weight: 800 !important;
  z-index: 10 !important;
}

#sns-risk-v8 button.sr-remove {
  position: absolute !important;
  top: 8px !important;
  right: 8px !important;
  left: auto !important;
  bottom: auto !important;
  width: 28px !important;
  height: 28px !important;
  min-width: 28px !important;
  min-height: 28px !important;
  max-width: 28px !important;
  max-height: 28px !important;
  margin: 0 !important;
  padding: 0 !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
  border: 0 !important;
  border-radius: 50% !important;
  background: rgba(15,23,42,.9) !important;
  color: #fff !important;
  font: 900 18px/1 Arial,sans-serif !important;
  cursor: pointer !important;
  box-shadow: 0 2px 7px rgba(0,0,0,.22) !important;
  transform: none !important;
  z-index: 20 !important;
}

#sns-risk-v8 button.sr-remove:hover {
  background: #ef4444 !important;
}

#sns-risk-v8 .sr-actions {
  display: flex;
  gap: 10px;
  margin-top: 18px;
}

#sns-risk-v8 .sr-start {
  flex: 1;
  min-height: 52px;
  border: 0;
  border-radius: 12px;
  background: linear-gradient(135deg,#4f46e5,#7c3aed);
  color: #fff;
  font-size: 15px;
  font-weight: 800;
  cursor: pointer;
}

#sns-risk-v8 .sr-start:disabled {
  opacity: .5;
  cursor: not-allowed;
}

#sns-risk-v8 .sr-reset {
  min-width: 95px;
  border: 1px solid #cbd5e1;
  border-radius: 12px;
  background: #fff;
  color: #475569;
  font-weight: 800;
  cursor: pointer;
}

#sns-risk-v8 .sr-status {
  display: none;
  margin-top: 16px;
  padding: 13px 14px;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  background: #f8fafc;
  color: #334155;
  font-size: 12px;
  font-weight: 700;
}

#sns-risk-v8 .sr-error {
  display: none;
  margin-top: 14px;
  padding: 13px 14px;
  border: 1px solid #fecaca;
  border-radius: 12px;
  background: #fef2f2;
  color: #b91c1c;
  font-size: 12px;
}

#sns-risk-v8 .sr-results {
  display: grid;
  gap: 14px;
  margin-top: 20px;
}

#sns-risk-v8 .sr-result {
  padding: 16px;
  border: 1px solid #e2e8f0;
  border-radius: 16px;
  background: #fff;
}

#sns-risk-v8 .sr-result-top {
  display: grid;
  grid-template-columns: 115px 1fr;
  gap: 16px;
}

#sns-risk-v8 .sr-result-img {
  width: 115px;
  height: 115px;
  object-fit: cover;
  border-radius: 11px;
}

#sns-risk-v8 .sr-result-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
}

#sns-risk-v8 .sr-name {
  font-size: 14px;
  font-weight: 800;
  color: #334155;
}

#sns-risk-v8 .sr-score {
  padding: 7px 11px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 900;
  white-space: nowrap;
}

#sns-risk-v8 .sr-low {
  background: #ecfdf5;
  color: #047857;
}

#sns-risk-v8 .sr-mid {
  background: #fffbeb;
  color: #b45309;
}

#sns-risk-v8 .sr-high {
  background: #fff1f2;
  color: #be123c;
}

#sns-risk-v8 .sr-critical {
  background: #be123c;
  color: #fff;
}

#sns-risk-v8 .sr-models {
  margin-top: 14px;
  border-top: 1px solid #eef2f7;
  padding-top: 14px;
}

#sns-risk-v8 .sr-model-title {
  margin-bottom: 10px;
  font-size: 12px;
  font-weight: 800;
  color: #334155;
}

#sns-risk-v8 .sr-model-row {
  display: grid;
  grid-template-columns: 72px 1fr 48px;
  gap: 10px;
  align-items: center;
  margin-top: 9px;
}

#sns-risk-v8 .sr-model-name {
  font-size: 11px;
  font-weight: 800;
  color: #475569;
}

#sns-risk-v8 .sr-bar-bg {
  height: 10px;
  border-radius: 999px;
  background: #e2e8f0;
  overflow: hidden;
}

#sns-risk-v8 .sr-bar-fill {
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg,#3b82f6,#7c3aed);
}

#sns-risk-v8 .sr-model-score {
  font-size: 11px;
  font-weight: 800;
  text-align: right;
  color: #334155;
}

#sns-risk-v8 .sr-summary {
  margin-top: 14px;
  padding: 11px 12px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 800;
  line-height: 1.6;
}

#sns-risk-v8 .sr-summary-low {
  background: #ecfdf5;
  color: #047857;
}

#sns-risk-v8 .sr-summary-mid {
  background: #fffbeb;
  color: #b45309;
}

#sns-risk-v8 .sr-summary-high {
  background: #fff1f2;
  color: #be123c;
}

#sns-risk-v8 .sr-note {
  margin-top: 18px;
  padding: 15px 16px;
  border: 1px solid #dbeafe;
  border-radius: 12px;
  background: #f8fbff;
  color: #475569;
  font-size: 11px;
  line-height: 1.75;
}

#sns-risk-v8 .sr-note strong {
  display: block;
  margin-bottom: 3px;
  color: #1d4ed8;
  font-size: 12px;
  font-weight: 800;
}


/* =========================
   모바일 최적화
   ========================= */

@media(max-width:700px) {

  /* 숏코드 내부 좌우 여백 축소 */
  #sns-risk-v8 .sr-head,
  #sns-risk-v8 .sr-body {
    padding-left: 12px !important;
    padding-right: 12px !important;
  }

  #sns-risk-v8 .sr-title-box {
    padding: 14px 14px 14px 18px;
    gap: 10px;
  }

  #sns-risk-v8 .sr-title {
    font-size: 21px !important;
  }

  #sns-risk-v8 .sr-title-icon {
    width: 36px;
    height: 36px;
    flex: 0 0 36px;
    font-size: 17px;
  }

  #sns-risk-v8 .sr-drop {
    padding-left: 12px;
    padding-right: 12px;
  }

  #sns-risk-v8 .sr-preview {
    grid-template-columns: repeat(4,minmax(0,1fr));
    gap: 8px;
  }

  #sns-risk-v8 .sr-result {
    padding: 12px;
  }

  #sns-risk-v8 .sr-result-top {
    grid-template-columns: 90px 1fr;
    gap: 12px;
  }

  #sns-risk-v8 .sr-result-img {
    width: 90px;
    height: 90px;
  }

  #sns-risk-v8 .sr-model-row {
    grid-template-columns: 62px 1fr 40px;
    gap: 7px;
  }

  #sns-risk-v8 .sr-model-name,
  #sns-risk-v8 .sr-model-score {
    font-size: 10px;
  }

  #sns-risk-v8 .sr-note {
    padding-left: 12px;
    padding-right: 12px;
  }
}


@media(max-width:480px) {

  #sns-risk-v8 .sr-head,
  #sns-risk-v8 .sr-body {
    padding-left: 10px !important;
    padding-right: 10px !important;
  }

  #sns-risk-v8 .sr-title-box {
    padding: 13px 11px 13px 16px;
    gap: 9px;
  }

  #sns-risk-v8 .sr-title {
    font-size: 19px !important;
    line-height: 1.3 !important;
  }

  #sns-risk-v8 .sr-title-icon {
    width: 34px;
    height: 34px;
    flex: 0 0 34px;
    font-size: 16px;
  }

  #sns-risk-v8 .sr-desc {
    font-size: 12px;
  }

  #sns-risk-v8 .sr-badges {
    gap: 5px;
  }

  #sns-risk-v8 .sr-badge {
    padding: 6px 8px;
    font-size: 10px;
  }

  #sns-risk-v8 .sr-drop {
    min-height: 160px;
    padding: 16px 8px;
  }

  #sns-risk-v8 .sr-drop strong {
    font-size: 14px;
  }

  #sns-risk-v8 .sr-drop span {
    font-size: 11px;
  }

  #sns-risk-v8 .sr-preview {
    grid-template-columns: repeat(3,minmax(0,1fr));
    gap: 7px;
  }

  #sns-risk-v8 .sr-actions {
    gap: 7px;
  }

  #sns-risk-v8 .sr-start {
    min-height: 48px;
    font-size: 13px;
  }

  #sns-risk-v8 .sr-reset {
    min-width: 72px;
    font-size: 12px;
  }

  #sns-risk-v8 .sr-result {
    padding: 10px;
  }

  #sns-risk-v8 .sr-result-top {
    grid-template-columns: 72px 1fr;
    gap: 9px;
  }

  #sns-risk-v8 .sr-result-img {
    width: 72px;
    height: 72px;
  }

  #sns-risk-v8 .sr-name {
    font-size: 12px;
  }

  #sns-risk-v8 .sr-score {
    padding: 5px 7px;
    font-size: 10px;
  }

  #sns-risk-v8 .sr-models {
    margin-top: 10px;
    padding-top: 10px;
  }

  #sns-risk-v8 .sr-model-title {
    font-size: 11px;
  }

  #sns-risk-v8 .sr-model-row {
    grid-template-columns: 52px 1fr 36px;
    gap: 5px;
    margin-top: 7px;
  }

  #sns-risk-v8 .sr-summary {
    padding: 9px 10px;
    font-size: 10px;
  }

  #sns-risk-v8 .sr-note {
    padding: 12px 10px;
  }
}
</style>


<div class="sr-card">

  <div class="sr-head">

    <div class="sr-title-box">

      <div class="sr-title-icon">
        AI
      </div>

      <h2 class="sr-title">
        SNS 게시물 위험도 검사기
      </h2>

    </div>

    <p class="sr-desc">
      서로 다른 3개의 이미지 분석 AI가 같은 이미지를 각각 검사하고,
      결과를 비교해 종합 위험도를 표시합니다.
    </p>

    <div class="sr-badges">
      <span class="sr-badge">최대 20장</span>
      <span class="sr-badge">3중 AI 분석</span>
      <span class="sr-badge">브라우저에서 처리</span>
      <span class="sr-badge">서버 업로드 없음</span>
    </div>

  </div>


  <div class="sr-body">

    <label class="sr-drop" for="sr8-file">

      <div>
        <strong>
          검사할 SNS 이미지를 선택하세요
        </strong>

        <span>
          JPG · PNG · WebP<br>
          카드뉴스·피드 이미지 등 최대 20장까지 한 번에 선택할 수 있습니다.
        </span>
      </div>

    </label>


    <input
      id="sr8-file"
      type="file"
      accept="image/jpeg,image/png,image/webp"
      multiple
    >


    <div
      class="sr-count"
      id="sr8-count"
    ></div>


    <div
      class="sr-preview"
      id="sr8-preview"
    ></div>


    <div class="sr-actions">

      <button
        type="button"
        class="sr-start"
        id="sr8-start"
        disabled
      >
        3중 AI 위험도 검사 시작
      </button>

      <button
        type="button"
        class="sr-reset"
        id="sr8-reset"
      >
        초기화
      </button>

    </div>


    <div
      class="sr-status"
      id="sr8-status"
    ></div>


    <div
      class="sr-error"
      id="sr8-error"
    ></div>


    <div
      class="sr-results"
      id="sr8-results"
    ></div>


    <div class="sr-note">

      <strong>
        검사 기준은 계속 업데이트됩니다.
      </strong>

      Instagram, Threads, X 등 주요 SNS의 정책 변화와 실제 사례를 참고해
      위험도 기준을 지속적으로 개선하고 있습니다.

    </div>

  </div>

</div>


<script type="module">
(async function () {

  const MAX_FILES = 20;

  const MODEL_A =
    "onnx-community/nsfw_image_detection-ONNX";

  const MODEL_B =
    "onnx-community/nsfw-classifier-ONNX";


  const state = {
    files: [],
    running: false,

    transformers: null,
    RawImage: null,

    modelA: null,
    modelB: null,
    modelC: null
  };


  const root =
    document.getElementById(
      "sns-risk-v8"
    );

  if (!root) return;


  const fileInput =
    root.querySelector("#sr8-file");

  const count =
    root.querySelector("#sr8-count");

  const preview =
    root.querySelector("#sr8-preview");

  const startBtn =
    root.querySelector("#sr8-start");

  const resetBtn =
    root.querySelector("#sr8-reset");

  const status =
    root.querySelector("#sr8-status");

  const errorBox =
    root.querySelector("#sr8-error");

  const results =
    root.querySelector("#sr8-results");


  function clamp(value) {
    return Math.max(
      0,
      Math.min(100,value)
    );
  }


  function showStatus(text) {
    status.style.display =
      "block";

    status.textContent =
      text;
  }


  function showError(text) {
    errorBox.style.display =
      "block";

    errorBox.textContent =
      text;
  }


  function clearError() {
    errorBox.style.display =
      "none";

    errorBox.textContent =
      "";
  }


  function getLevel(score) {

    if (score >= 85) {
      return {
        text:"매우 높음",
        cls:"sr-critical",
        summaryCls:"sr-summary-high",
        message:
          "3개 AI의 종합 결과에서 높은 민감 콘텐츠 위험 신호가 확인되었습니다."
      };
    }

    if (score >= 65) {
      return {
        text:"높음",
        cls:"sr-high",
        summaryCls:"sr-summary-high",
        message:
          "여러 AI 모델에서 민감 콘텐츠 위험 신호가 비교적 높게 감지되었습니다."
      };
    }

    if (score >= 35) {
      return {
        text:"주의",
        cls:"sr-mid",
        summaryCls:"sr-summary-mid",
        message:
          "AI 모델별 판단이 엇갈리거나 일부 민감 신호가 감지되었습니다."
      };
    }

    return {
      text:"낮음",
      cls:"sr-low",
      summaryCls:"sr-summary-low",
      message:
        "현재 3개 AI의 종합 결과에서는 강한 민감 콘텐츠 신호가 많지 않습니다."
    };
  }


  function renderPreview() {

    preview.innerHTML = "";


    state.files.forEach(
      function(item,index) {

        const box =
          document.createElement("div");

        box.className =
          "sr-thumb";


        box.innerHTML = `
          <img decoding="async" src="${item.url}" alt="">

          <span class="sr-num">
            ${index + 1}
          </span>

          <button
            type="button"
            class="sr-remove"
            data-id="${item.id}"
            aria-label="이미지 삭제"
          >
            ×
          </button>
        `;


        preview.appendChild(box);

      }
    );


    if (state.files.length) {

      count.style.display =
        "block";

      count.textContent =
        state.files.length +
        " / 20장 선택됨";

    } else {

      count.style.display =
        "none";

    }


    startBtn.disabled =
      !state.files.length ||
      state.running;


    preview
      .querySelectorAll(".sr-remove")
      .forEach(
        function(btn) {

          btn.addEventListener(
            "click",
            function(event) {

              event.preventDefault();
              event.stopPropagation();

              if (state.running) {
                return;
              }

              const id =
                btn.dataset.id;

              const found =
                state.files.find(
                  item =>
                    item.id === id
                );

              if (found) {
                URL.revokeObjectURL(
                  found.url
                );
              }

              state.files =
                state.files.filter(
                  item =>
                    item.id !== id
                );

              renderPreview();

            }
          );

        }
      );
  }


  fileInput.addEventListener(
    "change",
    function() {

      let files =
        Array.from(
          fileInput.files || []
        );


      files =
        files.filter(
          file =>
            /^image\/(jpeg|png|webp)$/i
              .test(file.type)
        );


      const remain =
        MAX_FILES -
        state.files.length;


      files =
        files.slice(
          0,
          remain
        );


      files.forEach(
        function(file) {

          state.files.push({

            id:
              Date.now().toString(36) +
              Math.random()
                .toString(36)
                .slice(2),

            file:file,

            url:
              URL.createObjectURL(file)

          });

        }
      );


      fileInput.value = "";

      renderPreview();

    }
  );


  async function loadModels() {

    if (
      state.modelA &&
      state.modelB &&
      state.modelC
    ) {
      return;
    }


    showStatus(
      "3개의 AI 모델을 처음 준비하는 중입니다..."
    );


    const transformers =
      await import(
        "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm"
      );


    state.transformers =
      transformers;

    state.RawImage =
      transformers.RawImage;


    if (!state.modelA) {

      showStatus(
        "AI 모델 A를 준비하는 중..."
      );

      state.modelA =
        await transformers.pipeline(
          "image-classification",
          MODEL_A,
          {
            device:"wasm"
          }
        );

    }


    if (!state.modelB) {

      showStatus(
        "AI 모델 B를 준비하는 중..."
      );

      state.modelB =
        await transformers.pipeline(
          "image-classification",
          MODEL_B,
          {
            device:"wasm"
          }
        );

    }


    if (!state.modelC) {

      showStatus(
        "AI 모델 C를 준비하는 중..."
      );


      const nsfwjs =
        await import(
          "https://cdn.jsdelivr.net/npm/nsfwjs@4.4.0/+esm"
        );


      state.modelC =
        await nsfwjs.load();

    }

  }


  async function getRawImage(file) {

    return await state.RawImage
      .fromBlob(file);

  }


  async function getCanvas(file) {

    return new Promise(
      function(resolve,reject) {

        const url =
          URL.createObjectURL(file);


        const img =
          new Image();


        img.onload =
          function() {

            let width =
              img.naturalWidth;

            let height =
              img.naturalHeight;


            const maxSide =
              768;


            if (
              Math.max(
                width,
                height
              ) > maxSide
            ) {

              const ratio =
                maxSide /
                Math.max(
                  width,
                  height
                );

              width =
                Math.round(
                  width * ratio
                );

              height =
                Math.round(
                  height * ratio
                );

            }


            const canvas =
              document.createElement(
                "canvas"
              );


            canvas.width =
              width;

            canvas.height =
              height;


            const ctx =
              canvas.getContext("2d");


            ctx.drawImage(
              img,
              0,
              0,
              width,
              height
            );


            URL.revokeObjectURL(url);


            resolve(canvas);

          };


        img.onerror =
          function() {

            URL.revokeObjectURL(url);

            reject(
              new Error(
                "이미지를 읽지 못했습니다."
              )
            );

          };


        img.src =
          url;

      }
    );

  }


  function parseModelA(output) {

    let nsfw = 0;


    (output || [])
      .forEach(
        function(item) {

          const label =
            String(
              item.label || ""
            ).toLowerCase();


          if (label === "nsfw") {

            nsfw =
              Number(
                item.score || 0
              ) * 100;

          }

        }
      );


    return Math.round(
      clamp(nsfw)
    );

  }


  function parseModelB(output) {

    let sexy = 0;
    let porn = 0;
    let hentai = 0;


    (output || [])
      .forEach(
        function(item) {

          const label =
            String(
              item.label || ""
            ).toLowerCase();


          const score =
            Number(
              item.score || 0
            ) * 100;


          if (label === "sexy") {
            sexy = score;
          }

          else if (label === "porn") {
            porn = score;
          }

          else if (label === "hentai") {
            hentai = score;
          }

        }
      );


    const risk =
      porn * 1.0 +
      sexy * 0.65 +
      hentai * 0.85;


    return Math.round(
      clamp(risk)
    );

  }


  function parseModelC(output) {

    let sexy = 0;
    let porn = 0;
    let hentai = 0;


    (output || [])
      .forEach(
        function(item) {

          const label =
            String(
              item.className || ""
            ).toLowerCase();


          const score =
            Number(
              item.probability || 0
            ) * 100;


          if (label === "sexy") {
            sexy = score;
          }

          else if (label === "porn") {
            porn = score;
          }

          else if (label === "hentai") {
            hentai = score;
          }

        }
      );


    const risk =
      porn * 1.0 +
      sexy * 0.55 +
      hentai * 0.8;


    return Math.round(
      clamp(risk)
    );

  }


  function median(values) {

    const arr =
      [...values]
        .sort(
          (a,b) => a - b
        );


    return arr[1];

  }


  function combinedScore(a,b,c) {

    const values =
      [a,b,c];


    const avg =
      (
        a +
        b +
        c
      ) / 3;


    const mid =
      median(values);


    let score =
      mid * 0.65 +
      avg * 0.35;


    const highCount =
      values.filter(
        v => v >= 70
      ).length;


    if (highCount >= 2) {

      score =
        Math.max(
          score,
          72
        );

    }


    const lowCount =
      values.filter(
        v => v <= 20
      ).length;


    if (lowCount >= 2) {

      score =
        Math.min(
          score,
          32
        );

    }


    return Math.round(
      clamp(score)
    );

  }


  function renderModelRow(
    name,
    score
  ) {

    return `
      <div class="sr-model-row">

        <div class="sr-model-name">
          ${name}
        </div>

        <div class="sr-bar-bg">

          <div
            class="sr-bar-fill"
            style="width:${score}%"
          ></div>

        </div>

        <div class="sr-model-score">
          ${score}%
        </div>

      </div>
    `;

  }


  function renderResult(
    fileItem,
    index,
    a,
    b,
    c
  ) {

    const total =
      combinedScore(
        a,
        b,
        c
      );


    const info =
      getLevel(total);


    const box =
      document.createElement(
        "div"
      );


    box.className =
      "sr-result";


    box.innerHTML = `

      <div class="sr-result-top">

        <img decoding="async"
          class="sr-result-img"
          src="${fileItem.url}"
          alt=""
        >


        <div>

          <div class="sr-result-head">

            <div class="sr-name">
              ${index + 1}번 이미지
            </div>


            <div
              class="sr-score ${info.cls}"
            >
              ${total}점 ·
              ${info.text}
            </div>

          </div>


          <div class="sr-models">

            <div class="sr-model-title">
              AI 모델별 검사 결과
            </div>


            ${renderModelRow(
              "모델 A",
              a
            )}


            ${renderModelRow(
              "모델 B",
              b
            )}


            ${renderModelRow(
              "모델 C",
              c
            )}

          </div>

        </div>

      </div>


      <div
        class="sr-summary ${info.summaryCls}"
      >
        ${info.message}
      </div>

    `;


    results.appendChild(box);

  }


  async function analyze() {

    if (
      state.running ||
      !state.files.length
    ) {
      return;
    }


    state.running =
      true;


    startBtn.disabled =
      true;

    resetBtn.disabled =
      true;


    results.innerHTML =
      "";


    clearError();


    try {

      await loadModels();


      for (
        let i = 0;
        i < state.files.length;
        i++
      ) {

        showStatus(
          (i + 1) +
          " / " +
          state.files.length +
          "장 3중 AI 분석 중..."
        );


        const file =
          state.files[i].file;


        const rawImage =
          await getRawImage(file);


        const outputA =
          await state.modelA(
            rawImage,
            {
              topk:2
            }
          );


        const scoreA =
          parseModelA(
            outputA
          );


        const outputB =
          await state.modelB(
            rawImage,
            {
              topk:5
            }
          );


        const scoreB =
          parseModelB(
            outputB
          );


        const canvas =
          await getCanvas(file);


        const outputC =
          await state.modelC
            .classify(canvas);


        const scoreC =
          parseModelC(
            outputC
          );


        renderResult(
          state.files[i],
          i,
          scoreA,
          scoreB,
          scoreC
        );


        canvas.width = 1;
        canvas.height = 1;

      }


      showStatus(
        "모든 이미지의 3중 AI 분석이 완료되었습니다."
      );

    }


    catch(error) {

      console.error(error);


      showError(
        "검사 중 문제가 발생했습니다. " +
        (
          error.message ||
          String(error)
        )
      );


      showStatus(
        "검사가 중단되었습니다."
      );

    }


    finally {

      state.running =
        false;


      startBtn.disabled =
        !state.files.length;


      resetBtn.disabled =
        false;

    }

  }


  startBtn.addEventListener(
    "click",
    analyze
  );


  resetBtn.addEventListener(
    "click",
    function() {

      if (state.running) {
        return;
      }


      state.files.forEach(
        function(item) {

          URL.revokeObjectURL(
            item.url
          );

        }
      );


      state.files = [];


      preview.innerHTML =
        "";


      results.innerHTML =
        "";


      count.style.display =
        "none";


      status.style.display =
        "none";


      clearError();


      startBtn.disabled =
        true;

    }
  );

})();
</script>

</div>



<h2 class="wp-block-heading">SNS 게시 전, 이미지 위험요소를 미리 확인하세요</h2>



<p class="wp-block-paragraph">SNSNOTE의 <strong>SNS 게시물 위험도 검사기</strong>는 Instagram, Threads, X 등에 이미지를 게시하기 전 민감 콘텐츠로 판단될 가능성을 사전에 확인할 수 있도록 제공하는 이미지 분석 도구입니다.<br>SNSNOTE는 Instagram과 X 등에서 <strong>총 팔로워 약 40만 규모의 SNS 계정을 운영하며 축적한 실제 게시 경험과 콘텐츠 사례</strong>를 바탕으로 서비스 기준을 지속적으로 점검하고 있습니다.<br>단순한 이미지 분석 결과만 제공하는 것이 아니라, 실제 계정 운영 과정에서 확인된 콘텐츠 유형과 주요 SNS 플랫폼의 정책 변화를 함께 참고하여 위험도 안내 기준을 지속적으로 개선합니다.<br><br>공식 정책은 아래 자료를 기준으로 지속적으로 확인하고 있습니다.<br>SNSNOTE는 Instagram과 X의 공식 정책 문서를 정기적으로 검토하며, 정책 변경이나 새로운 제한 기준이 확인될 경우 위험도 안내 기준과 서비스 설명을 지속적으로 점검·개선하고 있습니다.<br><strong>Instagram 커뮤니티 가이드라인</strong><br><a href="https://www.facebook.com/help/477434105621119" target="_blank" rel="noreferrer noopener">https://www.facebook.com/help/477434105621119</a><br><strong>X 콘텐츠 정책</strong><br><a href="https://help.x.com/ko/rules-and-policies" target="_blank" rel="noreferrer noopener">https://help.x.com/ko/rules-and-policies</a><br>SNS 플랫폼의 정책은 수시로 변경될 수 있기 때문에, SNSNOTE는 위 공식 정책과 실제 운영 사례를 함께 참고하여 검사 기준을 계속 업데이트하고 있습니다.</p>



<h2 class="wp-block-heading">SNS 게시물 위험도 검사기란?</h2>



<p class="wp-block-paragraph">SNS 게시물 위험도 검사기는 이미지에 포함된 민감 콘텐츠 가능성을 AI로 분석하여 <strong>0~100점의 위험도 점수</strong>로 표시합니다.<br>JPG, PNG, WebP 이미지를 지원하며 한 번에 <strong>최대 20장</strong>까지 검사할 수 있습니다.<br>Instagram 피드, 카드뉴스, 매거진 콘텐츠, Threads 및 X 게시용 이미지처럼 여러 장을 한 번에 게시하는 콘텐츠를 사전에 점검할 때 활용할 수 있습니다.<br>검사 결과는 각 이미지별로 구분하여 표시되므로 여러 장 중 위험도가 상대적으로 높은 이미지를 빠르게 확인할 수 있습니다.</p>



<h2 class="wp-block-heading">주요 기능</h2>



<ul class="wp-block-list">
<li>최대 20장 이미지 일괄 검사</li>



<li>JPG·PNG·WebP 지원</li>



<li>이미지별 민감 콘텐츠 위험도 표시</li>



<li>0~100점 위험도 점수 제공</li>



<li>낮음·주의·높음·매우 높음 단계별 표시</li>



<li>브라우저에서 직접 AI 분석</li>



<li>SNSNOTE 서버로 이미지 업로드 없음</li>



<li>별도 회원가입 없이 이용 가능</li>
</ul>



<h2 class="wp-block-heading">사용 방법</h2>



<h3 class="wp-block-heading">1. 검사할 이미지를 선택합니다</h3>



<p class="wp-block-paragraph">검사 영역을 눌러 SNS에 게시할 이미지를 선택합니다.<br>한 번에 최대 20장까지 추가할 수 있으며, 잘못 선택한 이미지는 오른쪽 상단의 삭제 버튼을 눌러 제외할 수 있습니다.</p>



<h3 class="wp-block-heading">2. 이미지 위험도 검사를 시작합니다</h3>



<p class="wp-block-paragraph"><strong>이미지 위험도 검사 시작</strong> 버튼을 누르면 선택한 이미지가 순서대로 분석됩니다.<br>첫 검사에서는 브라우저가 AI 분석에 필요한 데이터를 준비하기 때문에 이후 검사보다 조금 더 시간이 걸릴 수 있습니다.</p>



<h3 class="wp-block-heading">3. 이미지별 위험도를 확인합니다</h3>



<p class="wp-block-paragraph">분석이 완료되면 각 이미지에 다음과 같이 위험도가 표시됩니다.<br><strong>낮음</strong><br>강한 민감 콘텐츠 신호가 적게 감지된 이미지입니다.<br><strong>주의</strong><br>일부 민감 콘텐츠 가능성이 감지된 이미지입니다. 게시 전 직접 확인을 권장합니다.<br><strong>높음</strong><br>민감하거나 선정적인 콘텐츠로 분류될 가능성이 높게 감지된 이미지입니다.<br><strong>매우 높음</strong><br>강한 민감 콘텐츠 신호가 감지된 이미지입니다. 게시 전 이미지 재검토를 권장합니다.</p>



<h2 class="wp-block-heading">검사 기준은 계속 업데이트됩니다</h2>



<p class="wp-block-paragraph">Instagram, Threads, X를 비롯한 주요 SNS 플랫폼은 콘텐츠 정책과 추천 기준을 지속적으로 변경하고 있습니다.<br>SNSNOTE는 <strong>실제 SNS 계정 운영 과정에서 축적된 사례와 주요 플랫폼의 정책 변화를 참고하여 위험도 기준을 지속적으로 점검하고 개선하고 있습니다.</strong><br>정책 변화 또는 새로운 콘텐츠 유형이 확인되는 경우 서비스 안내 기준에도 순차적으로 반영합니다.</p>



<h2 class="wp-block-heading">이미지가 서버에 저장되나요?</h2>



<p class="wp-block-paragraph">아닙니다.<br>선택한 이미지는 SNSNOTE 서버에 업로드하여 분석하지 않습니다.<br>AI 분석에 필요한 프로그램과 모델은 브라우저에서 실행되며 실제 이미지 처리는 사용자의 기기에서 진행됩니다.<br>따라서 검사하기 위해 선택한 이미지가 SNSNOTE 서버에 별도로 저장되는 구조가 아닙니다.</p>



<h2 class="wp-block-heading">AI 분석 결과가 낮으면 반드시 안전한가요?</h2>



<p class="wp-block-paragraph">아닙니다.<br>AI 이미지 분석에는 기술적인 한계가 있습니다.<br>특히 다음과 같은 경우 민감 요소를 정확하게 인식하지 못할 수 있습니다.</p>



<ul class="wp-block-list">
<li>화면에서 매우 작게 표현된 노출 부위</li>



<li>신체 일부가 다른 물체나 문자로 가려진 이미지</li>



<li>스티커 또는 모자이크가 적용된 이미지</li>



<li>복잡한 배경과 인물이 겹쳐 있는 이미지</li>



<li>특수한 촬영 각도나 크롭 이미지</li>



<li>AI 학습 데이터에서 충분히 다뤄지지 않은 형태의 이미지</li>
</ul>



<p class="wp-block-paragraph">따라서 <strong>작은 노출 부위나 가려진 민감 요소는 위험도 점수에 충분히 반영되지 않을 수 있습니다.</strong><br>0점 또는 낮음이 표시되더라도 게시 전에는 실제 이미지를 직접 확인하는 것을 권장합니다.</p>



<h2 class="wp-block-heading">Instagram과 X의 기준은 동일한가요?</h2>



<p class="wp-block-paragraph">동일하지 않습니다.<br>각 SNS 플랫폼은 민감 콘텐츠에 대해 서로 다른 정책을 적용합니다.<br>예를 들어 특정 이미지가 한 플랫폼에서는 제한될 수 있지만 다른 플랫폼에서는 민감 콘텐츠 표시 등의 조건을 충족하면 허용될 수도 있습니다.<br>따라서 본 검사기의 점수는 여러 SNS에 공통적으로 적용되는 <strong>사전 위험 신호를 확인하기 위한 참고용 지표</strong>로 활용하는 것이 적절합니다.<br>최종 게시 여부는 반드시 이용하려는 플랫폼의 최신 정책을 함께 확인해야 합니다.</p>



<h2 class="wp-block-heading">카드뉴스도 검사할 수 있나요?</h2>



<p class="wp-block-paragraph">가능합니다.<br>한 번에 최대 20장까지 선택할 수 있기 때문에 여러 장으로 구성된 Instagram 카드뉴스나 매거진형 콘텐츠를 게시하기 전에 일괄 점검할 수 있습니다.<br>여러 이미지 중 일부만 높은 위험도로 표시되는 경우 해당 이미지를 우선적으로 재검토하는 방식으로 활용할 수 있습니다.</p>



<h2 class="wp-block-heading">검사 결과가 실제 계정 제재를 의미하나요?</h2>



<p class="wp-block-paragraph">아닙니다.<br>SNSNOTE의 위험도 점수는 Instagram, Threads, X 등 플랫폼 내부에서 사용하는 공식 심사 점수가 아닙니다.<br>실제 게시물의 삭제, 노출 제한, 추천 제외, 민감 콘텐츠 표시 또는 계정 제재 여부는 각 플랫폼이 자체 정책과 시스템에 따라 결정합니다.<br>콘텐츠의 맥락, 게시 위치, 계정 상태, 반복 게시 여부 등 여러 요소가 함께 영향을 줄 수 있습니다.</p>



<h2 class="wp-block-heading">이용 전 참고사항</h2>



<p class="wp-block-paragraph">본 서비스는 SNS 게시 전 콘텐츠를 한 번 더 확인할 수 있도록 제공되는 <strong>사전 점검 도구</strong>입니다.<br>AI 분석 결과만으로 게시 가능 여부를 확정하지 마시고, 점수가 낮더라도 중요한 게시물은 직접 확인하는 것을 권장합니다.<br>SNSNOTE는 서비스 품질 향상을 위해 주요 SNS의 정책 변화와 실제 운영 사례를 지속적으로 검토하고 위험도 안내 기준을 개선하고 있습니다.</p>



<style>
  /* 이 글에서만 본문 상단 대표 이미지 숨기기 */
  .featured-image, 
  .post-thumbnail, 
  .entry-header .post-image,
  .single-media {
    display: none !important;
  }
</style>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/sns-post-risk-checker/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/sns-post-risk-checker.webp" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1617</post-id>	</item>
		<item>
		<title>PDF JPG 변환 무료 사이트, 이미지 PDF 변환과 PNG·WebP 저장</title>
		<link>https://snsnote.com/pdf-jpg-converter/</link>
					<comments>https://snsnote.com/pdf-jpg-converter/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Wed, 09 Sep 2026 10:00:22 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1607</guid>

					<description><![CDATA[PDF JPG 변환 무료 도구 사용 방법 PDF 문서를 이미지로 바꿔야 하는데 프로그램을 설치하거나 회원가입을 해야 한다면 번거로울 수 있습니다. 특히 PDF의 특정 페이지만 JPG로...]]></description>
										<content:encoded><![CDATA[<div id="sns-pdf-image-tool">
<style>
#sns-pdf-image-tool {
  --spic-blue:#2563eb;
  --spic-blue2:#4f46e5;
  --spic-text:#172033;
  --spic-muted:#64748b;
  --spic-line:#dfe6f1;
  --spic-soft:#f8fafc;
  --spic-green:#15803d;
  --spic-red:#b91c1c;
  width:100%;
  max-width:1120px;
  margin:0 auto;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
  color:var(--spic-text);
}
#sns-pdf-image-tool *{box-sizing:border-box}
#sns-pdf-image-tool [hidden]{display:none!important}
#sns-pdf-image-tool button,#sns-pdf-image-tool input,#sns-pdf-image-tool select{font:inherit}

#sns-pdf-image-tool .spic-shell{
  width:100%;
  background:#fff;
  border:1px solid var(--spic-line);
  border-radius:18px;
  overflow:hidden;
  box-shadow:0 8px 28px rgba(15,23,42,.055);
}
#sns-pdf-image-tool .spic-tabs{
  display:grid;
  grid-template-columns:1fr 1fr;
  gap:12px;
  padding:14px;
  background:#f8fafc;
  border-bottom:1px solid var(--spic-line);
}
#sns-pdf-image-tool .spic-tab{
  min-height:72px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:11px;
  padding:12px 16px;
  border:1px solid #d7e0ec;
  border-radius:14px;
  background:#fff;
  color:#334155;
  text-align:left;
  cursor:pointer;
  box-shadow:0 2px 7px rgba(15,23,42,.045);
  transition:transform .16s ease,box-shadow .16s ease,border-color .16s ease,background .16s ease,color .16s ease;
}
#sns-pdf-image-tool .spic-tab:hover:not(.active){
  transform:translateY(-1px);
  border-color:#93c5fd;
  background:#eff6ff;
  color:#1d4ed8;
  box-shadow:0 5px 14px rgba(37,99,235,.09);
}
#sns-pdf-image-tool .spic-tab:focus-visible{
  outline:3px solid rgba(59,130,246,.28);
  outline-offset:2px;
}
#sns-pdf-image-tool .spic-tab.active{
  border-color:transparent;
  background:linear-gradient(105deg,var(--spic-blue),var(--spic-blue2));
  color:#fff;
  box-shadow:0 8px 20px rgba(37,99,235,.22);
}
#sns-pdf-image-tool .spic-tab-icon{
  flex:0 0 38px;
  width:38px;
  height:38px;
  display:flex;
  align-items:center;
  justify-content:center;
  border-radius:11px;
  background:#eef2ff;
  color:#4f46e5;
}
#sns-pdf-image-tool .spic-tab-icon svg{
  width:21px;
  height:21px;
}
#sns-pdf-image-tool .spic-tab.active .spic-tab-icon{
  background:rgba(255,255,255,.18);
  color:#fff;
}
#sns-pdf-image-tool .spic-tab-copy{
  display:flex;
  flex-direction:column;
  min-width:0;
}
#sns-pdf-image-tool .spic-tab-copy strong{
  font-size:16px;
  font-weight:850;
  line-height:1.25;
  color:inherit;
}
#sns-pdf-image-tool .spic-tab-copy small{
  display:block;
  margin-top:3px;
  font-size:11px;
  font-weight:650;
  line-height:1.3;
  color:#64748b;
}
#sns-pdf-image-tool .spic-tab.active .spic-tab-copy small{
  color:rgba(255,255,255,.86);
}
#sns-pdf-image-tool .spic-content{padding:22px}
#sns-pdf-image-tool .spic-grid{
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,1fr);
  gap:18px;
  align-items:start;
}
#sns-pdf-image-tool .spic-card{
  min-width:0;
  border:1px solid var(--spic-line);
  border-radius:15px;
  background:#fff;
  padding:17px;
}
#sns-pdf-image-tool .spic-card-title{
  display:flex;
  align-items:center;
  gap:8px;
  margin:0 0 14px;
  font-size:16px;
  font-weight:800;
}
#sns-pdf-image-tool .spic-num{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  width:24px;height:24px;
  border-radius:999px;
  background:#eff6ff;
  color:var(--spic-blue);
  font-size:12px;
  font-weight:900;
}
#sns-pdf-image-tool .spic-drop{
  width:100%;
  min-height:174px;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:6px;
  padding:20px;
  border:2px dashed #cbd5e1;
  border-radius:14px;
  background:#f8fbff;
  text-align:center;
  cursor:pointer;
  transition:.18s ease;
}
#sns-pdf-image-tool .spic-drop:hover,
#sns-pdf-image-tool .spic-drop.dragover{
  border-color:var(--spic-blue);
  background:#eff6ff;
}
#sns-pdf-image-tool .spic-drop-icon{font-size:34px;line-height:1}
#sns-pdf-image-tool .spic-drop strong{font-size:16px}
#sns-pdf-image-tool .spic-drop span{font-size:13px;color:var(--spic-muted);line-height:1.55}
#sns-pdf-image-tool .spic-file-info{
  margin-top:11px;
  padding:10px 11px;
  border-radius:10px;
  background:#f8fafc;
  color:#475569;
  font-size:13px;
  line-height:1.55;
  overflow-wrap:anywhere;
}
#sns-pdf-image-tool .spic-fields{display:grid;gap:13px}
#sns-pdf-image-tool .spic-field label{
  display:block;
  margin-bottom:6px;
  font-size:13px;
  font-weight:750;
  color:#334155;
}
#sns-pdf-image-tool .spic-field input[type="text"],
#sns-pdf-image-tool .spic-field input[type="password"],
#sns-pdf-image-tool .spic-field select{
  width:100%;
  min-height:44px;
  padding:9px 11px;
  border:1px solid #cbd5e1;
  border-radius:10px;
  background:#fff;
  color:#111827;
  outline:none;
}
#sns-pdf-image-tool .spic-field input:focus,
#sns-pdf-image-tool .spic-field select:focus{border-color:#60a5fa;box-shadow:0 0 0 3px rgba(59,130,246,.11)}
#sns-pdf-image-tool .spic-help{margin-top:5px;color:var(--spic-muted);font-size:12px;line-height:1.5}
#sns-pdf-image-tool .spic-two{display:grid;grid-template-columns:1fr 1fr;gap:11px}
#sns-pdf-image-tool .spic-btn-row{display:grid;grid-template-columns:1fr auto;gap:9px;margin-top:15px}
#sns-pdf-image-tool .spic-btn{
  min-height:46px;
  padding:11px 15px;
  border:0;
  border-radius:11px;
  font-weight:800;
  font-size:14px;
  cursor:pointer;
  transition:.15s ease;
}
#sns-pdf-image-tool .spic-btn:disabled{opacity:.46;cursor:not-allowed}
#sns-pdf-image-tool .spic-primary{background:linear-gradient(100deg,var(--spic-blue),var(--spic-blue2));color:#fff}
#sns-pdf-image-tool .spic-primary:hover:not(:disabled){filter:brightness(.97)}
#sns-pdf-image-tool .spic-secondary{background:#edf2f7;color:#334155}
#sns-pdf-image-tool .spic-danger{background:#fee2e2;color:#991b1b}
#sns-pdf-image-tool .spic-status{
  margin-top:14px;
  padding:11px 12px;
  border:1px solid #bfdbfe;
  border-radius:11px;
  background:#eff6ff;
  color:#1d4ed8;
  font-size:13px;
  line-height:1.6;
  overflow-wrap:anywhere;
}
#sns-pdf-image-tool .spic-status.success{background:#ecfdf5;border-color:#a7f3d0;color:#047857}
#sns-pdf-image-tool .spic-status.error{background:#fef2f2;border-color:#fecaca;color:var(--spic-red)}
#sns-pdf-image-tool .spic-status.warn{background:#fffbeb;border-color:#fde68a;color:#92400e}
#sns-pdf-image-tool .spic-progress{width:100%;height:10px;margin-top:11px;accent-color:var(--spic-blue)}
#sns-pdf-image-tool .spic-note{
  margin-top:15px;
  padding:12px 13px;
  border-radius:11px;
  background:#f8fafc;
  color:#64748b;
  font-size:12px;
  line-height:1.7;
}
#sns-pdf-image-tool .spic-summary{
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:9px;
  margin-top:13px;
}
#sns-pdf-image-tool .spic-summary-item{
  padding:10px 8px;
  border:1px solid #e2e8f0;
  border-radius:10px;
  background:#fff;
  text-align:center;
}
#sns-pdf-image-tool .spic-summary-item strong{display:block;font-size:15px;color:#0f172a}
#sns-pdf-image-tool .spic-summary-item span{display:block;margin-top:2px;font-size:11px;color:#64748b}

#sns-pdf-image-tool .spic-image-list{display:grid;gap:9px;margin-top:12px}
#sns-pdf-image-tool .spic-image-item{
  display:grid;
  grid-template-columns:50px minmax(0,1fr) auto;
  gap:10px;
  align-items:center;
  padding:8px;
  border:1px solid #e2e8f0;
  border-radius:11px;
  background:#fff;
}
#sns-pdf-image-tool .spic-thumb{
  width:50px;height:50px;
  object-fit:cover;
  border-radius:8px;
  background:#f1f5f9;
}
#sns-pdf-image-tool .spic-image-meta{min-width:0}
#sns-pdf-image-tool .spic-image-meta strong{display:block;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#sns-pdf-image-tool .spic-image-meta span{display:block;margin-top:3px;font-size:11px;color:#64748b}
#sns-pdf-image-tool .spic-item-actions{display:flex;gap:4px}
#sns-pdf-image-tool .spic-mini{
  width:30px;height:30px;
  padding:0;
  border:1px solid #dbe3ee;
  border-radius:8px;
  background:#f8fafc;
  color:#334155;
  cursor:pointer;
  font-size:13px;
  font-weight:800;
}
#sns-pdf-image-tool .spic-empty{
  padding:34px 15px;
  border:1px dashed #d7dee9;
  border-radius:12px;
  color:#94a3b8;
  text-align:center;
  font-size:13px;
  line-height:1.6;
}
#sns-pdf-image-tool .spic-inline{display:flex;gap:8px;align-items:center}
#sns-pdf-image-tool .spic-inline input{flex:1;min-width:0}
#sns-pdf-image-tool .spic-small-btn{
  flex:none;
  min-height:44px;
  padding:8px 11px;
  border:1px solid #dbe3ee;
  border-radius:10px;
  background:#f8fafc;
  color:#334155;
  font-size:12px;
  font-weight:800;
  cursor:pointer;
}
#sns-pdf-image-tool .spic-library-badge{
  display:inline-block;
  margin-left:5px;
  padding:2px 6px;
  border-radius:999px;
  background:#ecfdf5;
  color:#047857;
  font-size:10px;
  font-weight:800;
  vertical-align:1px;
}

@media(max-width:820px){
  #sns-pdf-image-tool .spic-grid{grid-template-columns:1fr}
}
@media(max-width:560px){
  #sns-pdf-image-tool .spic-content{padding:14px}
  #sns-pdf-image-tool .spic-card{padding:14px}
  #sns-pdf-image-tool .spic-two{grid-template-columns:1fr}
  #sns-pdf-image-tool .spic-btn-row{grid-template-columns:1fr}
  #sns-pdf-image-tool .spic-summary{grid-template-columns:1fr 1fr 1fr}
  #sns-pdf-image-tool .spic-tabs{grid-template-columns:1fr;gap:8px;padding:10px}
  #sns-pdf-image-tool .spic-tab{min-height:62px;padding:10px 13px;justify-content:flex-start}
  #sns-pdf-image-tool .spic-tab-icon{flex-basis:34px;width:34px;height:34px}
  #sns-pdf-image-tool .spic-tab-copy strong{font-size:15px}
  #sns-pdf-image-tool .spic-tab-copy small{font-size:11px}
  #sns-pdf-image-tool .spic-image-item{grid-template-columns:46px minmax(0,1fr);}
  #sns-pdf-image-tool .spic-thumb{width:46px;height:46px}
  #sns-pdf-image-tool .spic-item-actions{grid-column:1/-1;justify-content:flex-end}
}
</style>

<div class="spic-shell">
  <div class="spic-tabs" role="tablist" aria-label="PDF 이미지 변환 방향">
    <button type="button" class="spic-tab active" id="spic-tab-pdf" role="tab" aria-selected="true">
      <span class="spic-tab-icon" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M6 2h8l4 4v16H6z"></path>
          <path d="M14 2v5h5"></path>
          <path d="M8.5 14h7"></path>
          <path d="M8.5 18h5"></path>
        </svg>
      </span>
      <span class="spic-tab-copy">
        <strong>PDF → 이미지 변환</strong>
        <small>JPG · PNG · WebP로 저장</small>
      </span>
    </button>

    <button type="button" class="spic-tab" id="spic-tab-image" role="tab" aria-selected="false">
      <span class="spic-tab-icon" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <rect x="3" y="4" width="18" height="16" rx="2"></rect>
          <circle cx="8.5" cy="9" r="1.5"></circle>
          <path d="m3 17 5-5 4 4 2-2 7 6"></path>
        </svg>
      </span>
      <span class="spic-tab-copy">
        <strong>이미지 → PDF 변환</strong>
        <small>JPG · PNG · WebP를 PDF로 저장</small>
      </span>
    </button>
  </div>

  <!-- PDF -> IMAGE -->
  <section class="spic-content" id="spic-panel-pdf" role="tabpanel" aria-labelledby="spic-tab-pdf">
    <div class="spic-grid">
      <div class="spic-card">
        <h3 class="spic-card-title"><span class="spic-num">1</span> PDF 파일 선택</h3>
        <input id="spic-pdf-file" type="file" accept="application/pdf,.pdf" hidden>
        <label class="spic-drop" id="spic-pdf-drop" for="spic-pdf-file">
          <div class="spic-drop-icon"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4c4.png" alt="📄" class="wp-smiley" style="height: 1em; max-height: 1em;" /></div>
          <strong>PDF를 여기 놓으세요</strong>
          <span>또는 클릭해서 파일 선택 · 최대 100MB<br>파일은 SNSNOTE 서버로 업로드하지 않습니다.</span>
        </label>
        <div class="spic-file-info" id="spic-pdf-info">선택된 PDF 없음</div>

        <div class="spic-field" style="margin-top:13px;">
          <label for="spic-pdf-password">PDF 비밀번호 <span style="font-weight:500;color:#94a3b8;">(암호가 있을 때만)</span></label>
          <div class="spic-inline">
            <input id="spic-pdf-password" type="password" autocomplete="off" placeholder="비밀번호 입력">
            <button type="button" class="spic-small-btn" id="spic-pdf-reload" disabled>다시 불러오기</button>
          </div>
        </div>

        <div class="spic-summary" id="spic-pdf-summary" hidden>
          <div class="spic-summary-item"><strong id="spic-page-count">-</strong><span>전체 페이지</span></div>
          <div class="spic-summary-item"><strong id="spic-pdf-size">-</strong><span>파일 용량</span></div>
          <div class="spic-summary-item"><strong id="spic-selected-count">-</strong><span>변환 페이지</span></div>
        </div>
      </div>

      <div class="spic-card">
        <h3 class="spic-card-title"><span class="spic-num">2</span> 변환 설정</h3>
        <div class="spic-fields">
          <div class="spic-field">
            <label for="spic-pages">변환할 페이지</label>
            <div class="spic-inline">
              <input id="spic-pages" type="text" value="전체" placeholder="예: 1-3, 5, 8-10" disabled>
              <button type="button" class="spic-small-btn" id="spic-pages-all" disabled>전체</button>
            </div>
            <div class="spic-help">전체 또는 <b>1-3, 5, 8-10</b>처럼 필요한 페이지만 지정할 수 있습니다.</div>
          </div>

          <div class="spic-two">
            <div class="spic-field">
              <label for="spic-format">이미지 형식</label>
              <select id="spic-format" disabled>
                <option value="jpeg">JPG</option>
                <option value="png">PNG</option>
                <option value="webp">WebP</option>
              </select>
            </div>
            <div class="spic-field">
              <label for="spic-scale">출력 해상도</label>
              <select id="spic-scale" disabled>
                <option value="1.5">빠른 변환 (1.5x)</option>
                <option value="2" selected>표준 화질 (2x)</option>
                <option value="3">고화질 (3x)</option>
                <option value="4">최고화질 (4x)</option>
              </select>
            </div>
          </div>

          <div class="spic-field" id="spic-quality-wrap">
            <label for="spic-quality">JPG / WebP 품질</label>
            <select id="spic-quality" disabled>
              <option value="0.8">80% · 용량 절약</option>
              <option value="0.9" selected>90% · 권장</option>
              <option value="0.95">95% · 고화질</option>
              <option value="1">100% · 최대 품질</option>
            </select>
          </div>

          <div class="spic-field">
            <label for="spic-output-name">저장 파일명</label>
            <input id="spic-output-name" type="text" value="pdf-images" disabled>
            <div class="spic-help">여러 페이지는 ZIP으로 저장되며 각 이미지는 page-001 형식으로 이름이 붙습니다.</div>
          </div>
        </div>

        <div class="spic-btn-row">
          <button type="button" class="spic-btn spic-primary" id="spic-convert-pdf" disabled>PDF 이미지 변환 시작</button>
          <button type="button" class="spic-btn spic-danger" id="spic-cancel-pdf" hidden>중지</button>
        </div>
        <progress class="spic-progress" id="spic-pdf-progress" max="100" hidden></progress>
        <div class="spic-status" id="spic-pdf-status" role="status" aria-live="polite">PDF를 선택하면 페이지 수를 확인합니다.</div>
      </div>
    </div>

    <div class="spic-note">
      <b>브라우저 처리 방식:</b> PDF 원본과 변환된 이미지는 SNSNOTE 서버로 전송하지 않습니다. PDF.js 라이브러리는 외부 CDN에서 불러오며 실제 렌더링은 사용자의 브라우저에서 진행됩니다.<br>
      페이지 수 자체는 제한하지 않지만, 수백 페이지를 한꺼번에 고화질 ZIP으로 만들면 사용 중인 기기의 메모리를 많이 사용할 수 있습니다. 이 경우 필요한 페이지만 나누어 변환하는 것을 권장합니다.
    </div>
  </section>

  <!-- IMAGE -> PDF -->
  <section class="spic-content" id="spic-panel-image" role="tabpanel" aria-labelledby="spic-tab-image" hidden>
    <div class="spic-grid">
      <div class="spic-card">
        <h3 class="spic-card-title"><span class="spic-num">1</span> 이미지 선택</h3>
        <input id="spic-image-files" type="file" accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp" multiple hidden>
        <label class="spic-drop" id="spic-image-drop" for="spic-image-files">
          <div class="spic-drop-icon"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f5bc.png" alt="🖼" class="wp-smiley" style="height: 1em; max-height: 1em;" /></div>
          <strong>JPG · PNG · WebP 사진을 넣으세요</strong>
          <span>여러 장을 한 번에 선택 가능 · 총 100MB까지<br>위아래 버튼으로 PDF 페이지 순서를 바꿀 수 있습니다.</span>
        </label>
        <div class="spic-file-info" id="spic-image-info">선택된 이미지 없음</div>
        <div class="spic-image-list" id="spic-image-list">
          <div class="spic-empty">이미지를 선택하면 여기에서 순서를 확인할 수 있습니다.</div>
        </div>
      </div>

      <div class="spic-card">
        <h3 class="spic-card-title"><span class="spic-num">2</span> PDF 만들기 설정</h3>
        <div class="spic-fields">
          <div class="spic-two">
            <div class="spic-field">
              <label for="spic-page-size">PDF 페이지 크기</label>
              <select id="spic-page-size" disabled>
                <option value="auto" selected>A4 자동 방향</option>
                <option value="a4p">A4 세로</option>
                <option value="a4l">A4 가로</option>
                <option value="image">이미지 비율에 맞춤</option>
              </select>
            </div>
            <div class="spic-field">
              <label for="spic-margin">페이지 여백</label>
              <select id="spic-margin" disabled>
                <option value="0">없음</option>
                <option value="5">5mm</option>
                <option value="10" selected>10mm</option>
                <option value="15">15mm</option>
              </select>
            </div>
          </div>

          <div class="spic-two">
            <div class="spic-field">
              <label for="spic-image-max">이미지 해상도</label>
              <select id="spic-image-max" disabled>
                <option value="2000">빠른 처리 · 최대 2000px</option>
                <option value="3000" selected>권장 · 최대 3000px</option>
                <option value="4000">고화질 · 최대 4000px</option>
              </select>
            </div>
            <div class="spic-field">
              <label for="spic-pdf-quality">이미지 품질</label>
              <select id="spic-pdf-quality" disabled>
                <option value="0.8">80% · 파일 작게</option>
                <option value="0.9" selected>90% · 권장</option>
                <option value="0.95">95% · 고화질</option>
              </select>
            </div>
          </div>

          <div class="spic-field">
            <label for="spic-pdf-name">PDF 파일명</label>
            <input id="spic-pdf-name" type="text" value="images-to-pdf" disabled>
          </div>
        </div>

        <div class="spic-btn-row">
          <button type="button" class="spic-btn spic-primary" id="spic-create-pdf" disabled>이미지로 PDF 만들기</button>
          <button type="button" class="spic-btn spic-danger" id="spic-cancel-image" hidden>중지</button>
        </div>
        <progress class="spic-progress" id="spic-image-progress" max="100" hidden></progress>
        <div class="spic-status" id="spic-image-status" role="status" aria-live="polite">이미지를 선택하면 PDF로 묶을 수 있습니다.</div>
      </div>
    </div>

    <div class="spic-note">
      <b>이미지 → PDF도 브라우저에서 처리됩니다.</b> 사진을 SNSNOTE 서버로 업로드하지 않습니다. WebP와 투명 PNG는 PDF 호환성을 위해 브라우저에서 흰색 배경의 JPG 데이터로 변환한 뒤 PDF에 넣습니다.
    </div>
  </section>
</div>

<script type="module">
/*
  SNSNOTE PDF <-> Image Converter
  - PDF.js 5.7.284 (Apache-2.0): https://github.com/mozilla/pdf.js
  - pdf-lib 1.17.1 (MIT): https://github.com/Hopding/pdf-lib
  - JSZip 3.10.1 (MIT or GPL-3.0): https://stuk.github.io/jszip/
  External libraries are version-pinned and loaded from CDN with fallback.
*/
(async function(){
  const root=document.getElementById('sns-pdf-image-tool');
  if(!root || root.dataset.ready==='true') return;
  root.dataset.ready='true';
  const $=id=>root.querySelector('#'+id);

  const MAX_FILE_BYTES=100*1024*1024;
  const MAX_CANVAS_PIXELS=16_000_000;
  const MAX_CANVAS_DIMENSION=8192;
  const A4_P=[595.28,841.89];
  const A4_L=[841.89,595.28];
  const MM_TO_PT=72/25.4;

  const el={
    tabPdf:$('spic-tab-pdf'),tabImage:$('spic-tab-image'),panelPdf:$('spic-panel-pdf'),panelImage:$('spic-panel-image'),
    pdfFile:$('spic-pdf-file'),pdfDrop:$('spic-pdf-drop'),pdfInfo:$('spic-pdf-info'),pdfPassword:$('spic-pdf-password'),pdfReload:$('spic-pdf-reload'),
    pdfSummary:$('spic-pdf-summary'),pageCount:$('spic-page-count'),pdfSize:$('spic-pdf-size'),selectedCount:$('spic-selected-count'),pages:$('spic-pages'),pagesAll:$('spic-pages-all'),format:$('spic-format'),scale:$('spic-scale'),quality:$('spic-quality'),qualityWrap:$('spic-quality-wrap'),outputName:$('spic-output-name'),convertPdf:$('spic-convert-pdf'),cancelPdf:$('spic-cancel-pdf'),pdfProgress:$('spic-pdf-progress'),pdfStatus:$('spic-pdf-status'),
    imageFiles:$('spic-image-files'),imageDrop:$('spic-image-drop'),imageInfo:$('spic-image-info'),imageList:$('spic-image-list'),pageSize:$('spic-page-size'),margin:$('spic-margin'),imageMax:$('spic-image-max'),pdfQuality:$('spic-pdf-quality'),pdfName:$('spic-pdf-name'),createPdf:$('spic-create-pdf'),cancelImage:$('spic-cancel-image'),imageProgress:$('spic-image-progress'),imageStatus:$('spic-image-status')
  };

  let pdfFile=null,pdfDoc=null,pdfBytes=null,pdfjsLib=null,pdfJsBase='';
  let pdfBusy=false,pdfCancel=false;
  let imageItems=[];
  let imageBusy=false,imageCancel=false;

  function setTab(which){
    const pdf=which==='pdf';
    el.tabPdf.classList.toggle('active',pdf);el.tabPdf.setAttribute('aria-selected',String(pdf));
    el.tabImage.classList.toggle('active',!pdf);el.tabImage.setAttribute('aria-selected',String(!pdf));
    el.panelPdf.hidden=!pdf;el.panelImage.hidden=pdf;
  }
  el.tabPdf.addEventListener('click',()=>setTab('pdf'));
  el.tabImage.addEventListener('click',()=>setTab('image'));

  function sizeText(bytes){
    if(bytes<1024) return bytes+' B';
    if(bytes<1024*1024) return (bytes/1024).toFixed(bytes<10240?1:0)+' KB';
    return (bytes/1024/1024).toFixed(1)+' MB';
  }
  function safeBaseName(name,fallback){
    const s=(name||fallback||'file').trim().replace(/\.[^.]+$/,'').replace(/[\\/:*?"<>|]+/g,'_').replace(/\s+/g,'-').replace(/-+/g,'-').replace(/^[-_.]+|[-_.]+$/g,'');
    return s||fallback||'file';
  }
  function downloadBlob(blob,filename){
    const url=URL.createObjectURL(blob);
    const a=document.createElement('a');
    a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();
    setTimeout(()=>URL.revokeObjectURL(url),30000);
  }
  function status(target,message,type=''){
    target.textContent=message;
    target.className='spic-status'+(type?' '+type:'');
  }
  function loadScript(urls,test){
    return new Promise((resolve,reject)=>{
      if(test()) return resolve();
      let i=0;
      const next=()=>{
        if(i>=urls.length) return reject(new Error('외부 라이브러리를 불러오지 못했습니다. 네트워크 또는 콘텐츠 차단 설정을 확인해주세요.'));
        const src=urls[i++];
        const s=document.createElement('script');
        s.src=src;s.async=true;
        s.onload=()=>test()?resolve():next();
        s.onerror=()=>{s.remove();next();};
        document.head.appendChild(s);
      };
      next();
    });
  }
  async function loadPdfJs(){
    if(pdfjsLib) return pdfjsLib;
    const candidates=[
      'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.7.284',
      'https://unpkg.com/pdfjs-dist@5.7.284'
    ];
    let lastError=null;
    for(const base of candidates){
      try{
        const mod=await import(base+'/build/pdf.mjs');
        mod.GlobalWorkerOptions.workerSrc=base+'/build/pdf.worker.min.mjs';
        pdfjsLib=mod;pdfJsBase=base;return mod;
      }catch(e){lastError=e;}
    }
    throw new Error('PDF 변환 라이브러리를 불러오지 못했습니다. '+(lastError?.message||''));
  }
  async function loadJsZip(){
    await loadScript([
      'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js',
      'https://unpkg.com/jszip@3.10.1/dist/jszip.min.js'
    ],()=>typeof window.JSZip==='function');
    return window.JSZip;
  }
  async function loadPdfLib(){
    await loadScript([
      'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js',
      'https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js'
    ],()=>!!window.PDFLib?.PDFDocument);
    return window.PDFLib;
  }

  function setPdfControls(enabled){
    [el.pages,el.pagesAll,el.format,el.scale,el.quality,el.outputName].forEach(x=>x.disabled=!enabled || pdfBusy);
    el.convertPdf.disabled=!enabled || pdfBusy;
    el.pdfReload.disabled=!pdfFile || pdfBusy;
  }
  function setPdfBusy(busy){
    pdfBusy=busy;
    setPdfControls(!!pdfDoc);
    el.pdfFile.disabled=busy;el.pdfPassword.disabled=busy;
    el.cancelPdf.hidden=!busy;el.convertPdf.hidden=busy;
  }

  function parsePages(text,total){
    const raw=(text||'').trim().toLowerCase();
    if(!raw || raw==='전체' || raw==='all') return Array.from({length:total},(_,i)=>i+1);
    const out=[];const seen=new Set();
    for(const part0 of raw.split(',')){
      const part=part0.trim();if(!part) continue;
      let start,end;
      if(/^\d+$/.test(part)){start=end=Number(part);} else {
        const m=part.match(/^(\d+)\s*-\s*(\d+)$/);
        if(!m) throw new Error('페이지 입력 형식을 확인해주세요. 예: 1-3, 5, 8-10');
        start=Number(m[1]);end=Number(m[2]);
        if(start>end) throw new Error('페이지 범위는 작은 번호부터 입력해주세요: '+part);
      }
      if(start<1 || end>total) throw new Error(`페이지는 1부터 ${total} 사이로 입력해주세요.`);
      for(let p=start;p<=end;p++) if(!seen.has(p)){seen.add(p);out.push(p);}
    }
    if(!out.length) throw new Error('변환할 페이지를 입력해주세요.');
    return out;
  }
  function updateSelectedCount(){
    if(!pdfDoc){el.selectedCount.textContent='-';return;}
    try{el.selectedCount.textContent=parsePages(el.pages.value,pdfDoc.numPages).length.toLocaleString();}
    catch{el.selectedCount.textContent='!';}
  }
  el.pages.addEventListener('input',updateSelectedCount);
  el.pagesAll.addEventListener('click',()=>{el.pages.value='전체';updateSelectedCount();});
  el.format.addEventListener('change',()=>{el.qualityWrap.hidden=el.format.value==='png';});

  async function openPdf(file){
    if(!file) return;
    if(file.size>MAX_FILE_BYTES){status(el.pdfStatus,'PDF는 최대 100MB까지 선택할 수 있습니다.','error');return;}
    if(!(file.type==='application/pdf' || /\.pdf$/i.test(file.name))){status(el.pdfStatus,'PDF 파일만 선택해주세요.','error');return;}
    pdfFile=file;pdfDoc=null;pdfBytes=null;el.pdfSummary.hidden=true;setPdfControls(false);
    el.pdfInfo.textContent=`${file.name} · ${sizeText(file.size)}`;
    el.outputName.value=safeBaseName(file.name,'pdf-images')+'-images';
    await reloadPdf();
  }
  async function reloadPdf(){
    if(!pdfFile || pdfBusy) return;
    setPdfBusy(true);el.pdfProgress.hidden=false;el.pdfProgress.removeAttribute('value');
    try{
      status(el.pdfStatus,'PDF 정보를 읽는 중입니다...');
      const lib=await loadPdfJs();
      pdfBytes=new Uint8Array(await pdfFile.arrayBuffer());
      const opts={
        data:pdfBytes,
        password:el.pdfPassword.value||undefined,
        cMapUrl:pdfJsBase+'/cmaps/',cMapPacked:true,
        standardFontDataUrl:pdfJsBase+'/standard_fonts/',
        wasmUrl:pdfJsBase+'/wasm/',
        isEvalSupported:false
      };
      const task=lib.getDocument(opts);
      pdfDoc=await task.promise;
      el.pdfSummary.hidden=false;el.pageCount.textContent=pdfDoc.numPages.toLocaleString();el.pdfSize.textContent=sizeText(pdfFile.size);el.pages.value='전체';updateSelectedCount();
      status(el.pdfStatus,`PDF를 불러왔습니다. 총 ${pdfDoc.numPages.toLocaleString()}페이지입니다.`,'success');
    }catch(e){
      pdfDoc=null;
      const isPassword=e?.name==='PasswordException' || /password/i.test(e?.message||'');
      status(el.pdfStatus,isPassword?'암호가 걸린 PDF입니다. 비밀번호를 입력한 뒤 “다시 불러오기”를 눌러주세요.':'PDF를 열지 못했습니다. 손상되었거나 지원되지 않는 PDF일 수 있습니다.','error');
      console.error(e);
    }finally{el.pdfProgress.hidden=true;setPdfBusy(false);}
  }
  el.pdfReload.addEventListener('click',reloadPdf);
  el.pdfPassword.addEventListener('keydown',e=>{if(e.key==='Enter') reloadPdf();});

  function makeDrop(drop,input,onFiles){
    ['dragenter','dragover'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.add('dragover');}));
    ['dragleave','drop'].forEach(n=>drop.addEventListener(n,e=>{e.preventDefault();drop.classList.remove('dragover');}));
    drop.addEventListener('drop',e=>onFiles(e.dataTransfer?.files));
    input.addEventListener('change',()=>onFiles(input.files));
  }
  makeDrop(el.pdfDrop,el.pdfFile,files=>openPdf(files?.[0]));

  function getSafeViewport(page,requestedScale){
    const base=page.getViewport({scale:requestedScale});
    let factor=1;
    if(Math.max(base.width,base.height)>MAX_CANVAS_DIMENSION) factor=Math.min(factor,MAX_CANVAS_DIMENSION/Math.max(base.width,base.height));
    if(base.width*base.height>MAX_CANVAS_PIXELS) factor=Math.min(factor,Math.sqrt(MAX_CANVAS_PIXELS/(base.width*base.height)));
    return {viewport:page.getViewport({scale:requestedScale*factor}),reduced:factor<.999};
  }
  function canvasBlob(canvas,type,quality){
    return new Promise((resolve,reject)=>canvas.toBlob(b=>b?resolve(b):reject(new Error('이미지 파일 생성에 실패했습니다.')),type,quality));
  }

  el.convertPdf.addEventListener('click',async()=>{
    if(!pdfDoc || pdfBusy) return;
    let pages;
    try{pages=parsePages(el.pages.value,pdfDoc.numPages);}catch(e){status(el.pdfStatus,e.message,'error');return;}
    const fmt=el.format.value;
    const mime=fmt==='jpeg'?'image/jpeg':fmt==='png'?'image/png':'image/webp';
    const ext=fmt==='jpeg'?'jpg':fmt;
    const q=Number(el.quality.value);
    const requestedScale=Number(el.scale.value);
    const baseName=safeBaseName(el.outputName.value,'pdf-images');
    pdfCancel=false;setPdfBusy(true);el.pdfProgress.hidden=false;el.pdfProgress.value=0;
    try{
      if(pages.length>100) status(el.pdfStatus,`${pages.length.toLocaleString()}페이지를 변환합니다. 페이지가 많아 기기 메모리를 많이 사용할 수 있습니다.`,'warn');
      const pad=Math.max(3,String(pdfDoc.numPages).length);
      let zip=null;
      if(pages.length>1){const JSZip=await loadJsZip();zip=new JSZip();}
      let reducedAny=false;
      for(let i=0;i<pages.length;i++){
        if(pdfCancel) throw new Error('사용자가 변환을 중지했습니다.');
        const n=pages[i];
        status(el.pdfStatus,`페이지 ${n.toLocaleString()} 변환 중 · ${i+1}/${pages.length}`);
        const page=await pdfDoc.getPage(n);
        const {viewport,reduced}=getSafeViewport(page,requestedScale);reducedAny=reducedAny||reduced;
        const canvas=document.createElement('canvas');
        canvas.width=Math.max(1,Math.ceil(viewport.width));canvas.height=Math.max(1,Math.ceil(viewport.height));
        const ctx=canvas.getContext('2d',{alpha:false});
        ctx.save();ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.restore();
        await page.render({canvas,viewport,background:'rgb(255,255,255)'}).promise;
        const blob=await canvasBlob(canvas,mime,fmt==='png'?undefined:q);
        const filename=`${baseName}-page-${String(n).padStart(pad,'0')}.${ext}`;
        if(zip) zip.file(filename,blob); else downloadBlob(blob,filename);
        canvas.width=1;canvas.height=1;page.cleanup?.();
        el.pdfProgress.value=Math.round(((i+1)/pages.length)*90);
        await new Promise(r=>setTimeout(r,0));
      }
      if(zip){
        status(el.pdfStatus,'ZIP 파일을 만드는 중입니다...');
        const zipBlob=await zip.generateAsync({type:'blob',compression:'DEFLATE',compressionOptions:{level:5}},meta=>{el.pdfProgress.value=90+Math.round(meta.percent*.1);});
        if(pdfCancel) throw new Error('사용자가 변환을 중지했습니다.');
        downloadBlob(zipBlob,baseName+'.zip');
      }
      el.pdfProgress.value=100;
      status(el.pdfStatus,`변환 완료! ${pages.length.toLocaleString()}페이지를 ${ext.toUpperCase()} 형식으로 만들었습니다.${reducedAny?' 일부 매우 큰 페이지는 브라우저 안정성을 위해 자동으로 해상도를 조정했습니다.':''}`,'success');
    }catch(e){
      status(el.pdfStatus,e.message==='사용자가 변환을 중지했습니다.'?e.message:'변환 중 오류가 발생했습니다: '+(e.message||e),'error');
      console.error(e);
    }finally{setPdfBusy(false);el.pdfProgress.hidden=true;pdfCancel=false;}
  });
  el.cancelPdf.addEventListener('click',()=>{pdfCancel=true;el.cancelPdf.disabled=true;status(el.pdfStatus,'현재 페이지 처리가 끝나는 즉시 중지합니다.','warn');setTimeout(()=>el.cancelPdf.disabled=false,1000);});

  // IMAGE -> PDF
  function totalImageBytes(){return imageItems.reduce((s,x)=>s+x.file.size,0);}
  function revokeImageItems(){imageItems.forEach(x=>URL.revokeObjectURL(x.url));}
  function setImageControls(enabled){
    [el.pageSize,el.margin,el.imageMax,el.pdfQuality,el.pdfName].forEach(x=>x.disabled=!enabled || imageBusy);
    el.createPdf.disabled=!enabled || imageBusy;
  }
  function setImageBusy(busy){
    imageBusy=busy;el.imageFiles.disabled=busy;setImageControls(imageItems.length>0);
    el.cancelImage.hidden=!busy;el.createPdf.hidden=busy;
    root.querySelectorAll('.spic-mini').forEach(b=>b.disabled=busy);
  }
  function renderImageList(){
    el.imageList.innerHTML='';
    if(!imageItems.length){
      el.imageList.innerHTML='<div class="spic-empty">이미지를 선택하면 여기에서 순서를 확인할 수 있습니다.</div>';
      el.imageInfo.textContent='선택된 이미지 없음';setImageControls(false);return;
    }
    el.imageInfo.textContent=`${imageItems.length.toLocaleString()}장 · 총 ${sizeText(totalImageBytes())}`;
    imageItems.forEach((item,index)=>{
      const row=document.createElement('div');row.className='spic-image-item';
      const img=document.createElement('img');img.className='spic-thumb';img.src=item.url;img.alt='';
      const meta=document.createElement('div');meta.className='spic-image-meta';
      const strong=document.createElement('strong');strong.textContent=item.file.name;
      const span=document.createElement('span');span.textContent=sizeText(item.file.size)+' · '+(index+1)+'번째 페이지';
      meta.append(strong,span);
      const acts=document.createElement('div');acts.className='spic-item-actions';
      const up=document.createElement('button');up.type='button';up.className='spic-mini';up.textContent='▲';up.title='위로';up.disabled=index===0||imageBusy;
      const down=document.createElement('button');down.type='button';down.className='spic-mini';down.textContent='▼';down.title='아래로';down.disabled=index===imageItems.length-1||imageBusy;
      const del=document.createElement('button');del.type='button';del.className='spic-mini';del.textContent='×';del.title='삭제';del.disabled=imageBusy;
      up.addEventListener('click',()=>{[imageItems[index-1],imageItems[index]]=[imageItems[index],imageItems[index-1]];renderImageList();});
      down.addEventListener('click',()=>{[imageItems[index+1],imageItems[index]]=[imageItems[index],imageItems[index+1]];renderImageList();});
      del.addEventListener('click',()=>{URL.revokeObjectURL(item.url);imageItems.splice(index,1);renderImageList();});
      acts.append(up,down,del);row.append(img,meta,acts);el.imageList.appendChild(row);
    });
    setImageControls(true);
  }
  function addImages(files){
    if(!files?.length || imageBusy) return;
    const accepted=[];
    for(const f of Array.from(files)){
      if(['image/jpeg','image/png','image/webp'].includes(f.type) || /\.(jpe?g|png|webp)$/i.test(f.name)) accepted.push(f);
    }
    if(!accepted.length){status(el.imageStatus,'JPG, PNG, WebP 이미지만 선택할 수 있습니다.','error');return;}
    const newTotal=totalImageBytes()+accepted.reduce((s,f)=>s+f.size,0);
    if(newTotal>MAX_FILE_BYTES){status(el.imageStatus,'선택한 이미지의 총 용량은 100MB까지 가능합니다.','error');return;}
    accepted.forEach(file=>imageItems.push({file,url:URL.createObjectURL(file)}));
    if(imageItems.length===accepted.length) el.pdfName.value=safeBaseName(accepted[0].name,'images')+'-pdf';
    renderImageList();status(el.imageStatus,`${accepted.length.toLocaleString()}장을 추가했습니다. 순서를 확인한 뒤 PDF 만들기를 눌러주세요.`,'success');
    el.imageFiles.value='';
  }
  makeDrop(el.imageDrop,el.imageFiles,addImages);

  async function imageFileToJpeg(file,maxDim,quality){
    let bmp;
    if('createImageBitmap' in window){bmp=await createImageBitmap(file);}else{
      const url=URL.createObjectURL(file);try{bmp=await new Promise((res,rej)=>{const im=new Image();im.onload=()=>res(im);im.onerror=rej;im.src=url;});}finally{URL.revokeObjectURL(url);}
    }
    const sw=bmp.width||bmp.naturalWidth,sh=bmp.height||bmp.naturalHeight;
    if(!sw||!sh) throw new Error(file.name+' 이미지 크기를 읽을 수 없습니다.');
    const ratio=Math.min(1,maxDim/Math.max(sw,sh));
    const w=Math.max(1,Math.round(sw*ratio)),h=Math.max(1,Math.round(sh*ratio));
    const c=document.createElement('canvas');c.width=w;c.height=h;
    const cx=c.getContext('2d',{alpha:false});cx.fillStyle='#fff';cx.fillRect(0,0,w,h);cx.drawImage(bmp,0,0,w,h);
    if(typeof bmp.close==='function') bmp.close();
    const blob=await canvasBlob(c,'image/jpeg',quality);c.width=1;c.height=1;
    return {bytes:new Uint8Array(await blob.arrayBuffer()),width:w,height:h};
  }
  function pageDimensions(mode,imgW,imgH){
    if(mode==='a4p') return A4_P.slice();
    if(mode==='a4l') return A4_L.slice();
    if(mode==='auto') return imgW>=imgH?A4_L.slice():A4_P.slice();
    const longest=841.89;
    if(imgW>=imgH) return [longest,Math.max(72,longest*imgH/imgW)];
    return [Math.max(72,longest*imgW/imgH),longest];
  }
  el.createPdf.addEventListener('click',async()=>{
    if(!imageItems.length || imageBusy) return;
    imageCancel=false;setImageBusy(true);el.imageProgress.hidden=false;el.imageProgress.value=0;
    try{
      status(el.imageStatus,'PDF 생성 라이브러리를 불러오는 중입니다...');
      const {PDFDocument}=await loadPdfLib();
      const doc=await PDFDocument.create();
      const maxDim=Number(el.imageMax.value),quality=Number(el.pdfQuality.value),marginMm=Number(el.margin.value);
      for(let i=0;i<imageItems.length;i++){
        if(imageCancel) throw new Error('사용자가 PDF 생성을 중지했습니다.');
        const item=imageItems[i];status(el.imageStatus,`${i+1}/${imageItems.length} · ${item.file.name} 처리 중`);
        const data=await imageFileToJpeg(item.file,maxDim,quality);
        const jpg=await doc.embedJpg(data.bytes);
        const [pw,ph]=pageDimensions(el.pageSize.value,data.width,data.height);
        const page=doc.addPage([pw,ph]);
        let margin=marginMm*MM_TO_PT;
        if(el.pageSize.value==='image') margin=Math.min(margin,Math.min(pw,ph)*.15);
        const aw=Math.max(1,pw-margin*2),ah=Math.max(1,ph-margin*2);
        const scale=Math.min(aw/data.width,ah/data.height);
        const dw=data.width*scale,dh=data.height*scale;
        page.drawImage(jpg,{x:(pw-dw)/2,y:(ph-dh)/2,width:dw,height:dh});
        el.imageProgress.value=Math.round(((i+1)/imageItems.length)*88);
        await new Promise(r=>setTimeout(r,0));
      }
      status(el.imageStatus,'PDF 파일을 저장하는 중입니다...');
      const bytes=await doc.save({useObjectStreams:true,addDefaultPage:false});
      el.imageProgress.value=100;
      downloadBlob(new Blob([bytes],{type:'application/pdf'}),safeBaseName(el.pdfName.value,'images-to-pdf')+'.pdf');
      status(el.imageStatus,`완료! 이미지 ${imageItems.length.toLocaleString()}장을 하나의 PDF로 만들었습니다.`,'success');
    }catch(e){status(el.imageStatus,e.message==='사용자가 PDF 생성을 중지했습니다.'?e.message:'PDF 생성 중 오류가 발생했습니다: '+(e.message||e),'error');console.error(e);}
    finally{setImageBusy(false);el.imageProgress.hidden=true;imageCancel=false;}
  });
  el.cancelImage.addEventListener('click',()=>{imageCancel=true;el.cancelImage.disabled=true;status(el.imageStatus,'현재 이미지 처리가 끝나는 즉시 중지합니다.','warn');setTimeout(()=>el.cancelImage.disabled=false,1000);});

  window.addEventListener('pagehide',()=>{revokeImageItems();});
  setPdfControls(false);setImageControls(false);el.qualityWrap.hidden=false;
})();
</script>
</div>



<h2 class="wp-block-heading">PDF JPG 변환 무료 도구 사용 방법</h2>



<p class="wp-block-paragraph">PDF 문서를 이미지로 바꿔야 하는데 프로그램을 설치하거나 회원가입을 해야 한다면 번거로울 수 있습니다. 특히 PDF의 특정 페이지만 JPG로 저장하거나, 여러 장의 사진을 하나의 PDF로 묶어 제출해야 할 때는 간단한 브라우저 도구가 편리합니다.</p>



<p class="wp-block-paragraph">SNSNOTE의 PDF JPG 변환기는 PDF를 JPG·PNG·WebP 이미지로 변환하는 기능과, JPG·PNG·WebP 이미지를 PDF로 만드는 기능을 함께 제공합니다. 파일을 서버에 업로드하지 않고 브라우저에서 처리하도록 구성되어 있으며, 필요한 페이지 선택과 출력 화질 조절도 지원합니다.</p>



<h3 class="wp-block-heading">1. PDF 파일을 선택하세요</h3>



<p class="wp-block-paragraph">도구 상단에서 <strong>PDF → 이미지 변환</strong> 버튼을 선택한 뒤, PDF 파일을 업로드합니다. 파일을 클릭해서 선택하거나 업로드 영역으로 끌어다 놓을 수 있습니다.</p>



<p class="wp-block-paragraph">PDF 파일은 최대 100MB까지 선택할 수 있습니다. 파일을 불러오면 전체 페이지 수를 확인할 수 있으며, 문서에 암호가 설정되어 있다면 비밀번호를 입력해 다시 불러올 수 있습니다. 단, 모든 종류의 암호화 PDF가 지원되는 것은 아닙니다.</p>



<h3 class="wp-block-heading">2. 전체 또는 필요한 페이지만 지정하세요</h3>



<p class="wp-block-paragraph">변환할 페이지 입력란에 <code>전체</code>를 선택하면 문서의 모든 페이지를 이미지로 변환합니다. 특정 페이지만 필요하다면 아래와 같이 입력할 수 있습니다.</p>



<ul class="wp-block-list">
<li><code>1-3</code> : 1페이지부터 3페이지까지</li>



<li><code>5</code> : 5페이지만</li>



<li><code>1-3, 5, 8-10</code> : 여러 페이지 범위를 함께 선택</li>
</ul>



<p class="wp-block-paragraph">예를 들어 100페이지짜리 PDF에서 2페이지와 10~12페이지만 필요하다면, 전체 문서를 변환할 필요 없이 원하는 페이지만 지정하면 됩니다. PDF 내부 페이지 수에 별도의 고정 제한은 없지만, 페이지가 많을수록 처리 시간과 기기 메모리 사용량이 증가할 수 있습니다.</p>



<h3 class="wp-block-heading">3. JPG·PNG·WebP 형식과 화질 선택</h3>



<p class="wp-block-paragraph">이미지 형식에서 JPG, PNG, WebP 중 원하는 형식을 선택합니다. 출력 해상도는 1.5배부터 4배까지 설정할 수 있으며, JPG와 WebP는 이미지 품질도 조절할 수 있습니다.</p>



<p class="wp-block-paragraph">일반적인 블로그 업로드나 문서 공유 목적이라면 기본 설정부터 사용해보세요. 작은 글씨나 도표를 선명하게 저장해야 한다면 해상도를 높여볼 수 있지만, 해상도가 높을수록 파일 크기와 메모리 사용량이 늘어납니다.</p>



<p class="wp-block-paragraph">설정이 끝나면 <strong>PDF 이미지 변환 시작</strong> 버튼을 누르면 됩니다. 여러 페이지를 선택한 경우 결과 이미지는 ZIP 파일로 한 번에 다운로드할 수 있으며, 각 이미지에는 페이지 번호가 포함된 파일명이 붙습니다.</p>



<h2 class="wp-block-heading">JPG·PNG 이미지를 PDF로 변환하는 방법</h2>



<p class="wp-block-paragraph">반대로 여러 장의 사진을 하나의 PDF로 만들어야 한다면 상단의 <strong>이미지 → PDF 변환</strong> 버튼을 선택하세요. JPG, PNG, WebP 이미지를 여러 장 추가한 뒤 원하는 순서로 정리할 수 있습니다.</p>



<h3 class="wp-block-heading">사진 여러 장을 하나의 PDF로 합치기</h3>



<p class="wp-block-paragraph">이미지를 선택하면 목록에서 순서를 확인하고 위·아래 버튼으로 변경할 수 있습니다. 필요 없는 이미지는 제거하고 다른 사진을 추가할 수도 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 신분증 사본, 신청서 사진, 영수증 이미지 등을 순서대로 정리해 하나의 PDF로 만들 수 있습니다. 단, 개인정보가 포함된 서류는 공유하기 전에 제출 대상과 필요한 정보만 포함되어 있는지 확인하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">A4 용지 방향과 여백 설정</h3>



<p class="wp-block-paragraph">PDF를 만들 때는 A4 자동 방향, A4 세로, A4 가로 또는 이미지 비율에 맞춘 페이지 설정을 선택할 수 있습니다. 여백도 조절할 수 있어 문서를 출력하거나 제출할 목적에 맞게 구성할 수 있습니다.</p>



<p class="wp-block-paragraph">사진을 PDF로 변환할 때는 원본 이미지의 비율과 용지 비율이 다를 수 있습니다. 이미지가 잘리거나 지나치게 작게 보이지 않도록 미리보기와 용지 설정을 확인한 뒤 저장하는 것이 좋습니다.</p>



<h2 class="wp-block-heading">PDF를 JPG로 변환하면 언제 유용할까요?</h2>



<h3 class="wp-block-heading">블로그와 SNS에 문서 내용을 이미지로 올릴 때</h3>



<p class="wp-block-paragraph">블로그나 SNS에 PDF 문서의 일부 내용을 이미지로 소개하고 싶을 때 사용할 수 있습니다. 필요한 페이지만 JPG 또는 PNG로 변환하면 문서의 특정 부분을 이미지 형태로 게시할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 PDF를 이미지로 바꾸면 텍스트가 픽셀로 렌더링되기 때문에, 변환된 JPG에서 원래 PDF의 텍스트를 그대로 선택하거나 수정할 수 있는 것은 아닙니다. 글자를 편집해야 한다면 원본 문서나 별도의 텍스트 추출 기능을 사용하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">업무 서류를 이미지로 공유할 때</h3>



<p class="wp-block-paragraph">PDF로 받은 안내문, 도표, 신청서 등을 메신저나 문서에 이미지로 삽입해야 할 때 편리합니다. 여러 페이지 중 필요한 부분만 선택할 수 있어 불필요한 페이지를 함께 변환하지 않아도 됩니다.</p>



<h3 class="wp-block-heading">스캔 사진을 PDF 한 개로 정리할 때</h3>



<p class="wp-block-paragraph">휴대폰으로 촬영한 서류 사진이 여러 장이라면 이미지 → PDF 변환 기능으로 하나의 파일로 묶을 수 있습니다. 페이지 순서를 정리하고 A4 설정을 적용하면 제출용 또는 보관용 PDF를 만들기 편리합니다.</p>



<h2 class="wp-block-heading">PDF JPG 변환 시 화질이 달라지는 이유</h2>



<p class="wp-block-paragraph">PDF는 문서 안에 텍스트, 이미지, 도형 등을 포함할 수 있는 형식입니다. PDF를 JPG로 변환할 때는 각 페이지를 이미지로 렌더링하므로 출력 해상도에 따라 글자와 도형의 선명도가 달라집니다.</p>



<p class="wp-block-paragraph">출력 해상도를 높이면 작은 글씨나 선이 더 선명하게 보일 수 있지만, 원본 PDF에 들어 있는 저화질 사진의 실제 세부 정보까지 새롭게 만들어지는 것은 아닙니다. 또한 JPG는 손실 압축 형식이기 때문에 품질 설정에 따라 이미지의 세부 표현이 달라질 수 있습니다.</p>



<h3 class="wp-block-heading">JPG, PNG, WebP 중 어떤 형식을 선택할까요?</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>형식</th><th>특징</th><th>추천 용도</th></tr></thead><tbody><tr><td>JPG</td><td>사진에 적합한 손실 압축, 투명도 미지원</td><td>일반 문서 이미지, 블로그 업로드</td></tr><tr><td>PNG</td><td>무손실 압축, 투명도 지원</td><td>글자·도표·선이 많은 이미지</td></tr><tr><td>WebP</td><td>손실·무손실 압축 및 투명도 지원</td><td>웹사이트용 이미지, 용량 고려</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">문서의 글자와 도표를 선명하게 보존하고 싶다면 PNG를 선택해 비교해볼 수 있습니다. 일반적인 사진 위주의 PDF라면 JPG나 WebP가 파일 용량 측면에서 유리할 수 있습니다. 다만 실제 용량은 원본 내용과 출력 설정에 따라 달라집니다.</p>



<h2 class="wp-block-heading">PDF 파일을 서버에 업로드하나요?</h2>



<p class="wp-block-paragraph">SNSNOTE PDF 이미지 변환기는 사용자가 선택한 PDF와 이미지 파일을 브라우저에서 처리하도록 구성되어 있습니다. 변환에 필요한 JavaScript 라이브러리는 외부 CDN에서 불러오지만, 문서 변환 자체를 위해 SNSNOTE 서버에 파일을 업로드하는 방식은 아닙니다.</p>



<p class="wp-block-paragraph">따라서 파일 변환 연산은 방문자의 컴퓨터나 휴대폰에서 실행됩니다. 다만 웹페이지 접속에 따른 일반적인 서버 부하와 라이브러리 다운로드는 발생하며, 대용량 PDF 또는 고해상도 변환은 사용자의 기기 메모리와 성능에 영향을 받을 수 있습니다.</p>



<p class="wp-block-paragraph">중요한 계약서나 개인정보가 포함된 문서를 다룬다면 작업 후 다운로드한 파일의 내용을 확인하고, 공용 컴퓨터에서는 다운로드 폴더에 파일이 남지 않도록 주의하세요.</p>



<h2 class="wp-block-heading">자주 묻는 질문 (FAQ)</h2>



<h3 class="wp-block-heading">Q. PDF JPG 변환은 무료인가요?</h3>



<p class="wp-block-paragraph">네. SNSNOTE에서 제공하는 PDF 이미지 변환 도구는 별도의 회원가입이나 유료 API 키 입력 없이 사용할 수 있습니다. 파일 변환은 브라우저에서 처리되며, 처음 실행할 때 필요한 라이브러리 다운로드가 발생할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. PDF 파일은 몇 MB까지 업로드할 수 있나요?</h3>



<p class="wp-block-paragraph">현재 도구에서는 PDF 파일을 최대 100MB까지 선택할 수 있습니다. 이는 브라우저의 기술적 최대 용량이 아니라 도구에서 정한 운영 제한입니다. PDF 내부 페이지 수에는 별도의 고정 제한을 두지 않았지만, 페이지가 많으면 필요한 페이지만 선택해 변환하는 것을 권장합니다.</p>



<h3 class="wp-block-heading">Q. PDF 여러 페이지를 JPG로 한 번에 저장할 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. 전체 페이지 또는 원하는 페이지 범위를 지정한 뒤 변환하면 됩니다. 여러 페이지의 결과는 ZIP 파일로 다운로드할 수 있어 각각의 이미지를 한꺼번에 보관하기 편리합니다.</p>



<h3 class="wp-block-heading">Q. PDF에서 특정 페이지만 JPG로 저장할 수 있나요?</h3>



<p class="wp-block-paragraph">네. 페이지 입력란에 <code>2</code>, <code>3-5</code>, <code>1-3, 7</code>처럼 원하는 페이지 번호와 범위를 입력하면 선택한 페이지만 변환할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. JPG 여러 장을 PDF 하나로 만들 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. 이미지 → PDF 변환 탭에서 JPG, PNG, WebP 이미지를 여러 장 추가하고 순서를 정한 뒤 하나의 PDF로 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 암호가 걸린 PDF도 변환할 수 있나요?</h3>



<p class="wp-block-paragraph">도구에서 비밀번호 입력 기능을 제공합니다. 정상적인 비밀번호를 입력하면 지원되는 암호화 PDF를 불러올 수 있습니다. 다만 일부 보안 설정이나 특수한 PDF는 브라우저 라이브러리에서 처리하지 못할 수 있으며, 비밀번호를 모르는 PDF의 암호를 해제하는 기능은 아닙니다.</p>



<h3 class="wp-block-heading">Q. PDF를 JPG로 바꾸면 글자를 수정할 수 있나요?</h3>



<p class="wp-block-paragraph">아니요. PDF 페이지를 JPG로 변환하면 해당 페이지가 이미지로 저장됩니다. 이미지 안의 글자를 편집 가능한 텍스트로 바꾸는 기능과는 다릅니다. 글자 추출이 필요하다면 별도의 OCR 도구를 사용해야 합니다.</p>



<h3 class="wp-block-heading">Q. 휴대폰에서도 PDF 이미지 변환이 가능한가요?</h3>



<p class="wp-block-paragraph">브라우저에서 필요한 라이브러리를 실행할 수 있는 기기라면 모바일에서도 사용할 수 있습니다. 다만 대용량 PDF, 많은 페이지, 높은 출력 해상도는 메모리 부족이나 느린 처리 속도의 원인이 될 수 있습니다. 문제가 발생하면 필요한 페이지만 선택하거나 해상도를 낮춰보세요.</p>



<h2 class="wp-block-heading">함께 사용하면 좋은 무료 이미지 도구</h2>



<p class="wp-block-paragraph">PDF를 JPG로 변환한 뒤 파일 형식이나 크기를 추가로 변경하고 싶다면 <a href="https://snsnote.com/image-converter/">무료 이미지 변환기</a>를 이용할 수 있습니다. 사진을 공유하기 전 촬영정보나 위치정보를 확인하고 제거하고 싶다면 <a href="https://snsnote.com/photo-privacy-remover/">사진 개인정보 제거 도구</a>도 활용해보세요.</p>



<p class="wp-block-paragraph">이미지의 배경을 제거해 투명 PNG로 만들고 싶다면 <a href="https://snsnote.com/ai-background-remover/">AI 이미지 배경 제거기</a>를 사용할 수 있습니다. PDF 변환과 이미지 편집 도구를 목적에 맞게 함께 이용하면 별도의 프로그램 설치 없이 간단한 문서·이미지 작업을 처리하는 데 도움이 됩니다.</p>



<h2 class="wp-block-heading">마무리</h2>



<p class="wp-block-paragraph">PDF JPG 변환은 문서의 일부를 이미지로 공유하거나 블로그에 삽입할 때 유용하고, 이미지 PDF 변환은 여러 장의 사진을 하나의 문서로 정리할 때 편리합니다. SNSNOTE에서는 두 방향의 변환을 하나의 도구에서 제공하므로 필요한 작업에 맞춰 상단 버튼을 선택하면 됩니다.</p>



<p class="wp-block-paragraph">먼저 작은 PDF나 이미지 몇 장으로 변환 결과를 확인한 뒤, 필요한 페이지와 화질을 설정해 사용해보세요. 중요한 원본 문서는 별도로 보관하고, 다운로드한 결과가 원하는 순서와 형식으로 저장되었는지 확인하는 것을 권장합니다.</p>



<style>
  /* 이 글에서만 본문 상단 대표 이미지 숨기기 */
  .featured-image, 
  .post-thumbnail, 
  .entry-header .post-image,
  .single-media {
    display: none !important;
  }
</style>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/pdf-jpg-converter/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/pdf-jpg-converter.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1607</post-id>	</item>
		<item>
		<title>AI 이미지 배경 제거 무료 누끼 따기, 투명 PNG 만들기와 수동 보정</title>
		<link>https://snsnote.com/ai-background-remover/</link>
					<comments>https://snsnote.com/ai-background-remover/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Wed, 09 Sep 2026 08:54:31 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1601</guid>

					<description><![CDATA[AI 이미지 배경 제거 무료 도구 사용 방법 사진에서 인물이나 사물만 남기고 배경을 없애고 싶을 때, 별도의 이미지 편집 프로그램을 설치하지 않아도 됩니다. SNSNOTE의 AI...]]></description>
										<content:encoded><![CDATA[<div id="sns-bg-remover-v3">
<style>
#sns-bg-remover-v3 {
  --br-blue:#2563eb;
  --br-purple:#7c4df4;
  --br-text:#172033;
  --br-muted:#64748b;
  --br-border:#dfe6f1;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
  width:100%!important;
  max-width:none!important;
  min-width:0;
  display:block;
  box-sizing:border-box;
  color:var(--br-text);
}
#sns-bg-remover-v3 * {box-sizing:border-box}
#sns-bg-remover-v3 [hidden] {display:none!important}
#sns-bg-remover-v3 .br-layout {
  display:grid;
  grid-template-columns:minmax(0,340px) minmax(0,1fr);
  gap:22px;
  width:100%;
  align-items:stretch;
}
#sns-bg-remover-v3 .br-panel {
  min-width:0;
  width:100%;
  border:1px solid var(--br-border);
  border-radius:18px;
  padding:20px;
  background:#fff;
}
#sns-bg-remover-v3 .br-heading {
  margin:0 0 20px;
  padding:10px 16px;
  border-left:5px solid #2563eb;
  border-radius:10px;
  background:linear-gradient(100deg,#3477ed,#7c4df4);
  color:#fff;
  font-size:20px;
  font-weight:800;
  line-height:1.4;
}
#sns-bg-remover-v3 .br-drop {
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  min-height:185px;
  width:100%;
  padding:18px;
  border:2px dashed #cbd5e1;
  border-radius:14px;
  background:#f8fbff;
  text-align:center;
  cursor:pointer;
}
#sns-bg-remover-v3 .br-drop:hover,
#sns-bg-remover-v3 .br-drop.dragover {
  border-color:#2563eb;
  background:#eff6ff;
}
#sns-bg-remover-v3 .br-drop strong {font-size:16px}
#sns-bg-remover-v3 .br-drop span {
  margin-top:7px;
  color:var(--br-muted);
  font-size:13px;
  line-height:1.6;
}
#sns-bg-remover-v3 .br-drop-icon {font-size:34px;margin-bottom:10px}
#sns-bg-remover-v3 .br-file {
  margin:12px 0 18px;
  color:var(--br-muted);
  font-size:13px;
  overflow-wrap:anywhere;
}
#sns-bg-remover-v3 .br-field {margin-bottom:14px}
#sns-bg-remover-v3 .br-field label {
  display:block;
  margin-bottom:7px;
  font-size:14px;
  font-weight:700;
}
#sns-bg-remover-v3 select,
#sns-bg-remover-v3 input[type="color"],
#sns-bg-remover-v3 input[type="number"] {
  width:100%;
  min-height:44px;
  border:1px solid #cbd5e1;
  border-radius:10px;
  padding:9px 11px;
  background:#fff;
  color:var(--br-text);
  font-size:14px;
}
#sns-bg-remover-v3 input[type="color"] {padding:5px}
#sns-bg-remover-v3 input[type="range"] {
  width:100%;
  accent-color:#2563eb;
}
#sns-bg-remover-v3 .br-row {
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:10px;
}
#sns-bg-remover-v3 .br-buttons {
  display:grid;
  grid-template-columns:1fr 1fr;
  gap:10px;
  margin-top:18px;
}
#sns-bg-remover-v3 .br-btn {
  min-height:45px;
  border:0;
  border-radius:11px;
  padding:11px 13px;
  font-size:14px;
  font-weight:700;
  cursor:pointer;
}
#sns-bg-remover-v3 .br-btn:disabled {
  opacity:.43;
  cursor:not-allowed;
}
#sns-bg-remover-v3 .br-primary {background:#2563eb;color:#fff}
#sns-bg-remover-v3 .br-secondary {background:#edf1f7;color:#172033}
#sns-bg-remover-v3 .br-download {
  width:100%;
  margin-top:10px;
  background:#15803d;
  color:#fff;
}
#sns-bg-remover-v3 .br-status {
  margin-top:15px;
  padding:12px;
  border:1px solid #bfdbfe;
  border-radius:11px;
  background:#eff6ff;
  color:#1d4ed8;
  font-size:13px;
  line-height:1.6;
  overflow-wrap:anywhere;
}
#sns-bg-remover-v3 .br-status.error {
  background:#fef2f2;
  border-color:#fecaca;
  color:#b91c1c;
}
#sns-bg-remover-v3 .br-status.success {
  background:#ecfdf5;
  border-color:#a7f3d0;
  color:#047857;
}
#sns-bg-remover-v3 .br-progress {
  width:100%;
  height:9px;
  margin-top:12px;
  accent-color:#2563eb;
}
#sns-bg-remover-v3 .br-note {
  margin-top:14px;
  color:var(--br-muted);
  font-size:12px;
  line-height:1.7;
}
#sns-bg-remover-v3 .br-previews {
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,1fr);
  gap:14px;
  width:100%;
}
#sns-bg-remover-v3 .br-preview {
  min-width:0;
  width:100%;
  border:1px solid var(--br-border);
  border-radius:14px;
  overflow:hidden;
}
#sns-bg-remover-v3 .br-preview-title {
  padding:13px 14px;
  border-bottom:1px solid var(--br-border);
  background:#f8fafc;
  font-size:14px;
  font-weight:700;
}
#sns-bg-remover-v3 .br-preview-body {
  display:flex;
  align-items:center;
  justify-content:center;
  min-height:410px;
  padding:12px;
  background:#fff;
}
#sns-bg-remover-v3 .br-preview img {
  display:block;
  max-width:100%;
  max-height:520px;
  width:auto;
  height:auto;
  object-fit:contain;
}
#sns-bg-remover-v3 .br-empty {
  text-align:center;
  color:var(--br-muted);
  font-size:13px;
  line-height:1.7;
}
#sns-bg-remover-v3 .br-checker {
  display:flex;
  align-items:center;
  justify-content:center;
  width:100%;
  min-height:386px;
  background-color:#fff;
  background-image:
    linear-gradient(45deg,#e5e7eb 25%,transparent 25%),
    linear-gradient(-45deg,#e5e7eb 25%,transparent 25%),
    linear-gradient(45deg,transparent 75%,#e5e7eb 75%),
    linear-gradient(-45deg,transparent 75%,#e5e7eb 75%);
  background-size:20px 20px;
  background-position:0 0,0 10px,10px -10px,-10px 0;
}
#sns-bg-remover-v3 .br-checker canvas {
  display:block;
  max-width:100%;
  max-height:520px;
  width:auto;
  height:auto;
}
#sns-bg-remover-v3 .br-editor {
  margin-top:20px;
  border:1px solid var(--br-border);
  border-radius:14px;
  overflow:hidden;
}
#sns-bg-remover-v3 .br-editor-head {
  padding:14px 16px;
  border-bottom:1px solid var(--br-border);
  background:#f8fafc;
  font-weight:800;
  font-size:15px;
}
#sns-bg-remover-v3 .br-editor-controls {
  display:grid;
  grid-template-columns:repeat(3,minmax(0,1fr));
  gap:14px;
  padding:16px;
}
#sns-bg-remover-v3 .br-tool-buttons {
  display:flex;
  flex-wrap:wrap;
  gap:8px;
  padding:0 16px 14px;
}
#sns-bg-remover-v3 .br-tool {
  min-height:40px;
  padding:9px 13px;
  border:1px solid #cbd5e1;
  border-radius:9px;
  background:#fff;
  color:#172033;
  font-size:13px;
  font-weight:700;
  cursor:pointer;
}
#sns-bg-remover-v3 .br-tool.active {
  background:#2563eb;
  border-color:#2563eb;
  color:#fff;
}
#sns-bg-remover-v3 .br-tool:disabled {opacity:.4;cursor:not-allowed}
#sns-bg-remover-v3 .br-stage {
  position:relative;
  width:100%;
  min-height:340px;
  max-height:620px;
  overflow:auto;
  padding:12px;
  display:flex;
  align-items:flex-start;
  justify-content:flex-start;
  background:#e8edf4;
}
#sns-bg-remover-v3 .br-stage-inner {
  position:relative;
  flex:none;
  margin:auto;
  line-height:0;
}
#sns-bg-remover-v3 .br-edit-canvas {
  display:block;
  height:auto;
  max-width:none;
  touch-action:pan-x pan-y;
  cursor:crosshair;
  background-color:#fff;
  background-image:
    linear-gradient(45deg,#e5e7eb 25%,transparent 25%),
    linear-gradient(-45deg,#e5e7eb 25%,transparent 25%),
    linear-gradient(45deg,transparent 75%,#e5e7eb 75%),
    linear-gradient(-45deg,transparent 75%,#e5e7eb 75%);
  background-size:20px 20px;
  background-position:0 0,0 10px,10px -10px,-10px 0;
}
#sns-bg-remover-v3 .br-brush-cursor {
  position:absolute;
  border:1.5px solid #2563eb;
  border-radius:50%;
  pointer-events:none;
  display:none;
  box-shadow:0 0 0 1px white;
}
#sns-bg-remover-v3 .br-editor-note {
  padding:12px 16px 16px;
  color:var(--br-muted);
  font-size:12px;
  line-height:1.7;
}
#sns-bg-remover-v3 .br-error-details {
  margin-top:9px;
  font-size:12px;
}
#sns-bg-remover-v3 .br-error-details summary {cursor:pointer}
#sns-bg-remover-v3 .br-error-details pre {
  white-space:pre-wrap;
  overflow-wrap:anywhere;
  padding:10px;
  border-radius:8px;
  background:#f1f5f9;
  font-size:11px;
}

@media(max-width:900px) {
  #sns-bg-remover-v3 .br-layout {grid-template-columns:1fr}
}
@media(max-width:600px) {
  #sns-bg-remover-v3 .br-panel {padding:15px}
  #sns-bg-remover-v3 .br-previews {grid-template-columns:1fr}
  #sns-bg-remover-v3 .br-preview-body {min-height:270px}
  #sns-bg-remover-v3 .br-checker {min-height:246px}
  #sns-bg-remover-v3 .br-editor-controls {grid-template-columns:1fr 1fr}
  #sns-bg-remover-v3 .br-heading {font-size:18px}
}
</style>

<div class="br-layout">
  <section class="br-panel">
    <h2 class="br-heading">이미지 업로드</h2>

    <input id="snsbr3-file" type="file"
      accept="image/jpeg,image/png,image/webp" hidden>

    <label class="br-drop" for="snsbr3-file" id="snsbr3-drop">
      <div class="br-drop-icon"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f5bc.png" alt="🖼" class="wp-smiley" style="height: 1em; max-height: 1em;" /></div>
      <strong>사진을 여기 놓으세요</strong>
      <span>또는 클릭해서 파일을 선택하세요<br>JPG, PNG, WebP 지원</span>
    </label>

    <div class="br-file" id="snsbr3-fileinfo">선택된 파일 없음</div>

    <div class="br-field">
      <label for="snsbr3-quality">AI 처리 품질</label>
      <select id="snsbr3-quality">
        <option value="isnet">고품질 · 큰 모델 (느릴 수 있음)</option>
        <option value="isnet_fp16">표준 · 균형 잡힌 모델</option>
        <option value="isnet_quint8">빠른 처리 · 경량 모델</option>
      </select>
    </div>

    <div class="br-field">
      <label for="snsbr3-size">처리 이미지 크기</label>
      <select id="snsbr3-size">
        <option value="2400">최대 2400px · 권장</option>
        <option value="1600">최대 1600px · 빠른 처리</option>
        <option value="4096">최대 4096px · 고해상도</option>
      </select>
    </div>

    <div class="br-field">
      <label for="snsbr3-bg">배경 옵션</label>
      <select id="snsbr3-bg">
        <option value="transparent">투명 배경</option>
        <option value="white">흰색 배경</option>
        <option value="black">검은색 배경</option>
        <option value="custom">사용자 색상</option>
      </select>
    </div>

    <div class="br-field" id="snsbr3-colorbox" hidden>
      <label for="snsbr3-color">배경 색상</label>
      <input id="snsbr3-color" type="color" value="#ffffff">
    </div>

    <div class="br-field">
      <label for="snsbr3-format">다운로드 형식</label>
      <select id="snsbr3-format">
        <option value="png">PNG (투명 배경 지원)</option>
        <option value="jpg">JPG</option>
        <option value="webp">WebP</option>
      </select>
    </div>

    <div class="br-buttons">
      <button type="button" class="br-btn br-primary"
        id="snsbr3-start" disabled>배경 제거 시작</button>
      <button type="button" class="br-btn br-secondary"
        id="snsbr3-reset">초기화</button>
    </div>

    <button type="button" class="br-btn br-download"
      id="snsbr3-download" disabled>결과 다운로드</button>

    <progress id="snsbr3-progress" class="br-progress"
      max="100" hidden></progress>

    <div id="snsbr3-status" class="br-status" role="status" aria-live="polite">
      사진을 선택한 뒤 배경 제거 시작 버튼을 눌러주세요.
    </div>

    <details id="snsbr3-errorbox" class="br-error-details" hidden>
      <summary>오류 자세히 보기</summary>
      <pre id="snsbr3-error"></pre>
    </details>

    <div class="br-note">
      ※ 서버에 업로드하지 않고 브라우저에서 처리합니다.<br>
      ※ 첫 실행 시 외부 CDN에서 AI 모델을 다운로드합니다.<br>
      ※ 고품질 모델과 고해상도는 기기에 따라 느리거나 메모리가 부족할 수 있습니다.<br>
      ※ JPG는 투명도를 지원하지 않아 흰색 배경으로 저장됩니다.
    </div>
  </section>

  <section class="br-panel">
    <h2 class="br-heading">미리보기</h2>

    <div class="br-previews">
      <div class="br-preview">
        <div class="br-preview-title">원본 이미지</div>
        <div class="br-preview-body">
          <div class="br-empty" id="snsbr3-original-empty">
            업로드한 사진이<br>여기에 표시됩니다.
          </div>
          <img id="snsbr3-original" alt="원본 이미지" hidden>
        </div>
      </div>

      <div class="br-preview">
        <div class="br-preview-title">배경 제거 결과</div>
        <div class="br-preview-body">
          <div class="br-empty" id="snsbr3-result-empty">
            배경 제거 후 결과가<br>여기에 표시됩니다.
          </div>
          <div class="br-checker" id="snsbr3-checker" hidden>
            <canvas id="snsbr3-preview"></canvas>
          </div>
        </div>
      </div>
    </div>

    <div class="br-editor" id="snsbr3-editor" hidden>
      <div class="br-editor-head">세밀한 수동 보정</div>

      <div class="br-editor-controls">
        <div class="br-field">
          <label for="snsbr3-brush">브러시 크기: <span id="snsbr3-brush-value">30</span>px</label>
          <input id="snsbr3-brush" type="range" min="1" max="200" value="30">
        </div>

        <div class="br-field">
          <label for="snsbr3-hardness">브러시 선명도: <span id="snsbr3-hardness-value">80</span>%</label>
          <input id="snsbr3-hardness" type="range" min="0" max="100" value="80">
        </div>

        <div class="br-field">
          <label for="snsbr3-softness">경계 부드러움: <span id="snsbr3-softness-value">0</span>px</label>
          <input id="snsbr3-softness" type="range" min="0" max="4" step="0.5" value="0">
        </div>

        <div class="br-field">
          <label for="snsbr3-zoom">확대</label>
          <select id="snsbr3-zoom">
            <option value="1">화면 맞춤</option>
            <option value="2">2배 확대</option>
            <option value="4">4배 확대</option>
          </select>
        </div>
      </div>

      <div class="br-tool-buttons">
        <button type="button" class="br-tool active" id="snsbr3-restore">
          + 복원
        </button>
        <button type="button" class="br-tool" id="snsbr3-erase">
          − 지우기
        </button>
        <button type="button" class="br-tool" id="snsbr3-undo" disabled>
          실행 취소
        </button>
        <button type="button" class="br-tool" id="snsbr3-redo" disabled>
          다시 실행
        </button>
        <button type="button" class="br-tool" id="snsbr3-auto-reset">
          AI 결과로 되돌리기
        </button>
      </div>

      <div class="br-stage" id="snsbr3-stage">
        <div class="br-stage-inner" id="snsbr3-stage-inner">
          <canvas class="br-edit-canvas" id="snsbr3-edit"
            tabindex="0"
            role="img"
            aria-label="수동 보정 캔버스. 클릭하거나 드래그하여 보정합니다. 방향키로 위치를 이동하고 스페이스 또는 엔터로 칠할 수 있습니다."></canvas>
          <div class="br-brush-cursor" id="snsbr3-cursor"></div>
        </div>
      </div>

      <div class="br-editor-note">
        복원은 지워진 부분을 원본 사진에서 되살리고, 지우기는 남은 배경을 제거합니다.
        클릭하거나 드래그하여 칠할 수 있습니다. 확대해서 머리카락과 옷 가장자리를 보정하세요.
        실행 취소와 다시 실행을 사용할 수 있으며, AI 결과로 되돌리면 수동 보정이 초기화됩니다.
        키보드로는 방향키로 위치 이동, 스페이스 또는 엔터로 한 번 칠하기가 가능합니다.
      </div>
    </div>
  </section>
</div>

<script type="module">
(async function () {
  const root = document.getElementById("sns-bg-remover-v3");
  if (!root || root.dataset.initialized === "true") return;
  root.dataset.initialized = "true";

  const $ = id => root.querySelector("#" + id);
  const el = {
    file:$("snsbr3-file"), drop:$("snsbr3-drop"),
    fileinfo:$("snsbr3-fileinfo"), quality:$("snsbr3-quality"),
    size:$("snsbr3-size"), bg:$("snsbr3-bg"),
    colorbox:$("snsbr3-colorbox"), color:$("snsbr3-color"),
    format:$("snsbr3-format"), start:$("snsbr3-start"),
    reset:$("snsbr3-reset"), download:$("snsbr3-download"),
    progress:$("snsbr3-progress"), status:$("snsbr3-status"),
    errorbox:$("snsbr3-errorbox"), error:$("snsbr3-error"),
    original:$("snsbr3-original"),
    originalEmpty:$("snsbr3-original-empty"),
    resultEmpty:$("snsbr3-result-empty"),
    checker:$("snsbr3-checker"), preview:$("snsbr3-preview"),
    editor:$("snsbr3-editor"), edit:$("snsbr3-edit"),
    stage:$("snsbr3-stage"), stageInner:$("snsbr3-stage-inner"),
    cursor:$("snsbr3-cursor"), brush:$("snsbr3-brush"),
    brushValue:$("snsbr3-brush-value"),
    hardness:$("snsbr3-hardness"),
    hardnessValue:$("snsbr3-hardness-value"),
    softness:$("snsbr3-softness"),
    softnessValue:$("snsbr3-softness-value"),
    zoom:$("snsbr3-zoom"), restore:$("snsbr3-restore"),
    erase:$("snsbr3-erase"), undo:$("snsbr3-undo"),
    redo:$("snsbr3-redo"), autoReset:$("snsbr3-auto-reset")
  };

  let selectedFile = null;
  let originalUrl = "";
  let busy = false;
  let removeFn = null;
  let libraryPromise = null;
  let sourceCanvas = null;
  let maskCanvas = null;
  let maskCtx = null;
  let autoMask = null;
  let history = [];
  let historyIndex = -1;
  let tool = "restore";
  let painting = false;
  let lastPoint = null;
  let strokeChanged = false;
  let keyboardPoint = {x:0,y:0};

  const previewCtx = el.preview.getContext("2d");
  const editCtx = el.edit.getContext("2d");

  function setStatus(message, type = "") {
    el.status.textContent = message;
    el.status.className = "br-status" + (type ? " " + type : "");
  }

  function clearError() {
    el.errorbox.hidden = true;
    el.error.textContent = "";
  }

  function showError(error, message) {
    console.error("SNSNOTE 배경 제거 오류:", error);
    el.error.textContent = error && error.message
      ? error.message : String(error);
    el.errorbox.hidden = false;
    setStatus(message || "오류가 발생했습니다.", "error");
  }

  function setBusy(value) {
    busy = value;
    el.file.disabled = value;
    el.start.disabled = value || !selectedFile;
    el.reset.disabled = value;
    el.download.disabled = value || !sourceCanvas;
    el.quality.disabled = value;
    el.size.disabled = value;
  }

  function clearResult() {
    sourceCanvas = null;
    maskCanvas = null;
    maskCtx = null;
    autoMask = null;
    history = [];
    historyIndex = -1;
    painting = false;
    el.editor.hidden = true;
    el.checker.hidden = true;
    el.resultEmpty.hidden = false;
    el.download.disabled = true;
    el.preview.width = 0;
    el.preview.height = 0;
    updateHistoryButtons();
  }

  function resetAll() {
    if (busy) return;
    selectedFile = null;
    el.file.value = "";
    if (originalUrl) URL.revokeObjectURL(originalUrl);
    originalUrl = "";
    el.original.removeAttribute("src");
    el.original.hidden = true;
    el.originalEmpty.hidden = false;
    el.fileinfo.textContent = "선택된 파일 없음";
    el.quality.value = "isnet";
    el.size.value = "2400";
    el.bg.value = "transparent";
    el.color.value = "#ffffff";
    el.colorbox.hidden = true;
    el.format.value = "png";
    el.progress.hidden = true;
    el.progress.removeAttribute("value");
    clearResult();
    clearError();
    setBusy(false);
    setStatus("사진을 선택한 뒤 배경 제거 시작 버튼을 눌러주세요.");
  }

  function loadImage(url) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.onerror = () => reject(new Error("이미지를 읽을 수 없습니다."));
      img.src = url;
    });
  }

  function toBlob(canvas, type, quality) {
    return new Promise((resolve, reject) => {
      canvas.toBlob(blob => blob
        ? resolve(blob)
        : reject(new Error("이미지 파일 생성에 실패했습니다.")),
        type, quality);
    });
  }

  function fileSize(n) {
    return n < 1048576
      ? Math.round(n / 1024) + " KB"
      : (n / 1048576).toFixed(1) + " MB";
  }

  async function selectFile(file) {
    if (!file || busy) return;

    if (!["image/jpeg","image/png","image/webp"].includes(file.type)) {
      setStatus("JPG, PNG, WebP 이미지만 선택할 수 있습니다.", "error");
      return;
    }
    if (file.size > 30 * 1024 * 1024) {
      setStatus("30MB 이하의 이미지를 선택해주세요.", "error");
      return;
    }

    clearResult();
    clearError();
    if (originalUrl) URL.revokeObjectURL(originalUrl);

    selectedFile = file;
    originalUrl = URL.createObjectURL(file);
    el.original.src = originalUrl;
    el.original.hidden = false;
    el.originalEmpty.hidden = true;
    el.fileinfo.textContent = file.name + " · " + fileSize(file.size);
    setBusy(false);
    setStatus("이미지 선택 완료. 배경 제거 시작 버튼을 눌러주세요.");
  }

  async function prepareImage(file) {
    const url = URL.createObjectURL(file);
    try {
      const img = await loadImage(url);
      const limit = Number(el.size.value);
      const ratio = Math.min(1, limit / Math.max(img.naturalWidth,img.naturalHeight));
      const w = Math.max(1, Math.round(img.naturalWidth * ratio));
      const h = Math.max(1, Math.round(img.naturalHeight * ratio));

      const canvas = document.createElement("canvas");
      canvas.width = w;
      canvas.height = h;
      const ctx = canvas.getContext("2d");
      if (!ctx) throw new Error("Canvas를 사용할 수 없습니다.");
      ctx.drawImage(img,0,0,w,h);

      const blob = await toBlob(canvas,"image/png");
      return {canvas,blob};
    } finally {
      URL.revokeObjectURL(url);
    }
  }

  async function loadLibrary() {
    if (removeFn) return removeFn;
    if (!libraryPromise) {
      libraryPromise = (async () => {
        const mod = await import(
          "https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/+esm"
        );
        const fn = mod.removeBackground ||
          mod.default?.removeBackground || mod.default;
        if (typeof fn !== "function") {
          throw new Error("AI 배경 제거 함수를 찾지 못했습니다.");
        }
        removeFn = fn;
        return fn;
      })().catch(error => {
        libraryPromise = null;
        throw error;
      });
    }
    return libraryPromise;
  }

  function getBackground() {
    if (el.bg.value === "white") return "#ffffff";
    if (el.bg.value === "black") return "#000000";
    if (el.bg.value === "custom") return el.color.value;
    return null;
  }

  // 원본의 RGB를 유지하고 AI 마스크의 투명도만 사용한다.
  function createMask(resultImage) {
    maskCanvas = document.createElement("canvas");
    maskCanvas.width = sourceCanvas.width;
    maskCanvas.height = sourceCanvas.height;
    maskCtx = maskCanvas.getContext("2d",{willReadFrequently:true});
    maskCtx.drawImage(resultImage,0,0,maskCanvas.width,maskCanvas.height);
    maskCtx.globalCompositeOperation = "source-in";
    maskCtx.fillStyle = "#ffffff";
    maskCtx.fillRect(0,0,maskCanvas.width,maskCanvas.height);
    maskCtx.globalCompositeOperation = "source-over";
    autoMask = snapshotMask();
    history = [autoMask.slice()];
    historyIndex = 0;
    keyboardPoint = {x:sourceCanvas.width/2,y:sourceCanvas.height/2};
    updateHistoryButtons();
  }

  function snapshotMask() {
    const data = maskCtx.getImageData(
      0,0,maskCanvas.width,maskCanvas.height
    ).data;
    const alpha = new Uint8Array(maskCanvas.width * maskCanvas.height);
    for (let i=0;i<alpha.length;i++) alpha[i] = data[i*4+3];
    return alpha;
  }

  function restoreMask(alpha) {
    const image = maskCtx.createImageData(maskCanvas.width,maskCanvas.height);
    const data = image.data;
    for (let i=0;i<alpha.length;i++) {
      const j = i*4;
      data[j] = 255;
      data[j+1] = 255;
      data[j+2] = 255;
      data[j+3] = alpha[i];
    }
    maskCtx.putImageData(image,0,0);
    renderAll();
  }

  function historyLimit() {
    const bytes = maskCanvas.width * maskCanvas.height;
    return Math.max(2,Math.min(10,Math.floor(32*1024*1024/bytes)));
  }

  function pushHistory() {
    history = history.slice(0,historyIndex+1);
    history.push(snapshotMask());
    while (history.length > historyLimit()) history.shift();
    historyIndex = history.length-1;
    updateHistoryButtons();
  }

  function updateHistoryButtons() {
    el.undo.disabled = busy || historyIndex <= 0;
    el.redo.disabled = busy || historyIndex >= history.length-1;
    el.autoReset.disabled = busy || !autoMask;
  }

  function makeForeground(w,h,softness) {
    const fg = document.createElement("canvas");
    fg.width=w;
    fg.height=h;
    const ctx=fg.getContext("2d");
    ctx.drawImage(sourceCanvas,0,0,w,h);
    ctx.globalCompositeOperation="destination-in";
    const scale=w/sourceCanvas.width;
    const blur=softness*scale;
    if (blur>0) ctx.filter="blur("+blur+"px)";
    ctx.drawImage(maskCanvas,0,0,w,h);
    ctx.filter="none";
    ctx.globalCompositeOperation="source-over";
    return fg;
  }

  function compose(target,w,h,background,softness) {
    target.width=w;
    target.height=h;
    const ctx=target.getContext("2d");
    if (background) {
      ctx.fillStyle=background;
      ctx.fillRect(0,0,w,h);
    }
    const fg=makeForeground(w,h,softness);
    ctx.drawImage(fg,0,0);
    fg.width=0;
    fg.height=0;
  }

  function renderAll() {
    if (!sourceCanvas || !maskCanvas) return;

    const ratio=Math.min(1,1200/Math.max(sourceCanvas.width,sourceCanvas.height));
    const w=Math.max(1,Math.round(sourceCanvas.width*ratio));
    const h=Math.max(1,Math.round(sourceCanvas.height*ratio));
    const softness=Number(el.softness.value);

    compose(el.preview,w,h,getBackground(),softness);
    compose(el.edit,w,h,getBackground(),softness);

    el.checker.hidden=false;
    el.resultEmpty.hidden=true;
    el.editor.hidden=false;
    el.download.disabled=busy;
    updateZoom();
  }

  function updateZoom() {
    if (!sourceCanvas || !el.edit.width) return;
    const available=Math.max(180,el.stage.clientWidth-24);
    const fit=Math.min(available,el.edit.width);
    const width=fit*Number(el.zoom.value);
    el.edit.style.width=width+"px";
    el.stageInner.style.width=width+"px";
    el.cursor.style.display="none";
  }

  function setTool(value) {
    tool=value;
    el.restore.classList.toggle("active",value==="restore");
    el.erase.classList.toggle("active",value==="erase");
  }

  function pointFromEvent(event) {
    const rect=el.edit.getBoundingClientRect();
    return {
      x:Math.max(0,Math.min(sourceCanvas.width,
        (event.clientX-rect.left)/rect.width*sourceCanvas.width)),
      y:Math.max(0,Math.min(sourceCanvas.height,
        (event.clientY-rect.top)/rect.height*sourceCanvas.height))
    };
  }

  function stamp(x,y) {
    const radius=Number(el.brush.value)/2;
    const hardness=Number(el.hardness.value)/100;

    maskCtx.save();
    maskCtx.globalCompositeOperation =
      tool==="erase" ? "destination-out" : "source-over";

    if (hardness >= 0.99) {
      maskCtx.fillStyle="#ffffff";
    } else {
      const gradient=maskCtx.createRadialGradient(
        x,y,Math.max(0,radius*hardness),x,y,radius
      );
      gradient.addColorStop(0,"rgba(255,255,255,1)");
      gradient.addColorStop(1,"rgba(255,255,255,0)");
      maskCtx.fillStyle=gradient;
    }

    maskCtx.beginPath();
    maskCtx.arc(x,y,radius,0,Math.PI*2);
    maskCtx.fill();
    maskCtx.restore();
    strokeChanged=true;
  }

  function paintSegment(from,to) {
    const distance=Math.hypot(to.x-from.x,to.y-from.y);
    const step=Math.max(1,Number(el.brush.value)/6);
    const count=Math.max(1,Math.ceil(distance/step));
    for (let i=1;i<=count;i++) {
      const t=i/count;
      stamp(from.x+(to.x-from.x)*t,from.y+(to.y-from.y)*t);
    }
  }

  function updateCursor(event) {
    if (!sourceCanvas) return;
    const point=pointFromEvent(event);
    const rect=el.edit.getBoundingClientRect();
    const size=Number(el.brush.value)*rect.width/sourceCanvas.width;
    const x=(event.clientX-rect.left);
    const y=(event.clientY-rect.top);
    el.cursor.style.width=size+"px";
    el.cursor.style.height=size+"px";
    el.cursor.style.left=(x-size/2)+"px";
    el.cursor.style.top=(y-size/2)+"px";
    el.cursor.style.display="block";
  }

  function finishStroke() {
    if (!painting) return;
    painting=false;
    lastPoint=null;
    el.edit.style.touchAction="pan-x pan-y";
    if (strokeChanged) {
      pushHistory();
      renderAll();
      strokeChanged=false;
    }
  }

  async function runRemoval() {
    if (!selectedFile || busy) return;

    clearResult();
    clearError();
    setBusy(true);
    el.progress.hidden=false;
    el.progress.removeAttribute("value");

    try {
      if (typeof WebAssembly==="undefined") {
        throw new Error("이 브라우저는 WebAssembly를 지원하지 않습니다.");
      }

      setStatus("AI 라이브러리를 불러오는 중입니다...");
      const remove=await loadLibrary();

      setStatus("이미지를 준비하는 중입니다...");
      const prepared=await prepareImage(selectedFile);

      setStatus("AI 모델을 불러오고 있습니다. 첫 실행은 시간이 걸릴 수 있습니다.");

      const resultBlob=await remove(prepared.blob,{
        model:el.quality.value,
        device:"cpu",
        proxyToWorker:false,
        debug:false,
        output:{format:"image/png"},
        progress:(key,current,total)=>{
          if (total>0) {
            const percent=Math.min(100,Math.round(current/total*100));
            el.progress.value=percent;
            setStatus("AI 모델 다운로드 중: "+percent+"%");
          }
        }
      });

      if (!(resultBlob instanceof Blob) || !resultBlob.size) {
        throw new Error("AI가 유효한 결과 이미지를 반환하지 않았습니다.");
      }

      const url=URL.createObjectURL(resultBlob);
      let resultImage;
      try {
        resultImage=await loadImage(url);
      } finally {
        URL.revokeObjectURL(url);
      }

      sourceCanvas=prepared.canvas;
      createMask(resultImage);
      renderAll();

      el.progress.hidden=true;
      setStatus(
        "배경 제거 완료! 아래에서 복원·지우기 브러시로 세밀하게 보정할 수 있습니다.",
        "success"
      );
    } catch(error) {
      el.progress.hidden=true;
      clearResult();
      showError(error,
        "배경 제거에 실패했습니다. 오류 자세히 보기를 열면 원인을 확인할 수 있습니다.");
    } finally {
      setBusy(false);
      updateHistoryButtons();
    }
  }

  async function downloadResult() {
    if (!sourceCanvas || busy) return;

    try {
      const format=el.format.value;
      let mime="image/png";
      if (format==="jpg") mime="image/jpeg";
      if (format==="webp") mime="image/webp";

      let bg=getBackground();
      if (format==="jpg" && !bg) bg="#ffffff";

      const out=document.createElement("canvas");
      compose(out,sourceCanvas.width,sourceCanvas.height,
        bg,Number(el.softness.value));

      const blob=await toBlob(out,mime,
        format==="png" ? undefined : 0.92);

      out.width=0;
      out.height=0;

      let ext="png";
      if (blob.type==="image/jpeg") ext="jpg";
      if (blob.type==="image/webp") ext="webp";

      const name=(selectedFile.name || "image")
        .replace(/\.[^.]+$/,"")
        .replace(/[\\/:*?"<>|]/g,"_");

      const url=URL.createObjectURL(blob);
      const a=document.createElement("a");
      a.href=url;
      a.download=name+"-bg-removed."+ext;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(()=>URL.revokeObjectURL(url),20000);

      setStatus("파일 다운로드를 요청했습니다.","success");
    } catch(error) {
      showError(error,"다운로드 파일 생성에 실패했습니다.");
    }
  }

  el.file.addEventListener("change",event=>{
    selectFile(event.target.files?.[0]);
  });

  ["dragenter","dragover"].forEach(name=>{
    el.drop.addEventListener(name,event=>{
      event.preventDefault();
      if (!busy) el.drop.classList.add("dragover");
    });
  });

  ["dragleave","dragend","drop"].forEach(name=>{
    el.drop.addEventListener(name,event=>{
      event.preventDefault();
      el.drop.classList.remove("dragover");
    });
  });

  el.drop.addEventListener("drop",event=>{
    if (!busy) selectFile(event.dataTransfer?.files?.[0]);
  });

  el.start.addEventListener("click",runRemoval);
  el.reset.addEventListener("click",resetAll);
  el.download.addEventListener("click",downloadResult);

  el.bg.addEventListener("change",()=>{
    el.colorbox.hidden=el.bg.value!=="custom";
    renderAll();
  });
  el.color.addEventListener("input",renderAll);

  el.brush.addEventListener("input",()=>{
    el.brushValue.textContent=el.brush.value;
  });
  el.hardness.addEventListener("input",()=>{
    el.hardnessValue.textContent=el.hardness.value;
  });
  el.softness.addEventListener("input",()=>{
    el.softnessValue.textContent=el.softness.value;
    renderAll();
  });
  el.zoom.addEventListener("change",updateZoom);
  el.restore.addEventListener("click",()=>setTool("restore"));
  el.erase.addEventListener("click",()=>setTool("erase"));

  el.undo.addEventListener("click",()=>{
    if (historyIndex<=0 || busy) return;
    historyIndex--;
    restoreMask(history[historyIndex]);
    updateHistoryButtons();
  });

  el.redo.addEventListener("click",()=>{
    if (historyIndex>=history.length-1 || busy) return;
    historyIndex++;
    restoreMask(history[historyIndex]);
    updateHistoryButtons();
  });

  el.autoReset.addEventListener("click",()=>{
    if (!autoMask || busy) return;
    restoreMask(autoMask);
    history=[autoMask.slice()];
    historyIndex=0;
    updateHistoryButtons();
    setStatus("AI 자동 제거 결과로 되돌렸습니다.","success");
  });

  el.edit.addEventListener("pointerdown",event=>{
    if (!sourceCanvas || busy) return;
    if (event.pointerType==="mouse" && event.button!==0) return;
    event.preventDefault();
    painting=true;
    strokeChanged=false;
    lastPoint=pointFromEvent(event);
    stamp(lastPoint.x,lastPoint.y);
    el.edit.style.touchAction="none";
    el.edit.setPointerCapture(event.pointerId);
    updateCursor(event);
    renderAll();
  });

  el.edit.addEventListener("pointermove",event=>{
    if (!sourceCanvas || busy) return;
    updateCursor(event);
    if (!painting) return;
    const point=pointFromEvent(event);
    paintSegment(lastPoint,point);
    lastPoint=point;
    renderAll();
  });

  el.edit.addEventListener("pointerup",finishStroke);
  el.edit.addEventListener("pointercancel",finishStroke);
  el.edit.addEventListener("lostpointercapture",finishStroke);
  el.edit.addEventListener("pointerleave",()=>{
    if (!painting) el.cursor.style.display="none";
  });

  // 키보드로 위치 이동 및 한 번 칠하기
  el.edit.addEventListener("keydown",event=>{
    if (!sourceCanvas || busy) return;
    const step=event.shiftKey ? 20 : 5;
    let moved=true;

    if (event.key==="ArrowLeft") keyboardPoint.x-=step;
    else if (event.key==="ArrowRight") keyboardPoint.x+=step;
    else if (event.key==="ArrowUp") keyboardPoint.y-=step;
    else if (event.key==="ArrowDown") keyboardPoint.y+=step;
    else moved=false;

    if (moved) {
      event.preventDefault();
      keyboardPoint.x=Math.max(0,Math.min(sourceCanvas.width,keyboardPoint.x));
      keyboardPoint.y=Math.max(0,Math.min(sourceCanvas.height,keyboardPoint.y));
    }

    if (event.key===" " || event.key==="Enter") {
      event.preventDefault();
      stamp(keyboardPoint.x,keyboardPoint.y);
      pushHistory();
      renderAll();
      strokeChanged=false;
    }
  });

  if (typeof ResizeObserver!=="undefined") {
    new ResizeObserver(updateZoom).observe(el.stage);
  } else {
    window.addEventListener("resize",updateZoom);
  }

  resetAll();
})();
</script>
</div>

<!-- SNSNOTE 배경 제거기: 편집 화면 안정화 및 수동 보정 상시 표시 -->
<style>
/* 편집 영역은 고정된 크기 안에서 사진 전체를 표시 */
#sns-bg-remover-v3 .br-stage {
  height: clamp(300px, 46vw, 560px) !important;
  min-height: 0 !important;
  max-height: none !important;
  overflow: hidden !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
  padding: 14px !important;
}

/* 기존 JavaScript의 확대 너비 계산을 무시 */
#sns-bg-remover-v3 .br-stage-inner {
  width: 100% !important;
  height: 100% !important;
  min-width: 0 !important;
  min-height: 0 !important;
  max-width: 100% !important;
  max-height: 100% !important;
  display: flex !important;
  align-items: center !important;
  justify-content: center !important;
  position: relative !important;
  flex: 0 0 auto !important;
}

/* 원본 비율을 유지하면서 가로·세로 모두 화면 안에 맞춤 */
#sns-bg-remover-v3 .br-edit-canvas {
  width: auto !important;
  height: auto !important;
  max-width: 100% !important;
  max-height: 100% !important;
  min-width: 0 !important;
  min-height: 0 !important;
  flex: 0 1 auto !important;
  object-fit: contain !important;
  display: block !important;
}

/* 브러시 원형 표시를 실제 캔버스 위치에 맞춤 */
#sns-bg-remover-v3 .br-brush-cursor {
  transform: translate(
    var(--br-offset-x, 0px),
    var(--br-offset-y, 0px)
  ) !important;
}

/* 사진 처리 전에도 수동 보정 기능을 보여줌 */
#sns-bg-remover-v3 .br-editor {
  display: block !important;
}

/* 처리 전 안내문 */
#sns-bg-remover-v3:not([data-editor-ready="true"]) .br-stage::after {
  content: "사진을 업로드하고 배경을 제거하면\A이곳에서 세밀하게 수동 보정할 수 있습니다.";
  white-space: pre-line;
  position: absolute;
  z-index: 2;
  max-width: 280px;
  padding: 20px;
  border-radius: 12px;
  background: rgba(255,255,255,.92);
  border: 1px solid #dbe4f0;
  color: #64748b;
  text-align: center;
  font-size: 13px;
  line-height: 1.8;
  pointer-events: none;
}

/* 처리 전 캔버스는 숨기고 기능 안내를 표시 */
#sns-bg-remover-v3:not([data-editor-ready="true"]) .br-edit-canvas,
#sns-bg-remover-v3:not([data-editor-ready="true"]) .br-brush-cursor {
  visibility: hidden;
}

#sns-bg-remover-v3:not([data-editor-ready="true"]) .br-editor-controls,
#sns-bg-remover-v3:not([data-editor-ready="true"]) .br-tool-buttons {
  opacity: .55;
}

/* 확대 선택 항목 제거 */
#sns-bg-remover-v3 .br-field:has(#snsbr3-zoom) {
  display: none !important;
}

@media (max-width: 600px) {
  #sns-bg-remover-v3 .br-stage {
    height: 340px !important;
    padding: 10px !important;
  }
}
</style>

<script>
(function () {
  const root = document.getElementById("sns-bg-remover-v3");
  if (!root || root.dataset.editorFitPatch === "true") return;
  root.dataset.editorFitPatch = "true";

  const editor = root.querySelector("#snsbr3-editor");
  const checker = root.querySelector("#snsbr3-checker");
  const stage = root.querySelector("#snsbr3-stage");
  const canvas = root.querySelector("#snsbr3-edit");
  const cursor = root.querySelector("#snsbr3-cursor");
  const zoom = root.querySelector("#snsbr3-zoom");

  if (!editor || !checker || !stage || !canvas) return;

  // 확대는 사용하지 않고 항상 화면 맞춤으로 고정
  if (zoom) {
    zoom.value = "1";
    zoom.disabled = true;
    const zoomField = zoom.closest(".br-field");
    if (zoomField) zoomField.hidden = true;
  }

  // 기존 코드가 편집 영역을 숨겨도 다시 표시
  let lastReady = null;

  const basicControls = editor.querySelectorAll(
    ".br-editor-controls input, " +
    ".br-editor-controls select, " +
    "#snsbr3-restore, #snsbr3-erase"
  );

  const historyButtons = editor.querySelectorAll(
    "#snsbr3-undo, #snsbr3-redo, #snsbr3-auto-reset"
  );

  function syncEditor() {
    const ready = !checker.hidden && canvas.width > 0;

    if (editor.hidden) {
      editor.hidden = false;
    }

    if (ready !== lastReady) {
      lastReady = ready;
      root.dataset.editorReady = String(ready);

      basicControls.forEach(function (control) {
        if (control !== zoom) {
          control.disabled = !ready;
        }
      });

      // 실행 취소 등은 기존 코드의 활성화 상태를 유지
      if (!ready) {
        historyButtons.forEach(function (button) {
          button.disabled = true;
        });
      }
    }

    if (zoom && zoom.value !== "1") {
      zoom.value = "1";
    }
  }

  // 캔버스가 가운데 배치되어도 브러시 원형 위치가 맞도록 보정
  function syncCursorOffset() {
    if (!cursor || !canvas.width) return;

    const x = canvas.offsetLeft;
    const y = canvas.offsetTop;

    const xValue = x + "px";
    const yValue = y + "px";

    if (root.style.getPropertyValue("--br-offset-x") !== xValue) {
      root.style.setProperty("--br-offset-x", xValue);
    }

    if (root.style.getPropertyValue("--br-offset-y") !== yValue) {
      root.style.setProperty("--br-offset-y", yValue);
    }
  }

  // 결과가 나타나거나 초기화될 때만 상태 동기화
  const observer = new MutationObserver(syncEditor);

  observer.observe(editor, {
    attributes: true,
    attributeFilter: ["hidden"]
  });

  observer.observe(checker, {
    attributes: true,
    attributeFilter: ["hidden"]
  });

  // 편집 영역의 실제 크기가 바뀔 때만 커서 위치를 조정
  if (typeof ResizeObserver !== "undefined") {
    const resizeObserver = new ResizeObserver(syncCursorOffset);
    resizeObserver.observe(stage);
    resizeObserver.observe(canvas);
  } else {
    window.addEventListener("resize", syncCursorOffset);
  }

  syncEditor();
  syncCursorOffset();
})();
</script>



<h2 class="wp-block-heading">AI 이미지 배경 제거 무료 도구 사용 방법</h2>



<p class="wp-block-paragraph">사진에서 인물이나 사물만 남기고 배경을 없애고 싶을 때, 별도의 이미지 편집 프로그램을 설치하지 않아도 됩니다. SNSNOTE의 AI 이미지 배경 제거 도구는 사진을 브라우저에서 분석해 배경을 분리하고, 투명 PNG 저장과 배경색 변경, 수동 보정까지 지원합니다.</p>



<p class="wp-block-paragraph">프로필 사진, 쇼핑몰 상품 이미지, 블로그 썸네일, 발표 자료에 사용할 사진 등 다양한 이미지에 활용할 수 있습니다. 특히 자동 누끼 따기 결과가 완벽하지 않을 때는 복원·지우기 브러시로 남은 부분을 직접 수정할 수 있습니다.</p>



<h3 class="wp-block-heading">1. 배경을 제거할 사진 업로드</h3>



<p class="wp-block-paragraph">상단의 <strong>이미지 업로드</strong> 영역을 클릭하거나 사진을 끌어다 놓으세요. JPG, PNG, WebP 파일을 선택할 수 있습니다. 사진을 선택하면 오른쪽 미리보기에서 원본 이미지를 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">인물 사진뿐 아니라 제품, 반려동물, 소품 등 배경과 피사체가 구분되는 사진에 사용할 수 있습니다. 다만 투명한 유리, 복잡한 머리카락, 배경과 색상이 비슷한 물체는 AI가 일부 영역을 잘못 구분할 수 있습니다.</p>



<h3 class="wp-block-heading">2. AI 처리 품질과 이미지 크기 선택</h3>



<p class="wp-block-paragraph">AI 처리 품질에서 고품질, 표준, 빠른 처리 중 원하는 옵션을 선택합니다. 고품질 모델은 더 많은 기기 자원과 처리 시간이 필요할 수 있으며, 사진에 따라 표준 모델의 결과가 더 자연스러울 수도 있습니다.</p>



<p class="wp-block-paragraph">처리 이미지 크기는 기본적으로 최대 2400px가 권장됩니다. 고해상도 이미지가 필요한 경우 4096px 옵션을 선택할 수 있지만, 휴대폰이나 저사양 컴퓨터에서는 처리 시간이 길어지거나 메모리 부족이 발생할 수 있습니다. 처음에는 기본 설정으로 결과를 확인한 뒤 필요할 때 높은 설정을 사용하는 편이 좋습니다.</p>



<h3 class="wp-block-heading">3. 배경 제거 시작</h3>



<p class="wp-block-paragraph"><strong>배경 제거 시작</strong> 버튼을 누르면 AI 모델이 사진을 분석합니다. 처음 실행할 때는 외부 CDN에서 AI 모델을 다운로드하기 때문에 시간이 조금 걸릴 수 있습니다. 이후에도 이미지 크기와 기기 성능에 따라 처리 속도가 달라집니다.</p>



<p class="wp-block-paragraph">완료되면 원본 이미지와 배경 제거 결과를 나란히 비교할 수 있습니다. 결과 뒤에 표시되는 회색 체크무늬는 실제 배경이 아니라 투명한 영역을 알아보기 쉽게 보여주는 미리보기 표시입니다.</p>



<h3 class="wp-block-heading">4. 투명 배경 또는 원하는 배경색 선택</h3>



<p class="wp-block-paragraph">배경 옵션에서 투명 배경, 흰색, 검은색, 사용자 색상을 선택할 수 있습니다. 투명 배경은 다른 디자인 위에 사진을 합성할 때 편리하고, 흰색 배경은 상품 사진이나 깔끔한 프로필 이미지에 활용하기 좋습니다.</p>



<p class="wp-block-paragraph">사용자 색상을 선택하면 원하는 색상으로 배경을 변경할 수 있습니다. 블로그 썸네일이나 SNS 콘텐츠의 색상을 통일하고 싶을 때 유용합니다. 이미지를 잘라내는 것이 아니라 AI가 분리한 피사체 뒤에 새로운 배경을 적용하는 방식입니다.</p>



<h3 class="wp-block-heading">5. 수동 보정으로 누끼를 더 깔끔하게 수정하기</h3>



<p class="wp-block-paragraph">자동 배경 제거가 완료되면 미리보기 아래의 <strong>세밀한 수동 보정</strong> 영역을 사용할 수 있습니다. 자동 처리 결과에서 머리카락이 일부 사라졌거나, 옷 가장자리에 배경이 남아 있다면 브러시로 수정해보세요.</p>



<p class="wp-block-paragraph"><strong>+ 복원</strong>은 지워진 부분을 원본 사진에서 되살리는 기능입니다. 반대로 <strong>− 지우기</strong>는 남아 있는 불필요한 배경을 제거합니다. 브러시 크기와 선명도를 조절하면서 작은 부분부터 수정하는 것이 좋습니다.</p>



<p class="wp-block-paragraph">실수로 잘못 칠했다면 실행 취소를 사용하거나 AI 결과로 되돌릴 수 있습니다. 수동 보정은 원본 사진의 픽셀을 복원하는 방식이므로, 원본에 존재하지 않는 머리카락이나 사물을 새롭게 생성하는 기능은 아닙니다.</p>



<h3 class="wp-block-heading">6. PNG, JPG, WebP로 결과 다운로드</h3>



<p class="wp-block-paragraph">수정이 끝나면 원하는 다운로드 형식을 선택하고 <strong>결과 다운로드</strong> 버튼을 누르세요. 투명한 배경을 유지하려면 PNG를 선택하는 것이 가장 편리합니다. JPG는 투명도를 지원하지 않으므로 투명 배경을 선택하더라도 흰색 배경으로 저장됩니다.</p>



<p class="wp-block-paragraph">저장하기 전에 미리보기에서 피사체 가장자리와 배경색을 한 번 더 확인해보세요. 특히 상품 사진이나 발표 자료에 사용할 이미지는 밝은 배경과 어두운 배경에서 모두 확인하면 남아 있는 테두리를 발견하기 쉽습니다.</p>



<h2 class="wp-block-heading">PNG란? 배경 제거 후 PNG로 저장해야 하는 이유</h2>



<p class="wp-block-paragraph">PNG는 이미지의 투명도를 저장할 수 있는 파일 형식입니다. 배경을 제거한 뒤 투명 PNG로 저장하면 인물이나 상품만 남기고 나머지 영역을 투명하게 유지할 수 있습니다.</p>



<p class="wp-block-paragraph">예를 들어 흰색 배경의 상품 사진을 JPG로 저장하면 흰색 바탕이 이미지에 포함됩니다. 반면 투명 PNG로 저장하면 배경이 없는 상태로 다른 색상의 배너, 카드뉴스, 웹사이트 디자인 위에 배치할 수 있습니다.</p>



<p class="wp-block-paragraph">PNG는 무손실 압축을 사용하는 형식이지만, 사진의 크기와 내용에 따라 JPG나 WebP보다 파일 용량이 커질 수 있습니다. 따라서 투명도가 꼭 필요한지, 최종적으로 어디에 사용할지를 기준으로 형식을 선택하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">PNG, JPG, WebP 차이</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>형식</th><th>투명 배경</th><th>주요 특징</th><th>추천 용도</th></tr></thead><tbody><tr><td>PNG</td><td>지원</td><td>무손실 압축, 투명도 보존</td><td>누끼 이미지, 로고, 합성용 사진</td></tr><tr><td>JPG</td><td>지원하지 않음</td><td>사진에 적합한 손실 압축</td><td>일반 사진, 흰색 배경 상품 이미지</td></tr><tr><td>WebP</td><td>지원</td><td>손실·무손실 압축 및 투명도 지원</td><td>웹사이트 이미지, 용량을 고려한 이미지</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">투명 배경이 필요하다면 PNG 또는 투명도를 지원하는 WebP를 선택하세요. 일반적인 사진을 작은 용량으로 저장하려는 목적이라면 JPG나 WebP가 적합할 수 있습니다. 형식별 자세한 기술 정보는 <a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types" target="_blank" rel="noopener">MDN 이미지 파일 형식 안내</a>에서도 확인할 수 있습니다.</p>



<h2 class="wp-block-heading">AI 누끼 따기를 활용하기 좋은 상황</h2>



<h3 class="wp-block-heading">블로그와 SNS 썸네일 제작</h3>



<p class="wp-block-paragraph">인물이나 제품의 배경을 제거하면 텍스트와 배경색을 자유롭게 배치할 수 있습니다. 블로그 대표 이미지, 인스타그램 게시물, 유튜브 썸네일 등에서 피사체를 강조할 때 유용합니다. 배경을 제거한 뒤 원하는 색상을 적용하면 여러 이미지의 디자인을 통일하기도 쉽습니다.</p>



<h3 class="wp-block-heading">쇼핑몰 상품 사진 정리</h3>



<p class="wp-block-paragraph">상품 주변의 복잡한 배경을 제거하고 흰색이나 단색 배경으로 바꾸면 제품을 더 명확하게 보여줄 수 있습니다. 다만 상품의 실제 색상과 형태를 정확하게 전달해야 하므로, AI가 제품의 일부를 삭제하지 않았는지 가장자리를 확인하는 것이 중요합니다.</p>



<h3 class="wp-block-heading">발표 자료와 문서 이미지</h3>



<p class="wp-block-paragraph">파워포인트나 업무 문서에 사용할 사진도 투명 PNG로 만들면 배경과 자연스럽게 합성할 수 있습니다. 인물 사진을 발표 자료에 넣거나, 제품만 따로 배치할 때 활용할 수 있습니다.</p>



<h2 class="wp-block-heading">배경 제거가 잘되지 않을 때 확인할 점</h2>



<p class="wp-block-paragraph">AI 배경 제거는 사진마다 결과가 달라질 수 있습니다. 배경과 피사체의 색상이 비슷하거나, 머리카락이 복잡하게 흩어져 있거나, 피사체가 너무 작게 촬영된 경우에는 일부 영역이 제대로 분리되지 않을 수 있습니다.</p>



<p class="wp-block-paragraph">이럴 때는 원본 사진의 밝기와 선명도를 확인하고, 다른 품질 옵션으로 다시 처리해보세요. 자동 제거 후에는 복원·지우기 브러시로 필요한 부분만 수정하는 것이 효율적입니다. 고품질 모델을 선택하더라도 모든 사진에서 완벽한 결과가 보장되는 것은 아닙니다.</p>



<p class="wp-block-paragraph">AI 모델 로딩이 실패한다면 브라우저를 새로고침하거나 다른 브라우저에서 시도해볼 수 있습니다. 대용량 사진으로 기기 메모리가 부족한 경우에는 처리 크기를 낮춰보세요. 문제가 반복된다면 오류 자세히 보기의 메시지를 확인하면 원인을 파악하는 데 도움이 됩니다.</p>



<h2 class="wp-block-heading">사진은 서버에 업로드되나요?</h2>



<p class="wp-block-paragraph">이 도구는 사진을 SNSNOTE 서버에 업로드하여 처리하는 방식이 아니라, 사용자의 브라우저에서 AI 모델을 실행하는 방식으로 구성되어 있습니다. 처음 실행 시 필요한 AI 모델 파일은 외부 CDN에서 다운로드되며, 배경 제거 연산은 사용자의 기기에서 수행됩니다.</p>



<p class="wp-block-paragraph">따라서 사진 처리 자체를 위해 SNSNOTE의 VPS CPU나 RAM을 사용하는 구조는 아닙니다. 다만 웹페이지 접속에 따른 일반적인 서버 부하와 외부 모델 다운로드에 필요한 인터넷 연결은 발생합니다. 처리 속도는 사용자의 컴퓨터 또는 휴대폰 성능에 영향을 받습니다.</p>



<p class="wp-block-paragraph">민감한 사진을 다룰 때는 결과 이미지를 다운로드하기 전에 원본의 위치정보나 촬영정보 등 메타데이터가 필요한지 확인하는 것도 좋습니다. 사진을 공유하기 전에 개인정보를 별도로 확인하고 싶다면 <a href="https://snsnote.com/photo-privacy-remover/">사진 개인정보 제거 도구</a>를 함께 사용할 수 있습니다.</p>



<h2 class="wp-block-heading">자주 묻는 질문 (FAQ)</h2>



<h3 class="wp-block-heading">Q. AI 이미지 배경 제거는 무료인가요?</h3>



<p class="wp-block-paragraph">현재 SNSNOTE에서 제공하는 브라우저 배경 제거 도구는 별도의 회원가입이나 유료 API 키 입력 없이 사용할 수 있습니다. 다만 AI 모델을 처음 다운로드할 때 인터넷 데이터가 사용될 수 있으며, 기기 성능에 따라 처리 시간이 달라집니다.</p>



<h3 class="wp-block-heading">Q. 사진 배경을 제거하면 화질이 떨어지나요?</h3>



<p class="wp-block-paragraph">AI가 배경을 분리하는 과정에서 머리카락이나 피사체 가장자리의 일부 픽셀이 변경될 수 있습니다. 또한 선택한 처리 이미지 크기에 따라 원본보다 해상도가 줄어들 수 있습니다. 고해상도 결과가 필요하다면 처리 크기를 확인하고, 저장 전 미리보기에서 가장자리를 점검하세요.</p>



<h3 class="wp-block-heading">Q. 투명 PNG로 저장했는데 배경이 흰색으로 보이는 이유는 무엇인가요?</h3>



<p class="wp-block-paragraph">이미지 뷰어 또는 파일 탐색기가 투명한 영역을 흰색으로 표시하는 경우가 있습니다. 실제로 투명도가 저장되었는지 확인하려면 투명도를 지원하는 이미지 편집기에서 열어보세요. JPG로 저장했다면 투명도가 유지되지 않으므로 PNG를 선택해야 합니다.</p>



<h3 class="wp-block-heading">Q. JPG 사진도 누끼를 딸 수 있나요?</h3>



<p class="wp-block-paragraph">네. JPG 사진을 업로드하여 AI 배경 제거를 실행한 뒤, 결과를 PNG나 WebP로 저장할 수 있습니다. 원본이 JPG였더라도 결과 형식에서 PNG를 선택하면 투명 배경을 저장할 수 있습니다.</p>



<h3 class="wp-block-heading">Q. 머리카락이나 옷 일부가 잘못 지워졌을 때는 어떻게 하나요?</h3>



<p class="wp-block-paragraph">수동 보정 영역에서 복원 브러시를 선택하고 지워진 부분을 칠하면 원본 사진의 해당 영역을 되살릴 수 있습니다. 배경이 남아 있다면 지우기 브러시를 사용하세요. 브러시 크기를 작게 설정하면 좁은 가장자리를 수정하기 편리합니다.</p>



<h3 class="wp-block-heading">Q. 휴대폰에서도 사용할 수 있나요?</h3>



<p class="wp-block-paragraph">브라우저에서 AI 모델을 실행할 수 있는 기기라면 모바일에서도 사용할 수 있습니다. 다만 AI 처리에는 메모리와 연산 성능이 필요하므로 기기에 따라 속도가 느리거나 실행이 실패할 수 있습니다. 큰 사진은 처리 크기를 낮춰 시도하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">Q. 배경 제거 후 다른 이미지 형식으로 변환할 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. 이 도구에서 PNG, JPG, WebP 형식으로 저장할 수 있으며, 추가적인 크기 조절이나 다른 이미지 형식이 필요하다면 <a href="https://snsnote.com/image-converter/">무료 이미지 변환기</a>를 이용할 수 있습니다.</p>



<h2 class="wp-block-heading">함께 사용하면 좋은 SNSNOTE 무료 도구</h2>



<p class="wp-block-paragraph">배경 제거가 끝난 이미지는 사용 목적에 맞게 추가로 편집하면 더욱 편리합니다. 파일 형식이나 이미지 크기를 변경하고 싶다면 <a href="https://snsnote.com/image-converter/">이미지 변환기</a>를, 사진을 공유하기 전에 촬영정보와 위치정보를 확인하고 제거하고 싶다면 <a href="https://snsnote.com/photo-privacy-remover/">사진 개인정보 제거 도구</a>를 활용해보세요.</p>



<p class="wp-block-paragraph">SNSNOTE는 별도의 프로그램 설치 없이 브라우저에서 사용할 수 있는 이미지·업무 도구를 계속 추가하고 있습니다. 필요한 기능을 선택해 사용하고, 중요한 사진은 편집 전 원본을 별도로 보관해두는 것을 권장합니다.</p>



<style>
  /* 이 글에서만 본문 상단 대표 이미지 숨기기 */
  .featured-image, 
  .post-thumbnail, 
  .entry-header .post-image,
  .single-media {
    display: none !important;
  }
</style>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/ai-background-remover/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/ai-background-remover.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1601</post-id>	</item>
		<item>
		<title>화상회의 사전 점검, 마이크·웹캠·스피커 테스트 무료 도구</title>
		<link>https://snsnote.com/meeting-device-test/</link>
					<comments>https://snsnote.com/meeting-device-test/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 22:14:28 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1590</guid>

					<description><![CDATA[화상회의 사전 점검, 회의 시작 전에 미리 확인하세요 온라인 회의나 면접에 접속했는데 카메라가 켜지지 않거나, 말을 해도 상대방이 목소리를 듣지 못하는 상황은 생각보다 자주 발생합니다....]]></description>
										<content:encoded><![CDATA[<div id="sns-meeting-preflight">
<style>
#sns-meeting-preflight{
  --mc-bg:#f8fafc;--mc-card:#fff;--mc-text:#0f172a;
  --mc-muted:#64748b;--mc-line:#e2e8f0;--mc-blue:#2563eb;
  --mc-blue-soft:#eff6ff;--mc-green:#15803d;--mc-green-soft:#f0fdf4;
  --mc-red:#b91c1c;--mc-red-soft:#fef2f2;--mc-amber:#a16207;
  --mc-amber-soft:#fffbeb;
  width:100%;max-width:920px;margin:24px auto;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",Arial,sans-serif;
  color:var(--mc-text);line-height:1.6;box-sizing:border-box;
}
#sns-meeting-preflight *,#sns-meeting-preflight *::before,
#sns-meeting-preflight *::after{box-sizing:border-box}
#sns-meeting-preflight .mc-wrap{
  background:var(--mc-bg);border:1px solid var(--mc-line);
  border-radius:18px;padding:22px;
}
#sns-meeting-preflight .mc-title{
  margin:0;padding:14px 18px;border-radius:11px;color:#fff;
  font-size:25px;font-weight:800;letter-spacing:-.03em;
  background:linear-gradient(90deg,#3b82f6,#7c3aed);
}
#sns-meeting-preflight .mc-desc{margin:10px 0 0;color:var(--mc-muted);font-size:14px}
#sns-meeting-preflight .mc-privacy{
  margin-top:14px;padding:11px 13px;border-radius:10px;
  background:#ecfdf5;color:#166534;font-size:12px;line-height:1.7;
}
#sns-meeting-preflight .mc-summary{
  margin-top:18px;padding:18px;border:1px solid #c7d2fe;
  border-radius:14px;background:linear-gradient(135deg,#eff6ff,#f5f3ff);
}
#sns-meeting-preflight .mc-summary-head{
  display:flex;align-items:center;justify-content:space-between;
  gap:12px;flex-wrap:wrap;
}
#sns-meeting-preflight .mc-summary-title{font-size:18px;font-weight:800}
#sns-meeting-preflight .mc-progress-text{font-size:13px;font-weight:800;color:#4338ca}
#sns-meeting-preflight .mc-progress{
  height:8px;background:#dbeafe;border-radius:99px;overflow:hidden;margin:13px 0;
}
#sns-meeting-preflight .mc-progress-fill{
  height:100%;width:0;background:#2563eb;border-radius:99px;transition:width .2s;
}
#sns-meeting-preflight .mc-summary-result{
  padding:11px 13px;border-radius:10px;background:#fff;
  font-size:13px;font-weight:700;
}
#sns-meeting-preflight .mc-summary-result.ok{background:var(--mc-green-soft);color:var(--mc-green)}
#sns-meeting-preflight .mc-summary-result.warn{background:var(--mc-amber-soft);color:var(--mc-amber)}
#sns-meeting-preflight .mc-summary-sub{margin:10px 0 0;color:var(--mc-muted);font-size:12px}
#sns-meeting-preflight .mc-overview{
  display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:14px;
}
#sns-meeting-preflight .mc-overview-item{
  background:#fff;border:1px solid var(--mc-line);border-radius:10px;
  padding:11px 12px;display:flex;justify-content:space-between;align-items:center;gap:10px;
  font-size:13px;font-weight:700;
}
#sns-meeting-preflight .mc-badge{
  display:inline-flex;align-items:center;justify-content:center;
  padding:5px 10px;border-radius:99px;white-space:nowrap;
  font-size:12px;font-weight:800;background:#f1f5f9;color:#475569;
}
#sns-meeting-preflight .mc-badge.ok{background:var(--mc-green-soft);color:var(--mc-green)}
#sns-meeting-preflight .mc-badge.fail{background:var(--mc-red-soft);color:var(--mc-red)}
#sns-meeting-preflight .mc-badge.warn{background:var(--mc-amber-soft);color:var(--mc-amber)}
#sns-meeting-preflight .mc-badge.info{background:var(--mc-blue-soft);color:#1d4ed8}
#sns-meeting-preflight .mc-section{
  margin-top:18px;padding:18px;background:var(--mc-card);
  border:1px solid var(--mc-line);border-radius:14px;
}
#sns-meeting-preflight .mc-section-head{
  display:flex;align-items:flex-start;justify-content:space-between;gap:12px;
}
#sns-meeting-preflight .mc-section-title{
  display:flex;align-items:center;gap:9px;margin:0;font-size:18px;font-weight:800;
}
#sns-meeting-preflight .mc-number{
  display:inline-flex;align-items:center;justify-content:center;
  width:29px;height:29px;border-radius:9px;background:#eff6ff;color:#2563eb;
  font-size:14px;flex-shrink:0;
}
#sns-meeting-preflight .mc-section-desc{margin:8px 0 15px;color:var(--mc-muted);font-size:13px}
#sns-meeting-preflight .mc-controls{display:flex;flex-wrap:wrap;align-items:end;gap:9px}
#sns-meeting-preflight .mc-field{flex:1 1 220px;min-width:0}
#sns-meeting-preflight .mc-label{
  display:block;margin-bottom:6px;font-size:12px;font-weight:700;color:#475569;
}
#sns-meeting-preflight .mc-select{
  display:block;width:100%;min-width:0;height:42px;padding:0 12px;
  border:1px solid #cbd5e1;border-radius:9px;background:#fff;
  color:var(--mc-text);font-family:inherit;font-size:13px;
}
#sns-meeting-preflight .mc-btn{
  appearance:none;border:1px solid transparent;border-radius:9px;
  min-height:42px;padding:10px 14px;font-family:inherit;
  font-size:13px;font-weight:800;cursor:pointer;line-height:1.4;
}
#sns-meeting-preflight .mc-btn.primary{background:var(--mc-blue);color:#fff}
#sns-meeting-preflight .mc-btn.secondary{background:#fff;color:var(--mc-text);border-color:#cbd5e1}
#sns-meeting-preflight .mc-btn.good{background:var(--mc-green-soft);color:var(--mc-green);border-color:#bbf7d0}
#sns-meeting-preflight .mc-btn.bad{background:var(--mc-red-soft);color:var(--mc-red);border-color:#fecaca}
#sns-meeting-preflight .mc-btn:disabled{opacity:.45;cursor:not-allowed}
#sns-meeting-preflight .mc-btn:focus-visible,
#sns-meeting-preflight .mc-select:focus-visible{
  outline:3px solid #93c5fd;outline-offset:2px;
}
#sns-meeting-preflight .mc-preview{
  margin-top:14px;background:#0f172a;border-radius:12px;overflow:hidden;
  min-height:180px;display:flex;align-items:center;justify-content:center;position:relative;
}
#sns-meeting-preflight .mc-preview video{
  display:block;width:100%;max-height:300px;object-fit:contain;
  background:#0f172a;aspect-ratio:16/9;
}
#sns-meeting-preflight .mc-preview-placeholder{
  position:absolute;inset:0;display:flex;align-items:center;
  justify-content:center;padding:18px;text-align:center;
  color:#cbd5e1;font-size:13px;pointer-events:none;
}
#sns-meeting-preflight .mc-preview-placeholder[hidden]{display:none}
#sns-meeting-preflight .mc-message{
  margin-top:12px;padding:11px 13px;border-radius:10px;
  background:#f1f5f9;color:#475569;font-size:12px;line-height:1.7;
}
#sns-meeting-preflight .mc-message.ok{background:var(--mc-green-soft);color:var(--mc-green)}
#sns-meeting-preflight .mc-message.fail{background:var(--mc-red-soft);color:var(--mc-red)}
#sns-meeting-preflight .mc-message.warn{background:var(--mc-amber-soft);color:var(--mc-amber)}
#sns-meeting-preflight .mc-confirm{
  display:flex;flex-wrap:wrap;align-items:center;gap:8px;
  margin-top:12px;padding-top:12px;border-top:1px solid var(--mc-line);
}
#sns-meeting-preflight .mc-confirm-label{font-size:12px;font-weight:700;margin-right:3px}
#sns-meeting-preflight .mc-permission{margin:10px 0 0;font-size:12px;color:var(--mc-muted)}
#sns-meeting-preflight .mc-info{margin:9px 0 0;color:var(--mc-muted);font-size:11px}
#sns-meeting-preflight .mc-meter{
  margin-top:14px;padding:14px;background:#f8fafc;border:1px solid var(--mc-line);
  border-radius:11px;
}
#sns-meeting-preflight .mc-meter-head{display:flex;justify-content:space-between;gap:10px;font-size:12px;font-weight:700}
#sns-meeting-preflight .mc-meter-track{
  height:15px;margin-top:10px;background:#e2e8f0;border-radius:99px;overflow:hidden;
}
#sns-meeting-preflight .mc-meter-fill{
  height:100%;width:0;background:linear-gradient(90deg,#22c55e,#84cc16);
  border-radius:99px;transition:width .08s;
}
#sns-meeting-preflight .mc-meter-note{margin:8px 0 0;color:var(--mc-muted);font-size:11px}
#sns-meeting-preflight .mc-footer{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px}
#sns-meeting-preflight .mc-note{margin:13px 0 0;font-size:11px;color:var(--mc-muted);line-height:1.8}
#sns-meeting-preflight .mc-live{font-size:12px;margin-top:9px;color:var(--mc-muted)}
@media(max-width:760px){
  #sns-meeting-preflight .mc-wrap{padding:15px;border-radius:14px}
  #sns-meeting-preflight .mc-title{font-size:21px}
  #sns-meeting-preflight .mc-overview{grid-template-columns:1fr}
  #sns-meeting-preflight .mc-section{padding:15px}
}
@media(max-width:460px){
  #sns-meeting-preflight .mc-section-head{flex-direction:column;gap:8px}
  #sns-meeting-preflight .mc-controls .mc-field{flex-basis:100%}
  #sns-meeting-preflight .mc-controls .mc-btn{flex:1 1 auto}
  #sns-meeting-preflight .mc-confirm .mc-confirm-label{flex-basis:100%}
  #sns-meeting-preflight .mc-confirm .mc-btn{flex:1 1 auto}
  #sns-meeting-preflight .mc-footer{display:grid;grid-template-columns:1fr}
}
</style>

<div class="mc-wrap">
  <h2 class="mc-title">화상회의 사전 점검</h2>
  <p class="mc-desc">회의 시작 전 카메라·마이크·스피커를 직접 확인하고, 화면 공유까지 점검하세요.</p>
  <div class="mc-privacy"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f512.png" alt="🔒" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 카메라 영상과 마이크 음성은 현재 브라우저 안에서만 처리됩니다. 녹음·녹화·업로드하지 않으며, 장치 권한은 테스트 버튼을 누를 때만 요청합니다.</div>

  <div class="mc-summary">
    <div class="mc-summary-head">
      <div class="mc-summary-title">회의 준비 상태</div>
      <div class="mc-progress-text" data-mc="progress-text">0 / 3 완료</div>
    </div>
    <div class="mc-progress" role="progressbar" aria-label="기본 점검 진행률" aria-valuemin="0" aria-valuemax="3" aria-valuenow="0" data-mc="progress">
      <div class="mc-progress-fill" data-mc="progress-fill"></div>
    </div>
    <div class="mc-summary-result" data-mc="summary-result" aria-live="polite">카메라, 마이크, 스피커를 차례대로 테스트해 주세요.</div>
    <div class="mc-overview">
      <div class="mc-overview-item"><span>카메라</span><span class="mc-badge" data-mc="overview-camera">미완료</span></div>
      <div class="mc-overview-item"><span>마이크</span><span class="mc-badge" data-mc="overview-mic">미완료</span></div>
      <div class="mc-overview-item"><span>스피커</span><span class="mc-badge" data-mc="overview-speaker">미완료</span></div>
      <div class="mc-overview-item"><span>화면 공유</span><span class="mc-badge" data-mc="overview-screen">미완료</span></div>
    </div>
    <p class="mc-summary-sub" data-mc="environment">브라우저 기능을 확인하고 있습니다.</p>
  </div>

  <section class="mc-section">
    <div class="mc-section-head">
      <h3 class="mc-section-title"><span class="mc-number">1</span> 카메라 테스트</h3>
      <span class="mc-badge" data-mc="badge-camera">미완료</span>
    </div>
    <p class="mc-section-desc">카메라를 켜서 실제 화면이 보이는지 확인합니다. 노트북 내장 카메라와 외장 웹캠, iPad 전·후면 카메라를 선택할 수 있습니다.</p>
    <div class="mc-controls">
      <div class="mc-field">
        <label class="mc-label" for="mc-camera-choice">카메라 선택</label>
        <select class="mc-select" id="mc-camera-choice" data-mc="camera-choice">
          <option value="default">기본 카메라 (자동)</option>
          <option value="front">전면 카메라 우선</option>
          <option value="rear">후면 카메라 우선</option>
        </select>
      </div>
      <button type="button" class="mc-btn primary" data-mc="camera-start">카메라 켜기</button>
      <button type="button" class="mc-btn secondary" data-mc="camera-stop" disabled>카메라 끄기</button>
    </div>
    <p class="mc-permission" data-mc="permission-camera">권한 상태: 확인 중...</p>
    <div class="mc-preview">
      <video data-mc="camera-video" autoplay muted playsinline></video>
      <div class="mc-preview-placeholder" data-mc="camera-placeholder">카메라를 켜면 이곳에 내 화면이 표시됩니다.</div>
    </div>
    <p class="mc-info" data-mc="camera-info">카메라 영상은 다른 사람에게 전송되지 않습니다.</p>
    <div class="mc-message" data-mc="camera-message" aria-live="polite">카메라 켜기 버튼을 눌러 테스트를 시작하세요.</div>
    <div class="mc-confirm">
      <span class="mc-confirm-label">내 화면이 정상적으로 보이나요?</span>
      <button type="button" class="mc-btn good" data-mc="camera-yes" disabled>네, 잘 보여요</button>
      <button type="button" class="mc-btn bad" data-mc="camera-no" disabled>아니요, 문제가 있어요</button>
    </div>
  </section>

  <section class="mc-section">
    <div class="mc-section-head">
      <h3 class="mc-section-title"><span class="mc-number">2</span> 마이크 테스트</h3>
      <span class="mc-badge" data-mc="badge-mic">미완료</span>
    </div>
    <p class="mc-section-desc">마이크에 대고 말해보세요. 실제 입력이 감지되면 초록색 게이지가 움직입니다. 소리는 재생하거나 저장하지 않습니다.</p>
    <div class="mc-controls">
      <div class="mc-field">
        <label class="mc-label" for="mc-mic-choice">마이크 선택</label>
        <select class="mc-select" id="mc-mic-choice" data-mc="mic-choice">
          <option value="default">기본 마이크 (자동)</option>
        </select>
      </div>
      <button type="button" class="mc-btn primary" data-mc="mic-start">마이크 켜기</button>
      <button type="button" class="mc-btn secondary" data-mc="mic-stop" disabled>마이크 끄기</button>
    </div>
    <p class="mc-permission" data-mc="permission-mic">권한 상태: 확인 중...</p>
    <div class="mc-meter">
      <div class="mc-meter-head"><span>마이크 입력 레벨</span><span data-mc="mic-level-text">대기 중</span></div>
      <div class="mc-meter-track" role="progressbar" aria-label="마이크 입력 레벨" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-mc="mic-meter">
        <div class="mc-meter-fill" data-mc="mic-meter-fill"></div>
      </div>
      <p class="mc-meter-note">이 게이지는 상대적인 입력 크기이며 데시벨(dB) 측정값은 아닙니다.</p>
    </div>
    <div class="mc-message" data-mc="mic-message" aria-live="polite">마이크 켜기 버튼을 누른 뒤 평소 말하는 목소리로 테스트해 주세요.</div>
    <div class="mc-confirm">
      <span class="mc-confirm-label">말할 때 게이지가 움직이나요?</span>
      <button type="button" class="mc-btn good" data-mc="mic-yes" disabled>네, 입력이 감지돼요</button>
      <button type="button" class="mc-btn bad" data-mc="mic-no" disabled>아니요, 움직이지 않아요</button>
    </div>
  </section>

  <section class="mc-section">
    <div class="mc-section-head">
      <h3 class="mc-section-title"><span class="mc-number">3</span> 스피커 테스트</h3>
      <span class="mc-badge" data-mc="badge-speaker">미완료</span>
    </div>
    <p class="mc-section-desc">짧은 테스트 음을 재생합니다. 실제로 소리가 들리는지는 직접 확인해야 합니다. 헤드폰이나 블루투스 이어폰을 사용한다면 먼저 연결해 주세요.</p>
    <div class="mc-controls">
      <button type="button" class="mc-btn primary" data-mc="speaker-play">테스트 음 재생</button>
      <button type="button" class="mc-btn secondary" data-mc="speaker-choose" hidden>출력 장치 선택</button>
      <button type="button" class="mc-btn secondary" data-mc="speaker-stop">소리 중지</button>
    </div>
    <audio data-mc="tone" preload="none"></audio>
    <p class="mc-info" data-mc="speaker-device">출력 장치: 시스템 기본 스피커 / 헤드폰</p>
    <div class="mc-message" data-mc="speaker-message" aria-live="polite">재생 버튼을 누르면 짧은 테스트 음이 나옵니다. 음량을 적당히 조절해 주세요.</div>
    <div class="mc-confirm">
      <span class="mc-confirm-label">테스트 음이 실제로 들렸나요?</span>
      <button type="button" class="mc-btn good" data-mc="speaker-yes" disabled>네, 소리가 들려요</button>
      <button type="button" class="mc-btn bad" data-mc="speaker-no" disabled>아니요, 안 들려요</button>
    </div>
  </section>

  <section class="mc-section">
    <div class="mc-section-head">
      <h3 class="mc-section-title"><span class="mc-number">4</span> 화면 공유 테스트</h3>
      <span class="mc-badge" data-mc="badge-screen">미완료</span>
    </div>
    <p class="mc-section-desc">지원되는 브라우저에서 화면, 창 또는 탭을 선택해 실제 공유 미리보기를 확인합니다. 이 검사는 선택 사항이며, 지원되지 않아도 카메라·마이크·스피커 점검에는 영향을 주지 않습니다.</p>
    <div class="mc-controls">
      <button type="button" class="mc-btn primary" data-mc="screen-start">화면 공유 테스트</button>
      <button type="button" class="mc-btn secondary" data-mc="screen-stop" disabled>공유 중지</button>
    </div>
    <div class="mc-preview">
      <video data-mc="screen-video" autoplay muted playsinline></video>
      <div class="mc-preview-placeholder" data-mc="screen-placeholder">화면 공유를 시작하면 선택한 화면의 미리보기가 표시됩니다.</div>
    </div>
    <div class="mc-message" data-mc="screen-message" aria-live="polite">테스트 버튼을 누른 뒤 브라우저의 화면 선택창에서 공유할 대상을 직접 선택하세요.</div>
    <div class="mc-confirm">
      <span class="mc-confirm-label">선택한 화면이 정상적으로 보이나요?</span>
      <button type="button" class="mc-btn good" data-mc="screen-yes" disabled>네, 잘 보여요</button>
      <button type="button" class="mc-btn bad" data-mc="screen-no" disabled>아니요, 문제가 있어요</button>
    </div>
    <p class="mc-note">※ 화면 공유는 브라우저와 운영체제에 따라 지원 범위가 다릅니다. iPad에서 웹 화면 공유를 지원하지 않는 경우에는 실제 화상회의 앱의 화면 공유 기능을 확인해 주세요. 민감한 정보가 있는 화면은 선택하지 않는 것이 좋습니다.</p>
  </section>

  <div class="mc-footer">
    <button type="button" class="mc-btn primary" data-mc="copy">점검 결과 복사</button>
    <button type="button" class="mc-btn secondary" data-mc="reset">결과 초기화</button>
  </div>
  <div class="mc-live" data-mc="copy-message" aria-live="polite"></div>
  <p class="mc-note">※ 이 도구는 현재 브라우저의 장치 입력·출력을 확인하는 사전 점검입니다. 실제 Zoom, Google Meet, Teams 등의 서비스 연결, 상대방에게 전달되는 영상·음성, 인터넷 속도 및 회사 네트워크 정책까지 보장하지는 않습니다. 카메라·마이크에는 HTTPS와 사용자 권한이 필요합니다.</p>
</div>

<script>
(function(){
'use strict';

var root=document.getElementById('sns-meeting-preflight');
if(!root||root.dataset.mcReady==='1')return;
root.dataset.mcReady='1';

function $(name){return root.querySelector('[data-mc="'+name+'"]');}
function text(name,value){var el=$(name);if(el)el.textContent=value;}
function message(kind,value,mode){
  var el=$(kind+'-message');
  if(!el)return;
  el.textContent=value;
  el.className='mc-message'+(mode?' '+mode:'');
}
function badge(name,value,mode){
  var el=$(name);
  if(!el)return;
  el.textContent=value;
  el.className='mc-badge'+(mode?' '+mode:'');
}

var state={
  results:{camera:'pending',mic:'pending',speaker:'pending',screen:'pending'},
  streams:{camera:null,mic:null,screen:null},
  serial:{camera:0,mic:0,screen:0},
  micContext:null,micAnalyser:null,micData:null,micFrame:0,
  micSignal:false,toneStarted:false,toneURL:null,
  outputLabel:'시스템 기본 스피커 / 헤드폰',
  outputId:'',closing:false
};

var labels={
  pending:['미완료',''],
  pass:['완료','ok'],
  fail:['확인 필요','fail'],
  unavailable:['테스트 제한','warn']
};

function result(kind,value){
  state.results[kind]=value;
  var item=labels[value]||labels.pending;
  badge('badge-'+kind,item[0],item[1]);
  badge('overview-'+kind,item[0],item[1]);
  updateSummary();
}

function updateSummary(){
  var required=['camera','mic','speaker'];
  var done=required.filter(function(k){return state.results[k]==='pass';}).length;
  var issues=required.filter(function(k){
    return state.results[k]==='fail'||state.results[k]==='unavailable';
  });
  var share=state.results.screen;
  var el=$('summary-result');
  text('progress-text',done+' / 3 완료');
  $('progress-fill').style.width=(done/3*100)+'%';
  $('progress').setAttribute('aria-valuenow',String(done));

  if(done===3){
    el.className='mc-summary-result'+(share==='fail'?' warn':' ok');
    el.textContent=share==='fail'
      ?'카메라·마이크·스피커 점검은 완료됐습니다. 화면 공유는 추가 확인이 필요합니다.'
      :'기본 장치 3가지 점검 완료! 실제 회의에 들어가기 전 사용할 앱과 인터넷 연결도 확인해 주세요.';
  }else if(issues.length){
    el.className='mc-summary-result warn';
    el.textContent='확인이 필요한 항목이 있습니다. 아래 안내에 따라 다시 테스트해 주세요.';
  }else{
    el.className='mc-summary-result';
    el.textContent=done
      ?'기본 장치 '+done+'개 완료. 남은 항목도 직접 확인해 주세요.'
      :'카메라, 마이크, 스피커를 차례대로 테스트해 주세요.';
  }
  text('environment',window.isSecureContext
    ?'보안 연결 사용 중 · 카메라/마이크 권한은 사용자가 직접 허용합니다.'
    :'HTTPS가 아닌 환경입니다. 카메라·마이크 기능이 제한될 수 있습니다.');
}

function stopTracks(stream){
  if(stream)stream.getTracks().forEach(function(track){
    try{track.stop();}catch(e){}
  });
}

function controls(kind,active,ready){
  $(kind+'-start').disabled=active;
  $(kind+'-stop').disabled=!active;
  $(kind+'-yes').disabled=!ready;
  $(kind+'-no').disabled=!ready;
  var select=$(kind+'-choice');
  if(select)select.disabled=active;
}

function stopCapture(kind,note){
  state.serial[kind]++;
  var stream=state.streams[kind];
  state.streams[kind]=null;
  stopTracks(stream);

  if(kind==='mic'){
    if(state.micFrame)cancelAnimationFrame(state.micFrame);
    state.micFrame=0;
    state.micAnalyser=null;
    state.micData=null;
    state.micSignal=false;
    var ctx=state.micContext;
    state.micContext=null;
    if(ctx&&ctx.state!=='closed')ctx.close().catch(function(){});
    setMeter(0,'대기 중');
  }else{
    var video=$(kind+'-video');
    video.pause();
    video.srcObject=null;
    $(kind+'-placeholder').hidden=false;
  }

  controls(kind,false,false);
  if(note)message(kind,note);
}

function stopOther(except){
  ['camera','mic','screen'].forEach(function(kind){
    if(kind!==except&&state.streams[kind]){
      stopCapture(kind,'다른 장치 테스트를 시작하여 이전 테스트를 종료했습니다.');
    }
  });
}

function captureError(error,kind){
  var name=error&&error.name||'';
  var label=kind==='camera'?'카메라':kind==='mic'?'마이크':'화면 공유';
  if(name==='NotAllowedError'||name==='PermissionDeniedError'){
    return label+' 권한 요청이 거부되거나 취소되었습니다. 주소창의 사이트 권한과 운영체제 개인정보 설정을 확인한 뒤 다시 시도하세요.';
  }
  if(name==='NotFoundError'||name==='DevicesNotFoundError'){
    return label+' 장치를 찾을 수 없습니다. 장치 연결과 Windows/iPad 설정을 확인하세요.';
  }
  if(name==='NotReadableError'||name==='TrackStartError'){
    return '장치를 시작하지 못했습니다. 다른 앱이 사용 중일 수 있으니 회의 앱을 종료하거나 장치를 다시 연결해 보세요.';
  }
  if(name==='OverconstrainedError'){
    return '선택한 장치를 사용할 수 없습니다. 기본 장치로 변경하여 다시 테스트하세요.';
  }
  if(name==='AbortError'){
    return '장치 시작이 중단되었습니다. 다시 시도해 주세요.';
  }
  if(name==='InvalidStateError'){
    return '현재 탭이 활성화되어 있는지 확인한 뒤 다시 시도해 주세요.';
  }
  if(name==='TypeError'||name==='NotSupportedError'){
    return '현재 브라우저에서 이 기능을 사용할 수 없습니다. 최신 브라우저 또는 실제 화상회의 앱에서 확인해 주세요.';
  }
  return label+' 테스트에 실패했습니다. 장치 연결과 브라우저 권한을 확인한 뒤 다시 시도해 주세요.';
}

async function readPermission(kind){
  var target='permission-'+kind;
  if(!navigator.permissions||!navigator.permissions.query){
    text(target,'권한 상태: 브라우저에서 확인 제한 · 테스트 버튼으로 확인');
    return;
  }
  try{
    var p=await navigator.permissions.query({name:kind==='camera'?'camera':'microphone'});
    var names={granted:'허용됨',denied:'차단됨',prompt:'아직 선택 안 함'};
    text(target,'이 사이트의 권한: '+(names[p.state]||'확인 제한'));
  }catch(e){
    text(target,'권한 상태: 브라우저에서 확인 제한 · 테스트 버튼으로 확인');
  }
}

function fillSelect(name,items,base){
  var el=$(name),previous=el.value;
  el.replaceChildren();
  base.forEach(function(item){
    var o=document.createElement('option');
    o.value=item[0];o.textContent=item[1];el.appendChild(o);
  });
  items.forEach(function(device,index){
    if(!device.deviceId)return;
    var o=document.createElement('option');
    o.value='dev:'+device.deviceId;
    o.textContent=device.label||(name==='camera-choice'?'카메라 ':'마이크 ')+(index+1);
    el.appendChild(o);
  });
  if(Array.from(el.options).some(function(o){return o.value===previous;}))el.value=previous;
}

async function refreshDevices(){
  if(!navigator.mediaDevices||!navigator.mediaDevices.enumerateDevices)return;
  try{
    var devices=await navigator.mediaDevices.enumerateDevices();
    fillSelect('camera-choice',devices.filter(function(d){return d.kind==='videoinput';}),[
      ['default','기본 카메라 (자동)'],['front','전면 카메라 우선'],['rear','후면 카메라 우선']
    ]);
    fillSelect('mic-choice',devices.filter(function(d){return d.kind==='audioinput';}),[
      ['default','기본 마이크 (자동)']
    ]);
  }catch(e){}
}

function getConstraint(kind){
  var choice=$(kind+'-choice').value;
  if(choice.indexOf('dev:')===0)return {deviceId:{exact:choice.slice(4)}};
  if(kind==='camera'){
    if(choice==='front')return {facingMode:{ideal:'user'},width:{ideal:1280},height:{ideal:720}};
    if(choice==='rear')return {facingMode:{ideal:'environment'},width:{ideal:1280},height:{ideal:720}};
    return {width:{ideal:1280},height:{ideal:720}};
  }
  return {echoCancellation:true,noiseSuppression:true,autoGainControl:true};
}

async function startCamera(){
  if(!window.isSecureContext||!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
    result('camera','unavailable');
    message('camera','HTTPS 또는 카메라 API가 필요합니다. 최신 Safari, Chrome, Edge 등에서 이 페이지를 다시 열어 주세요.','warn');
    return;
  }
  stopCapture('camera');
  stopOther('camera');
  result('camera','pending');
  var ticket=++state.serial.camera;
  $('camera-start').disabled=true;
  $('camera-stop').disabled=false;
  $('camera-choice').disabled=true;
  message('camera','카메라 권한을 요청하고 있습니다. 브라우저의 허용 버튼을 눌러 주세요.','warn');
  var stream=null;
  try{
    stream=await navigator.mediaDevices.getUserMedia({video:getConstraint('camera'),audio:false});
    if(ticket!==state.serial.camera){stopTracks(stream);return;}
    state.streams.camera=stream;
    var video=$('camera-video');
    video.srcObject=stream;
    await video.play();
    if(ticket!==state.serial.camera)return;
    $('camera-placeholder').hidden=true;
    controls('camera',true,true);
    var track=stream.getVideoTracks()[0];
    var settings=track.getSettings?track.getSettings():{};
    text('camera-info','현재 영상: '+video.videoWidth+' × '+video.videoHeight+
      (settings.frameRate?' · 약 '+Math.round(settings.frameRate)+'fps':'')+
      ' · 영상은 업로드되지 않습니다.');
    message('camera','미리보기가 시작됐습니다. 얼굴과 주변 화면이 정상적으로 보이는지 확인해 주세요.','ok');
    track.onended=function(){
      if(state.streams.camera===stream){
        result('camera','fail');
        stopCapture('camera','카메라 연결이 종료되었습니다. 장치 연결을 확인하고 다시 테스트해 주세요.');
      }
    };
    refreshDevices();
    readPermission('camera');
  }catch(e){
    if(stream)stopTracks(stream);
    if(ticket!==state.serial.camera)return;
    stopCapture('camera');
    result('camera',e.name==='NotSupportedError'?'unavailable':'fail');
    message('camera',captureError(e,'camera'),'fail');
    readPermission('camera');
  }
}

function setMeter(level,label){
  $('mic-meter-fill').style.width=level+'%';
  $('mic-meter').setAttribute('aria-valuenow',String(level));
  text('mic-level-text',label);
}

function meterLoop(){
  var analyser=state.micAnalyser;
  if(!analyser||!state.streams.mic)return;
  analyser.getFloatTimeDomainData(state.micData);
  var sum=0;
  for(var i=0;i<state.micData.length;i++)sum+=state.micData[i]*state.micData[i];
  var rms=Math.sqrt(sum/state.micData.length);
  var level=Math.min(100,Math.round(rms*450));
  setMeter(level,level+' / 100');
  if(rms>0.012&&!state.micSignal){
    state.micSignal=true;
    $('mic-yes').disabled=false;
    message('mic','마이크 입력이 감지됐습니다. 말할 때 게이지가 움직이는지 확인해 주세요.','ok');
  }
  state.micFrame=requestAnimationFrame(meterLoop);
}

async function startMic(){
  if(!window.isSecureContext||!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
    result('mic','unavailable');
    message('mic','HTTPS 또는 마이크 API가 필요합니다. 지원되는 최신 브라우저에서 다시 열어 주세요.','warn');
    return;
  }
  if(!(window.AudioContext||window.webkitAudioContext)){
    result('mic','unavailable');
    message('mic','현재 브라우저에서 오디오 입력 분석 기능을 사용할 수 없습니다.','warn');
    return;
  }
  stopCapture('mic');
  stopOther('mic');
  result('mic','pending');
  var ticket=++state.serial.mic;
  $('mic-start').disabled=true;
  $('mic-stop').disabled=false;
  $('mic-choice').disabled=true;
  message('mic','마이크 권한을 요청하고 있습니다. 허용한 뒤 평소 목소리로 말해 주세요.','warn');
  var stream=null;
  try{
    stream=await navigator.mediaDevices.getUserMedia({audio:getConstraint('mic'),video:false});
    if(ticket!==state.serial.mic){stopTracks(stream);return;}
    state.streams.mic=stream;
    var Ctx=window.AudioContext||window.webkitAudioContext;
    var ctx=new Ctx();
    state.micContext=ctx;
    var analyser=ctx.createAnalyser();
    analyser.fftSize=2048;
    var source=ctx.createMediaStreamSource(stream);
    source.connect(analyser);
    /* 마이크 소리를 스피커로 연결하지 않아 피드백을 방지합니다. */
    state.micAnalyser=analyser;
    state.micData=new Float32Array(analyser.fftSize);
    state.micSignal=false;
    await ctx.resume();
    if(ticket!==state.serial.mic)return;
    controls('mic',true,true);
    $('mic-yes').disabled=true;
    setMeter(0,'입력 대기 중');
    message('mic','마이크가 시작됐습니다. 평소 목소리로 말하면 입력 게이지가 움직입니다.');
    state.micFrame=requestAnimationFrame(meterLoop);
    stream.getAudioTracks()[0].onended=function(){
      if(state.streams.mic===stream){
        result('mic','fail');
        stopCapture('mic','마이크 연결이 종료되었습니다. 다른 앱의 사용 여부와 장치 연결을 확인해 주세요.');
      }
    };
    refreshDevices();
    readPermission('mic');
  }catch(e){
    if(stream)stopTracks(stream);
    if(ticket!==state.serial.mic)return;
    stopCapture('mic');
    result('mic',e.name==='NotSupportedError'?'unavailable':'fail');
    message('mic',captureError(e,'mic'),'fail');
    readPermission('mic');
  }
}

function confirmCapture(kind,good){
  if(!state.streams[kind])return;
  if(kind==='mic'&&good&&!state.micSignal)return;
  result(kind,good?'pass':'fail');
  var goodText={
    camera:'카메라 미리보기를 정상적으로 확인했습니다.',
    mic:'실제 마이크 입력이 감지되고 게이지가 움직이는 것을 확인했습니다.',
    screen:'선택한 화면의 공유 미리보기를 정상적으로 확인했습니다.'
  };
  var badText={
    camera:'카메라 화면에 문제가 있습니다. 다른 카메라를 선택하거나 장치 권한을 확인해 주세요.',
    mic:'마이크 입력을 확인하지 못했습니다. 음소거, 입력 장치, 운영체제 마이크 권한을 확인해 주세요.',
    screen:'화면 공유 미리보기에 문제가 있습니다. 다른 창 또는 화면을 선택해 다시 테스트해 주세요.'
  };
  stopCapture(kind);
  message(kind,good?goodText[kind]+' 장치를 종료했습니다.':badText[kind],good?'ok':'fail');
}

function makeTone(){
  var rate=44100,duration=1.35,count=Math.floor(rate*duration);
  var buffer=new ArrayBuffer(44+count*2),view=new DataView(buffer);
  function str(pos,s){for(var i=0;i<s.length;i++)view.setUint8(pos+i,s.charCodeAt(i));}
  str(0,'RIFF');view.setUint32(4,36+count*2,true);str(8,'WAVE');
  str(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);
  view.setUint16(22,1,true);view.setUint32(24,rate,true);
  view.setUint32(28,rate*2,true);view.setUint16(32,2,true);
  view.setUint16(34,16,true);str(36,'data');view.setUint32(40,count*2,true);
  var notes=[523.25,659.25,783.99];
  for(var n=0;n<count;n++){
    var t=n/rate,segment=Math.floor(t/0.43),local=t-segment*0.43,sample=0;
    if(segment<3&&local<0.30){
      var fade=Math.min(1,local/0.015,(0.30-local)/0.025);
      sample=0.20*Math.max(0,fade)*Math.sin(2*Math.PI*notes[segment]*local);
    }
    view.setInt16(44+n*2,Math.round(sample*32767),true);
  }
  return URL.createObjectURL(new Blob([buffer],{type:'audio/wav'}));
}

async function playTone(){
  var audio=$('tone');
  if(!state.toneURL)state.toneURL=makeTone();
  if(!audio.src)audio.src=state.toneURL;
  try{
    audio.pause();
    audio.currentTime=0;
    await audio.play();
    state.toneStarted=true;
    $('speaker-yes').disabled=false;
    $('speaker-no').disabled=false;
    message('speaker','테스트 음을 재생했습니다. 실제로 소리가 들리는지 확인한 뒤 아래 버튼을 눌러 주세요.');
  }catch(e){
    state.toneStarted=false;
    message('speaker','소리를 재생하지 못했습니다. 브라우저 음소거, 시스템 음량, 출력 장치와 자동재생 차단 여부를 확인해 주세요.','fail');
  }
}

function stopTone(){
  var audio=$('tone');
  audio.pause();
  try{audio.currentTime=0;}catch(e){}
}

async function chooseSpeaker(){
  if(!navigator.mediaDevices||!navigator.mediaDevices.selectAudioOutput||!$('tone').setSinkId)return;
  try{
    var device=await navigator.mediaDevices.selectAudioOutput();
    await $('tone').setSinkId(device.deviceId);
    state.outputId=device.deviceId;
    state.outputLabel=device.label||'선택한 출력 장치';
    text('speaker-device','출력 장치: '+state.outputLabel);
    state.toneStarted=false;
    $('speaker-yes').disabled=true;
    $('speaker-no').disabled=true;
    result('speaker','pending');
    message('speaker','출력 장치를 선택했습니다. 테스트 음을 다시 재생하여 실제로 들리는지 확인해 주세요.');
  }catch(e){
    message('speaker','출력 장치 선택이 취소되었거나 브라우저에서 허용되지 않았습니다. 시스템 기본 출력 장치로도 테스트할 수 있습니다.','warn');
  }
}

function confirmSpeaker(good){
  if(!state.toneStarted)return;
  stopTone();
  result('speaker',good?'pass':'fail');
  message('speaker',good
    ?'테스트 음을 실제로 들었다고 확인했습니다. 스피커 점검 완료!'
    :'소리가 들리지 않았습니다. 시스템 음량, 브라우저 탭 음소거, 블루투스 연결, 출력 장치를 확인하고 다시 테스트해 주세요.',
    good?'ok':'fail');
}

async function startScreen(){
  if(!window.isSecureContext||!navigator.mediaDevices||!navigator.mediaDevices.getDisplayMedia){
    result('screen','unavailable');
    message('screen','현재 브라우저에서는 웹페이지 내 화면 공유 테스트를 지원하지 않습니다. iPad에서는 실제 화상회의 앱에서 화면 공유를 확인해 주세요.','warn');
    return;
  }
  stopCapture('screen');
  stopOther('screen');
  result('screen','pending');
  var ticket=++state.serial.screen;
  $('screen-start').disabled=true;
  $('screen-stop').disabled=false;
  message('screen','브라우저의 화면 선택창에서 공유할 화면이나 창을 선택해 주세요.','warn');
  var stream=null;
  try{
    stream=await navigator.mediaDevices.getDisplayMedia({video:true,audio:false});
    if(ticket!==state.serial.screen){stopTracks(stream);return;}
    state.streams.screen=stream;
    var video=$('screen-video');
    video.srcObject=stream;
    await video.play();
    if(ticket!==state.serial.screen)return;
    $('screen-placeholder').hidden=true;
    controls('screen',true,true);
    message('screen','공유 미리보기가 시작됐습니다. 선택한 화면이 정상적으로 표시되는지 확인해 주세요.','ok');
    stream.getVideoTracks()[0].onended=function(){
      if(state.streams.screen===stream){
        stopCapture('screen','화면 공유가 종료되었습니다. 다시 테스트하려면 버튼을 눌러 주세요.');
      }
    };
  }catch(e){
    if(stream)stopTracks(stream);
    if(ticket!==state.serial.screen)return;
    stopCapture('screen');
    if(e.name==='NotAllowedError'||e.name==='AbortError'){
      result('screen','pending');
      message('screen','화면 선택이 취소되었거나 권한이 거부되었습니다. 공유하려면 다시 버튼을 눌러 주세요.','warn');
    }else{
      result('screen',e.name==='NotSupportedError'?'unavailable':'fail');
      message('screen',captureError(e,'screen'),'fail');
    }
  }
}

function report(){
  var names={camera:'카메라',mic:'마이크',speaker:'스피커',screen:'화면 공유'};
  var lines=['화상회의 사전 점검 결과','점검 시각: '+new Date().toLocaleString('ko-KR'),''];
  ['camera','mic','speaker','screen'].forEach(function(k){
    lines.push(names[k]+': '+(labels[state.results[k]]||labels.pending)[0]);
  });
  lines.push('','화면 공유는 선택 검사입니다.','실제 회의 서비스 연결 및 인터넷 품질은 검사하지 않았습니다.');
  return lines.join('\n');
}

async function copyReport(){
  var value=report();
  try{
    if(navigator.clipboard&&window.isSecureContext){
      await navigator.clipboard.writeText(value);
    }else{
      var ta=document.createElement('textarea');
      ta.value=value;ta.style.position='fixed';ta.style.opacity='0';
      document.body.appendChild(ta);ta.select();
      var ok=document.execCommand('copy');ta.remove();
      if(!ok)throw new Error('copy');
    }
    text('copy-message','점검 결과를 복사했습니다.');
  }catch(e){
    text('copy-message','자동 복사가 제한되었습니다. 브라우저 권한을 확인한 뒤 다시 시도해 주세요.');
  }
}

function resetAll(){
  ['camera','mic','screen'].forEach(function(k){stopCapture(k);result(k,'pending');});
  stopTone();
  state.toneStarted=false;
  $('speaker-yes').disabled=true;$('speaker-no').disabled=true;
  result('speaker','pending');
  message('camera','카메라 켜기 버튼을 눌러 테스트를 시작하세요.');
  message('mic','마이크 켜기 버튼을 누른 뒤 평소 말하는 목소리로 테스트해 주세요.');
  message('speaker','재생 버튼을 누르면 짧은 테스트 음이 나옵니다.');
  message('screen','테스트 버튼을 누른 뒤 공유할 대상을 직접 선택하세요.');
  text('copy-message','점검 결과를 초기화하고 사용 중이던 장치를 종료했습니다.');
  updateCapabilities();
}

function updateCapabilities(){
  var media=!!(window.isSecureContext&&navigator.mediaDevices&&navigator.mediaDevices.getUserMedia);
  var share=!!(window.isSecureContext&&navigator.mediaDevices&&navigator.mediaDevices.getDisplayMedia);
  if(!media){
    ['camera','mic'].forEach(function(k){
      result(k,'unavailable');
      $(k+'-start').disabled=true;
      message(k,'현재 페이지에서 장치 접근 API를 사용할 수 없습니다. HTTPS로 접속했는지 확인해 주세요.','warn');
    });
  }
  if(!share){
    $('screen-start').disabled=true;
    result('screen','unavailable');
    message('screen','현재 브라우저에서는 웹 화면 공유 테스트가 지원되지 않습니다. 실제 회의 앱에서 확인해 주세요.','warn');
  }
  if(navigator.mediaDevices&&navigator.mediaDevices.selectAudioOutput&&$('tone').setSinkId){
    $('speaker-choose').hidden=false;
  }
  var rtc=typeof RTCPeerConnection==='function';
  text('environment',(window.isSecureContext?'HTTPS 보안 연결':'HTTPS 필요')+
    ' · WebRTC 기본 API: '+(rtc?'지원':'확인 제한')+
    ' · 실제 회의 연결은 별도 확인');
}

function cleanup(){
  ['camera','mic','screen'].forEach(function(k){stopCapture(k);});
  stopTone();
  if(state.toneURL){URL.revokeObjectURL(state.toneURL);state.toneURL=null;}
}

$('camera-start').addEventListener('click',startCamera);
$('camera-stop').addEventListener('click',function(){stopCapture('camera','카메라를 종료했습니다.');});
$('camera-yes').addEventListener('click',function(){confirmCapture('camera',true);});
$('camera-no').addEventListener('click',function(){confirmCapture('camera',false);});
$('mic-start').addEventListener('click',startMic);
$('mic-stop').addEventListener('click',function(){stopCapture('mic','마이크를 종료했습니다.');});
$('mic-yes').addEventListener('click',function(){confirmCapture('mic',true);});
$('mic-no').addEventListener('click',function(){confirmCapture('mic',false);});
$('speaker-play').addEventListener('click',playTone);
$('speaker-stop').addEventListener('click',function(){stopTone();message('speaker','테스트 음을 중지했습니다.');});
$('speaker-choose').addEventListener('click',chooseSpeaker);
$('speaker-yes').addEventListener('click',function(){confirmSpeaker(true);});
$('speaker-no').addEventListener('click',function(){confirmSpeaker(false);});
$('screen-start').addEventListener('click',startScreen);
$('screen-stop').addEventListener('click',function(){stopCapture('screen','화면 공유를 종료했습니다.');});
$('screen-yes').addEventListener('click',function(){confirmCapture('screen',true);});
$('screen-no').addEventListener('click',function(){confirmCapture('screen',false);});
$('copy').addEventListener('click',copyReport);
$('reset').addEventListener('click',resetAll);

if(navigator.mediaDevices&&navigator.mediaDevices.addEventListener){
  navigator.mediaDevices.addEventListener('devicechange',refreshDevices);
}
document.addEventListener('visibilitychange',function(){
  if(document.hidden){
    ['camera','mic','screen'].forEach(function(k){
      if(state.streams[k])stopCapture(k,'페이지가 숨겨져 장치 사용을 종료했습니다. 다시 테스트하려면 버튼을 눌러 주세요.');
    });
    stopTone();
  }
});
window.addEventListener('pagehide',cleanup,{once:true});

updateSummary();
updateCapabilities();
refreshDevices();
readPermission('camera');
readPermission('mic');

})();
</script>
</div>



<h2 class="wp-block-heading">화상회의 사전 점검, 회의 시작 전에 미리 확인하세요</h2>



<p class="wp-block-paragraph">온라인 회의나 면접에 접속했는데 카메라가 켜지지 않거나, 말을 해도 상대방이 목소리를 듣지 못하는 상황은 생각보다 자주 발생합니다. 특히 회의 시작 직전에 장치 문제를 발견하면 Windows 설정과 브라우저 권한을 찾느라 시간을 허비하기 쉽습니다.</p>



<p class="wp-block-paragraph">이럴 때는 회의에 입장하기 전에 <strong>카메라, 마이크, 스피커가 실제로 작동하는지 미리 확인</strong>해 두는 것이 좋습니다.</p>



<p class="wp-block-paragraph">위의 화상회의 사전 점검 도구는 별도의 프로그램 설치나 회원가입 없이 브라우저에서 바로 사용할 수 있습니다. Windows 노트북과 데스크톱은 물론, 지원되는 iPad 브라우저에서도 카메라와 마이크, 스피커를 테스트할 수 있습니다. 화면 공유 기능은 브라우저에서 지원하는 경우에만 별도로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">단순히 장치가 연결되어 있는지만 표시하는 것이 아니라, 사용자가 직접 화면을 보고, 목소리를 입력하고, 테스트 음을 들어본 뒤 정상 작동 여부를 확인하는 방식입니다.</p>



<h2 class="wp-block-heading">화상회의 사전 점검 사용 방법</h2>



<p class="wp-block-paragraph">처음 사용하는 분이라도 순서대로 진행하면 어렵지 않습니다.</p>



<ol class="wp-block-list">
<li>위의 도구에서 <strong>카메라 켜기</strong> 버튼을 누르고 브라우저의 카메라 권한을 허용합니다.</li>



<li>내 얼굴이나 주변 화면이 미리보기에 정상적으로 표시되는지 확인한 뒤 <strong>네, 잘 보여요</strong>를 선택합니다.</li>



<li><strong>마이크 켜기</strong> 버튼을 누르고 권한을 허용한 다음 평소 목소리로 말해봅니다.</li>



<li>입력 게이지가 움직이는 것을 확인하고 <strong>네, 입력이 감지돼요</strong>를 선택합니다.</li>



<li><strong>테스트 음 재생</strong> 버튼을 눌러 실제로 소리가 들리는지 확인합니다.</li>



<li>필요한 경우 화면 공유 테스트를 실행하고 선택한 화면이 정상적으로 표시되는지 확인합니다.</li>



<li>점검이 끝나면 상단의 완료 상태를 확인하고, 필요하다면 <strong>점검 결과 복사</strong> 버튼으로 결과를 복사합니다.</li>
</ol>



<p class="wp-block-paragraph">카메라·마이크·스피커는 기본 점검 항목이며, 화면 공유는 선택 항목입니다. 화면 공유를 지원하지 않는 브라우저라고 해서 카메라나 마이크까지 사용할 수 없는 것은 아닙니다.</p>



<h2 class="wp-block-heading">1. 웹캠 테스트로 내 화면이 실제로 보이는지 확인</h2>



<p class="wp-block-paragraph">웹캠 테스트는 카메라 장치가 연결되어 있다는 정보만 확인하는 것이 아니라 <strong>실제 카메라 영상을 브라우저에 표시하는 방식</strong>입니다.</p>



<p class="wp-block-paragraph">카메라 켜기 버튼을 누르면 브라우저에서 권한 허용을 요청할 수 있습니다. 권한을 허용하면 내장 웹캠이나 연결된 카메라의 미리보기가 나타납니다.</p>



<p class="wp-block-paragraph">화면이 정상적으로 보이는지, 카메라가 가려져 있지는 않은지, 화상회의에서 사용할 구도가 적절한지 직접 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">노트북에 내장 카메라와 외장 웹캠이 모두 연결되어 있다면 장치 선택 메뉴에서 원하는 카메라를 선택할 수 있습니다. iPad처럼 전면과 후면 카메라가 있는 기기에서는 지원되는 환경에서 카메라 방향을 우선 선택할 수도 있습니다.</p>



<p class="wp-block-paragraph">테스트를 마치면 <strong>네, 잘 보여요</strong> 버튼을 눌러 카메라 점검을 완료할 수 있습니다. 문제가 있다면 다른 카메라를 선택하거나 장치 연결 및 권한 설정을 확인해 보세요.</p>



<h2 class="wp-block-heading">2. 마이크 테스트로 내 목소리가 입력되는지 확인</h2>



<p class="wp-block-paragraph">마이크는 연결되어 있다고 해서 반드시 정상적으로 입력되는 것은 아닙니다. Windows에서 다른 입력 장치가 선택되어 있거나, 브라우저 권한이 차단되어 있거나, 다른 프로그램이 장치를 사용 중일 수도 있습니다.</p>



<p class="wp-block-paragraph">위의 마이크 테스트는 실제 마이크 입력을 분석해 <strong>말할 때 움직이는 입력 게이지</strong>를 보여줍니다.</p>



<p class="wp-block-paragraph">마이크 켜기 버튼을 누른 뒤 권한을 허용하고 평소 회의에서 말하는 목소리로 이야기해 보세요. 소리가 입력되면 게이지가 움직입니다.</p>



<p class="wp-block-paragraph">게이지가 전혀 움직이지 않는다면 마이크가 음소거되어 있는지, 올바른 입력 장치를 선택했는지, 운영체제와 브라우저에서 마이크 사용을 허용했는지 확인하는 것이 좋습니다.</p>



<p class="wp-block-paragraph">이 테스트는 마이크 소리를 스피커로 되돌려 재생하지 않으므로 하울링을 방지할 수 있습니다. 또한 목소리를 녹음하거나 서버에 저장하지 않고 현재 브라우저 안에서 입력 크기만 확인합니다.</p>



<h2 class="wp-block-heading">3. 스피커 테스트로 소리가 실제로 들리는지 확인</h2>



<p class="wp-block-paragraph">마이크가 정상이어도 스피커나 헤드폰에서 소리가 나오지 않으면 회의에 참여하기 어렵습니다.</p>



<p class="wp-block-paragraph">스피커 테스트에서는 <strong>테스트 음 재생</strong> 버튼을 누르면 짧은 소리가 재생됩니다. 소리가 실제로 들리는지 확인한 뒤 <strong>네, 소리가 들려요</strong>를 선택하면 됩니다.</p>



<p class="wp-block-paragraph">브라우저에서 소리 재생이 성공했다는 사실만으로 사용자가 실제로 소리를 들었다고 판단하지는 않습니다. 따라서 직접 듣고 확인하는 과정이 중요합니다.</p>



<p class="wp-block-paragraph">소리가 들리지 않는다면 컴퓨터의 시스템 음량, 브라우저 탭 음소거, 헤드폰 연결 상태, 블루투스 이어폰의 출력 장치 설정 등을 확인해 보세요. 지원되는 브라우저에서는 출력 장치를 직접 선택하는 기능도 사용할 수 있습니다.</p>



<h2 class="wp-block-heading">4. 화면 공유 테스트로 발표 화면을 미리 확인</h2>



<p class="wp-block-paragraph">화면 공유를 자주 사용하는 직장인이라면 발표 자료나 브라우저 창이 제대로 공유되는지도 미리 확인해 두면 좋습니다.</p>



<p class="wp-block-paragraph">지원되는 브라우저에서는 <strong>화면 공유 테스트</strong> 버튼을 누른 뒤 전체 화면, 특정 창 또는 브라우저 탭 중 공유할 대상을 선택할 수 있습니다.</p>



<p class="wp-block-paragraph">공유를 시작하면 선택한 화면이 미리보기에 표시됩니다. 발표 자료가 정상적으로 보이는지 확인한 뒤 테스트를 종료하면 됩니다.</p>



<p class="wp-block-paragraph">다만 화면 공유 기능은 운영체제와 브라우저에 따라 지원 범위가 다릅니다. 특히 iPad에서는 웹페이지 내부의 화면 공유 테스트가 제한될 수 있으므로, 지원되지 않는다는 안내가 나오면 실제로 사용할 화상회의 앱에서 화면 공유 기능을 확인해 주세요.</p>



<p class="wp-block-paragraph">화면 공유를 테스트할 때는 개인정보나 업무상 민감한 정보가 표시된 창을 선택하지 않는 것이 좋습니다.</p>



<h2 class="wp-block-heading">Windows 노트북과 iPad에서도 사용할 수 있나요?</h2>



<p class="wp-block-paragraph">네. 최신 브라우저에서 필요한 웹 기능을 지원하고 권한을 허용한다면 Windows 노트북, 데스크톱, iPad 등에서 사용할 수 있습니다.</p>



<p class="wp-block-paragraph">Windows에서는 Chrome이나 Edge 등의 브라우저에서 카메라·마이크·스피커를 테스트할 수 있으며, 지원되는 환경에서는 화면 공유와 출력 장치 선택도 가능합니다.</p>



<p class="wp-block-paragraph">iPad에서는 Safari를 통해 카메라와 마이크 권한을 허용한 뒤 실제 미리보기와 입력 게이지를 확인할 수 있습니다. 스피커 테스트 역시 테스트 음이 들리는지 직접 확인하는 방식입니다.</p>



<p class="wp-block-paragraph">다만 모든 기기와 브라우저에서 동일한 기능을 제공하는 것은 아닙니다. 화면 공유나 출력 장치 선택처럼 지원이 제한되는 기능은 해당 브라우저에서 사용할 수 없는 것으로 안내됩니다.</p>



<h2 class="wp-block-heading">카메라나 마이크가 작동하지 않을 때 확인할 것</h2>



<p class="wp-block-paragraph">화상회의 사전 점검 중 장치가 작동하지 않는다면 다음과 같은 원인을 확인해 보세요.</p>



<p class="wp-block-paragraph"><strong>브라우저 권한이 차단된 경우</strong></p>



<p class="wp-block-paragraph">주소창 왼쪽의 사이트 정보 또는 권한 설정에서 카메라와 마이크가 허용되어 있는지 확인하세요. 권한을 다시 허용한 뒤 페이지를 새로고침하고 테스트할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>Windows 개인정보 설정이 차단된 경우</strong></p>



<p class="wp-block-paragraph">Windows 설정의 개인정보 및 보안 메뉴에서 카메라 또는 마이크에 대한 앱 접근 권한이 허용되어 있는지 확인해야 합니다. 브라우저 자체가 장치 접근을 허용받지 못하면 웹페이지에서도 사용할 수 없습니다.</p>



<p class="wp-block-paragraph"><strong>다른 프로그램이 장치를 사용 중인 경우</strong></p>



<p class="wp-block-paragraph">일부 환경에서는 다른 회의 프로그램이나 카메라 앱이 장치를 점유하고 있어 테스트가 실패할 수 있습니다. 사용 중인 앱을 종료하거나 장치를 다시 연결한 뒤 확인해 보세요.</p>



<p class="wp-block-paragraph"><strong>잘못된 장치를 선택한 경우</strong></p>



<p class="wp-block-paragraph">외장 웹캠, USB 마이크, 블루투스 헤드셋을 사용하는 경우 기본 장치가 다른 장치로 설정되어 있을 수 있습니다. 도구의 장치 선택 메뉴에서 원하는 장치를 선택해 보세요.</p>



<p class="wp-block-paragraph"><strong>HTTPS 또는 브라우저 지원 문제</strong></p>



<p class="wp-block-paragraph">카메라와 마이크에 접근하는 웹 기능은 보안 연결이 필요합니다. 지원되는 최신 브라우저에서 HTTPS 주소로 접속했는지 확인해 주세요.</p>



<h2 class="wp-block-heading">점검 결과는 어떤 의미인가요?</h2>



<p class="wp-block-paragraph">상단의 회의 준비 상태는 카메라, 마이크, 스피커의 기본 점검 진행 상황을 보여줍니다.</p>



<p class="wp-block-paragraph">세 가지 모두 직접 정상 작동을 확인하면 <strong>기본 장치 점검 완료</strong>로 표시됩니다. 화면 공유는 선택 검사이므로 별도로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">다만 이 결과가 실제 Zoom, Google Meet, Microsoft Teams 등의 회의 연결까지 보장하는 것은 아닙니다. 각 서비스의 권한 설정, 회사 네트워크 정책, 인터넷 연결 상태, 상대방에게 전달되는 영상과 음성은 별도로 영향을 받을 수 있습니다.</p>



<p class="wp-block-paragraph">따라서 중요한 온라인 면접이나 발표가 있다면 이 도구로 장치를 확인한 뒤, 실제 사용할 회의 서비스에서도 미리 입장 테스트를 진행하는 것이 좋습니다.</p>



<h2 class="wp-block-heading">개인정보는 어떻게 처리되나요?</h2>



<p class="wp-block-paragraph">이 도구는 카메라와 마이크를 자동으로 켜지 않습니다. 사용자가 직접 테스트 버튼을 누르고 브라우저 권한을 허용한 경우에만 장치에 접근합니다.</p>



<p class="wp-block-paragraph">카메라 영상은 현재 페이지의 미리보기에 사용되고, 마이크 음성은 입력 게이지를 표시하는 데 사용됩니다. <strong>진단 도구 자체에서는 영상이나 음성을 녹음·녹화하거나 서버로 업로드하지 않습니다.</strong></p>



<p class="wp-block-paragraph">테스트가 끝나거나 장치를 끄면 사용 중인 카메라·마이크 스트림을 종료하도록 구성되어 있습니다. 화면 공유도 사용자가 직접 시작하고 중지할 수 있습니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">자주 묻는 질문 FAQ</h2>



<h3 class="wp-block-heading">화상회의 사전 점검은 무료인가요?</h3>



<p class="wp-block-paragraph">네. 별도의 프로그램 설치나 회원가입 없이 무료로 사용할 수 있습니다. 카메라, 마이크, 스피커 및 지원되는 환경의 화면 공유를 브라우저에서 직접 테스트할 수 있습니다.</p>



<h3 class="wp-block-heading">웹캠 테스트를 하면 영상이 서버에 저장되나요?</h3>



<p class="wp-block-paragraph">아니요. 이 도구의 카메라 미리보기는 사용자의 브라우저 안에서 처리됩니다. 영상 파일을 생성하거나 서버로 업로드하는 기능은 포함되어 있지 않습니다.</p>



<h3 class="wp-block-heading">마이크 테스트에서 제 목소리가 녹음되나요?</h3>



<p class="wp-block-paragraph">아니요. 마이크 입력 신호의 크기를 분석해 게이지로 보여주는 방식이며, 음성을 녹음하거나 저장하지 않습니다.</p>



<h3 class="wp-block-heading">마이크 게이지가 움직이면 정상이라고 봐도 되나요?</h3>



<p class="wp-block-paragraph">마이크 입력이 감지된다는 뜻입니다. 다만 실제 회의에서 상대방에게 목소리가 전달되는지, 음질이 적절한지까지 보장하지는 않습니다. 중요한 회의라면 실제 회의 서비스에서도 확인하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">스피커 테스트를 눌렀는데 소리가 안 들립니다. 어떻게 해야 하나요?</h3>



<p class="wp-block-paragraph">시스템 음량과 브라우저 탭 음소거 여부를 먼저 확인하세요. 헤드폰이나 블루투스 이어폰을 사용한다면 현재 출력 장치가 올바르게 선택되어 있는지도 확인해야 합니다.</p>



<h3 class="wp-block-heading">iPad에서도 카메라와 마이크 테스트가 가능한가요?</h3>



<p class="wp-block-paragraph">지원되는 최신 Safari 등에서 카메라와 마이크 접근을 허용하면 사용할 수 있습니다. 다만 iPadOS와 브라우저 버전에 따라 일부 장치 선택 또는 화면 공유 기능은 제한될 수 있습니다.</p>



<h3 class="wp-block-heading">화면 공유가 지원되지 않으면 화상회의도 할 수 없나요?</h3>



<p class="wp-block-paragraph">아닙니다. 웹페이지의 화면 공유 테스트가 제한된다는 것은 이 도구에서 해당 API를 사용할 수 없다는 의미입니다. 카메라·마이크·스피커 사용 가능 여부와는 별개이며, 실제 화상회의 앱에서는 화면 공유가 가능할 수도 있습니다.</p>



<h3 class="wp-block-heading">카메라 권한을 차단했는데 다시 허용할 수 있나요?</h3>



<p class="wp-block-paragraph">네. 브라우저 주소창의 사이트 권한 설정에서 카메라 접근을 허용한 뒤 다시 테스트할 수 있습니다. Windows나 iPad의 운영체제 설정에서도 카메라 접근이 허용되어 있어야 합니다.</p>



<h3 class="wp-block-heading">Zoom, Google Meet, Teams에서도 정상 작동하는지 확인할 수 있나요?</h3>



<p class="wp-block-paragraph">이 도구는 브라우저의 기본 장치 입력·출력을 점검하는 용도입니다. 특정 화상회의 서비스의 로그인, 서버 연결, 회사 정책 또는 상대방에게 전달되는 품질까지 직접 검사하는 것은 아니므로 실제 서비스에서도 최종 확인이 필요합니다.</p>



<h3 class="wp-block-heading">인터넷 속도나 Ping도 측정하나요?</h3>



<p class="wp-block-paragraph">아니요. 이 도구는 카메라·마이크·스피커·화면 공유에 초점을 맞춘 장치 점검 도구입니다. 다운로드 속도, 업로드 속도, Ping 등의 인터넷 품질은 측정하지 않습니다.</p>



<h3 class="wp-block-heading">점검 결과를 다른 사람에게 전달할 수 있나요?</h3>



<p class="wp-block-paragraph">네. <strong>점검 결과 복사</strong> 버튼을 이용하면 각 장치의 완료 또는 확인 필요 상태를 텍스트로 복사할 수 있습니다. 온라인 면접 담당자나 회사 IT 지원팀에 현재 점검 상황을 전달할 때 활용할 수 있습니다.</p>



<h2 class="wp-block-heading">회의 시작 전에 3분만 확인해 보세요</h2>



<p class="wp-block-paragraph">온라인 회의에서 가장 난감한 문제는 입장한 뒤 카메라와 마이크가 작동하지 않는다는 사실을 알게 되는 것입니다. 회의 전에 웹캠 화면, 마이크 입력, 스피커 소리를 한 번씩 확인해 두면 이런 상황을 줄이는 데 도움이 됩니다.</p>



<p class="wp-block-paragraph">위의 <strong>화상회의 사전 점검 무료 도구</strong>를 이용해 기본 장치 세 가지를 확인하고, 발표가 예정되어 있다면 화면 공유도 미리 테스트해 보세요. 설치 없이 브라우저에서 바로 사용할 수 있어 재택근무, 온라인 수업, 화상 면접을 준비할 때 간편하게 활용할 수 있습니다.</p>



<style>
  /* 이 글에서만 본문 상단 대표 이미지 숨기기 */
  .featured-image, 
  .post-thumbnail, 
  .entry-header .post-image,
  .single-media {
    display: none !important;
  }
</style>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/meeting-device-test/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/meeting-device-test.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1590</post-id>	</item>
		<item>
		<title>윈도우 32비트 64비트 확인, 내 PC·브라우저 환경 진단 무료 도구</title>
		<link>https://snsnote.com/windows-32bit-64bit-check/</link>
					<comments>https://snsnote.com/windows-32bit-64bit-check/#respond</comments>
		
		<dc:creator><![CDATA[JH]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 19:49:07 +0000</pubDate>
				<category><![CDATA[유용한 도구]]></category>
		<guid isPermaLink="false">https://snsnote.com/?p=1584</guid>

					<description><![CDATA[윈도우 32비트 64비트 확인 방법 프로그램을 다운로드하다 보면 x86, x64, 32-bit, 64-bit, ARM64처럼 여러 종류의 설치 파일이 표시되는 경우가 있습니다. 평소에는 신경 쓸 일이 거의...]]></description>
										<content:encoded><![CDATA[<div id="sns-browser-diagnostic">

<style>
#sns-browser-diagnostic {
  --bg:#f8fafc;
  --card:#ffffff;
  --text:#0f172a;
  --muted:#64748b;
  --line:#e2e8f0;
  --blue:#2563eb;
  --blue-soft:#eff6ff;
  --green:#15803d;
  --green-soft:#f0fdf4;
  --red:#b91c1c;
  --red-soft:#fef2f2;
  --amber:#a16207;
  --amber-soft:#fffbeb;

  width:100%;
  max-width:920px;
  margin:24px auto;
  color:var(--text);
  line-height:1.55;
  box-sizing:border-box;

  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    "Noto Sans KR",
    Arial,
    sans-serif;
}

#sns-browser-diagnostic *,
#sns-browser-diagnostic *::before,
#sns-browser-diagnostic *::after {
  box-sizing:border-box;
}

#sns-browser-diagnostic .bd-wrap {
  background:var(--bg);
  border:1px solid var(--line);
  border-radius:18px;
  padding:22px;
}

#sns-browser-diagnostic .bd-title {
  margin:0;
  padding:13px 18px;
  border-radius:10px;
  color:#fff;
  font-size:25px;
  font-weight:800;
  letter-spacing:-.03em;
  background:linear-gradient(90deg,#3b82f6,#7c3aed);
  box-shadow:0 5px 15px rgba(37,99,235,.2);
}

#sns-browser-diagnostic .bd-desc {
  margin:8px 0 0;
  color:var(--muted);
  font-size:14px;
}

#sns-browser-diagnostic .bd-summary {
  margin-top:18px;
  padding:18px;
  border:1px solid #c7d2fe;
  border-radius:14px;
  background:linear-gradient(135deg,#eff6ff,#f5f3ff);
}

#sns-browser-diagnostic .bd-summary-title {
  margin:0 0 12px;
  font-size:18px;
  font-weight:800;
}

#sns-browser-diagnostic .bd-summary-main {
  font-size:20px;
  font-weight:800;
  line-height:1.5;
}

#sns-browser-diagnostic .bd-summary-sub {
  margin-top:6px;
  color:var(--muted);
  font-size:13px;
}

#sns-browser-diagnostic .bd-summary-result {
  margin-top:12px;
  padding:10px 12px;
  border-radius:9px;
  font-size:13px;
  font-weight:700;
}

#sns-browser-diagnostic .summary-ok {
  color:var(--green);
  background:var(--green-soft);
}

#sns-browser-diagnostic .summary-warn {
  color:var(--amber);
  background:var(--amber-soft);
}

#sns-browser-diagnostic .bd-main {
  display:grid;
  grid-template-columns:repeat(3,minmax(0,1fr));
  gap:12px;
  margin:18px 0;
}

#sns-browser-diagnostic .bd-main-card {
  background:var(--card);
  border:1px solid var(--line);
  border-radius:14px;
  padding:16px;
  min-height:112px;
}

#sns-browser-diagnostic .bd-main-card.highlight {
  border-color:#bfdbfe;
  background:var(--blue-soft);
}

#sns-browser-diagnostic .bd-label {
  display:block;
  color:var(--muted);
  font-size:12px;
  font-weight:700;
  margin-bottom:7px;
}

#sns-browser-diagnostic .bd-main-value {
  display:block;
  font-size:19px;
  font-weight:800;
  line-height:1.35;
}

#sns-browser-diagnostic .bd-main-sub {
  display:block;
  margin-top:6px;
  color:var(--muted);
  font-size:12px;
}

#sns-browser-diagnostic .bd-download-guide {
  display:none;
  margin-bottom:18px;
  padding:17px;
  border-radius:14px;
  border:1px solid #bfdbfe;
  background:var(--blue-soft);
}

#sns-browser-diagnostic .bd-download-title {
  margin:0 0 8px;
  font-size:16px;
  font-weight:800;
  color:#1d4ed8;
}

#sns-browser-diagnostic .bd-download-main {
  margin-bottom:10px;
  font-size:20px;
  font-weight:900;
}

#sns-browser-diagnostic .bd-download-tags {
  display:flex;
  flex-wrap:wrap;
  gap:6px;
  margin-top:8px;
}

#sns-browser-diagnostic .bd-tag {
  display:inline-flex;
  padding:5px 9px;
  border-radius:999px;
  background:#fff;
  border:1px solid #bfdbfe;
  color:#1d4ed8;
  font-size:12px;
  font-weight:800;
}

#sns-browser-diagnostic .bd-download-note {
  margin:10px 0 0;
  color:#475569;
  font-size:12px;
}

#sns-browser-diagnostic .bd-section-title {
  margin:26px 0 10px;
  padding:7px 0 7px 13px;
  border-left:5px solid #6366f1;
  border-bottom:1px solid #a78bfa;
  font-size:17px;
  font-weight:800;
}

#sns-browser-diagnostic .bd-grid {
  display:grid;
  grid-template-columns:repeat(2,minmax(0,1fr));
  gap:10px;
}

#sns-browser-diagnostic .bd-row {
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:14px;
  min-height:64px;
  padding:13px 14px;
  background:var(--card);
  border:1px solid var(--line);
  border-radius:12px;
}

#sns-browser-diagnostic .bd-row-left {
  min-width:0;
}

#sns-browser-diagnostic .bd-row-name {
  display:block;
  font-size:14px;
  font-weight:700;
}

#sns-browser-diagnostic .bd-row-help {
  display:block;
  margin-top:2px;
  color:var(--muted);
  font-size:11px;
}

#sns-browser-diagnostic .bd-value {
  flex:0 1 auto;
  text-align:right;
  font-size:13px;
  font-weight:700;
  word-break:break-word;
}

#sns-browser-diagnostic .bd-status {
  display:inline-flex;
  align-items:center;
  justify-content:center;
  min-width:72px;
  padding:5px 9px;
  border-radius:999px;
  font-size:12px;
  font-weight:800;
  white-space:nowrap;
}

#sns-browser-diagnostic .is-ok {
  color:var(--green);
  background:var(--green-soft);
}

#sns-browser-diagnostic .is-no {
  color:var(--red);
  background:var(--red-soft);
}

#sns-browser-diagnostic .is-warn {
  color:var(--amber);
  background:var(--amber-soft);
}

#sns-browser-diagnostic .is-info {
  color:#1d4ed8;
  background:var(--blue-soft);
}

#sns-browser-diagnostic .bd-meaning-grid {
  display:grid;
  grid-template-columns:1fr;
  gap:9px;
}

#sns-browser-diagnostic .bd-meaning {
  display:flex;
  gap:12px;
  align-items:flex-start;
  padding:14px;
  border:1px solid var(--line);
  border-radius:12px;
  background:#fff;
}

#sns-browser-diagnostic .bd-meaning-icon {
  flex:0 0 30px;
  width:30px;
  height:30px;
  display:flex;
  align-items:center;
  justify-content:center;
  border-radius:50%;
  font-size:15px;
  font-weight:900;
}

#sns-browser-diagnostic .meaning-ok .bd-meaning-icon {
  background:var(--green-soft);
  color:var(--green);
}

#sns-browser-diagnostic .meaning-warn .bd-meaning-icon {
  background:var(--amber-soft);
  color:var(--amber);
}

#sns-browser-diagnostic .meaning-no .bd-meaning-icon {
  background:var(--red-soft);
  color:var(--red);
}

#sns-browser-diagnostic .bd-meaning-name {
  margin:0;
  font-size:14px;
  font-weight:800;
}

#sns-browser-diagnostic .bd-meaning-desc {
  margin:3px 0 0;
  color:var(--muted);
  font-size:12px;
}

#sns-browser-diagnostic .bd-actions {
  display:flex;
  flex-wrap:wrap;
  gap:9px;
  margin-top:18px;
}

#sns-browser-diagnostic .bd-btn {
  appearance:none;
  border:0;
  border-radius:10px;
  padding:11px 15px;
  font-family:inherit;
  font-size:13px;
  font-weight:800;
  cursor:pointer;
}

#sns-browser-diagnostic .bd-btn.primary {
  color:#fff;
  background:var(--blue);
}

#sns-browser-diagnostic .bd-btn.secondary {
  color:var(--text);
  background:#fff;
  border:1px solid var(--line);
}

#sns-browser-diagnostic .bd-note {
  margin:14px 0 0;
  color:var(--muted);
  font-size:11px;
}

#sns-browser-diagnostic .bd-noscript {
  margin-top:15px;
  padding:14px;
  border-radius:12px;
  background:var(--red-soft);
  color:var(--red);
  font-weight:700;
}

@media(max-width:760px) {

  #sns-browser-diagnostic .bd-wrap {
    padding:16px;
    border-radius:14px;
  }

  #sns-browser-diagnostic .bd-title {
    font-size:21px;
  }

  #sns-browser-diagnostic .bd-main,
  #sns-browser-diagnostic .bd-grid {
    grid-template-columns:1fr;
  }

  #sns-browser-diagnostic .bd-main-card {
    min-height:auto;
  }

  #sns-browser-diagnostic .bd-summary-main {
    font-size:18px;
  }
}

@media(max-width:460px) {

  #sns-browser-diagnostic .bd-row {
    align-items:flex-start;
  }

  #sns-browser-diagnostic .bd-value {
    max-width:54%;
  }

  #sns-browser-diagnostic .bd-actions {
    display:grid;
    grid-template-columns:1fr;
  }

  #sns-browser-diagnostic .bd-btn {
    width:100%;
  }
}
</style>


<div class="bd-wrap">

  <div class="bd-head">

    <h2 class="bd-title">
      내 브라우저·PC 환경 진단
    </h2>

    <p class="bd-desc">
      Windows 32·64비트, 브라우저 버전과 주요 웹 기능을
      설치 없이 한 번에 확인합니다.
    </p>

  </div>


  <noscript>
    <div class="bd-noscript">
      JavaScript가 꺼져 있어 진단을 실행할 수 없습니다.
    </div>
  </noscript>


  <div class="bd-summary">

    <div class="bd-summary-title">
      내 PC 한눈에 보기
    </div>

    <div
      class="bd-summary-main"
      id="bd-summary-main"
    >
      환경을 확인하고 있습니다...
    </div>

    <div
      class="bd-summary-sub"
      id="bd-summary-sub"
    >
      잠시만 기다려 주세요.
    </div>

    <div
      class="bd-summary-result summary-ok"
      id="bd-summary-result"
    >
      브라우저 기능을 진단하고 있습니다.
    </div>

  </div>


  <div class="bd-main">

    <div class="bd-main-card">

      <span class="bd-label">
        운영체제
      </span>

      <strong
        class="bd-main-value"
        id="bd-os"
      >
        확인 중...
      </strong>

      <span
        class="bd-main-sub"
        id="bd-os-detail"
      >
        시스템 정보 확인 중
      </span>

    </div>


    <div class="bd-main-card highlight">

      <span class="bd-label">
        시스템 종류
      </span>

      <strong
        class="bd-main-value"
        id="bd-bitness"
      >
        확인 중...
      </strong>

      <span
        class="bd-main-sub"
        id="bd-arch"
      >
        CPU 아키텍처 확인 중
      </span>

    </div>


    <div class="bd-main-card">

      <span class="bd-label">
        브라우저
      </span>

      <strong
        class="bd-main-value"
        id="bd-browser"
      >
        확인 중...
      </strong>

      <span
        class="bd-main-sub"
        id="bd-browser-detail"
      >
        버전 확인 중
      </span>

    </div>

  </div>


  <div
    class="bd-download-guide"
    id="bd-download-guide"
  ></div>


  <h3 class="bd-section-title">
    브라우저 기능
  </h3>


  <div class="bd-grid">

    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">JavaScript</span>
        <span class="bd-row-help">웹페이지 기능 실행 가능 여부</span>
      </div>
      <div class="bd-value">
        <span class="bd-status is-ok">정상 작동</span>
      </div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">쿠키</span>
        <span class="bd-row-help">로그인·설정 저장 등에 사용</span>
      </div>
      <div class="bd-value" id="bd-cookie">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">LocalStorage</span>
        <span class="bd-row-help">브라우저 데이터 저장 기능</span>
      </div>
      <div class="bd-value" id="bd-local">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">SessionStorage</span>
        <span class="bd-row-help">현재 탭의 임시 데이터 저장</span>
      </div>
      <div class="bd-value" id="bd-session">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">팝업</span>
        <span class="bd-row-help">결제·로그인·본인인증 창 확인</span>
      </div>
      <div class="bd-value" id="bd-popup">
        <span class="bd-status is-warn">테스트 필요</span>
      </div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">WebGL</span>
        <span class="bd-row-help">웹 3D·그래픽 기능 지원</span>
      </div>
      <div class="bd-value" id="bd-webgl">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">WebP 이미지</span>
        <span class="bd-row-help">최신 웹 이미지 형식 지원</span>
      </div>
      <div class="bd-value" id="bd-webp">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">AVIF 이미지</span>
        <span class="bd-row-help">고효율 이미지 형식 지원</span>
      </div>
      <div class="bd-value" id="bd-avif">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">클립보드</span>
        <span class="bd-row-help">웹페이지 복사 기능 지원</span>
      </div>
      <div class="bd-value" id="bd-clipboard">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">PDF 브라우저 보기</span>
        <span class="bd-row-help">PDF를 브라우저에서 바로 열 수 있는지 확인</span>
      </div>
      <div class="bd-value" id="bd-pdf">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">웹사이트 알림</span>
        <span class="bd-row-help">브라우저 알림 권한 상태</span>
      </div>
      <div class="bd-value" id="bd-notification">확인 중...</div>
    </div>

  </div>


  <h3 class="bd-section-title">
    웹사이트 사용 환경
  </h3>


  <div class="bd-meaning-grid">

    <div class="bd-meaning" id="meaning-storage">

      <div class="bd-meaning-icon">·</div>

      <div>
        <p class="bd-meaning-name">로그인·설정 저장</p>
        <p class="bd-meaning-desc" id="meaning-storage-text">
          확인 중...
        </p>
      </div>

    </div>


    <div class="bd-meaning" id="meaning-copy">

      <div class="bd-meaning-icon">·</div>

      <div>
        <p class="bd-meaning-name">웹페이지 복사 기능</p>
        <p class="bd-meaning-desc" id="meaning-copy-text">
          확인 중...
        </p>
      </div>

    </div>


    <div class="bd-meaning" id="meaning-popup">

      <div class="bd-meaning-icon">!</div>

      <div>
        <p class="bd-meaning-name">결제·본인인증·새 창</p>
        <p class="bd-meaning-desc" id="meaning-popup-text">
          팝업 차단 테스트 버튼을 눌러 확인할 수 있습니다.
        </p>
      </div>

    </div>


    <div class="bd-meaning" id="meaning-graphics">

      <div class="bd-meaning-icon">·</div>

      <div>
        <p class="bd-meaning-name">지도·3D·웹 그래픽</p>
        <p class="bd-meaning-desc" id="meaning-graphics-text">
          확인 중...
        </p>
      </div>

    </div>


    <div class="bd-meaning" id="meaning-images">

      <div class="bd-meaning-icon">·</div>

      <div>
        <p class="bd-meaning-name">최신 이미지 표시</p>
        <p class="bd-meaning-desc" id="meaning-images-text">
          확인 중...
        </p>
      </div>

    </div>

  </div>


  <h3 class="bd-section-title">
    화면·언어 정보
  </h3>


  <div class="bd-grid">

    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">브라우저 화면 크기</span>
        <span class="bd-row-help">운영체제의 화면 배율이 적용된 크기</span>
      </div>
      <div class="bd-value" id="bd-screen">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">화면 픽셀 비율</span>
        <span class="bd-row-help">화면 배율·브라우저 확대/축소의 영향을 받습니다</span>
      </div>
      <div class="bd-value" id="bd-pixel-ratio">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">현재 창 크기</span>
        <span class="bd-row-help">현재 웹페이지가 사용하는 영역</span>
      </div>
      <div class="bd-value" id="bd-viewport">확인 중...</div>
    </div>


    <div class="bd-row">
      <div class="bd-row-left">
        <span class="bd-row-name">브라우저 언어</span>
        <span class="bd-row-help">브라우저에 설정된 기본 언어</span>
      </div>
      <div class="bd-value" id="bd-language">확인 중...</div>
    </div>

  </div>


  <div class="bd-actions">

    <button
      type="button"
      class="bd-btn primary"
      id="bd-popup-test">
      팝업 차단 테스트
    </button>

    <button
      type="button"
      class="bd-btn secondary"
      id="bd-copy">
      진단 결과 복사
    </button>

    <button
      type="button"
      class="bd-btn secondary"
      id="bd-refresh">
      다시 진단
    </button>

  </div>


  <p class="bd-note">
    ※ 브라우저 보안 정책에 따라 운영체제 버전,
    CPU 아키텍처, 32·64비트 등의 일부 정보는 제한될 수 있습니다.
    표시되는 검사는 사용자의 브라우저에서 처리됩니다.
  </p>

</div>


<script>
(function(){

'use strict';

var root=document.getElementById('sns-browser-diagnostic');

if(!root){
  return;
}


var diagnosticState={
  cookie:null,
  local:null,
  session:null,
  webgl:null,
  webp:null,
  avif:null,
  clipboard:null,
  pdf:null,
  notification:null,
  popup:null,

  os:'',
  bitness:'',
  arch:'',
  browser:'',
  browserVersion:''
};


function $(id){
  return document.getElementById(id);
}


function esc(str){

  return String(str)
    .replace(/&/g,'&amp;')
    .replace(/</g,'&lt;')
    .replace(/>/g,'&gt;')
    .replace(/"/g,'&quot;')
    .replace(/'/g,'&#039;');

}


function status(text,type){

  return (
    '<span class="bd-status '+
    (type||'is-info')+
    '">'+
    esc(text)+
    '</span>'
  );

}


function setHtml(id,html){

  var el=$(id);

  if(el){
    el.innerHTML=html;
  }

}


function setText(id,text){

  var el=$(id);

  if(el){
    el.textContent=text;
  }

}


function testStorage(storage){

  try{

    var key=
      '__sns_diag_'+
      Date.now()+
      '_'+
      Math.random();

    storage.setItem(key,'1');

    var ok=
      storage.getItem(key)==='1';

    storage.removeItem(key);

    return ok;

  }catch(e){

    return false;

  }

}


function testCookies(){

  try{

    var key=
      'sns_diag_cookie_'+
      Date.now();

    document.cookie=
      key+
      '=1; path=/; SameSite=Lax';

    var ok=
      document.cookie
        .split('; ')
        .some(function(part){

          return (
            part.indexOf(
              key+'='
            )===0
          );

        });

    document.cookie=
      key+
      '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';

    return ok;

  }catch(e){

    return false;

  }

}


function detectWebGL(){

  try{

    var canvas=
      document.createElement(
        'canvas'
      );

    if(
      canvas.getContext(
        'webgl2'
      )
    ){
      return 'WebGL 2';
    }

    if(
      canvas.getContext('webgl') ||
      canvas.getContext(
        'experimental-webgl'
      )
    ){
      return 'WebGL 1';
    }

    return '';

  }catch(e){

    return '';

  }

}


function detectWebP(){

  try{

    var canvas=
      document.createElement(
        'canvas'
      );

    canvas.width=1;
    canvas.height=1;

    var data=
      canvas.toDataURL(
        'image/webp'
      );

    return (
      data.indexOf(
        'data:image/webp'
      )===0
    );

  }catch(e){

    return false;

  }

}


function detectImage(dataUri){

  return new Promise(
    function(resolve){

      var img=new Image();
      var done=false;

      function finish(result){

        if(done){
          return;
        }

        done=true;
        resolve(result);

      }

      img.onload=function(){
        finish(true);
      };

      img.onerror=function(){
        finish(false);
      };

      img.src=dataUri;

      setTimeout(
        function(){
          finish(false);
        },
        2000
      );

    }
  );

}


async function detectAVIF(){

  try{

    if(
      window.ImageDecoder &&
      typeof window.ImageDecoder
        .isTypeSupported==='function'
    ){

      var supported=
        await window.ImageDecoder
          .isTypeSupported(
            'image/avif'
          );

      if(supported){
        return true;
      }

    }

  }catch(e){}


  var data=
    'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';

  return await detectImage(data);

}


function detectPDF(){

  if(
    'pdfViewerEnabled' in navigator
  ){

    return navigator.pdfViewerEnabled;

  }

  return null;

}


function detectNotification(){

  if(
    !('Notification' in window)
  ){

    return 'unsupported';

  }

  return Notification.permission;

}


function fallbackBrowser(){

  var ua=
    navigator.userAgent||'';

  var m;


  m=ua.match(
    /Edg\/([\d.]+)/
  );

  if(m){
    return {
      name:'Microsoft Edge',
      version:m[1]
    };
  }


  m=ua.match(
    /OPR\/([\d.]+)/
  );

  if(m){
    return {
      name:'Opera',
      version:m[1]
    };
  }


  m=ua.match(
    /Firefox\/([\d.]+)/
  );

  if(m){
    return {
      name:'Firefox',
      version:m[1]
    };
  }


  m=ua.match(
    /Chrome\/([\d.]+)/
  );

  if(
    m &&
    !/Edg\//.test(ua) &&
    !/OPR\//.test(ua)
  ){

    return {
      name:'Google Chrome',
      version:m[1]
    };

  }


  m=ua.match(
    /Version\/([\d.]+).*Safari/
  );

  if(
    m &&
    !/Chrome\//.test(ua)
  ){

    return {
      name:'Safari',
      version:m[1]
    };

  }


  return {
    name:'알 수 없는 브라우저',
    version:''
  };

}


function browserFromBrands(list){

  if(
    !Array.isArray(list)
  ){
    return null;
  }


  var priority=[
    'Microsoft Edge',
    'Google Chrome',
    'Opera',
    'Chromium'
  ];


  for(
    var i=0;
    i<priority.length;
    i++
  ){

    for(
      var j=0;
      j<list.length;
      j++
    ){

      if(
        list[j].brand===
        priority[i]
      ){

        return {
          name:list[j].brand,
          version:
            list[j].version||''
        };

      }

    }

  }

  return null;

}


function fallbackOS(){

  var ua=
    navigator.userAgent||'';

  var platform=
    navigator.platform||'';


  if(
    /Windows NT 10\.0/.test(
      ua
    )
  ){
    return 'Windows 10/11';
  }

  if(
    /Windows NT 6\.3/.test(
      ua
    )
  ){
    return 'Windows 8.1';
  }

  if(
    /Windows NT 6\.1/.test(
      ua
    )
  ){
    return 'Windows 7';
  }

  if(
    /Android/.test(
      ua
    )
  ){
    return 'Android';
  }

  if(
    /iPhone|iPad|iPod/.test(
      ua
    )
  ){
    return 'iOS/iPadOS';
  }

  if(
    platform==='MacIntel' &&
    navigator.maxTouchPoints>1
  ){
    return 'iPadOS';
  }

  if(
    /Mac OS X/.test(
      ua
    )
  ){
    return 'macOS';
  }

  if(
    /CrOS/.test(
      ua
    )
  ){
    return 'ChromeOS';
  }

  if(
    /Linux/.test(
      ua
    )
  ){
    return 'Linux';
  }

  return (
    platform||
    '확인 제한'
  );

}


function fallbackBitness(){

  var combined=
    (navigator.userAgent||'')+
    ' '+
    (navigator.platform||'');


  if(
    /ARM64|AArch64/i.test(
      combined
    )
  ){

    return {
      bits:'64',
      arch:'ARM64'
    };

  }


  if(
    /Win64|x64|x86_64|WOW64|amd64/i.test(
      combined
    )
  ){

    return {
      bits:'64',
      arch:'x64'
    };

  }


  if(
    /Win32|i[3-6]86|x86/i.test(
      combined
    )
  ){

    return {
      bits:'32',
      arch:'x86'
    };

  }


  return {
    bits:'',
    arch:''
  };

}


function windowsName(version){

  if(!version){
    return 'Windows';
  }

  var major=
    parseInt(
      String(version)
        .split('.')[0],
      10
    );

  if(isNaN(major)){
    return 'Windows';
  }

  if(major>=13){
    return 'Windows 11';
  }

  if(major>0){
    return 'Windows 10';
  }

  return 'Windows';

}


function normalizeArch(
  arch,
  bits
){

  arch=
    (arch||'')
      .toLowerCase();

  bits=
    String(
      bits||''
    );


  if(
    arch.indexOf('arm')!==-1
  ){

    return (
      bits==='64'
        ? 'ARM64'
        : 'ARM'
    );

  }


  if(
    arch.indexOf('x86')!==-1 ||
    arch.indexOf('amd')!==-1
  ){

    return (
      bits==='64'
        ? 'x64'
        : 'x86'
    );

  }


  return (
    arch
      ? arch.toUpperCase()
      : ''
  );

}


function downloadGuide(
  os,
  bits,
  arch
){

  var guide=
    $('bd-download-guide');

  if(!guide){
    return;
  }


  var a=
    String(
      arch||''
    ).toUpperCase();

  var html='';


  if(
    /Windows/i.test(os)
  ){

    if(
      a==='ARM64'
    ){

      html=
        '<div class="bd-download-title">내 PC에 맞는 다운로드 파일</div>'+

        '<div class="bd-download-main">Windows ARM64</div>'+

        '<div class="bd-download-tags">'+
        '<span class="bd-tag">✓ ARM64</span>'+
        '<span class="bd-tag">✓ Windows ARM</span>'+
        '</div>'+

        '<p class="bd-download-note">'+
        '프로그램 다운로드 페이지에서 ARM64 버전을 우선 선택하세요. '+
        'ARM64 버전이 없다면 해당 프로그램의 호환 정보를 확인하는 것이 좋습니다.'+
        '</p>';

    }else if(
      bits==='64'
    ){

      html=
        '<div class="bd-download-title">내 PC에 맞는 다운로드 파일</div>'+

        '<div class="bd-download-main">Windows x64 / 64-bit</div>'+

        '<div class="bd-download-tags">'+
        '<span class="bd-tag">✓ x64</span>'+
        '<span class="bd-tag">✓ 64-bit</span>'+
        '<span class="bd-tag">✓ Windows 64-bit</span>'+
        '</div>'+

        '<p class="bd-download-note">'+
        '일반적인 Intel·AMD 64비트 Windows PC에서는 위 표기의 파일을 선택하면 됩니다. '+
        'x86 또는 32-bit 버전은 보통 선택할 필요가 없습니다.'+
        '</p>';

    }else if(
      bits==='32'
    ){

      html=
        '<div class="bd-download-title">내 PC에 맞는 다운로드 파일</div>'+

        '<div class="bd-download-main">Windows x86 / 32-bit</div>'+

        '<div class="bd-download-tags">'+
        '<span class="bd-tag">✓ x86</span>'+
        '<span class="bd-tag">✓ 32-bit</span>'+
        '<span class="bd-tag">✓ Windows 32-bit</span>'+
        '</div>'+

        '<p class="bd-download-note">'+
        '프로그램 다운로드 페이지에서 x86 또는 32-bit 버전을 선택하세요.'+
        '</p>';

    }

  }


  if(html){

    guide.innerHTML=html;
    guide.style.display='block';

  }else{

    guide.innerHTML='';
    guide.style.display='none';

  }

}


function updateScreen(){

  setText(
    'bd-screen',
    screen.width+
    ' × '+
    screen.height
  );


  var ratio=
    Number(
      (
        window.devicePixelRatio||
        1
      ).toFixed(2)
    );


  setText(
    'bd-pixel-ratio',
    ratio+'배'
  );


  setText(
    'bd-viewport',
    window.innerWidth+
    ' × '+
    window.innerHeight
  );

}


function updateNotificationDisplay(){

  var value=
    diagnosticState.notification;


  if(
    value==='granted'
  ){

    setHtml(
      'bd-notification',
      status(
        '허용됨',
        'is-ok'
      )
    );

  }else if(
    value==='denied'
  ){

    setHtml(
      'bd-notification',
      status(
        '차단됨',
        'is-no'
      )
    );

  }else if(
    value==='default'
  ){

    setHtml(
      'bd-notification',
      status(
        '아직 선택 안 함',
        'is-info'
      )
    );

  }else{

    setHtml(
      'bd-notification',
      status(
        '미지원',
        'is-warn'
      )
    );

  }

}


function setMeaning(
  id,
  textId,
  mode,
  text
){

  var el=$(id);

  if(!el){
    return;
  }


  el.classList.remove(
    'meaning-ok',
    'meaning-warn',
    'meaning-no'
  );


  var icon=
    el.querySelector(
      '.bd-meaning-icon'
    );


  if(mode==='ok'){

    el.classList.add(
      'meaning-ok'
    );

    if(icon){
      icon.textContent='✓';
    }

  }else if(
    mode==='no'
  ){

    el.classList.add(
      'meaning-no'
    );

    if(icon){
      icon.textContent='!';
    }

  }else{

    el.classList.add(
      'meaning-warn'
    );

    if(icon){
      icon.textContent='!';
    }

  }


  setText(
    textId,
    text
  );

}


function updateMeanings(){

  if(
    diagnosticState.cookie &&
    diagnosticState.local &&
    diagnosticState.session
  ){

    setMeaning(
      'meaning-storage',
      'meaning-storage-text',
      'ok',
      '쿠키와 브라우저 저장 기능을 정상적으로 사용할 수 있습니다.'
    );

  }else{

    setMeaning(
      'meaning-storage',
      'meaning-storage-text',
      'no',
      '일부 저장 기능이 제한되어 있습니다. 로그인 유지나 사이트 설정 저장에 영향을 줄 수 있습니다.'
    );

  }


  if(
    diagnosticState.clipboard
  ){

    setMeaning(
      'meaning-copy',
      'meaning-copy-text',
      'ok',
      '웹사이트의 복사 버튼과 클립보드 기능을 사용할 수 있는 환경입니다.'
    );

  }else{

    setMeaning(
      'meaning-copy',
      'meaning-copy-text',
      'warn',
      '브라우저 보안 설정에 따라 웹페이지 복사 기능이 제한될 수 있습니다.'
    );

  }


  if(
    diagnosticState.popup===true
  ){

    setMeaning(
      'meaning-popup',
      'meaning-popup-text',
      'ok',
      '팝업 창이 허용되어 있습니다.'
    );

  }else if(
    diagnosticState.popup===false
  ){

    setMeaning(
      'meaning-popup',
      'meaning-popup-text',
      'no',
      '팝업이 차단되어 있습니다. 결제·본인인증·로그인 창이 열리지 않을 수 있습니다.'
    );

  }else{

    setMeaning(
      'meaning-popup',
      'meaning-popup-text',
      'warn',
      '팝업 차단 테스트 버튼을 눌러 실제 허용 여부를 확인하세요.'
    );

  }


  if(
    diagnosticState.webgl
  ){

    setMeaning(
      'meaning-graphics',
      'meaning-graphics-text',
      'ok',
      diagnosticState.webgl+
      '을 사용할 수 있어 지도·3D·웹 그래픽 기능을 사용할 수 있습니다.'
    );

  }else{

    setMeaning(
      'meaning-graphics',
      'meaning-graphics-text',
      'no',
      'WebGL을 사용할 수 없어 일부 지도·3D·웹 그래픽 기능이 정상 작동하지 않을 수 있습니다.'
    );

  }


  if(
    diagnosticState.webp &&
    diagnosticState.avif===true
  ){

    setMeaning(
      'meaning-images',
      'meaning-images-text',
      'ok',
      'WebP와 AVIF를 모두 지원해 최신 웹 이미지 형식을 표시할 수 있습니다.'
    );

  }else if(
    diagnosticState.webp
  ){

    setMeaning(
      'meaning-images',
      'meaning-images-text',
      'warn',
      'WebP는 지원하지만 AVIF는 지원되지 않거나 확인이 제한될 수 있습니다.'
    );

  }else{

    setMeaning(
      'meaning-images',
      'meaning-images-text',
      'no',
      '일부 최신 이미지 형식을 표시하지 못할 수 있습니다.'
    );

  }

}


function updateSummary(){

  var parts=[];


  if(
    diagnosticState.os
  ){
    parts.push(
      diagnosticState.os
    );
  }


  if(
    diagnosticState.bitness
  ){

    parts.push(
      diagnosticState.bitness+
      '비트'
    );

  }


  if(
    diagnosticState.arch
  ){

    parts.push(
      diagnosticState.arch
    );

  }


  setText(
    'bd-summary-main',

    parts.length
      ? parts.join(' · ')
      : 'PC 환경 확인 제한'
  );


  var browserText=
    diagnosticState.browser||
    '브라우저';


  if(
    diagnosticState.browserVersion
  ){

    browserText+=
      ' '+
      diagnosticState.browserVersion;

  }


  var downloadText='';


  if(
    /Windows/i.test(
      diagnosticState.os
    )
  ){

    if(
      String(
        diagnosticState.arch
      ).toUpperCase()==='ARM64'
    ){

      downloadText=
        ' · 다운로드 권장: Windows ARM64';

    }else if(
      diagnosticState.bitness==='64'
    ){

      downloadText=
        ' · 다운로드 권장: Windows x64 / 64-bit';

    }else if(
      diagnosticState.bitness==='32'
    ){

      downloadText=
        ' · 다운로드 권장: Windows x86 / 32-bit';

    }

  }


  setText(
    'bd-summary-sub',
    browserText+
    downloadText
  );


  var issueCount=0;


  if(
    diagnosticState.cookie===false
  ){
    issueCount++;
  }

  if(
    diagnosticState.local===false
  ){
    issueCount++;
  }

  if(
    diagnosticState.session===false
  ){
    issueCount++;
  }

  if(
    diagnosticState.webgl===false
  ){
    issueCount++;
  }

  if(
    diagnosticState.webp===false
  ){
    issueCount++;
  }


  var result=
    $('bd-summary-result');


  if(!result){
    return;
  }


  if(issueCount===0){

    result.className=
      'bd-summary-result summary-ok';

    result.textContent=
      '✓ 현재 검사에서 특별히 발견된 브라우저 환경 문제는 없습니다.';

  }else{

    result.className=
      'bd-summary-result summary-warn';

    result.textContent=
      '&#x26a0; 일부 브라우저 기능에서 확인이 필요한 항목이 있습니다. 아래 상세 결과를 확인하세요.';

  }

}


function basicChecks(){

  diagnosticState.cookie=
    testCookies();

  diagnosticState.local=
    testStorage(
      window.localStorage
    );

  diagnosticState.session=
    testStorage(
      window.sessionStorage
    );


  setHtml(
    'bd-cookie',

    diagnosticState.cookie
      ? status(
          '사용 가능',
          'is-ok'
        )
      : status(
          '사용 불가',
          'is-no'
        )
  );


  setHtml(
    'bd-local',

    diagnosticState.local
      ? status(
          '사용 가능',
          'is-ok'
        )
      : status(
          '사용 불가',
          'is-no'
        )
  );


  setHtml(
    'bd-session',

    diagnosticState.session
      ? status(
          '사용 가능',
          'is-ok'
        )
      : status(
          '사용 불가',
          'is-no'
        )
  );


  var gl=
    detectWebGL();


  diagnosticState.webgl=
    gl||false;


  setHtml(
    'bd-webgl',

    gl
      ? status(
          gl+' 지원',
          'is-ok'
        )
      : status(
          '미지원',
          'is-no'
        )
  );


  diagnosticState.webp=
    detectWebP();


  setHtml(
    'bd-webp',

    diagnosticState.webp
      ? status(
          '지원',
          'is-ok'
        )
      : status(
          '미지원',
          'is-no'
        )
  );


  if(
    navigator.clipboard &&
    window.isSecureContext
  ){

    diagnosticState.clipboard=true;

    setHtml(
      'bd-clipboard',
      status(
        '지원',
        'is-ok'
      )
    );

  }else{

    diagnosticState.clipboard=false;

    setHtml(
      'bd-clipboard',
      status(
        '제한적 지원',
        'is-warn'
      )
    );

  }


  diagnosticState.pdf=
    detectPDF();


  if(
    diagnosticState.pdf===true
  ){

    setHtml(
      'bd-pdf',
      status(
        '지원',
        'is-ok'
      )
    );

  }else if(
    diagnosticState.pdf===false
  ){

    setHtml(
      'bd-pdf',
      status(
        '미지원',
        'is-no'
      )
    );

  }else{

    setHtml(
      'bd-pdf',
      status(
        '확인 제한',
        'is-warn'
      )
    );

  }


  diagnosticState.notification=
    detectNotification();

  updateNotificationDisplay();


  updateScreen();


  setText(
    'bd-language',
    navigator.language||
    '확인 제한'
  );


  updateMeanings();
  updateSummary();

}


async function imageChecks(){

  try{

    var avif=
      await detectAVIF();


    diagnosticState.avif=
      avif;


    setHtml(
      'bd-avif',

      avif
        ? status(
            '지원',
            'is-ok'
          )
        : status(
            '미지원',
            'is-no'
          )
    );

  }catch(e){

    diagnosticState.avif=
      null;


    setHtml(
      'bd-avif',
      status(
        '확인 제한',
        'is-warn'
      )
    );

  }


  updateMeanings();
  updateSummary();

}


async function systemChecks(){

  var browser=
    fallbackBrowser();

  var os=
    fallbackOS();

  var bit=
    fallbackBitness();

  var detail=
    '브라우저 기본 정보 기준';


  if(
    navigator.userAgentData &&
    navigator.userAgentData
      .getHighEntropyValues
  ){

    try{

      var ua=
        await navigator
          .userAgentData
          .getHighEntropyValues([
            'architecture',
            'bitness',
            'platformVersion',
            'fullVersionList',
            'wow64'
          ]);


      var b=
        browserFromBrands(
          ua.fullVersionList
        );


      if(b){
        browser=b;
      }


      var platform=
        navigator.userAgentData
          .platform||'';


      if(
        platform==='Windows'
      ){

        os=
          windowsName(
            ua.platformVersion
          );


        detail=
          ua.platformVersion
            ? 'Windows 플랫폼 버전 '+
              ua.platformVersion
            : 'Windows';

      }else if(platform){

        os=platform;

        detail=
          '브라우저 제공 정보';

      }


      if(
        ua.bitness
      ){

        bit.bits=
          String(
            ua.bitness
          );

      }


      if(
        ua.architecture
      ){

        bit.arch=
          normalizeArch(
            ua.architecture,
            bit.bits
          );

      }


      if(
        ua.wow64===true
      ){

        bit.bits='64';

        if(
          !bit.arch
        ){
          bit.arch='x64';
        }

      }

    }catch(e){}

  }


  diagnosticState.os=os;
  diagnosticState.bitness=bit.bits;
  diagnosticState.arch=bit.arch;
  diagnosticState.browser=browser.name;
  diagnosticState.browserVersion=browser.version;


  setText(
    'bd-os',
    os||
    '확인 제한'
  );


  setText(
    'bd-os-detail',
    detail
  );


  if(
    bit.bits
  ){

    setText(
      'bd-bitness',
      bit.bits+
      '비트'
    );


    setText(
      'bd-arch',

      bit.arch
        ? 'CPU 아키텍처: '+
          bit.arch
        : 'CPU 아키텍처 확인 제한'
    );

  }else{

    setText(
      'bd-bitness',
      '확인 제한'
    );


    setText(
      'bd-arch',
      '현재 브라우저가 세부 정보를 제공하지 않습니다.'
    );

  }


  setText(
    'bd-browser',
    browser.name
  );


  setText(
    'bd-browser-detail',

    browser.version
      ? '버전 '+
        browser.version
      : '세부 버전 확인 제한'
  );


  downloadGuide(
    os,
    bit.bits,
    bit.arch
  );


  updateSummary();

}


function popupTest(){

  var popup=null;


  try{

    popup=
      window.open(
        '',
        '_blank',
        'width=340,height=220'
      );

  }catch(e){}


  if(!popup){

    diagnosticState.popup=false;


    setHtml(
      'bd-popup',
      status(
        '차단됨',
        'is-no'
      )
    );


    updateMeanings();

    return;

  }


  diagnosticState.popup=true;


  setHtml(
    'bd-popup',
    status(
      '허용됨',
      'is-ok'
    )
  );


  updateMeanings();


  try{

    popup.document.write(
      '<!doctype html>'+
      '<html lang="ko">'+
      '<head>'+
      '<meta charset="utf-8">'+
      '<title>팝업 테스트</title>'+
      '</head>'+
      '<body style="font-family:sans-serif;padding:24px">'+
      '<strong>팝업이 정상적으로 허용되어 있습니다.</strong>'+
      '<p>이 창은 자동으로 닫힙니다.</p>'+
      '</body>'+
      '</html>'
    );

    popup.document.close();

  }catch(e){}


  setTimeout(
    function(){

      try{
        popup.close();
      }catch(e){}

    },
    700
  );

}


function getCopyText(){

  return [

    '브라우저·PC 환경 진단 결과',

    '',

    '운영체제: '+
    $('bd-os')
      .textContent
      .trim(),

    '시스템 종류: '+
    $('bd-bitness')
      .textContent
      .trim(),

    'CPU 아키텍처: '+
    $('bd-arch')
      .textContent
      .replace(
        'CPU 아키텍처:',
        ''
      )
      .trim(),

    '브라우저: '+
    $('bd-browser')
      .textContent
      .trim()+
    ' / '+
    $('bd-browser-detail')
      .textContent
      .trim(),

    '',

    '쿠키: '+
    $('bd-cookie')
      .textContent
      .trim(),

    'LocalStorage: '+
    $('bd-local')
      .textContent
      .trim(),

    'SessionStorage: '+
    $('bd-session')
      .textContent
      .trim(),

    '팝업: '+
    $('bd-popup')
      .textContent
      .trim(),

    'WebGL: '+
    $('bd-webgl')
      .textContent
      .trim(),

    'WebP: '+
    $('bd-webp')
      .textContent
      .trim(),

    'AVIF: '+
    $('bd-avif')
      .textContent
      .trim(),

    '클립보드: '+
    $('bd-clipboard')
      .textContent
      .trim(),

    'PDF 브라우저 보기: '+
    $('bd-pdf')
      .textContent
      .trim(),

    '웹사이트 알림: '+
    $('bd-notification')
      .textContent
      .trim(),

    '',

    '브라우저 화면 크기: '+
    $('bd-screen')
      .textContent
      .trim(),

    '화면 픽셀 비율: '+
    $('bd-pixel-ratio')
      .textContent
      .trim(),

    '현재 창 크기: '+
    $('bd-viewport')
      .textContent
      .trim(),

    '브라우저 언어: '+
    $('bd-language')
      .textContent
      .trim()

  ].join('\n');

}


async function copyResults(){

  var btn=
    $('bd-copy');

  var original=
    btn.textContent;


  try{

    if(
      navigator.clipboard &&
      window.isSecureContext
    ){

      await navigator.clipboard
        .writeText(
          getCopyText()
        );


      btn.textContent=
        '복사 완료';

    }else{

      var ta=
        document.createElement(
          'textarea'
        );


      ta.value=
        getCopyText();

      ta.style.position=
        'fixed';

      ta.style.opacity=
        '0';


      document.body
        .appendChild(ta);


      ta.select();


      var copied=
        document.execCommand(
          'copy'
        );


      ta.remove();


      btn.textContent=
        copied
          ? '복사 완료'
          : '복사 실패';

    }

  }catch(e){

    btn.textContent=
      '복사 실패';

  }


  setTimeout(
    function(){

      btn.textContent=
        original;

    },
    1400
  );

}


function runAll(){

  diagnosticState.popup=null;

  basicChecks();

  imageChecks();

  systemChecks();

}


$('bd-popup-test')
  .addEventListener(
    'click',
    popupTest
  );


$('bd-copy')
  .addEventListener(
    'click',
    copyResults
  );


$('bd-refresh')
  .addEventListener(
    'click',
    runAll
  );


window.addEventListener(
  'resize',
  updateScreen
);


runAll();

})();
</script>

</div>



<h2 class="wp-block-heading">윈도우 32비트 64비트 확인 방법</h2>



<p class="wp-block-paragraph">프로그램을 다운로드하다 보면 <strong>x86, x64, 32-bit, 64-bit, ARM64</strong>처럼 여러 종류의 설치 파일이 표시되는 경우가 있습니다. 평소에는 신경 쓸 일이 거의 없지만, 막상 프로그램을 설치하려고 하면 내 컴퓨터가 32비트인지 64비트인지 몰라 어떤 파일을 받아야 할지 헷갈릴 수 있습니다.</p>



<p class="wp-block-paragraph">위의 무료 진단 도구를 이용하면 별도의 프로그램을 설치하지 않아도 <strong>현재 Windows 버전과 32비트·64비트 여부, CPU 아키텍처, 사용 중인 브라우저와 버전</strong>을 바로 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">특히 Windows PC에서는 확인 가능한 경우 현재 환경에 맞춰 <strong>x64, 64-bit, x86, ARM64 중 어떤 설치 파일을 선택해야 하는지도 함께 안내</strong>합니다.</p>



<h2 class="wp-block-heading">사용 방법</h2>



<p class="wp-block-paragraph">별도의 프로그램 설치나 회원가입은 필요하지 않습니다.</p>



<ol class="wp-block-list">
<li>페이지에 접속하면 자동으로 PC와 브라우저 환경을 진단합니다.</li>



<li>상단의 <strong>내 PC 한눈에 보기</strong>에서 Windows 버전과 32비트·64비트 여부를 확인합니다.</li>



<li><strong>내 PC에 맞는 다운로드 파일</strong> 항목에서 프로그램을 받을 때 선택해야 할 파일 종류를 확인합니다.</li>



<li>아래의 브라우저 기능에서 쿠키, WebGL, WebP, AVIF, PDF 보기 등의 지원 여부를 확인할 수 있습니다.</li>



<li>팝업 차단 여부가 궁금하다면 <strong>팝업 차단 테스트</strong> 버튼을 누릅니다.</li>



<li>문제가 발생했을 때 다른 사람에게 환경 정보를 전달해야 한다면 <strong>진단 결과 복사</strong> 버튼을 이용하면 됩니다.</li>
</ol>



<p class="wp-block-paragraph">검사 결과는 현재 사용 중인 브라우저가 제공하는 정보를 기준으로 표시됩니다. 브라우저 보안 정책에 따라 일부 운영체제 또는 시스템 정보는 제한될 수 있습니다.</p>



<h2 class="wp-block-heading">x86, x64, 32비트, 64비트는 무엇이 다른가요?</h2>



<p class="wp-block-paragraph">프로그램 다운로드 페이지에서 가장 많이 볼 수 있는 표기가 <code>x86</code>과 <code>x64</code>입니다.</p>



<p class="wp-block-paragraph"><strong>x86 또는 32-bit</strong>는 일반적으로 32비트 환경용 프로그램을 의미합니다.</p>



<p class="wp-block-paragraph"><strong>x64 또는 64-bit</strong>는 64비트 Windows 환경용 프로그램을 의미합니다.</p>



<p class="wp-block-paragraph">요즘 사용되는 대부분의 Windows PC는 64비트 환경이기 때문에 진단 결과가 <strong>64비트 / x64</strong>로 나온다면 프로그램 다운로드 페이지에서 보통 다음과 같은 파일을 선택하면 됩니다.</p>



<ul class="wp-block-list">
<li>x64</li>



<li>64-bit</li>



<li>Windows 64-bit</li>
</ul>



<p class="wp-block-paragraph">반대로 결과가 32비트라면 <code>x86</code>, <code>32-bit</code>, <code>Windows 32-bit</code>라고 표시된 버전을 선택하는 것이 일반적입니다.</p>



<h2 class="wp-block-heading">ARM64는 무엇인가요?</h2>



<p class="wp-block-paragraph">ARM64는 일반적인 x64 Windows PC와 다른 CPU 아키텍처입니다. Windows용 ARM 기기에서는 프로그램 다운로드 페이지에 <strong>ARM64 전용 설치 파일</strong>이 제공되는 경우가 있습니다.</p>



<p class="wp-block-paragraph">진단 결과가 ARM64로 표시된다면 가능한 경우 <strong>Windows ARM64 버전</strong>을 선택하는 것이 좋습니다.</p>



<p class="wp-block-paragraph">단, 모든 프로그램이 ARM64 전용 버전을 제공하는 것은 아닙니다. 이 경우 프로그램 제작사가 x64 버전 실행을 지원하는지 확인해야 합니다.</p>



<h2 class="wp-block-heading">브라우저 버전도 함께 확인할 수 있습니다</h2>



<p class="wp-block-paragraph">이 도구에서는 Windows 32비트 64비트 확인뿐 아니라 현재 사용 중인 <strong>Chrome, Edge, Firefox, Safari 등의 브라우저와 버전 정보</strong>도 확인할 수 있습니다.</p>



<p class="wp-block-paragraph">웹사이트가 정상적으로 열리지 않거나 특정 기능이 작동하지 않을 때 고객센터에서 다음과 같은 정보를 요청하는 경우가 있습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">사용 중인 운영체제와 브라우저 버전을 알려주세요.</p>
</blockquote>



<p class="wp-block-paragraph">이럴 때 진단 결과를 확인한 뒤 <strong>진단 결과 복사</strong> 기능을 사용하면 일일이 정보를 찾아 입력할 필요가 없습니다.</p>



<h2 class="wp-block-heading">웹사이트가 정상 작동하는지도 확인할 수 있습니다</h2>



<p class="wp-block-paragraph">현재 브라우저가 웹사이트의 주요 기능을 정상적으로 사용할 수 있는지도 함께 진단합니다.</p>



<p class="wp-block-paragraph"><strong>쿠키</strong></p>



<p class="wp-block-paragraph">웹사이트 로그인 유지, 설정 저장 등에 사용됩니다. 쿠키가 차단되어 있으면 일부 사이트에서 로그인이 풀리거나 기능이 정상 작동하지 않을 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>LocalStorage / SessionStorage</strong></p>



<p class="wp-block-paragraph">웹사이트가 브라우저 안에 간단한 정보를 저장하는 기능입니다. 사이트 설정, 임시 작업 내용 등 다양한 웹 기능에 사용됩니다.</p>



<p class="wp-block-paragraph"><strong>팝업</strong></p>



<p class="wp-block-paragraph">결제, 본인인증, 로그인 또는 새 창을 사용하는 사이트에서는 팝업 허용이 필요할 수 있습니다. 위의 <strong>팝업 차단 테스트</strong> 버튼으로 현재 상태를 직접 확인할 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>WebGL</strong></p>



<p class="wp-block-paragraph">웹브라우저에서 3D 그래픽과 일부 지도·시각화 기능을 표시할 때 사용됩니다. WebGL을 지원하지 않거나 차단되어 있으면 일부 웹사이트 화면이 제대로 표시되지 않을 수 있습니다.</p>



<p class="wp-block-paragraph"><strong>WebP / AVIF</strong></p>



<p class="wp-block-paragraph">최근 웹사이트에서 많이 사용되는 고효율 이미지 형식입니다. 현재 브라우저가 해당 이미지 파일을 정상적으로 표시할 수 있는지 확인합니다.</p>



<p class="wp-block-paragraph"><strong>PDF 브라우저 보기</strong></p>



<p class="wp-block-paragraph">PDF 파일을 별도의 프로그램으로 열지 않고 브라우저 안에서 바로 표시할 수 있는 환경인지 확인합니다.</p>



<h2 class="wp-block-heading">진단 결과가 실제 PC 설정과 다르게 보일 수도 있나요?</h2>



<p class="wp-block-paragraph">가능합니다. 웹사이트는 보안과 개인정보 보호 때문에 PC의 모든 하드웨어 정보를 직접 읽을 수 없습니다.</p>



<p class="wp-block-paragraph">특히 일부 브라우저에서는 <strong>Windows 세부 버전, CPU 아키텍처, 32비트·64비트 정보</strong>를 제한할 수 있습니다. 이 경우 도구에서는 임의로 값을 추측하지 않고 확인 가능한 정보만 표시합니다.</p>



<p class="wp-block-paragraph">또한 화면 크기와 화면 픽셀 비율은 Windows의 디스플레이 배율이나 브라우저 확대·축소 설정에 따라 달라질 수 있습니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">자주 묻는 질문 FAQ</h2>



<h3 class="wp-block-heading">내 컴퓨터가 32비트인지 64비트인지 어떻게 확인하나요?</h3>



<p class="wp-block-paragraph">위의 진단 도구에 접속하면 별도의 프로그램 설치 없이 자동으로 확인합니다. 지원되는 브라우저에서는 상단에 <strong>32비트 또는 64비트</strong>와 함께 x64, x86, ARM64 등의 아키텍처 정보가 표시됩니다.</p>



<h3 class="wp-block-heading">64비트라고 나오면 어떤 프로그램을 다운로드해야 하나요?</h3>



<p class="wp-block-paragraph">일반적인 Windows 64비트 PC라면 다운로드 페이지에서 <strong>x64, 64-bit, Windows 64-bit</strong>라고 표시된 파일을 선택하면 됩니다.</p>



<h3 class="wp-block-heading">x86과 x64 중 어떤 것을 받아야 하나요?</h3>



<p class="wp-block-paragraph">진단 결과가 64비트·x64라면 <strong>x64</strong> 버전을 선택하면 됩니다. 32비트 환경에서는 일반적으로 <strong>x86</strong> 버전을 선택합니다.</p>



<h3 class="wp-block-heading">Windows 11도 64비트인가요?</h3>



<p class="wp-block-paragraph">현재 Windows 11은 64비트 환경을 기반으로 제공됩니다. 다만 프로그램 다운로드 시에는 CPU 아키텍처에 따라 일반적인 x64와 ARM64 버전이 나뉠 수 있으므로 진단 결과의 아키텍처도 함께 확인하는 것이 좋습니다.</p>



<h3 class="wp-block-heading">x64와 ARM64는 같은 것인가요?</h3>



<p class="wp-block-paragraph">아닙니다. 둘 다 64비트 환경이지만 CPU 아키텍처가 다릅니다. 일반적인 Intel 또는 AMD Windows PC는 주로 x64를 사용하며, 일부 ARM 기반 Windows 기기는 ARM64를 사용합니다.</p>



<h3 class="wp-block-heading">브라우저 버전은 어디에서 확인할 수 있나요?</h3>



<p class="wp-block-paragraph">도구 상단의 <strong>브라우저</strong> 항목에서 현재 사용 중인 브라우저 이름과 버전을 바로 확인할 수 있습니다.</p>



<h3 class="wp-block-heading">WebP와 AVIF 지원 여부는 왜 확인하나요?</h3>



<p class="wp-block-paragraph">WebP와 AVIF는 웹사이트에서 사용하는 최신 이미지 형식입니다. 브라우저가 해당 형식을 지원하지 않으면 일부 이미지가 정상적으로 표시되지 않을 수 있습니다.</p>



<h3 class="wp-block-heading">팝업이 차단되어 있는지도 확인할 수 있나요?</h3>



<p class="wp-block-paragraph">가능합니다. <strong>팝업 차단 테스트</strong> 버튼을 누르면 실제 새 창을 열어보고 현재 브라우저가 팝업을 허용하는지 확인합니다.</p>



<h3 class="wp-block-heading">검사하려면 프로그램을 설치해야 하나요?</h3>



<p class="wp-block-paragraph">아니요. 별도의 프로그램이나 확장 프로그램을 설치할 필요가 없습니다. 페이지에 접속하면 브라우저에서 바로 검사할 수 있습니다.</p>



<h3 class="wp-block-heading">내 PC 정보가 서버로 전송되나요?</h3>



<p class="wp-block-paragraph">이 도구의 진단 기능은 페이지에 포함된 JavaScript를 이용해 <strong>현재 브라우저 안에서 확인 가능한 정보를 검사하는 방식</strong>입니다. 별도의 PC 진단 프로그램을 설치하거나 파일을 업로드할 필요가 없습니다.</p>



<p class="wp-block-paragraph">프로그램을 설치할 때 x86, x64, 32-bit, 64-bit 중 무엇을 선택해야 할지 헷갈렸다면 위의 진단 결과에서 <strong>시스템 종류와 권장 다운로드 파일</strong>을 먼저 확인해 보세요. Windows 버전과 브라우저 환경까지 함께 확인할 수 있어 웹사이트 오류나 프로그램 설치 전 환경을 점검할 때도 활용할 수 있습니다.</p>



<style>
  /* 이 글에서만 본문 상단 대표 이미지 숨기기 */
  .featured-image, 
  .post-thumbnail, 
  .entry-header .post-image,
  .single-media {
    display: none !important;
  }
</style>
]]></content:encoded>
					
					<wfw:commentRss>https://snsnote.com/windows-32bit-64bit-check/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		
	<media:content url="https://snsnote.com/wp-content/uploads/2026/09/windows-32bit-64bit-check.jpg" medium="image" />
<post-id xmlns="com-wordpress:feed-additions:1">1584</post-id>	</item>
	</channel>
</rss>
