2026년 9월 12일 토요일

샤나인코더 짝퉁.. 가나인코더.. 만들기.

집컴을 리눅스 민트로 바꾸고 계속 사용중이다..

오늘은 중간걸림돌이 였던 샤나 인코더를 대체할 프로그램을 찾다가.
Shutter Encoder라는 걸 찾았는데.. 뭐랄까.. 좀.. UI가 좋긴한데
내마음에 들지 않았다..  (중간에 멈추는 문제도 있고..)

인코딩을 하는것은..  가끔하기는 하지만 중요한 문제이기도 해서..
차라리 파이썬으로 ffmpeg를 subprocess에서 출력만 로그창에 보이는걸로
처리하는게 훨씬 낫겠다.. 싶었다.. (역시 ffmpeg는 위대하다..)

어짜피 대부분 사용자는 인코딩 프리셋을 한번가져가면 많이 안바뀌니까..

그래서 그걸 사용하려 한다.  이름은 가나 인코더..  -_-;;

실행폴더에..  presets.json 파일안에 다음처럼 ffmpeg에 들어가는 옵션을 집어넣었고
{
  "H.264 - CRF 23 (Standard)": [
    "-c:v", "h264_nvenc",
    "-preset", "p4",
    "-crf", "23",
    "-c:a", "aac",
    "-b:a", "128k"
  ],
  "H.264 - CRF 20 (High Quality)": [
    "-c:v", "h264_nvenc",
    "-preset", "p4",
    "-crf", "20",
    "-c:a", "aac",
    "-b:a", "192k"
  ],
  "H.264 - Fast (Draft)": [
    "-c:v", "h264_nvenc",
    "-preset", "p1",
    "-crf", "26",
    "-c:a", "aac",
    "-b:a", "96k"
  ]
}


내용은 스텐다드와 고퀄 하옵.. 이렇게 3개 세킹 갖춰넣고..  음질은 aac 로 128k를
기본 셋업으로 했고 엔비디아 gpu 가속을 받는 옵션으로 했다.


import json
import os
import subprocess
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk


class ShanaLikeEncoder:

  def __init__(self, root):
    self.root = root
    self.root.title("Gana MP4 Encoder")
    self.root.geometry("900x560")

    self.presets = self.load_presets()
    
    # 프로세스 및 상태 제어를 위한 변수
    self.current_process = None
    self.is_running = False

    self.create_widgets()

  def load_presets(self):
    try:
      with open("presets.json", "r", encoding="utf-8") as f:
        return json.load(f)
    except FileNotFoundError:
      return {
          "Standard MP4": [
              "-c:v",
              "libx264",
              "-crf",
              "23",
              "-c:a",
              "aac",
          ]
      }

  def create_widgets(self):
    # 상단: 옵션 및 제어 프레임
    top_frame = tk.Frame(self.root, padx=10, pady=10)
    top_frame.pack(fill=tk.X)

    tk.Label(top_frame, text="프리셋:").pack(side=tk.LEFT, padx=(0, 5))
    self.preset_combobox = ttk.Combobox(
        top_frame,
        values=list(self.presets.keys()),
        state="readonly",
        width=20,
    )
    if self.presets:
      self.preset_combobox.current(0)
    self.preset_combobox.pack(side=tk.LEFT, padx=(0, 15))

    tk.Label(top_frame, text="해상도 제한:").pack(side=tk.LEFT, padx=(0, 5))
    self.res_options = {
        "원본 유지 (Keep Original)": None,
        "최대 1080p (FHD)": "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2",
        "최대 720p (HD)": "scale='min(1280,iw)':'min(720,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2",
    }
    self.res_combobox = ttk.Combobox(
        top_frame,
        values=list(self.res_options.keys()),
        state="readonly",
        width=18,
    )
    self.res_combobox.current(0)
    self.res_combobox.pack(side=tk.LEFT, padx=(0, 15))

    # 시작 / 중지 버튼
    self.start_btn = tk.Button(
        top_frame,
        text="인코딩 시작",
        command=self.start_encoding_thread,
        bg="#4CAF50",
        fg="white",
        width=10,
    )
    self.start_btn.pack(side=tk.LEFT, padx=(0, 5))

    self.stop_btn = tk.Button(
        top_frame,
        text="중지",
        command=self.stop_encoding,
        bg="#f44336",
        fg="white",
        width=8,
        state=tk.DISABLED,
    )
    self.stop_btn.pack(side=tk.LEFT)

    # 중앙: 좌우 분할 컨테이너 (약 35:65 비율)
    main_pane = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashrelief=tk.RAISED)
    main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))

    # 왼쪽: 대상 파일 목록 프레임 (35%)
    left_frame = tk.Frame(main_pane)
    main_pane.add(left_frame, width=315)

    list_ctrl_frame = tk.Frame(left_frame)
    list_ctrl_frame.pack(fill=tk.X, pady=(0, 5))

    self.add_btn = tk.Button(list_ctrl_frame, text="파일 추가", command=self.add_files)
    self.add_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 2))
    
    self.del_btn = tk.Button(list_ctrl_frame, text="선택 삭제", command=self.remove_selected)
    self.del_btn.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(2, 0))

    # 파일 리스트박스와 스크롤바
    list_container = tk.Frame(left_frame)
    list_container.pack(fill=tk.BOTH, expand=True)

    self.file_listbox = tk.Listbox(
        list_container, selectmode=tk.EXTENDED, bg="#2b2b2b", fg="#ffffff"
    )
    list_scroll = tk.Scrollbar(
        list_container, orient=tk.VERTICAL, command=self.file_listbox.yview
    )
    self.file_listbox.configure(yscrollcommand=list_scroll.set)

    self.file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    list_scroll.pack(side=tk.RIGHT, fill=tk.Y)

    # 오른쪽: 로그 출력 텍스트박스 (65%)
    right_frame = tk.Frame(main_pane)
    main_pane.add(right_frame, width=585)

    self.log_area = scrolledtext.ScrolledText(
        right_frame, wrap=tk.WORD, bg="#1e1e1e", fg="#00ff00", insertbackground="white"
    )
    self.log_area.pack(fill=tk.BOTH, expand=True)

  def add_files(self):
    file_paths = filedialog.askopenfilenames(
        filetypes=[("Video Files", "*.mp4 *.mkv *.avi *.mov *.webm"), ("All Files", "*.*")]
    )
    for path in file_paths:
      if path not in self.file_listbox.get(0, tk.END):
        self.file_listbox.insert(tk.END, path)

  def remove_selected(self):
    if self.is_running:
      return
    selected_indices = self.file_listbox.curselection()
    for index in reversed(selected_indices):
      self.file_listbox.delete(index)

  def start_encoding_thread(self):
    files = self.file_listbox.get(0, tk.END)
    if not files:
      messagebox.showerror("오류", "변환할 파일을 하나 이상 추가해주세요.")
      return

    # 상태 변경 (실행 중)
    self.is_running = True
    self.start_btn.config(state=tk.DISABLED)
    self.stop_btn.config(state=tk.NORMAL)
    self.add_btn.config(state=tk.DISABLED)
    self.del_btn.config(state=tk.DISABLED)
    self.preset_combobox.config(state=tk.DISABLED)
    self.res_combobox.config(state=tk.DISABLED)

    threading.Thread(
        target=self.run_batch_encoding, args=(files,), daemon=True
    ).start()

  def run_batch_encoding(self, files):
    selected_preset = self.preset_combobox.get()
    preset_args = self.presets.get(selected_preset, [])

    selected_res_name = self.res_combobox.get()
    filter_arg = self.res_options.get(selected_res_name)

    total_files = len(files)
    for idx, input_file in enumerate(files, 1):
      if not self.is_running:
        break

      if not os.path.exists(input_file):
        continue

      base, _ = os.path.splitext(input_file)
      output_file = f"{base}_encoded.mp4"

      cmd = ["ffmpeg", "-y", "-i", input_file] + preset_args
      if filter_arg:
        cmd.extend(["-vf", filter_arg])
      cmd.append(output_file)

      self.log_area.insert(
          tk.END,
          f"[{idx}/{total_files}] 변환 중: {os.path.basename(input_file)}\n"
          f"실행 명령어: {' '.join(cmd)}\n" + "-" * 50 + "\n",
      )
      self.log_area.see(tk.END)

      self.current_process = subprocess.Popen(
          cmd,
          stdout=subprocess.PIPE,
          stderr=subprocess.STDOUT,
          universal_newlines=True,
          encoding="utf-8",
          errors="replace",
      )

      # 프로세스 출력을 실시간으로 읽음 (중지 시그널 대응)
      while True:
        line = self.current_process.stdout.readline()
        if not line and self.current_process.poll() is not None:
          break
        if line:
          self.log_area.insert(tk.END, line)
          self.log_area.see(tk.END)

      returncode = self.current_process.poll()
      self.current_process = None

      if not self.is_running:
        self.log_area.insert(tk.END, "\n[중지됨] 사용자에 의해 작업이 중단되었습니다.\n\n")
        break

      if returncode != 0:
        self.log_area.insert(
            tk.END, f"\n[오류] 파일 변환 실패 (코드: {returncode})\n\n"
        )
      else:
        self.log_area.insert(tk.END, f"\n[완료] 성공: {output_file}\n\n")

    if self.is_running:
      self.log_area.insert(tk.END, "[전체 작업 완료] 모든 파일의 인코딩이 끝났습니다.\n")
    
    self.log_area.see(tk.END)
    self.reset_ui_state()

  def stop_encoding(self):
    if self.is_running:
      self.is_running = False
      if self.current_process:
        try:
          self.current_process.terminate()
          self.current_process.wait(timeout=2)
        except subprocess.TimeoutExpired:
          self.current_process.kill()
        except Exception:
          pass
      self.log_area.insert(tk.END, "\n[알림] 중지 버튼이 눌렸습니다. 현재 파일을 정리하는 중...\n")
      self.log_area.see(tk.END)

  def reset_ui_state(self):
    self.is_running = False
    self.current_process = None
    self.start_btn.config(state=tk.NORMAL)
    self.stop_btn.config(state=tk.DISABLED)
    self.add_btn.config(state=tk.NORMAL)
    self.del_btn.config(state=tk.NORMAL)
    self.preset_combobox.config(state="readonly")
    self.res_combobox.config(state="readonly")


if __name__ == "__main__":
  root = tk.Tk()
  app = ShanaLikeEncoder(root)
  root.mainloop()








요로코롬

2026년 8월 3일 월요일

리눅스 wine에서 한글깨짐 잡기

와인 설치이후 맑은고딕 ttf로 변경을 해줘야함.
아무래도 이 폰트가 한국을 대표하는 폰트이니까..


[아래 경로에 맑은고딕 폰트를 다운로드 하여 복사한다.]
 ~/.wine/drive_c/windows/Fonts/*.ttf


이후 터미널에서 레지스트리를 편집하여 맑은 고딕을 기본으로 설정함
[아래의 내용을 붙혀넣기]


---------------------------------------------------------------

cat << 'EOF' > font_fix_malgun.reg
REGEDIT4

[HKEY_CURRENT_USER\Software\Wine\FontSubstitutes]
"MS Shell Dlg"="Malgun Gothic"
"MS Shell Dlg 2"="Malgun Gothic"
"System"="Malgun Gothic"
"Tahoma"="Malgun Gothic"
EOF

regedit font_fix_malgun.reg
wineserver -k

---------------------------------------------------------------


이후 winecfg 를 실행시켜서 그래픽 탭으로 들어가보면 잘나오는걸 확인한다.




맑은고딕 다운로드





2026년 7월 23일 목요일

PostgreSQL 모든테이블의 SerialType의 인덱스를 최신으로 맞추기

DB 마이그레이션 하거나 그럴때 ID값이 안따라갈수 있음..
아래의 내용으로 인덱스를 최신으로 바꿔줘야함..
안하면 duplicate key value violates unique constraint  에러를 만나게됨..





DO $$
DECLARE
    r RECORD;
BEGIN
    FOR r IN
        SELECT
            n.nspname AS schema_name,
            c.relname AS table_name,
            a.attname AS column_name,
            pg_get_serial_sequence(
                quote_ident(n.nspname) || '.' || quote_ident(c.relname),
                a.attname
            ) AS seq_name
        FROM pg_class c
        JOIN pg_namespace n ON n.oid = c.relnamespace
        JOIN pg_attribute a ON a.attrelid = c.oid
        WHERE c.relkind = 'r'
          AND pg_get_serial_sequence(
                quote_ident(n.nspname) || '.' || quote_ident(c.relname),
                a.attname
              ) IS NOT NULL
    LOOP
        EXECUTE format(
            'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 1), true)',
            r.seq_name,
            r.column_name,
            r.schema_name,
            r.table_name
        );
    END LOOP;
END $$;






ㅇㅇ

민트나 조린OS로 프로그램 바로가기 (desktop 파일) 만들기

예를들어 RDRemote라는 프로그램 실행파일이 있을경우 
다음과 같은 ~/MyLauncher/RDRemote.desktop 파일을 만든다.
내용은 다음처럼

[Desktop Entry]
Type=Application
Name=RD Remote
Comment=Remote Support Launcher
Exec=/home/username/MyApps/RDRemote/RDRemoteLauncher
Path=/home/username/MyApps/RDRemote
Icon=/home/dyflrytlf/MyApps/RDRemote/mainlogo.png
Terminal=false
Categories=Utility;



이거는 실제 실행화일은
/home/username/MyApps/RDRemote/RDRemoteLauncher
이고  실행경로는
/home/username/MyApps/RDRemote

이다..  저장하고  다음명령으로 폴더와 바로가기를 복사한다.


mkdir -p ~/.local/share/applications
cp ~/MyLauncher/RDRemote.desktop ~/.local/share/applications/
chmod +x ~/.local/share/applications/RDRemote.desktop



이렇게 하면 앱런처 화면에 아이콘이 복사되어 있을것이다.

음..  lnk 파일은 편리한거였네..



2026년 7월 10일 금요일

남자 셀프 헤어컷 정리

남자는 이제 셀프 헤어컷을 해야한다.
동네 미용실 예약제와 폭리가격에 개짜증이 나니까 이를 정리하겠다.








ㅇㅇ

2026년 7월 4일 토요일

ini-parser 만들기.. C#

누겟에 있는 ini-parser가 리눅스환경에서 정상동작하지 않는다.

윈도우에 있는 ini파일이 개행이 \r\n이거니와.

리눅스쪽으로 복사가 되면서 더미 데이터가 들어가는것 같다..

그래서 ini-parser를 쓰면 정상적으로 쪼개기가 안된다.

짜증나니까.. 나만의 ini-parser (읽기전용)을 만들었드랬다..





Public class MyIniParser
{
/// <summary>
/// 내부 자료
/// </summary>
private Dictionary<string, List<KeyValuePair<string, string>>> _iniData = new Dictionary<string, List<KeyValuePair<string, string>>>();


/// <summary>
/// 데이터 가져오기
/// </summary>
public T GetIniData<T>(string section, string key)
{
T ret_data = default;
if (!_iniData.ContainsKey(section)) { return ret_data; }
if (!_iniData[section].Exists(x => x.Key == key)) { return ret_data; }
KeyValuePair<string, string> ini_data = _iniData[section].Find(x => x.Key == key);
//
try
{
return (T)Convert.ChangeType(ini_data.Value, typeof(T));
}
catch
{
return ret_data;
}
}


/// <summary>
/// 파일경로로 파싱처리하기
/// </summary>
public bool ParseIniFile(string iniFile, Encoding encoding)
{
try
{
if (!File.Exists(iniFile)) { return false; }
string rawContent = File.ReadAllText(iniFile, encoding);

// ini를 읽기 좋게 변환처리
string result = rawContent.Replace("\r", "");
string[] lines = result.Split('\n');
var validLines = new List<string>();
foreach (string line in lines)
{
string trimmed = line.Trim();

// 빈라인 무시
if (string.IsNullOrWhiteSpace(trimmed)) continue;
// 주석으로 시작하는 라인제거 (';' 또는 '#')
if (trimmed.StartsWith(";") || trimmed.StartsWith("#")) continue;
// 깨진문자열 제외
if (trimmed.Contains("M-lM")) continue;
//
bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]");
bool isKeyValue = trimmed.Contains("=");
if (isSection || isKeyValue) { validLines.Add(trimmed); }
}


//필터링된 라인으로 KeyValue 삽입
string lastSection = "";
foreach (string one_line in validLines)
{
bool isSection = one_line.StartsWith("[") && one_line.EndsWith("]");
bool isKeyValue = one_line.Contains("=");
//
if (isSection)
{
string sectionName = one_line.Substring(1, one_line.Length - 2);
lastSection = sectionName;
if (!_iniData.ContainsKey(sectionName))
{
_iniData.Add(sectionName, new List<KeyValuePair<string, string>>());
}
}
else if (isKeyValue)
{
string[] keyValue = one_line.Split('=');
if (keyValue.Length != 2) { continue; }
string key = keyValue[0].Trim();
string value = keyValue[1].Trim();
if (_iniData.ContainsKey(lastSection))
{
//동일한 키가 있으면 제거하고 새로 추가
var list = _iniData[lastSection];
int index = list.FindIndex(x => x.Key == key);
if (index != -1) { list.RemoveAt(index); }
//
_iniData[lastSection].Add(new KeyValuePair<string, string>(key, value));
}
}
}

//
return true;
} catch (Exception ex) {
throw ex;
}
}
}

2026년 6월 28일 일요일

모바일 삼성인터넷 브라우저에서 웹앱만들기

스마트폰에서 웹페이지 자체를 앱처럼 만들려는 시도는 계속 이어져왔다..
나도.. 자바나 코틀린으로 안드로이드 스튜디오에서 웹뷰만 가지고 처리하도록
하는 앱으로 실행해서 특정웹페이지 URL을 지정해서 apk로 빌드하곤했다. 
하나의 중앙집중식 웹서버라면 문제가 없지만 분산형 웹서버인경우 배포문제가 발생함
또한 처리과정에서 카메라,센서,전화,이메일로 전환하는 것들이 부가적으로 필요하다.
이런부분이 많이 번거롭고 또한 안드로이드 정책변화에 따라서 apk 파일설치가
제한될 수 도 있다.   그래서 그냥 브라우저 자체가 이러한 기능을 해주면 좋겠다는 생각을
했다.. 그런데.. 그걸 삼성인터넷 브라우저가 먼저 이거 해줬다..
매니페스트(아이콘과 앱이름)정보와 부가파일만 있으면 처리되고 실행시 스플레쉬 이미지를 
띄워주고 지정된 URL로 바로 진입하고.. 센서나 카메라에 접근 가능 하도록 하게되는 것이다.
pwa도 아니고 pwa의 보안요건을 충족하는것도 아니므로 https의 보안과는
관계없는 기술이다 라고 생각하고 또 그냥 http에서도 동작한다.  
그런데 알림기술만 제외하면 pwa에 준하는 형태로 보여진다.
삼성인터넷을 이용해서 설치과 되어서 실행되는 방법이 있고 그냥 홈화면 바로가기가 
그렇게 되는 경우가 있다.  이게 브라우저 버전에 따라 다른것 같은데
두개의 차이는 표면상으로는 설치는 런처아이콘에 삼성인터넷 뱃지가 없고 바로가기는 있다.
앱형태 이므로 URL주소창과 도구모음 메뉴가 없고 삼성인터넷이 띄우지면서 탭에 
추가되는 형태가 아니고 개별 실행되고 OS의 앱전환 명령에도 별로의 프로그램으로 
띄워진다.  구현을 위해 일단 서버는 아래와 같이 준비만 하고 있으면 된다.





[준비물]

1. manifest.webmanifest 파일
2. sw.js 파일
3. icon에 쓰일  192*192,  512*512 아이콘 이미지파일


[처리사항]
 
manifest.webmanifest 파일의 내용을 다음 처럼 기재한다.

{
"name": "마이어플",
"short_name": "마이어플",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/myicon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/myicon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}


그 다음 sw.js 파일의 내용을 다음처럼 기재한다.

self.addEventListener("install", e=>{
self.skipWaiting();
});

self.addEventListener("activate", e=>{
self.clients.claim();
});


그 다음 특정 html페이지에서 (예: 로그인페이지)
메타링크 아래에.. 
<link rel="manifest" href="/manifest.webmanifest">
라고 기제하고

스크립트 테그에서
<script>
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js');
}
</script>


그 다음 static 폴더에 그림파일 2개와 위의 파일 2개를 복사하면 끝~



이렇게 하면 삼성 인터넷 브라우저를 통해서 홈화면 바로가기를 한다면..
그 바로가기는 핸드폰 어플처럼 동작한다.
주소창없고 task 스위처에서 별도의 어플처럼 띄워짐

웹앱 공수 안들이고  빠르게 만드는 방법임..  (땡큐 삼성~)