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()








요로코롬

댓글 없음:

댓글 쓰기