Chủ Nhật, 3 tháng 5, 2026

Giới thiệu bộ lập trình giá rẻ CH341A và phần mềm NeoProgrammer 2.2.0.10 “quốc dân” cho dân điện tử

Trong lĩnh vực sửa chữa phần cứng, lập trình firmware hay nghiên cứu hệ thống nhúng, cái tên CH341A gần như đã trở thành “huyền thoại” trong phân khúc giá rẻ. Chỉ với chi phí rất thấp, người dùng đã có thể sở hữu một thiết bị đủ khả năng đọc, ghi và phục hồi dữ liệu cho nhiều loại chip nhớ phổ biến. Vậy CH341A là gì, hoạt động ra sao, và tại sao nó lại được sử dụng rộng rãi đến vậy?

Có thể mua trên app "con cá" hoặc tự làm, có link ở dưới


 

1. CH341A là gì?

CH341A là một bộ lập trình (programmer) sử dụng giao tiếp USB, được thiết kế để làm việc với các loại bộ nhớ không bay hơi như EEPROM và Flash. Về bản chất, nó là một thiết bị trung gian giúp kết nối chip nhớ với máy tính, cho phép người dùng thực hiện các thao tác như đọc (read), ghi (write), xóa (erase) hoặc sao lưu firmware.

Thiết bị này thường được sử dụng với các dòng chip phổ biến như:

  • EEPROM dòng 24Cxx (I2C)
  • SPI Flash dòng 25xx (Winbond, MXIC, SST…)
  • Một số chip BIOS trên mainboard máy tính

Nhờ khả năng này, CH341A trở thành công cụ quen thuộc trong các công việc như:

  • Sửa lỗi BIOS máy tính
  • Dump firmware thiết bị (router, TV, đầu thu…)
  • Phục hồi thiết bị bị lỗi firmware
  • Nghiên cứu bảo mật phần cứng

2. Thiết kế phần cứng và nguyên lý hoạt động

CH341A thường có dạng một USB dongle nhỏ gọn, kích thước chỉ vài cm, rất tiện mang theo. Bên trong là IC CH341A – một chip giao tiếp đa năng có thể hỗ trợ UART, SPI và I2C. 

Nguyên lý hoạt động khá đơn giản:

  1. Người dùng kết nối CH341A với máy tính qua USB
  2. Gắn chip cần đọc/ghi vào socket hoặc dùng kẹp SOP8 (SOIC clip)
  3. Sử dụng phần mềm để giao tiếp và thao tác với chip

Thiết bị sẽ đóng vai trò cầu nối, chuyển đổi dữ liệu từ USB sang giao thức SPI/I2C để giao tiếp trực tiếp với chip nhớ. 

Một điểm đáng chú ý là CH341A loại board mạch màu đen chỉ hỗ trợ nhiều mức điện áp 5V, như vậy sẽ rất nguy hiểm với những chip hoạt động 3.3V (phải mod lại mạch), 1.8V chắc chắn phải dùng với adapter để giúp tương thích với nhiều loại IC. 

3. Tính năng nổi bật

Mặc dù có giá rất rẻ, CH341A vẫn sở hữu nhiều tính năng đáng chú ý:

✔ Hỗ trợ nhiều loại chip

CH341A tương thích với hầu hết các chip EEPROM và SPI Flash phổ biến trên thị trường, đặc biệt là dòng 24 và 25 series. 

✔ Kết nối USB tiện lợi

Chỉ cần cắm vào máy tính là có thể sử dụng, không cần nguồn ngoài phức tạp.

✔ Đa chức năng

Thiết bị cho phép:
  • Đọc dữ liệu từ chip
  • Ghi firmware mới
  • Xóa chip
  • So sánh dữ liệu (verify)

✔ Tương thích phần mềm rộng

CH341A có thể sử dụng với nhiều phần mềm khác nhau như:

  • Flashrom (Linux)
  • IMSProg
  • AsProgrammer
  • CH341A Programmer
  • NeoProgrammer
  • CH341Programmer
  • avrdudess
  • Postal
  • AVR Full mode programmer
  • SiberiaProg-CH341A
  • PinTester
  • Colibri
  • SNANDer
  • CH341ACH347 Programmer

✔ Giá thành cực thấp

Đây chính là điểm mạnh lớn nhất. CH341A thường chỉ có giá vài đô la nhưng vẫn đáp ứng được nhu cầu cơ bản của cả người mới lẫn kỹ thuật viên.

4. Ứng dụng thực tế

CH341A được sử dụng trong rất nhiều tình huống thực tế:

🔧 Sửa lỗi BIOS máy tính

Khi BIOS bị lỗi (ví dụ update sai), máy không thể khởi động. CH341A cho phép nạp lại firmware trực tiếp vào chip BIOS mà không cần boot hệ thống.

📺 Sửa thiết bị điện tử

TV, router, đầu thu kỹ thuật số… thường lưu firmware trong SPI Flash. Khi firmware bị lỗi, CH341A giúp nạp lại dữ liệu để khôi phục thiết bị.

🔍 Dump firmware và reverse engineering

Trong lĩnh vực bảo mật, CH341A được dùng để đọc firmware nhằm phân tích hệ thống, tìm lỗ hổng hoặc nghiên cứu thiết bị.

🧪 Học tập và nghiên cứu

Với giá rẻ, CH341A là lựa chọn phổ biến cho sinh viên và người mới học về embedded systems.

5. Ưu điểm

CH341A được ưa chuộng không phải ngẫu nhiên, mà nhờ những ưu điểm rõ ràng:

  • Giá cực rẻ, phù hợp với mọi đối tượng
  • Dễ sử dụng, chỉ cần phần mềm đơn giản
  • Nhỏ gọn, tiện lợi
  • Hỗ trợ đa dạng chip
  • Cộng đồng sử dụng lớn, dễ tìm tài liệu

Chính vì vậy, nó thường được gọi là “tool quốc dân” trong giới sửa chữa phần cứng.

6. Nhược điểm và lưu ý quan trọng

Tuy nhiên, CH341A không phải là hoàn hảo. Người dùng cần lưu ý một số điểm:

⚠ Vấn đề điện áp

Một số phiên bản CH341A (đặc biệt bản đen phổ biến) có thể xuất mức điện áp 5V trên chân dữ liệu, gây nguy hiểm cho chip 3.3V và có thể làm hỏng IC.

⚠ Cần adapter cho chip 1.8V

Nhiều chip hiện đại (đặc biệt BIOS laptop) dùng 1.8V, nên cần adapter riêng.

⚠ Tốc độ không cao

So với các programmer chuyên nghiệp, CH341A có tốc độ đọc/ghi khá chậm.

⚠ Phụ thuộc phần mềm

Một số chip mới không được hỗ trợ tốt, cần chọn đúng phần mềm hoặc cấu hình thủ công.

7. So sánh với các giải pháp khác

So với các programmer cao cấp như TL866 hoặc Bus Pirate, CH341A có thể thua về:

  • Độ ổn định
  • Tốc độ
  • Khả năng hỗ trợ chip mới

Tuy nhiên, xét về giá/hiệu năng, CH341A gần như không có đối thủ trong phân khúc giá rẻ.

NeoProgrammer 2.2.0.10 – phần mềm “quốc dân” cho CH341A

Trong hệ sinh thái các công cụ dành cho bộ lập trình CH341A, bên cạnh những phần mềm quen thuộc như CH341A Programmer hay AsProgrammer, NeoProgrammer 2.2.0.10 nổi lên như một lựa chọn mạnh mẽ, ổn định và được cộng đồng kỹ thuật viên đánh giá rất cao. Đây là phần mềm được cải tiến từ AsProgrammer, với nhiều nâng cấp về khả năng hỗ trợ chip, giao diện và độ ổn định khi làm việc thực tế. 

1. NeoProgrammer là gì?

NeoProgrammer là phần mềm chuyên dùng để đọc, ghi và quản lý dữ liệu trên các chip nhớ khi sử dụng programmer như CH341A. Nó đóng vai trò là “bộ não” điều khiển toàn bộ quá trình giao tiếp giữa máy tính và IC.

Phần mềm này hỗ trợ nhiều loại bộ nhớ như:

  • SPI NOR Flash
  • SPI NAND Flash
  • EEPROM (24Cxx, 25xx, 93Cxx…)
  • FRAM và một số vi điều khiển (MCU)

Nhờ khả năng này, NeoProgrammer được sử dụng rộng rãi trong:

  • Sửa chữa BIOS máy tính
  • Dump firmware thiết bị điện tử
  • Nạp firmware cho router, TV box
  • Nghiên cứu phần cứng và reverse engineering

2. Nguồn gốc và sự phát triển

NeoProgrammer thực chất là một phiên bản nâng cấp từ AsProgrammer, một dự án mã nguồn mở phổ biến. tác giả  TTAV134 tại diễn đàn https://4pda.to/forum/index.php?showuser=9359810 cải tiến thêm nhiều tính năng và độ ổn định. 

Phiên bản 2.2.0.10 được xem là một trong những bản ổn định nhất, với các cải tiến:

Nhờ đó, NeoProgrammer dần thay thế các phần mềm cũ vốn kém ổn định hoặc hỗ trợ hạn chế.

3. Giao diện và cách sử dụng

Một trong những điểm mạnh lớn của NeoProgrammer là giao diện trực quan, dễ sử dụng hơn so với nhiều tool khác.

Quy trình sử dụng cơ bản:

  1. Kết nối CH341A với máy tính
  2. Gắn chip vào socket hoặc kẹp SOP8
  3. Mở NeoProgrammer
  4. Nhấn Detect IC để nhận diện chip hoặc chọn thủ công
  5. Thực hiện Read / Write / Erase / Verify

Ngoài ra, phần mềm còn tích hợp sơ đồ chân (pinout) trực quan, giúp người dùng tránh cắm sai chip – một lỗi rất phổ biến với người mới.

4. Tính năng nổi bật

✔ Hỗ trợ số lượng chip lớn

NeoProgrammer mặc định ban đầu đã hỗ trợ hơn 1600 loại IC khác nhau từ những đóng góp từ cộng đồng, thời điểm hiện tại hỗ trợ là 2110 chip, bao gồm cả các dòng chip mới. 

✔ Hỗ trợ nhiều định dạng firmware

Phần mềm cho phép làm việc với các file:

  • BIN
  • HEX
  • ELF

✔ Tự động kiểm tra dữ liệu (Verify)

Sau khi ghi firmware, phần mềm sẽ tự động so sánh dữ liệu để đảm bảo không bị lỗi.

✔ Tương thích tốt với CH341A

NeoProgrammer được tối ưu đặc biệt cho CH341A (cả bản đen và bản xanh), giúp giảm lỗi nhận chip và tăng độ ổn định. 

✔ Portable – không cần cài đặt

Chỉ cần giải nén và chạy, không cần cài đặt phức tạp. 

✔ Công cụ chẩn đoán và sửa lỗi

Một số bản tích hợp thêm:

  • Công cụ test LED (set-top box)
  • Công cụ chẩn đoán firmware
  • Hỗ trợ sửa lỗi thiết bị

5. Ứng dụng thực tế

NeoProgrammer thường được dùng trong các tình huống:

🔧 Sửa BIOS (cứu máy “brick”)

Đây là ứng dụng phổ biến nhất. Khi BIOS lỗi, NeoProgrammer + CH341A có thể:

  • Dump BIOS cũ
  • Nạp BIOS mới
  • Phục hồi mainboard

📺 Sửa thiết bị điện tử

Router, TV, đầu thu kỹ thuật số… thường sử dụng SPI Flash, có thể nạp lại firmware bằng NeoProgrammer.

🔍 Nghiên cứu bảo mật

Các chuyên gia bảo mật sử dụng NeoProgrammer để:

  • Dump firmware
  • Phân tích hệ thống
  • Tìm lỗ hổng

🧪 Học tập

Đây là công cụ rất phù hợp cho sinh viên học embedded systems.

6. Ưu điểm

NeoProgrammer 2.2.0.10 được ưa chuộng nhờ:

  • Miễn phí hoàn toàn
  • Giao diện dễ dùng
  • Hỗ trợ nhiều chip hơn phần mềm gốc
  • Hoạt động ổn định với CH341A
  • Cộng đồng sử dụng lớn

Ngoài ra, nhiều người đánh giá đây là “cứu tinh” khi cần phục hồi thiết bị bị lỗi firmware (brick). 

7. Nhược điểm và hạn chế

Tuy mạnh mẽ, NeoProgrammer vẫn có một số điểm cần lưu ý:

⚠ Không phải chip nào cũng hỗ trợ hoàn hảo

Dù danh sách chip lớn, vẫn có những IC mới hoặc đặc biệt không nhận diện được.

⚠ Phụ thuộc phần cứng CH341A

Nếu CH341A gặp lỗi (điện áp sai, tiếp xúc kém), phần mềm cũng không thể hoạt động đúng.

⚠ Một số lỗi “IC not responding”

Đây là lỗi phổ biến, thường do:

  • Kẹp chip không chắc
  • Sai điện áp
  • Chip đang bị khóa

⚠ Không có tài liệu chính thức đầy đủ

Phần lớn người dùng phải học qua forum và cộng đồng.

8. So sánh với các phần mềm khác

Phần mềmƯu điểmNhược điểm
CH341A Programmer        Đơn giản        Ít chip hỗ trợ
AsProgrammer        Mã nguồn mở        Giao diện khó dùng
NeoProgrammer        Nhiều chip, ổn định        Ít tài liệu

Có thể nói NeoProgrammer là sự cân bằng tốt nhất giữa tính năng – dễ dùng – độ ổn định.

9. Kết luận

CH341A là một công cụ nhỏ gọn nhưng cực kỳ hữu ích trong lĩnh vực điện tử và sửa chữa phần cứng. Với khả năng đọc/ghi EEPROM và Flash, hỗ trợ nhiều loại chip và giá thành thấp, nó đã trở thành lựa chọn hàng đầu cho cả người mới bắt đầu lẫn kỹ thuật viên chuyên nghiệp.

NeoProgrammer 2.2.0.10 là một trong những phần mềm tốt nhất dành cho CH341A hiện nay. Với khả năng hỗ trợ hàng nghìn loại chip, giao diện thân thiện và hiệu năng ổn định, nó đã trở thành công cụ không thể thiếu đối với:

  • Kỹ thuật viên sửa chữa phần cứng
  • Người nghiên cứu firmware
  • Sinh viên và maker

Nếu CH341A là “phần cứng quốc dân”, thì NeoProgrammer chính là “phần mềm quốc dân” đi kèm. Khi kết hợp hai công cụ này, người dùng có thể thực hiện từ những thao tác cơ bản như đọc EEPROM cho đến các công việc nâng cao như phục hồi BIOS hay phân tích firmware một cách hiệu quả.

Nếu bạn đang bước chân vào lĩnh vực phần cứng, thì combo NeoProgrammer + CH341A gần như là một thiết bị “phải có” trong bộ công cụ của mình.


Nguồn tham khảo:

https://4pda.to/forum/index.php?showtopic=884713

https://oshwhub.com/misslee/ch341a-nextprogrammer

https://www.right.com.cn/forum/thread-8290935-1-1.html

 

Link tải phần mềm dự phòng:

Bonus: Tôi thích chương trình NeoProgrammer vì tính ổn định và hiện được cộng đồng anh em sử dụng rất nhiều, nhưng có 1 điểm là muốn thêm chip mới hơi có vẻ rườm rà và chiplist.dat hiện tại được nén + mã hóa nên không thể trích xuất toàn bộ nội dung của nó ra file xml, thực chất việc này là vô ích vì có thể tự thêm nó vào trong file Import.xml.....nhưng vì sở thích cá nhân nên thích làm chuyện vô bổ.

Dưới đây là cách tôi đã nhờ người bạn @tongo0448 (discord) sử dụng Reverse engineering với IDA MCP để trích xuất chiplist từ Memory:

Một lần nữa cảm ơn đặc biệt tới @tongo0448 đã giúp tôi.

import ctypes
import os
import struct
import sys
from ctypes import wintypes

APP_DIR = r"CHANGE_DIR_PATH\NeoProgrammer V2.2.0.10"
APP_EXE = os.path.join(APP_DIR, "NeoProgrammer.exe")
OUT_XML = os.path.join(r"CHANGE_DIR_PATH", "chiplist.xml")

DEBUG_ONLY_THIS_PROCESS = 0x00000002
CREATE_NEW_CONSOLE = 0x00000010
DBG_CONTINUE = 0x00010002
DBG_EXCEPTION_NOT_HANDLED = 0x80010001
EXCEPTION_DEBUG_EVENT = 1
CREATE_PROCESS_DEBUG_EVENT = 3
EXIT_PROCESS_DEBUG_EVENT = 5
EXCEPTION_BREAKPOINT = 0x80000003
STATUS_WX86_BREAKPOINT = 0x4000001F
EXCEPTION_SINGLE_STEP = 0x80000004
CONTEXT_FULL = 0x00010007
MEM_COMMIT = 0x1000
PAGE_GUARD = 0x100
PAGE_NOACCESS = 0x01

RVA_BPS = {
    0x27C70: "load chiplist database",
    0x5B87A: "before stream transform",
    0x5B87F: "after stream transform",
    0x27D1C: "XML parser load",
}


class STARTUPINFO(ctypes.Structure):
    _fields_ = [
        ("cb", wintypes.DWORD),
        ("lpReserved", wintypes.LPWSTR),
        ("lpDesktop", wintypes.LPWSTR),
        ("lpTitle", wintypes.LPWSTR),
        ("dwX", wintypes.DWORD),
        ("dwY", wintypes.DWORD),
        ("dwXSize", wintypes.DWORD),
        ("dwYSize", wintypes.DWORD),
        ("dwXCountChars", wintypes.DWORD),
        ("dwYCountChars", wintypes.DWORD),
        ("dwFillAttribute", wintypes.DWORD),
        ("dwFlags", wintypes.DWORD),
        ("wShowWindow", wintypes.WORD),
        ("cbReserved2", wintypes.WORD),
        ("lpReserved2", ctypes.c_void_p),
        ("hStdInput", wintypes.HANDLE),
        ("hStdOutput", wintypes.HANDLE),
        ("hStdError", wintypes.HANDLE),
    ]


class PROCESS_INFORMATION(ctypes.Structure):
    _fields_ = [
        ("hProcess", wintypes.HANDLE),
        ("hThread", wintypes.HANDLE),
        ("dwProcessId", wintypes.DWORD),
        ("dwThreadId", wintypes.DWORD),
    ]


class EXCEPTION_RECORD(ctypes.Structure):
    _fields_ = [
        ("ExceptionCode", wintypes.DWORD),
        ("ExceptionFlags", wintypes.DWORD),
        ("ExceptionRecord", ctypes.c_void_p),
        ("ExceptionAddress", ctypes.c_void_p),
        ("NumberParameters", wintypes.DWORD),
        ("ExceptionInformation", ctypes.c_size_t * 15),
    ]


class EXCEPTION_DEBUG_INFO(ctypes.Structure):
    _fields_ = [
        ("ExceptionRecord", EXCEPTION_RECORD),
        ("dwFirstChance", wintypes.DWORD),
    ]


class CREATE_PROCESS_DEBUG_INFO(ctypes.Structure):
    _fields_ = [
        ("hFile", wintypes.HANDLE),
        ("hProcess", wintypes.HANDLE),
        ("hThread", wintypes.HANDLE),
        ("lpBaseOfImage", ctypes.c_void_p),
        ("dwDebugInfoFileOffset", wintypes.DWORD),
        ("nDebugInfoSize", wintypes.DWORD),
        ("lpThreadLocalBase", ctypes.c_void_p),
        ("lpStartAddress", ctypes.c_void_p),
        ("lpImageName", ctypes.c_void_p),
        ("fUnicode", wintypes.WORD),
    ]


class EXIT_PROCESS_DEBUG_INFO(ctypes.Structure):
    _fields_ = [("dwExitCode", wintypes.DWORD)]


class DEBUG_EVENT_UNION(ctypes.Union):
    _fields_ = [
        ("Exception", EXCEPTION_DEBUG_INFO),
        ("CreateProcessInfo", CREATE_PROCESS_DEBUG_INFO),
        ("ExitProcess", EXIT_PROCESS_DEBUG_INFO),
        ("raw", ctypes.c_byte * 160),
    ]


class DEBUG_EVENT(ctypes.Structure):
    _fields_ = [
        ("dwDebugEventCode", wintypes.DWORD),
        ("dwProcessId", wintypes.DWORD),
        ("dwThreadId", wintypes.DWORD),
        ("u", DEBUG_EVENT_UNION),
    ]


class MEMORY_BASIC_INFORMATION(ctypes.Structure):
    _fields_ = [
        ("BaseAddress", ctypes.c_void_p),
        ("AllocationBase", ctypes.c_void_p),
        ("AllocationProtect", wintypes.DWORD),
        ("RegionSize", ctypes.c_size_t),
        ("State", wintypes.DWORD),
        ("Protect", wintypes.DWORD),
        ("Type", wintypes.DWORD),
    ]


class WOW64_CONTEXT(ctypes.Structure):
    _fields_ = [
        ("ContextFlags", wintypes.DWORD),
        ("Dr0", wintypes.DWORD),
        ("Dr1", wintypes.DWORD),
        ("Dr2", wintypes.DWORD),
        ("Dr3", wintypes.DWORD),
        ("Dr6", wintypes.DWORD),
        ("Dr7", wintypes.DWORD),
        ("FloatSave", ctypes.c_byte * 112),
        ("SegGs", wintypes.DWORD),
        ("SegFs", wintypes.DWORD),
        ("SegEs", wintypes.DWORD),
        ("SegDs", wintypes.DWORD),
        ("Edi", wintypes.DWORD),
        ("Esi", wintypes.DWORD),
        ("Ebx", wintypes.DWORD),
        ("Edx", wintypes.DWORD),
        ("Ecx", wintypes.DWORD),
        ("Eax", wintypes.DWORD),
        ("Ebp", wintypes.DWORD),
        ("Eip", wintypes.DWORD),
        ("SegCs", wintypes.DWORD),
        ("EFlags", wintypes.DWORD),
        ("Esp", wintypes.DWORD),
        ("SegSs", wintypes.DWORD),
        ("ExtendedRegisters", ctypes.c_byte * 512),
    ]


kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateProcessW.argtypes = [
    wintypes.LPCWSTR,
    wintypes.LPWSTR,
    ctypes.c_void_p,
    ctypes.c_void_p,
    wintypes.BOOL,
    wintypes.DWORD,
    ctypes.c_void_p,
    wintypes.LPCWSTR,
    ctypes.POINTER(STARTUPINFO),
    ctypes.POINTER(PROCESS_INFORMATION),
]
kernel32.CreateProcessW.restype = wintypes.BOOL
kernel32.WaitForDebugEvent.argtypes = [ctypes.POINTER(DEBUG_EVENT), wintypes.DWORD]
kernel32.WaitForDebugEvent.restype = wintypes.BOOL
kernel32.ContinueDebugEvent.argtypes = [wintypes.DWORD, wintypes.DWORD, wintypes.DWORD]
kernel32.ReadProcessMemory.argtypes = [
    wintypes.HANDLE,
    ctypes.c_void_p,
    ctypes.c_void_p,
    ctypes.c_size_t,
    ctypes.POINTER(ctypes.c_size_t),
]
kernel32.WriteProcessMemory.argtypes = [
    wintypes.HANDLE,
    ctypes.c_void_p,
    ctypes.c_void_p,
    ctypes.c_size_t,
    ctypes.POINTER(ctypes.c_size_t),
]
kernel32.VirtualQueryEx.argtypes = [
    wintypes.HANDLE,
    ctypes.c_void_p,
    ctypes.POINTER(MEMORY_BASIC_INFORMATION),
    ctypes.c_size_t,
]
kernel32.VirtualQueryEx.restype = ctypes.c_size_t
kernel32.FlushInstructionCache.argtypes = [
    wintypes.HANDLE,
    ctypes.c_void_p,
    ctypes.c_size_t,
]
kernel32.OpenThread.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenThread.restype = wintypes.HANDLE
kernel32.Wow64GetThreadContext.argtypes = [
    wintypes.HANDLE,
    ctypes.POINTER(WOW64_CONTEXT),
]
kernel32.Wow64SetThreadContext.argtypes = [
    wintypes.HANDLE,
    ctypes.POINTER(WOW64_CONTEXT),
]
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]


def fail(msg):
    raise ctypes.WinError(ctypes.get_last_error(), msg)


def read_mem(hp, addr, size):
    buf = ctypes.create_string_buffer(size)
    got = ctypes.c_size_t()
    if not kernel32.ReadProcessMemory(
        hp, ctypes.c_void_p(addr), buf, size, ctypes.byref(got)
    ):
        return None
    return buf.raw[: got.value]


def write_mem(hp, addr, data):
    buf = ctypes.create_string_buffer(data)
    done = ctypes.c_size_t()
    if not kernel32.WriteProcessMemory(
        hp, ctypes.c_void_p(addr), buf, len(data), ctypes.byref(done)
    ):
        fail("WriteProcessMemory")
    kernel32.FlushInstructionCache(hp, ctypes.c_void_p(addr), len(data))


def get_ctx(tid):
    thread = kernel32.OpenThread(0x001F03FF, False, tid)
    if not thread:
        fail("OpenThread")
    try:
        ctx = WOW64_CONTEXT()
        ctx.ContextFlags = CONTEXT_FULL
        if not kernel32.Wow64GetThreadContext(thread, ctypes.byref(ctx)):
            fail("Wow64GetThreadContext")
        return ctx
    finally:
        kernel32.CloseHandle(thread)


def set_eip(tid, eip):
    thread = kernel32.OpenThread(0x001F03FF, False, tid)
    if not thread:
        fail("OpenThread")
    try:
        ctx = WOW64_CONTEXT()
        ctx.ContextFlags = CONTEXT_FULL
        if not kernel32.Wow64GetThreadContext(thread, ctypes.byref(ctx)):
            fail("Wow64GetThreadContext")
        ctx.Eip = eip
        if not kernel32.Wow64SetThreadContext(thread, ctypes.byref(ctx)):
            fail("Wow64SetThreadContext")
    finally:
        kernel32.CloseHandle(thread)


def install_bps(hp, base):
    bps = {}
    for rva, name in RVA_BPS.items():
        addr = base + rva
        orig = read_mem(hp, addr, 1)
        if not orig:
            raise RuntimeError(f"cannot read breakpoint byte at {addr:08X}")
        write_mem(hp, addr, b"\xcc")
        bps[addr] = (orig, name, rva)
        print(f"bp {addr:08X} ({name})")
    return bps


def search_and_dump_xml(hp):
    mbi = MEMORY_BASIC_INFORMATION()
    addr = 0
    hits = []
    while addr < 0x80000000:
        ret = kernel32.VirtualQueryEx(
            hp, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)
        )
        if not ret:
            addr += 0x10000
            continue
        base = int(mbi.BaseAddress or 0)
        size = int(mbi.RegionSize)
        readable = mbi.State == MEM_COMMIT and not (
            mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)
        )
        if readable and size:
            data = read_mem(hp, base, min(size, 64 * 1024 * 1024))
            if data:
                for needle in (b"<?xml", b"<chiplist", b"SPI_NOR", b"SPI_NAND"):
                    pos = data.find(needle)
                    if pos != -1:
                        hits.append(
                            (base + pos, needle.decode("ascii", "ignore"), base, data)
                        )
        addr = base + max(size, 0x1000)

    for hit_addr, needle, base, data in hits:
        print(f"hit {needle} at {hit_addr:08X}")

    candidates = []
    for hit_addr, needle, base, data in hits:
        for marker in (b"<?xml", b"<chiplist"):
            start = data.find(marker)
            end = data.find(b"</chiplist>", start)
            if start != -1 and end != -1:
                end += len(b"</chiplist>")
                candidates.append(
                    (0 if marker == b"<?xml" else 1, base + start, data[start:end])
                )

    if candidates:
        candidates.sort(key=lambda item: (item[0], item[1]))
        _, start_addr, xml = candidates[0]
        with open(OUT_XML, "wb") as f:
            f.write(xml)
        print(f"dumped {len(xml)} bytes from {start_addr:08X} to {OUT_XML}")
        return True
    return False


def main():
    si = STARTUPINFO()
    si.cb = ctypes.sizeof(si)
    pi = PROCESS_INFORMATION()
    cmd = f'"{APP_EXE}"'
    if not kernel32.CreateProcessW(
        None,
        cmd,
        None,
        None,
        False,
        DEBUG_ONLY_THIS_PROCESS | CREATE_NEW_CONSOLE,
        None,
        APP_DIR,
        ctypes.byref(si),
        ctypes.byref(pi),
    ):
        fail("CreateProcessW")
    print(f"started pid={pi.dwProcessId}")

    hp = pi.hProcess
    bps = {}
    base = None
    saw_initial_bp = False
    dumped = False
    try:
        while True:
            ev = DEBUG_EVENT()
            if not kernel32.WaitForDebugEvent(ctypes.byref(ev), 30000):
                print("timeout waiting for debug event")
                break
            status = DBG_CONTINUE
            if ev.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT:
                base = int(ev.u.CreateProcessInfo.lpBaseOfImage)
                print(f"image base {base:08X}")
                bps = install_bps(hp, base)
                if ev.u.CreateProcessInfo.hFile:
                    kernel32.CloseHandle(ev.u.CreateProcessInfo.hFile)
            elif ev.dwDebugEventCode == EXCEPTION_DEBUG_EVENT:
                ex = ev.u.Exception.ExceptionRecord
                code = ex.ExceptionCode
                exaddr = int(ex.ExceptionAddress or 0)
                if code in (EXCEPTION_BREAKPOINT, STATUS_WX86_BREAKPOINT):
                    bp_addr = exaddr - 1
                    if bp_addr not in bps and exaddr in bps:
                        bp_addr = exaddr
                    if not saw_initial_bp and bp_addr not in bps:
                        saw_initial_bp = True
                        print(f"initial/system breakpoint at {exaddr:08X}")
                    elif bp_addr in bps:
                        orig, name, rva = bps.pop(bp_addr)
                        write_mem(hp, bp_addr, orig)
                        set_eip(ev.dwThreadId, bp_addr)
                        ctx = get_ctx(ev.dwThreadId)
                        stack0 = read_mem(hp, ctx.Esp, 4)
                        stack0_val = struct.unpack("<I", stack0)[0] if stack0 else None
                        stack0_text = (
                            f"{stack0_val:08X}"
                            if stack0_val is not None
                            else "????????"
                        )
                        print(
                            f"break {bp_addr:08X} {name}: "
                            f"EAX={ctx.Eax:08X} ECX={ctx.Ecx:08X} EDX={ctx.Edx:08X} "
                            f"ESP={ctx.Esp:08X} [ESP]={stack0_text}"
                        )
                        if rva == 0x5B87F:
                            dumped = search_and_dump_xml(hp)
                            if dumped:
                                break
                        elif rva == 0x27D1C:
                            dumped = search_and_dump_xml(hp)
                            break
                    else:
                        status = DBG_EXCEPTION_NOT_HANDLED
                elif code == EXCEPTION_SINGLE_STEP:
                    pass
                else:
                    print(f"exception {code:08X} at {exaddr:08X}")
                    status = DBG_EXCEPTION_NOT_HANDLED
            elif ev.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT:
                print(f"process exited {ev.u.ExitProcess.dwExitCode}")
                break
            kernel32.ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, status)
    finally:
        if not dumped:
            print("XML not dumped")


if __name__ == "__main__":
    main()

Update cơ chế decrypt từ chiplist.dat:

import hashlib
import base64
import pathlib
import struct
import zlib
INPUT = pathlib.Path("chiplist.dat")
OUTPUT = pathlib.Path("chiplist_algorithm.xml")
def rc4_init(key: bytes) -> list[int]:
    s = list(range(256))
    j = 0
    key_len = len(key)
    for i in range(256):
        j = (j + s[i] + key[i % key_len]) & 0xFF
        s[i], s[j] = s[j], s[i]
    return s
def rc4_apply(data: bytes, s: list[int]) -> bytes:
    i = 0
    j = 0
    out = bytearray(len(data))
    for n, b in enumerate(data):
        i = (i + 1) & 0xFF
        j = (j + s[i]) & 0xFF
        s[i], s[j] = s[j], s[i]
        out[n] = b ^ s[(s[i] + s[j]) & 0xFF]
    return bytes(out)
def rc4(data: bytes, key: bytes) -> bytes:
    return rc4_apply(data, rc4_init(key))
def rc4_chunked(data: bytes, key: bytes, chunk_size: int = 0x2000) -> bytes:
    s = rc4_init(key)
    out = bytearray()
    for offset in range(0, len(data), chunk_size):
        out += rc4_apply(data[offset:offset + chunk_size], s)
    return bytes(out)
def build_password() -> str:
    first_order = ["vd7", "SQP", "RBs", "HgX", "0bv", "pii", "sn8"]
    second_order = ["z1J", "92Z", "7xA", "eex", "MEW", "ulI", "wdX"]
    return "".join(s[1:3] for s in first_order + second_order)
def derive_real_password() -> bytes:
    obfuscated = base64.b64decode(build_password().encode("ascii"))
    unwrap_key = hashlib.sha1(b"chiplist.dat").digest()
    return rc4(obfuscated, unwrap_key)
def decrypt_chiplist(raw: bytes) -> bytes:
    if len(raw) < 8:
        raise ValueError(f"chiplist data is too short: {len(raw)} bytes")

    password = derive_real_password()
    key = hashlib.sha1(password).digest()
    decrypted = rc4_chunked(raw, key)

    unpacked_size, reserved = struct.unpack_from("<II", decrypted, 0)
    if reserved != 0:
        raise ValueError(f"unexpected header reserved value: 0x{reserved:08X}")

    xml = zlib.decompress(decrypted[8:])
    if len(xml) != unpacked_size:
        raise ValueError(f"size mismatch: header={unpacked_size}, zlib={len(xml)}")
    return xml
def main():
    xml = decrypt_chiplist(INPUT.read_bytes())
    OUTPUT.write_bytes(xml)
    print(f"obfuscated password: {build_password()}")
    print(f"real password: {derive_real_password().decode('ascii')}")
    print(f"wrote {len(xml)} bytes to {OUTPUT}")
if __name__ == "__main__":
    main()

Dựa vào cơ chế này xây dựng 1 ứng dụng GUI cho phép chỉnh sửa trực tiếp file chiplist.dat


Thứ Sáu, 7 tháng 3, 2025

Hướng dẫn sử dụng VideoSubFinder và cấu hình OCR (PHẦN 2 - END)

Phần này giới thiệu 1 công cụ mình tự viết bằng python dựa vào AI genimi hỗ trợ và có tham khảo 1 số nguồn ngoài.







Giới thiệu chương trình.

Chương trình Python này có tên là "SEGG OCR Tool v1.36_Optimizer" và được thiết kế để tự động hóa quá trình tạo phụ đề SRT từ video bằng cách sử dụng kết hợp công cụ VideoSubFinder (VSF) và Google Drive API cho OCR (Nhận dạng ký tự quang học).

Dưới đây là mô tả chi tiết về chức năng của chương trình:


Chức năng chính:
  1. Trích xuất ảnh từ video bằng VideoSubFinder (VSF):
    • Chương trình sử dụng một công cụ bên ngoài là VideoSubFinderWXW_intel.exe để trích xuất các khung hình chứa phụ đề từ video.
    • Người dùng có thể chỉ định đường dẫn đến file video.
    • Chương trình cung cấp các tùy chọn crop (cắt xén) video để tập trung vào vùng chứa phụ đề, giúp cải thiện độ chính xác của OCR. Các thông số crop có thể được nhập thủ công hoặc chọn từ các profile định sẵn (ví dụ: vlxx, javhd, sextop, phimKK, titdam) hoặc tùy chỉnh bằng công cụ chọn tọa độ trực quan trên video.
    • VSF tạo ra thư mục RGBImages (hoặc TXTImages tùy chọn) chứa các ảnh khung hình đã được trích xuất và crop.
  2. Nhận dạng ký tự quang học (OCR) bằng Google Drive API:
    • Chương trình sử dụng Google Drive API để thực hiện OCR trên các ảnh đã trích xuất.
    • Mỗi ảnh được tải lên Google Drive dưới dạng tài liệu.
    • Google Drive thực hiện OCR và chuyển đổi ảnh thành văn bản.
    • Văn bản thô (raw text) sau OCR được tải xuống và lưu vào thư mục raw_texts.
    • Văn bản được xử lý (loại bỏ các dòng thừa) và lưu vào thư mục texts.
    • Sau khi tải văn bản về, tài liệu ảnh trên Google Drive sẽ bị xóa.
  3. Tạo file phụ đề SRT:
    • Chương trình tổng hợp văn bản OCR từ các ảnh theo thứ tự thời gian (dựa trên tên file ảnh do VSF tạo ra, chứa thông tin thời gian).
    • Nội dung văn bản và thông tin thời gian được định dạng theo chuẩn SRT (SubRip Subtitle).
    • Người dùng có thể xem trước nội dung SRT trước khi lưu.
    • File phụ đề SRT được lưu vào vị trí do người dùng chỉ định.
  4. Quản lý và Tùy chọn
    • Cấu hình: Các cài đặt như ID thư mục Google Drive, đường dẫn VideoSubFinder, tùy chọn xóa/nén file, profile crop được lưu trong file config.ini.
    • Giám sát thư mục: Chương trình giám sát thư mục RGBImages và tự động bắt đầu quá trình OCR khi có ảnh mới được tạo bởi VSF.
    • Tiến trình và Log: Hiển thị thanh tiến trình và thông báo trạng thái trong giao diện đồ họa. Ghi log chi tiết quá trình hoạt động vào cửa sổ log và file log (nếu có).
    • Tùy chọn xóa file: Người dùng có thể chọn xóa thư mục raw_texts và texts sau khi hoàn tất quá trình OCR và tạo phụ đề.
    • Tùy chọn nén file: Có tùy chọn nén thư mục raw_texts thành file zip trước khi xóa (hoặc thay vì xóa).
    • Tùy chọn tạo TXTImages: Người dùng có thể chọn tạo thư mục TXTImages thay vì RGBImages khi chạy VideoSubFinder (tùy chọn này có thể ảnh hưởng đến kết quả OCR tùy thuộc vào video).
    • Công cụ chọn tọa độ Crop: Cung cấp giao diện trực quan để người dùng chọn vùng crop phụ đề trên khung hình video, giúp tùy chỉnh chính xác hơn.
  5. Giao diện người dùng (GUI) Tkinter:
    • Giao diện đồ họa đơn giản, dễ sử dụng với các trường nhập liệu, nút bấm và checkbox.
    • Cho phép người dùng chọn file video, thư mục ảnh (trong trường hợp đã có ảnh), nơi lưu file phụ đề.
    • Cung cấp các tùy chọn cấu hình và điều khiển quá trình OCR.
    • Hiển thị log quá trình hoạt động và thanh tiến trình.

Quy trình hoạt động tổng quát:

  1. Người dùng cấu hình API Google Drive (credentials.json, token.json) và cài đặt ban đầu (config.ini).
  2. Người dùng chọn file video (hoặc thư mục ảnh nếu đã có sẵn).
  3. Người dùng có thể tùy chỉnh các thông số crop hoặc chọn profile crop.
  4. Người dùng nhấn nút "Chạy VSF" để trích xuất ảnh từ video (nếu cần).
  5. Chương trình tự động giám sát thư mục ảnh và bắt đầu quá trình OCR cho từng ảnh bằng Google Drive API.
  6. Sau khi OCR hoàn tất, chương trình tạo file phụ đề SRT và hiển thị xem trước.
  7. Người dùng có thể lưu file SRT.
  8. Chương trình thực hiện các thao tác dọn dẹp (xóa/nén thư mục) tùy theo cấu hình.
Yêu cầu:

Để chương trình "SEGG OCR Tool v1.36_Optimizer" có thể chạy được, bạn cần đảm bảo các yêu cầu sau:
  1. Môi trường Python:
    • Python: Cần cài đặt Python trên máy tính của bạn. Chương trình có thể được viết cho Python 3.x (nên dùng phiên bản mới nhất ổn định). Thư viện Python (Libraries): Bạn cần cài đặt các thư viện Python sau. Bạn có thể cài đặt chúng bằng pip (trình quản lý gói của Python). Mở command prompt hoặc terminal và chạy các lệnh sau:
    • pip install google-api-python-client 
      pip install google-auth-httplib2 
      pip install google-auth-oauthlib 
      pip install httplib2 
      pip install oauth2client 
      pip install pathlib 
      pip install opencv-python # OpenCV - cho xử lý video (lấy thời lượng) 
      pip install psutil # psutil - để quản lý tiến trình hệ thống
  2. Cấu hình Google Drive API:
    • Google Cloud Project & API Credentials:
    • Tạo dự án trên Google Cloud Console: Truy cập Google Cloud Console và tạo một dự án mới (hoặc chọn một dự án hiện có).
    • Bật Google Drive API: Trong dự án của bạn, tìm kiếm "Drive API" trong thanh tìm kiếm và bật Google Drive API.
    • Tạo Credentials (Thông tin xác thực):
      • Trong dự án, vào "APIs & Services" -> "Credentials".
      • Click "Create credentials" -> "OAuth client ID".
      • Chọn "Application type" là "Desktop app".
      • Đặt tên cho OAuth client ID (ví dụ: "OCR Tool Client").
      • Click "Create".
      • Tải xuống file credentials.json. Đặt file này vào cùng thư mục với file OCR_v1.36_optimizer.py.
    • token.json (Token xác thực): Khi bạn chạy chương trình lần đầu tiên, nó sẽ cố gắng lấy thông tin xác thực từ file token.json. Nếu file này không tồn tại hoặc không hợp lệ, chương trình sẽ mở trình duyệt để bạn đăng nhập vào tài khoản Google và cấp quyền truy cập Google Drive cho ứng dụng. Sau khi cấp quyền, token.json sẽ được tạo tự động.
  3. VideoSubFinder (VSF):
    • Tải và cài đặt VideoSubFinder: Chương trình sử dụng VideoSubFinderWXW_intel.exe. Bạn cần tải xuống phiên bản VideoSubFinder (ví dụ, phiên bản 6.10 hoặc mới hơn) từ nguồn tin cậy (thường là từ các diễn đàn hoặc trang web liên quan đến phụ đề).
    • Đường dẫn VideoSubFinder: Chương trình cấu hình đường dẫn mặc định là D:\VideoSubFinder_6.10\VideoSubFinderWXW_intel.exe. Bạn cần chỉnh sửa đường dẫn này trong file config.ini (hoặc trực tiếp trong code nếu cần) để trỏ đúng đến vị trí thực tế của file VideoSubFinderWXW_intel.exe trên máy tính của bạn.
  4. File cấu hình config.ini:
    • Tạo file config.ini: Nếu file config.ini chưa tồn tại trong cùng thư mục với script Python, chương trình sẽ tạo nó khi chạy lần đầu hoặc khi bạn lưu cấu hình từ giao diện.
    • Nội dung config.ini: File này chứa các cấu hình sau:
      • folder_id: ID của thư mục Google Drive mà bạn muốn sử dụng để lưu trữ tạm thời ảnh OCR (mặc định là thư mục gốc).
      • delete_raw_texts, delete_texts, nen_raw_texts: Các tùy chọn boolean (True/False) cho việc xóa/nén thư mục sau khi xử lý.
      • videosubfinder_path: Đường dẫn đến file VideoSubFinderWXW_intel.exe.
      • crop_profiles: Các profile crop định sẵn và giá trị crop tùy chỉnh.
      • threads: Số luồng xử lý OCR đồng thời (mặc định 20).
  5. File đầu vào:
    • File video: Nếu bạn muốn sử dụng VideoSubFinder để trích xuất ảnh, bạn cần có file video ở định dạng được hỗ trợ bởi VSF (ví dụ: MP4, AVI, MKV, MOV).
    • Thư mục ảnh (tùy chọn): Nếu bạn đã có sẵn thư mục chứa các ảnh khung hình phụ đề (ví dụ, đã trích xuất bằng công cụ khác), bạn có thể chọn thư mục này làm đầu vào thay vì file video.
  6. Quyền truy cập Google Drive:
    • Khi chạy chương trình lần đầu và cấp quyền, bạn cần đảm bảo tài khoản Google bạn sử dụng có đủ dung lượng lưu trữ trên Google Drive để chứa các ảnh tải lên tạm thời và các file văn bản OCR.

Tóm tắt các bước cài đặt chính:
  1. Cài đặt Python và các thư viện cần thiết (pip install ...).
  2. Cấu hình Google Cloud Project, bật Drive API, và tải credentials.json. Đặt credentials.json vào cùng thư mục với script.
  3. Tải và cài đặt VideoSubFinder. Chỉnh sửa config.ini để trỏ đến đường dẫn đúng của VideoSubFinderWXW_intel.exe.
  4. Chạy script OCR_v1.36_optimizer.py. Lần đầu chạy, cấp quyền truy cập Google Drive khi được yêu cầu.
  5. Sử dụng giao diện chương trình để chọn video (hoặc thư mục ảnh), cấu hình crop, và bắt đầu quá trình OCR.
Sau khi hoàn thành các bước trên, chương trình sẽ có thể chạy và thực hiện chức năng tạo phụ đề SRT từ video một cách tự động. Nếu gặp lỗi, hãy kiểm tra kỹ các bước cài đặt, đường dẫn file, và quyền truy cập API.

Đây là phiên bản dùng Google Docs.., ngoài ra có thể dùng Google lens để OCR ra kết qủa rất tốt và nhanh.
TẢI XUỐNG Code
[Các bạn tự buil nhé..]

import httplib2
import os
import io
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import configparser
from apiclient import discovery
from oauth2client import client
from oauth2client import tools
from oauth2client.file import Storage
from apiclient.http import MediaFileUpload, MediaIoBaseDownload
from pathlib import Path
import shutil
import time
import tkinter as tk
from tkinter import messagebox, ttk, filedialog, scrolledtext
import concurrent.futures
import datetime
import subprocess
import re
import cv2
from PIL import Image , ImageTk
import psutil
import signal
import sys

# Cấu hình (Hằng số nên viết hoa toàn bộ)
SCOPES = "https://www.googleapis.com/auth/drive"
CLIENT_SECRET_FILE = "credentials.json"
APPLICATION_NAME = "Drive API Python Quickstart"

# Khởi tạo mặc định để tránh lỗi nếu không load được từ config
DEFAULT_FOLDER_ID = ""
DEFAULT_VIDEOSUBFINDER_PATH = r"D:\VideoSubFinder_6.10\VideoSubFinderWXW_intel.exe"

# Biến toàn cục
FOLDER_ID = DEFAULT_FOLDER_ID
SRT_FILE_LIST = {}  # Sử dụng tên viết hoa phù hợp cho hằng số
PROGRESS_LOCK = threading.Lock()
STOP_FLAG = False
LOG_FILE_PATH = None
EXTRACTED_TIME = None
DURATION = None
EVENT_HANDLER = None
OBSERVER = None
WORKER_THREADS = []


class RGBImagesEventHandler(FileSystemEventHandler):
    """Giám sát thư mục RGBImages và ghi log khi có file mới."""

    def __init__(self, video_duration="00:00:00"):
        super().__init__()
        self.video_duration = video_duration
        self.file_count = 0
        try:
            h, m, s = map(int, video_duration.split(':'))
            self.video_duration_timedelta = datetime.timedelta(hours=h, minutes=m, seconds=s)
        except ValueError:
            print("Lỗi định dạng thời lượng video, sử dụng giá trị mặc định '00:00:00'")
            self.video_duration_timedelta = datetime.timedelta()

    def on_created(self, event):
        if event.is_directory:
            return  # Bỏ qua thư mục

        file_name = os.path.basename(event.src_path)
        self.file_count += 1  # Tăng số lượng ảnh

        log_message(f"📂 RGBImages: {file_name}")

        match = re.search(r"(\d+)_(\d+)_(\d+)_(\d+)", file_name)
        if match:
            global EXTRACTED_TIME
            EXTRACTED_TIME = f"{match.group(1)}:{match.group(2)}:{match.group(3)}:{match.group(4)}"
            try:
                extracted_time_obj = datetime.datetime.strptime(EXTRACTED_TIME, "%H:%M:%S:%f").time()
                extracted_timedelta = datetime.timedelta(
                    hours=extracted_time_obj.hour,
                    minutes=extracted_time_obj.minute,
                    seconds=extracted_time_obj.second,
                    microseconds=extracted_time_obj.microsecond,
                )

                time_remaining = self.video_duration_timedelta - extracted_timedelta
                time_remaining_str = str(time_remaining)
                time_remaining_str = time_remaining_str[:-7]  # Loại bỏ microseconds

                percentage_complete = (extracted_timedelta / self.video_duration_timedelta) * 100
                percentage_complete = max(0, min(percentage_complete, 100))

                status_label.config(text=f"VSF đang chạy...👀 Còn lại: {time_remaining_str} |⏱ Tổng thời gian: {self.video_duration} |📂 Ảnh: {self.file_count}")
                root.after(0, progress_bar.config, {"value": percentage_complete})
            except ValueError as e:
                log_message(f"Lỗi xử lý thời gian: {e}")


def start_monitoring_rgbimages(rgb_images_folder, video_duration="00:00:00"):
    """Bắt đầu giám sát thư mục RGBImages."""
    if not os.path.exists(rgb_images_folder):
        log_message("❌ Thư mục RGBImages chưa được tạo, không thể giám sát.")
        return

    global OBSERVER, WORKER_THREADS, EVENT_HANDLER

    # Kiểm tra observer đang chạy chưa. Nếu rồi thì không làm gì cả.
    if OBSERVER and OBSERVER.is_alive():
        return

    OBSERVER = Observer()
    EVENT_HANDLER = RGBImagesEventHandler(video_duration)
    OBSERVER.schedule(EVENT_HANDLER, rgb_images_folder, recursive=True)
    observer_thread = threading.Thread(target=OBSERVER.start, daemon=True)
    WORKER_THREADS.append(observer_thread)
    observer_thread.start()


def wait_for_rgbimages_and_monitor(path, video_duration, retries=10, delay=1):
    """Chờ thư mục RGBImages tồn tại trước khi giám sát."""
    while retries > 0:
        if os.path.exists(path):
            log_message(f"👀 Đã thấy thư mục: {path}.\n🚀 Bắt đầu giám sát!")
            start_monitoring_rgbimages(path, video_duration)
            return
        log_message(f"⚠️ Không thấy RGBImages. Đang đợi... {retries}s còn lại")
        time.sleep(delay)
        retries -= 1

    log_message("❌ LỖI: Không thể tìm thấy thư mục RGBImages sau thời gian chờ.")


def stop_processing():
    global STOP_FLAG
    STOP_FLAG = True
    start_button.config(state=tk.NORMAL)
    stop_button.config(state=tk.DISABLED)
    VSF_button.config(state=tk.NORMAL)
    log_message("Quá trình đã được dừng.")

try:
    import argparse

    flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args()
except ImportError:
    flags = None
    
def get_credentials():
    """Lấy chứng chỉ người dùng hợp lệ từ lưu trữ."""
    credential_path = os.path.join("./", "token.json")
    store = Storage(credential_path)
    credentials = store.get()
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
        flow.user_agent = APPLICATION_NAME
        if flags:
            credentials = tools.run_flow(flow, store, flags)
        else:
            credentials = tools.run(flow, store)
        print("Storing credentials to " + credential_path)
    return credentials


def save_config(
    folder_id, delete_raw_texts, delete_texts, nen_raw_texts, videosubfinder_path, crop_profiles
):
    """Lưu cấu hình vào file config.ini mà không ghi đè toàn bộ nội dung."""
    config = configparser.ConfigParser()

    # Thêm section [settings]
    config["settings"] = {
        "folder_id": folder_id,
        "delete_raw_texts": str(delete_raw_texts),
        "delete_texts": str(delete_texts),
        "nen_raw_texts": str(nen_raw_texts),
        "videosubfinder_path": videosubfinder_path,
        "threads": "20"  # Giá trị mặc định cho threads
    }

    # Thêm section [crop_profiles]
    config["crop_profiles"] = {}
    for profile, values in crop_profiles.items():
        profile_key = profile.replace(", ", "_").lower()  # Chuẩn hóa key
        config["crop_profiles"][f"{profile_key}_top"] = str(values["top"])
        config["crop_profiles"][f"{profile_key}_bottom"] = str(values["bottom"])
        config["crop_profiles"][f"{profile_key}_left"] = str(values["left"])
        config["crop_profiles"][f"{profile_key}_right"] = str(values["right"])

    # Ghi file config.ini
    with open("config.ini", "w") as configfile:
        config.write(configfile)

def load_config():
    """Đọc cấu hình từ file config.ini, tạo file mới nếu không tồn tại."""
    config = configparser.ConfigParser()
    config_file = "config.ini"

    # Các giá trị mặc định
    DEFAULT_FOLDER_ID = ""
    DEFAULT_VIDEOSUBFINDER_PATH = r"D:\VideoSubFinder_6.10\VideoSubFinderWXW_intel.exe"
    DEFAULT_THREADS = 20
    DEFAULT_DELETE_RAW_TEXTS = False
    DEFAULT_DELETE_TEXTS = False
    DEFAULT_NEN_RAW_TEXTS = False
    
    # Mặc định cho crop profiles
    default_crop_profiles = {
        "vlxx, javhd": {"top": 0.1692, "bottom": 0.0058, "left": 0, "right": 1},
        "sextop": {"top": 0.3052, "bottom": 0.0545, "left": 0, "right": 1},
        "phimKK": {"top": 0.1861, "bottom": 0.0198, "left": 0, "right": 1},
        "titdam": {"top": 0.2455, "bottom": 0.0746, "left": 0.1743, "right": 0.8322},
    }

    # Kiểm tra sự tồn tại của file config.ini
    if not os.path.exists(config_file):
        # Nếu không tồn tại, tạo file mới với giá trị mặc định
        save_config(
            DEFAULT_FOLDER_ID,
            DEFAULT_DELETE_RAW_TEXTS,
            DEFAULT_DELETE_TEXTS,
            DEFAULT_NEN_RAW_TEXTS,
            DEFAULT_VIDEOSUBFINDER_PATH,
            default_crop_profiles
        )
        # Sau khi tạo, đọc lại file để đảm bảo dữ liệu được load
        config.read(config_file)
    else:
        # Nếu file đã tồn tại, đọc bình thường
        config.read(config_file)

    # Lấy các giá trị cơ bản từ [settings]
    if "settings" in config:
        folder_id = config["settings"].get("folder_id", DEFAULT_FOLDER_ID)
        delete_raw_texts = config.getboolean("settings", "delete_raw_texts", fallback=DEFAULT_DELETE_RAW_TEXTS)
        delete_texts = config.getboolean("settings", "delete_texts", fallback=DEFAULT_DELETE_TEXTS)
        nen_raw_texts = config.getboolean("settings", "nen_raw_texts", fallback=DEFAULT_NEN_RAW_TEXTS)
        videosubfinder_path = config["settings"].get("videosubfinder_path", DEFAULT_VIDEOSUBFINDER_PATH)
        threads = config["settings"].getint("threads", DEFAULT_THREADS)
        if threads <= 0:
            threads = DEFAULT_THREADS  # Đảm bảo threads hợp lệ
    else:
        folder_id = DEFAULT_FOLDER_ID
        delete_raw_texts = DEFAULT_DELETE_RAW_TEXTS
        delete_texts = DEFAULT_DELETE_TEXTS
        nen_raw_texts = DEFAULT_NEN_RAW_TEXTS
        videosubfinder_path = DEFAULT_VIDEOSUBFINDER_PATH
        threads = DEFAULT_THREADS

    # Lấy các giá trị crop từ [crop_profiles]
    crop_profiles = default_crop_profiles.copy()  # Sao chép mặc định để chỉnh sửa nếu cần
    if "crop_profiles" in config:
        for profile in default_crop_profiles:
            profile_key = profile.replace(", ", "_").lower()  # Chuẩn hóa key (ví dụ: "vlxx, javhd" -> "vlxx_javhd")
            crop_profiles[profile]["top"] = config["crop_profiles"].getfloat(
                f"{profile_key}_top", default_crop_profiles[profile]["top"]
            )
            crop_profiles[profile]["bottom"] = config["crop_profiles"].getfloat(
                f"{profile_key}_bottom", default_crop_profiles[profile]["bottom"]
            )
            crop_profiles[profile]["left"] = config["crop_profiles"].getfloat(
                f"{profile_key}_left", default_crop_profiles[profile]["left"]
            )
            crop_profiles[profile]["right"] = config["crop_profiles"].getfloat(
                f"{profile_key}_right", default_crop_profiles[profile]["right"]
            )
            
    return folder_id, delete_raw_texts, delete_texts, nen_raw_texts, videosubfinder_path, threads, crop_profiles


def ocr_image(image, line, credentials, current_directory, progress_callback):
    """Thực hiện OCR trên một ảnh."""
    global STOP_FLAG
    tries = 0
    while True:
        if STOP_FLAG:
            print("Đã dừng quá trình.")
            log_message("❌ Quá trình đã được dừng.")
            return

        try:
            http = credentials.authorize(httplib2.Http())
            service = discovery.build("drive", "v3", http=http)
            imgfile = str(image.absolute())
            imgname = str(image.name)
            raw_txtfile = f"{current_directory}/raw_texts/{imgname[:-5]}.txt"
            txtfile = f"{current_directory}/texts/{imgname[:-5]}.txt"

            mime = "application/vnd.google-apps.document"

            res = (
                service.files()
                .create(
                    body={"name": imgname, "mimeType": mime, "parents": [FOLDER_ID]},
                    media_body=MediaFileUpload(imgfile, mimetype=mime, resumable=True),
                )
                .execute()
            )

            print(f"{imgname} Done.")
            

            downloader = MediaIoBaseDownload(
                io.FileIO(raw_txtfile, "wb"),
                service.files().export_media(fileId=res["id"], mimeType="text/plain"),
            )
            done = False
            while done is False:
                status, done = downloader.next_chunk()

            service.files().delete(fileId=res["id"]).execute()

            # Xử lý nội dung raw text
            with open(raw_txtfile, "r", encoding="utf-8") as raw_text_file:
                text_content = raw_text_file.read()

            text_content = text_content.split("\n")
            text_content = "".join(text_content[2:])
            
            log_message(f"✅ Đã OCR: {text_content[:55]}..." if len(text_content) > 55 else f"✅ Đã OCR: {text_content}")

            with open(txtfile, "w", encoding="utf-8") as text_file:
                text_file.write(text_content)

            try:
                start_hour = imgname.split("_")[0][:2]
                start_min = imgname.split("_")[1][:2]
                start_sec = imgname.split("_")[2][:2]
                start_micro = imgname.split("_")[3][:3]

                end_hour = imgname.split("__")[1].split("_")[0][:2]
                end_min = imgname.split("__")[1].split("_")[1][:2]
                end_sec = imgname.split("__")[1].split("_")[2][:2]
                end_micro = imgname.split("__")[1].split("_")[3][:3]

            except IndexError:
                print(
                    f"Error processing {imgname}: Filename format is incorrect. Please ensure the correct format is used."
                )
                return

            start_time = f"{start_hour}:{start_min}:{start_sec},{start_micro}"
            end_time = f"{end_hour}:{end_min}:{end_sec},{end_micro}"
            SRT_FILE_LIST[line] = [
                f"{line}\n",
                f"{start_time} --> {end_time}\n",
                f"{text_content}\n\n",
                "",
            ]

            progress_callback()
            break
        except Exception as e:
            tries += 1
            if tries > 5:
                print(f"Lỗi sau 5 lần thử: {e}")
                raise  # Re-raise exception sau nhiều lần thử
            time.sleep(1) # Thêm delay trước khi thử lại
            continue

def preview_srt(srt_content, save_callback):
    """Hiển thị cửa sổ xem trước nội dung SRT và cho phép lưu."""
    # Kiểm tra và khôi phục cửa sổ chính nếu nó đang bị thu nhỏ
    if root.state() == "iconic":  # "iconic" là trạng thái thu nhỏ trong Tkinter
        root.deiconify()  # Khôi phục cửa sổ chính từ thanh tác vụ

    # Tạo cửa sổ xem trước
    preview_window = tk.Toplevel(root)
    preview_window.title("Xem trước phụ đề SRT")
    preview_window.geometry("600x400")
    
    # Biến cửa sổ thành modal
    preview_window.transient(root)  # Gắn cửa sổ con vào cửa sổ cha
    preview_window.grab_set()       # Chặn tương tác với cửa sổ cha
    
    # Text widget để hiển thị nội dung SRT
    srt_text = scrolledtext.ScrolledText(preview_window, wrap="word", height=20, width=70)
    srt_text.pack(padx=5, pady=5, fill="both", expand=True)
    srt_text.insert(tk.END, srt_content)
    srt_text.config(state="normal")  # Cho phép chỉnh sửa (nếu muốn)

    # Frame chứa các nút
    button_frame = tk.Frame(preview_window)
    button_frame.pack(pady=10)

    # Nút Lưu
    save_button = tk.Button(
        button_frame,
        text="Cập nhật và Đóng",
        command=lambda: [save_callback(srt_text.get("1.0", tk.END)), preview_window.destroy()],
    )
    save_button.pack(side=tk.LEFT, padx=5)

    # Nút Hủy
    cancel_button = tk.Button(
        button_frame,
        text="Hủy",
        command=lambda: [save_callback(srt_content), preview_window.destroy()],
    )
    cancel_button.pack(side=tk.LEFT, padx=5)

    # Đảm bảo cửa sổ được căn giữa cửa sổ cha
    preview_window.update_idletasks()
    x = root.winfo_x() + (root.winfo_width() - preview_window.winfo_width()) // 2
    y = root.winfo_y() + (root.winfo_height() - preview_window.winfo_height()) // 2
    preview_window.geometry(f"+{x}+{y}")

    # Giữ cửa sổ trên cùng (tuỳ chọn)
    preview_window.attributes("-topmost", True)

    # Đưa focus vào cửa sổ xem trước
    preview_window.focus_set()
    
    
def start_processing(
    file_sub,
    images_dirr,
    delete_raw_texts,
    delete_texts,
    nen_raw_texts,
    progress_callback,
):
    """Hàm chính xử lý và chạy OCR."""
    global total_images, completed_scans, STOP_FLAG, start_time_processing
    
    _, _, _, _, _, threads, _ = load_config()
    
    start_time_processing = time.time()
    completed_scans = 0
    total_images = 0

    credentials = get_credentials()
    http = credentials.authorize(httplib2.Http())
    service = discovery.build("drive", "v3", http=http)

    current_directory = Path(Path.cwd())
    images_dir = Path(images_dirr)
    raw_texts_dir = Path(f"{current_directory}/raw_texts")
    texts_dir = Path(f"{current_directory}/texts")

    subtitle_path = Path(file_sub)
    if subtitle_path.suffix != ".srt":
        subtitle_path = subtitle_path.with_suffix(".srt")

    try:
        # Kiểm tra sự tồn tại của thư mục hình ảnh
        if not images_dir.exists():
            print(f"Không tìm thấy thư mục: {images_dir}")
            log_message(f"❌ Lỗi: Thư mục {images_dir} không tồn tại.")
            messagebox.showerror(
                "Lỗi",
                f"Thư mục hình ảnh '{images_dirr}' không tồn tại.\nVui lòng kiểm tra lại đường dẫn.",
            )
            return

        if not raw_texts_dir.exists():
            raw_texts_dir.mkdir()
        if not texts_dir.exists():
            texts_dir.mkdir()

        images = []
        for extension in ("*.jpeg", "*.jpg", "*.png", "*.bmp", "*.gif"):
            images.extend(list(Path(images_dirr).rglob(extension)))

        total_images = len(images)
        
        log_message(f"|| Số luồng xử lý cùng lúc: {threads}")
        log_message(f"👀 Tổng số ảnh tìm thấy trong thư mục '{images_dirr}': {total_images}")

        if total_images == 0:
            messagebox.showerror(
                "Lỗi",
                f"Thư mục '{images_dirr}' không chứa hình ảnh hợp lệ.\n"
                "Hãy kiểm tra định dạng: JPEG, PNG, BMP, GIF.",
            )
            log_message(f"❌ Lỗi: Thư mục '{images_dirr}' không chứa hình ảnh hợp lệ.")
            return

        with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
            future_to_image = {
                executor.submit(
                    ocr_image,
                    image,
                    index + 1,
                    credentials,
                    current_directory,
                    progress_callback,
                ): image
                for index, image in enumerate(images)
            }

            for future in concurrent.futures.as_completed(future_to_image):
                if STOP_FLAG:
                    break
                try:
                    future.result()
                except Exception as exc:
                    print(f"{future_to_image[future]} generated an exception: {exc}")
                else:
                    with PROGRESS_LOCK:
                        completed_scans += 1
                        progress_callback()

        if STOP_FLAG:
            log_message("✅ Quá trình đã được dừng.")
            messagebox.showinfo("Dừng", "Quá trình đã dừng lại.")
            return

        # Tạo nội dung SRT từ SRT_FILE_LIST
        srt_content = ""
        for i in sorted(SRT_FILE_LIST):
            srt_content += "".join(SRT_FILE_LIST[i])

        # Hàm callback để lưu file SRT
        def save_srt_content(content):
            try:
                with open(subtitle_path, "w", encoding="utf-8") as srt_file:
                    srt_file.write(content)
                log_message(f"✅ Đã lưu file SRT: {subtitle_path}")
                #messagebox.showinfo("Hoàn thành", f"Đã lưu file SRT: {subtitle_path}")
            except Exception as e:
                log_message(f"❌ Lỗi khi lưu file SRT: {e}")
                messagebox.showerror("Lỗi", f"Không thể lưu file SRT: {e}")
            finally:
                finalize_processing(
                    subtitle_path, delete_raw_texts, delete_texts, nen_raw_texts, raw_texts_dir, texts_dir
                )

        # Hiển thị cửa sổ xem trước
        preview_srt(srt_content, save_srt_content)

    except Exception as e:
        log_message(f"❌ Lỗi trong quá trình xử lý: {e}")
        messagebox.showerror("Lỗi", f"Xảy ra lỗi trong quá trình xử lý: {e}")
        finalize_processing(
            subtitle_path, delete_raw_texts, delete_texts, nen_raw_texts, raw_texts_dir, texts_dir
        )

def finalize_processing(subtitle_path, delete_raw_texts, delete_texts, nen_raw_texts, raw_texts_dir, texts_dir):
    """Xử lý các bước cuối sau khi hoàn thành hoặc lỗi."""
    if nen_raw_texts:
        try:
            zip_file_path = shutil.make_archive(
                str(subtitle_path), "zip", str(raw_texts_dir)
            )
            new_zip_file_path = zip_file_path.replace(".srt.zip", ".zip")
            os.rename(zip_file_path, new_zip_file_path)
            print(f"✅ Đã nén thư mục {raw_texts_dir}")
            log_message(f"✅ Đã nén thư mục: {raw_texts_dir}")
        except Exception as e:
            print(f"❌ Lỗi khi nén thư mục {raw_texts_dir}: {e}")
            messagebox.showerror("Lỗi", f"Không thể nén thư mục {raw_texts_dir}: {e}")

    if delete_raw_texts:
        try:
            shutil.rmtree(raw_texts_dir)
            print(f"Đã xóa thư mục {raw_texts_dir}")
            log_message(f"✅ Đã xóa thư mục: {raw_texts_dir}")
        except Exception as e:
            print(f"Lỗi khi xóa thư mục raw_texts: {e}")
            log_message(f"❌ Lỗi: {e}")
            messagebox.showerror("Lỗi", f"Không thể xóa thư mục raw_texts: {e}")

    if delete_texts:
        try:
            shutil.rmtree(texts_dir)
            print(f"Đã xóa thư mục {texts_dir}")
            log_message(f"✅ Đã xóa thư mục: {texts_dir}")
        except Exception as e:
            print(f"Lỗi khi xóa thư mục texts: {e}")
            messagebox.showerror("Lỗi", f"Không thể xóa thư mục texts: {e}")

    end_time_processing = time.time()
    total_time = end_time_processing - start_time_processing
    formatted_time = time.strftime("%H:%M:%S", time.gmtime(total_time))

    status_label.config(text=f"✅ Hoàn thành OCR {total_images} ảnh. Tổng thời gian: {formatted_time}")
    start_button.config(state=tk.NORMAL)
    VSF_button.config(state=tk.NORMAL)
    stop_button.config(state=tk.DISABLED)
    subtitle_button.config(state=tk.NORMAL)
    images_button.config(state=tk.NORMAL)
    log_message(f"✅ Hoàn thành OCR {total_images} hình ảnh.")
    log_message(f"✅ Thời gian xử lý OCR: {formatted_time}")


# Các hàm GUI (update_crop_values, set_entries_state, validate_float_input) giữ nguyên

# Hàm kiểm tra đầu vào chỉ là số và dấu chấm
def validate_float_input(action, value):
    if action != "1":  # Không phải hành động thêm ký tự
        return True
    return value.replace(".", "", 1).isdigit()

# Hàm thay đổi trạng thái của các ô nhập liệu
def set_entries_state(state):
    entry_crop_top.config(state=state)
    entry_crop_bottom.config(state=state)
    entry_crop_left.config(state=state)
    entry_crop_right.config(state=state)

# Hàm cập nhật giá trị crop dựa trên profile được chọn
def update_crop_values(event=None, top=None, bottom=None, left=None, right=None):
    """Cập nhật giá trị Crop vào các ô nhập liệu tương ứng"""
    # Lấy crop_profiles từ config
    _, _, _, _, _, threads, crop_profiles = load_config()
    if top is not None:
        crop_top_var.set(top)
    if bottom is not None:
        crop_bottom_var.set(bottom)
    if left is not None:
        crop_left_var.set(left)
    if right is not None:
        crop_right_var.set(right)

    selected_profile = profile_combobox.get()
    if selected_profile in crop_profiles:
        crop_top_var.set(crop_profiles[selected_profile]["top"])
        crop_bottom_var.set(crop_profiles[selected_profile]["bottom"])
        crop_left_var.set(crop_profiles[selected_profile]["left"])
        crop_right_var.set(crop_profiles[selected_profile]["right"])
        set_entries_state("readonly")
        toado_button.config(state=tk.DISABLED)
        VSF_button.config(state=tk.NORMAL)
    elif selected_profile == "Tuỳ chỉnh":
        set_entries_state("normal")
        toado_button.config(state=tk.NORMAL)
        VSF_button.config(state=tk.DISABLED)
    else:
        set_entries_state("readonly")

def on_start_button_click():
    """Xử lý sự kiện khi nút Start được click."""
    global STOP_FLAG, LOG_FILE_PATH, subtitle_button, images_button
    STOP_FLAG = False
    file_sub = subtitle_entry.get()
    images_dirr = images_entry.get()

    delete_raw_texts = delete_raw_texts_var.get()
    delete_texts = delete_texts_var.get()
    nen_raw_texts = nen_raw_texts_var.get()

    if not file_sub or not images_dirr:
        log_message("⚠️ Vui lòng nhập đầy đủ thông tin...")
        messagebox.showwarning("Cảnh báo", "Vui lòng nhập đầy đủ thông tin.")
        return

    # Lưu cấu hình vào file
    save_config(
        FOLDER_ID, delete_raw_texts, delete_texts, nen_raw_texts, videosubfinder_path, crop_profiles
    )

    LOG_FILE_PATH = file_sub if file_sub.endswith(".srt") else f"{file_sub}.srt"
    LOG_FILE_PATH = LOG_FILE_PATH.replace(".srt", ".log")

    # Ghi log
    with open(LOG_FILE_PATH, "w", encoding="utf-8") as log_file:
        log_file.write("=== STARTING NEW SESSION ===\n")

    log_message("🎬 Bắt đầu quá trình xử lý...")

    # Disable/Enable buttons
    start_button.config(state=tk.DISABLED)
    stop_button.config(state=tk.NORMAL)
    VSF_button.config(state=tk.DISABLED)
    subtitle_button.config(state=tk.DISABLED)
    images_button.config(state=tk.DISABLED)
    progress_bar["value"] = 0

    # Run OCR in a separate thread
    threading.Thread(
        target=start_processing,
        args=(
            file_sub,
            images_dirr,
            delete_raw_texts,
            delete_texts,
            nen_raw_texts,
            progress_callback,
        ),
    ).start()


def progress_callback():
    """Cập nhật thanh tiến trình."""
    global completed_scans, total_images, status_label

    progress_bar["value"] = (completed_scans / total_images) * 100
    status_label.config(text=f"✅ Đã OCR: {completed_scans}/{total_images}")
    root.update_idletasks()


def choose_images_directory(entry_images_dir):
    """Chọn thư mục hình ảnh."""
    images_dirr = filedialog.askdirectory(title="Chọn thư mục chứa hình ảnh")
    if images_dirr:
        entry_images_dir.delete(0, tk.END)
        entry_images_dir.insert(0, images_dirr)
        log_message(f"✅ Đường dẫn thư mục đã chọn: {images_dirr}")
    else:
        log_message("⚠️ Không có thư mục nào được chọn.")


def log_message(message):
    """Ghi log vào text widget và file."""
    global log_text, LOG_FILE_PATH
    log_text.config(state="normal")
    log_text.insert(tk.END, message + "\n")
    log_text.see(tk.END)
    log_text.config(state="disabled")
    root.update_idletasks()

    if LOG_FILE_PATH:
        with open(LOG_FILE_PATH, "a", encoding="utf-8") as log_file:
            timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            log_file.write(f"[{timestamp}] {message}\n")


def create_gui():
    """Tạo giao diện người dùng."""
    global root, entry_subtitle, subtitle_entry, entry_images_dir, progress_bar, start_button, delete_raw_texts_var, delete_texts_var, nen_raw_texts_var, log_text, status_label, subtitle_button, images_button, create_txtimages_var

    log_frame = tk.Frame(root)
    log_frame.pack(pady=(0, 5), fill="both", expand=True)
    log_text = tk.Text(
        log_frame, height=5, wrap="word", state="disabled", bg="#0C0C0C", fg="#CCCCCC"
    )
    log_text.pack(fill="both", expand=True, padx=5, pady=5)


def choose_subtitle_file(entry_subtitle):
    """Chọn nơi lưu file phụ đề."""
    subtitle_file = filedialog.asksaveasfilename(
        defaultextension=".srt",
        filetypes=[("SRT files", "*.srt")],
        title="Lưu file phụ đề",
    )
    if subtitle_file:
        entry_subtitle.delete(0, tk.END)
        entry_subtitle.insert(0, subtitle_file)
        log_message(f"✅ Nơi lưu phụ đề: {subtitle_file}")
    else:
        log_message("⚠️ BẮT BUỘC PHẢI CHỌN LƯU PHỤ ĐỀ.")


def get_video_duration_opencv(video_path):
    """Lấy thời lượng video bằng OpenCV."""
    try:
        cap = cv2.VideoCapture(video_path)
        if not cap.isOpened():
            print("Không thể mở video.")
            return None

        fps = cap.get(cv2.CAP_PROP_FPS)
        total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        duration_seconds = total_frames / fps
        cap.release()

        timedelta_obj = datetime.timedelta(seconds=duration_seconds)

        # Định dạng timedelta thành "HH:MM:SS"
        hours, remainder = divmod(timedelta_obj.seconds, 3600)
        minutes, seconds = divmod(remainder, 60)
        formatted_duration = f"{hours:02d}:{minutes:02d}:{seconds:02d}"

        return formatted_duration
    except Exception as e:
        print(f"Lỗi khi lấy thời lượng bằng OpenCV: {e}")
        return None


def choose_video_file(
    entry_video,
    entry_crop_left,
    entry_crop_right,
    entry_crop_top,
    entry_crop_bottom,
    subtitle_entry,
    progress_bar,
    root,
    log_message,
    videosubfinder_path,
    VSF_button,
    status_label,
    start_button,
    create_txtimages_var,
):
    """Chọn video và chạy VideoSubFinder, sử dụng video từ entry nếu đã có."""
    global DURATION
    selected_profile = profile_combobox.get()
    # Nếu profile không phải "Tuỳ chỉnh" hoặc entry_video rỗng, mở hộp thoại chọn video
    if selected_profile != "Tuỳ chỉnh" or not entry_video.get():
        video_file = filedialog.askopenfilename(
            filetypes=[("Video files", "*.mp4 *.avi *.mov *.mkv")],
            title="Chọn video để xử lý",
        )
        if not video_file:
            log_message("⚠️ Không có video nào được chọn.")
            return
        entry_video.delete(0, tk.END)
        entry_video.insert(0, video_file)
    else:
        video_file = entry_video.get()

    log_message(f"✅ Đã chọn video: {video_file}")

    # Lấy thời lượng video
    DURATION = get_video_duration_opencv(video_file)
    if DURATION:
        status_label.config(text=f"⏳ Thời lượng Video: | {DURATION}")
    else:
        status_label.config(text="⏳ Đang xử lý")

    subtitle_file = os.path.splitext(video_file)[0] + ".srt"
    subtitle_entry.delete(0, tk.END)
    subtitle_entry.insert(0, subtitle_file)

    try:
        crop_left = float(entry_crop_left.get())
        crop_right = float(entry_crop_right.get())
        crop_top = float(entry_crop_top.get())
        crop_bottom = float(entry_crop_bottom.get())
    except ValueError:
        log_message(
            "❌ Lỗi nhập liệu: Vui lòng nhập đúng giá trị số cho các tham số crop."
        )
        messagebox.showerror(
            "Lỗi nhập liệu", "Vui lòng nhập đúng giá trị số cho các tham số crop."
        )
        return

    if not os.path.exists(videosubfinder_path):
        log_message(f"❌ Lỗi: Không tìm thấy VideoSubFinder tại: {videosubfinder_path}")
        messagebox.showerror(
            "Lỗi", f"Không tìm thấy VideoSubFinder tại: {videosubfinder_path}"
        )
        return

    output_file = video_file.rsplit(".", 1)[0] + "_out"
    output_folder = "TXTImages" if create_txtimages_var.get() else "RGBImages"

    command = [
        videosubfinder_path,
        "-c",
        "-r",
    ] + (["-ccti"] if create_txtimages_var.get() else []) + [
        "-i",
        video_file,
        "-o",
        output_file,
        "-te",
        str(crop_top),
        "-be",
        str(crop_bottom),
        "-le",
        str(crop_left),
        "-re",
        str(crop_right),
    ]

    VSF_button.config(state=tk.DISABLED)
    start_button.config(state=tk.DISABLED)
    subtitle_button.config(state=tk.DISABLED)
    images_button.config(state=tk.DISABLED)

    def run_videosubfinder():
        """Chạy VideoSubFinder trong một thread riêng."""
        global images_dirr, rbg_images_folder, DURATION

        try:
            log_message(f"🚀 Đang chạy lệnh VideoSubFinder: {' '.join(command)}")

            video_output_folder = output_file
            images_folder = os.path.join(video_output_folder, output_folder)
            rbg_images_folder = os.path.join(video_output_folder, "RGBImages")

            #🟢 Bắt đầu giám sát ngay khi VideoSubFinder chạy
            log_message(f"👀 Bắt đầu giám sát thư mục RGBImages tại: {rbg_images_folder}")
            # Nếu thư mục chưa tồn tại, chờ nó được tạo
            threading.Thread(target=wait_for_rgbimages_and_monitor, args=[rbg_images_folder, DURATION]).start()

            process = subprocess.Popen(
                command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
            )

            # Sử dụng vòng lặp để đọc và cập nhật tiến trình
            while True:
                line = process.stdout.readline()
                if not line and process.poll() is not None:
                    break

                if line:
                    line = line.strip()
                    log_message(line)

                    match = re.search(r"%(\d+)", line)  # Tìm kiếm % theo sau là số
                    if match:
                        try:
                            percentage = int(match.group(1))
                            root.after(0, progress_bar.config, {"value": percentage})

                        except ValueError:
                            log_message("Lỗi chuyển đổi phần trăm")

            stderr_output = process.stderr.read()
            if stderr_output:
                log_message(f"❌ Lỗi VideoSubFinder: {stderr_output}")
                root.after(
                    0,
                    lambda: messagebox.showerror(
                        "Lỗi", f"Quá trình xử lý video thất bại: {stderr_output}"
                    ),
                )
                root.after(
                    0, status_label.config, {"text": "❌ Lỗi!"}
                )  # Cập nhật trạng thái lỗi

            returncode = process.wait()

            if returncode != 0:
                log_message(f"\n✅ VideoSubFinder đã hoàn tất xử lý ảnh từ Video")
                root.after(
                    0,
                    lambda: messagebox.showinfo(
                        "Thông báo", f"VideoSubFinder đã hoàn tất xử lý ảnh từ Video"
                    ),
                )
                root.after(
                    0, status_label.config, {"text": f"Đã xử lý xong Video! | 📂 Tổng ảnh: {EVENT_HANDLER.file_count}"}
                )  # Cập nhật trạng thái lỗi
            else:
                log_message("✅ Quá trình xử lý video đã hoàn tất.")
                root.after(
                    0, status_label.config, {"text": "✅ Hoàn thành!"}
                )  # Cập nhật trạng thái hoàn thành

            if os.path.exists(images_folder):
                root.after(0, lambda: images_entry.delete(0, "end"))
                root.after(0, lambda: images_entry.insert(0, images_folder))
                images_dirr = images_folder  # <--- -="" 0="" 1="" 2="" _="load_config()" __init__="" a="" and="" ang="" app="CropSelectorApp(" args="[rbg_images_folder," as="" attrs="[" avi="" bg="black" c="" canvas="" ch="" choose_video_for_crop="" create="" create_txtimages_var="" cropselectorapp:="" cursor="arrow" d="" def="" displaying="" drawing="" duration="" e:="" e="" elements="" else:="" entry_crop_bottom="" entry_crop_left="" entry_crop_right="" entry_crop_top="" entry_video="" except="" exception="" expand="" f="" file:="" filenotfounderror:="" files="" filetypes="[(" fill="" finally:="" for="" frame="" gi="" global="" h="" i.="" i:="" i="" ideo="" ideosubfinderwxw_intel.exe="" if="" images_button.config="" images_dirr="" in="" init_ui="" instead="" ix:="" k="" kh="" khi="" ki="" l="" lambda:="" log_message="" m="" main_frame.pack="" main_frame="tk.Frame(self.root)" messagebox.askokcancel="" messagebox.showerror="" mkv="" modal.="" mov="" mp4="" mu="" n.="" n="" name="" ng.="" ng:="" ng="" nh.="" nh:="" nh="" observer.is_alive="" observer.join="" observer.stop="" observer="" of="" on_exit="" organize="" os.kill="" p="" padx="1," pady="1)" pass="" ph="" pid="" process.info="" process="" progress_bar="" psutil.process_iter="" python="" rgbimages="" root.after="" root.destroy="" root.winfo_height="" root.winfo_width="" root="" root_crop.attributes="" root_crop.geometry="" root_crop.grab_set="" root_crop.mainloop="" root_crop.title="" root_crop.transient="" root_crop.update_idletasks="" root_crop.winfo_height="" root_crop.winfo_width="" root_crop="" root_main="" run_crop_selector="" s="" self.bottom_line_id="None" self.bottom_line_y="" self.bottom_var="tk.StringVar(value=" self.canvas.bind="" self.canvas.pack="" self.canvas="tk.Canvas(main_frame," self.canvas_height="0" self.canvas_width="0" self.cap="cv2.VideoCapture(video_path)" self.create_txtimages_var="create_txtimages_var" self.current_frame_index="0" self.end_x="" self.end_y="0," self.entry_crop_bottom="entry_crop_bottom" self.entry_crop_left="entry_crop_left" self.entry_crop_right="entry_crop_right" self.entry_crop_top="entry_crop_top" self.entry_video="entry_video" self.frame="None" self.init_ui="" self.is_playing="False" self.left_line_id="None" self.left_line_x="" self.left_var="tk.StringVar(value=" self.log_message="log_message" self.photo="None" self.progress_bar="progress_bar" self.rect_id="None" self.right_line_id="None" self.right_line_x="0," self.right_var="tk.StringVar(value=" self.root="root" self.root_main="root_main" self.selected_line="None" self.slider="None" self.start_button="start_button" self.start_x="" self.start_y="" self.status_label="status_label" self.subtitle_entry="subtitle_entry" self.time_label="None" self.top_line_id="None" self.top_line_y="" self.top_var="tk.StringVar(value=" self.total_frames="0" self.update_crop_callback="update_crop_callback" self.video_height="0" self.video_path="video_path" self.video_width="0" self.videosubfinder_path="videosubfinder_path" self.vsf_button="VSF_button" self="" signal.sigterm="" start="" start_button.config="" start_button="" start_monitoring_rgbimages="" state="" status_label.config="" status_label="" subtitle_button.config="" subtitle_entry="" sys.exit="" t="" target="run_videosubfinder).start()" text="" th="" the="" thi="" tho="" thread.is_alive="" thread.join="" thread="" threading.thread="" threading.timer="" threads="" ti="" timeout="1)" title="Chọn video để chọn tọa độ" tk.normal="" to="" topmost="" tr="" tra="" true="" try:="" u="" update_crop_callback="" update_crop_values="" use="" uttonpress-1="" v="" video="" video_duration="" video_file:="" video_file="" video_path="" videosubfinder="" videosubfinder_path="" videosubfinderwxw_intel.exe...="" videosubfinderwxw_intel.exe:="" videosubfinderwxw_intel.exe="" vsf="" vsf_button.config="" vsf_button="" window="" worker="" worker_threads:="" worker_threads="" x600="" x="" y="">", self.on_mouse_press)
        self.canvas.bind("", self.on_mouse_drag)
        self.canvas.bind("", self.on_mouse_release)
        self.canvas.bind("", self.on_mouse_move)

        # Frame for parameters
        param_frame = tk.Frame(main_frame)
        param_frame.pack(fill=tk.X, pady=5)

        tk.Label(param_frame, text="Top:").pack(side=tk.LEFT, padx=5)
        tk.Entry(param_frame, textvariable=self.top_var, width=10, state="readonly",
                 font=("Consolas", 10, "bold")).pack(side=tk.LEFT, padx=2)

        tk.Label(param_frame, text="Bottom:").pack(side=tk.LEFT, padx=5)
        tk.Entry(param_frame, textvariable=self.bottom_var, width=10, state="readonly",
                 font=("Consolas", 10, "bold")).pack(side=tk.LEFT, padx=2)

        tk.Label(param_frame, text="Left:").pack(side=tk.LEFT, padx=5)
        tk.Entry(param_frame, textvariable=self.left_var, width=10, state="readonly",
                 font=("Consolas", 10, "bold")).pack(side=tk.LEFT, padx=2)

        tk.Label(param_frame, text="Right:").pack(side=tk.LEFT, padx=5)
        tk.Entry(param_frame, textvariable=self.right_var, width=10, state="readonly",
                 font=("Consolas", 10, "bold")).pack(side=tk.LEFT, padx=2)

        ttk.Button(param_frame, text="Xác nhận vùng chọn", command=self.confirm_selection).pack(side=tk.LEFT, padx=5)

        # Timeline slider (Horizontal Scale)
        timeline_frame = tk.Frame(main_frame)  # Created new frame to house both slider and time
        timeline_frame.pack(fill=tk.X, pady=5)

        # Timeline slider (Horizontal Scale)
        self.slider = ttk.Scale(timeline_frame, orient="horizontal", command=self.seek_video)
        self.slider.pack(side=tk.LEFT, expand=True, fill=tk.X)

        # Time label
        self.time_label = tk.Label(timeline_frame, text="00:00:00.000", font=("Helvetica", 10))
        self.time_label.pack(side=tk.LEFT, padx=2)
        ttk.Button(timeline_frame, text=">>1giây", command=self.fast_forward_1s, width=8).pack(side=tk.LEFT, padx=5)

        self.root.after(100, self.load_video)

    def load_video(self):
        """Tải video và thiết lập kích thước phù hợp với cửa sổ"""
        self.cap = cv2.VideoCapture(self.video_path)
        if not self.cap.isOpened():
            messagebox.showerror("Error", f"Could not open video file at {self.video_path}")
            return

        # Lấy kích thước gốc của video
        self.video_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        self.video_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

        # Lấy kích thước cửa sổ
        self.root.update_idletasks()  # Cập nhật UI trước khi lấy kích thước
        window_width = self.root.winfo_width()
        window_height = self.root.winfo_height()

        # Tính toán tỷ lệ co để video vừa với cửa sổ
        scale_ratio = min(window_width / self.video_width, window_height / self.video_height)

        # Cập nhật kích thước Canvas dựa trên tỷ lệ co
        self.canvas_width = int(self.video_width * scale_ratio)
        self.canvas_height = int(self.video_height * scale_ratio)
        self.canvas.config(width=self.canvas_width, height=self.canvas_height)

        # Khởi tạo các toạ độ đường kẻ
        if self.video_width > 0 and self.video_height > 0:
            self.top_line_y = int(0.8574 * self.video_height)
            self.bottom_line_y = int(0.9898 * self.video_height)
            self.left_line_x = int(0.1 * self.video_width)
            self.right_line_x = int(0.9 * self.video_width)
        else:
            self.top_line_y, self.bottom_line_y, self.left_line_x, self.right_line_x = 0, 0, 0, 0
        # Lấy tổng số frame và thiết lập thanh trượt
        self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
        self.slider.config(to=self.total_frames - 1)
        self.current_frame_index = 0

        self.show_frame()  # Hiển thị frame đầu tiên

    def seek_video(self, value):
        """Seeks to a particular frame based on scale's value."""
        frame_index = int(float(value))
        if frame_index != self.current_frame_index:
            self.current_frame_index = frame_index
            self.show_frame()

    def fast_forward_1s(self):
        """Fast forward the video by 1 seconds."""
        if not self.cap:
            return

        # Calculate the current time in seconds
        current_time = self.current_frame_index / self.cap.get(cv2.CAP_PROP_FPS)

        # Add 10 seconds to the current time
        new_time = current_time + 1

        # Calculate the new frame index
        new_frame_index = int(new_time * self.cap.get(cv2.CAP_PROP_FPS))

        # Ensure the new frame index is within bounds
        if new_frame_index >= self.total_frames:
            new_frame_index = self.total_frames - 1

        # Update the current frame index and seek to the new frame
        self.current_frame_index = new_frame_index
        self.slider.set(new_frame_index)
        self.show_frame()

    def show_frame(self):
        """Hiển thị frame video lên canvas với kích thước phù hợp"""
        if not self.cap or not self.cap.isOpened():
            return

        # Đảm bảo Canvas đã cập nhật kích thước
        self.canvas_width = self.canvas.winfo_width()
        self.canvas_height = self.canvas.winfo_height()

        self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame_index)
        ret, frame = self.cap.read()
        if not ret:
            return

        # Resize frame để vừa với kích thước Canvas
        if self.video_width > 0 and self.video_height > 0:
            frame = cv2.resize(frame, (self.canvas_width, self.canvas_height))
        self.frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

        img = Image.fromarray(self.frame)
        self.photo = ImageTk.PhotoImage(image=img)

        self.canvas.delete("all")  # Xóa hình cũ trước khi vẽ
        self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo)

        # Vẽ lại bounding box nếu có
        self.draw_bounding_lines()

        # Cập nhật thời gian
        current_time = self.current_frame_index / self.cap.get(cv2.CAP_PROP_FPS)
        self.update_time_display(current_time)

    def on_mouse_press(self, event):
        # Chuyển đổi tọa độ chuột trên canvas về tọa độ trên video gốc
        x = int(event.x * (self.video_width / self.canvas_width)) if self.canvas_width > 0 else event.x
        y = int(event.y * (self.video_height / self.canvas_height)) if self.canvas_height > 0 else event.y
        self.selected_line = self.get_clicked_line(x, y)

    def get_clicked_line(self, x, y):
        """Determine which line is selected."""
        tolerance = 5  # Adjust as needed
        if abs(y - self.top_line_y) < tolerance:
            return "top"
        elif abs(y - self.bottom_line_y) < tolerance:
            return "bottom"
        elif abs(x - self.left_line_x) < tolerance:
            return "left"
        elif abs(x - self.right_line_x) < tolerance:
            return "right"
        else:
            return None

    def on_mouse_drag(self, event):
        """Draws a rectangle during mouse drag."""
        # Chuyển đổi tọa độ chuột trên canvas về tọa độ trên video gốc
        x = int(event.x * (self.video_width / self.canvas_width)) if self.canvas_width > 0 else event.x
        y = int(event.y * (self.video_height / self.canvas_height)) if self.canvas_height > 0 else event.y

        if self.selected_line == "top":
            self.top_line_y = y
            self.top_line_y = max(0, min(self.top_line_y, self.bottom_line_y))  # Keep within bounds
        elif self.selected_line == "bottom":
            self.bottom_line_y = y
            self.bottom_line_y = max(self.top_line_y, min(self.bottom_line_y, self.video_height))  # Keep within bounds
        elif self.selected_line == "left":
            self.left_line_x = x
            self.left_line_x = max(0, min(self.left_line_x, self.right_line_x))  # Keep within bounds
        elif self.selected_line == "right":
            self.right_line_x = x
            self.right_line_x = max(self.left_line_x, min(self.right_line_x, self.video_width))  # Keep within bounds

        self.draw_bounding_lines()
        self.update_parameters()

    def on_mouse_release(self, event):
        """Finalize the drawing of rectangle and update parameter."""
        self.selected_line = None
        self.canvas.config(cursor="arrow")  # Reset cursor

    def update_parameters(self):
        """Calculates and updates the parameter variables based on selected rectangle"""
        if not self.video_width or not self.video_height:
            return

        # Calculate the cropping percentages:
        top_crop = 1 - (self.top_line_y / self.video_height)
        bottom_crop = (self.video_height - self.bottom_line_y) / self.video_height
        left_crop = self.left_line_x / self.video_width
        right_crop = self.right_line_x / self.video_width

        self.top_var.set(f"{top_crop:.4f}")
        self.bottom_var.set(f"{bottom_crop:.4f}")
        self.left_var.set(f"{left_crop:.4f}")
        self.right_var.set(f"{right_crop:.4f}")

    def update_time_display(self, current_time):
        """Formats and updates the time display label."""
        time_obj = datetime.datetime.min + datetime.timedelta(seconds=current_time)
        time_str = time_obj.strftime("%H:%M:%S.%f")[:-3]  # format the time to HH:MM:SS.MS
        self.time_label.config(text=time_str)

    def draw_bounding_lines(self):
        """Draws bounding lines on canvas based on the coordinates"""
        if not self.video_width or not self.video_height:
            return

        # Chuyển đổi tọa độ video gốc sang tọa độ canvas để vẽ đường viền
        canvas_top_y = int(self.top_line_y * (self.canvas_height / self.video_height)) if self.canvas_height > 0 and self.video_height > 0 else self.top_line_y
        canvas_bottom_y = int(self.bottom_line_y * (self.canvas_height / self.video_height)) if self.canvas_height > 0 and self.video_height > 0 else self.bottom_line_y
        canvas_left_x = int(self.left_line_x * (self.canvas_width / self.video_width)) if self.canvas_width > 0 and self.video_width > 0 else self.left_line_x
        canvas_right_x = int(self.right_line_x * (self.canvas_width / self.video_width)) if self.canvas_width > 0 and self.video_width > 0 else self.right_line_x

        # XÓA CÁC ĐƯỜNG KẺ CŨ TRƯỚC KHI VẼ MỚI
        self.canvas.delete("bounding_lines")

        # Vẽ các đường viền với tag "bounding_lines"
        self.top_line_id = self.canvas.create_line(0, canvas_top_y, self.canvas_width, canvas_top_y, fill="yellow",
                                                    width=2, tags="bounding_lines")
        self.bottom_line_id = self.canvas.create_line(0, canvas_bottom_y, self.canvas_width, canvas_bottom_y,
                                                      fill="yellow", width=2, tags="bounding_lines")
        self.left_line_id = self.canvas.create_line(canvas_left_x, 0, canvas_left_x, self.canvas_height,
                                                   fill="yellow", width=2, tags="bounding_lines")
        self.right_line_id = self.canvas.create_line(canvas_right_x, 0, canvas_right_x, self.canvas_height,
                                                    fill="yellow", width=2, tags="bounding_lines")

    def confirm_selection(self):
        """Xác nhận vùng chọn và cập nhật giá trị Crop ở màn hình chính"""
        top_crop = float(self.top_var.get())
        bottom_crop = float(self.bottom_var.get())
        left_crop = float(self.left_var.get())
        right_crop = float(self.right_var.get())

        # Gọi hàm cập nhật giá trị Crop ở màn hình chính
        self.update_crop_callback(
            top=top_crop,
            bottom=bottom_crop,
            left=left_crop,
            right=right_crop
        )
        
        # Kiểm tra nếu profile là "Tùy chỉnh" thì chạy VSF
        if profile_combobox.get() == "Tuỳ chỉnh":
            # Sử dụng video_path đã chọn thay vì gọi lại hộp thoại
            self.entry_video.delete(0, tk.END)
            self.entry_video.insert(0, self.video_path)
            choose_video_file(
                self.entry_video,
                self.entry_crop_left,
                self.entry_crop_right,
                self.entry_crop_top,
                self.entry_crop_bottom,
                self.subtitle_entry,
                self.progress_bar,
                self.root_main,
                self.log_message,
                self.videosubfinder_path,
                self.VSF_button,
                self.status_label,
                self.start_button,
                self.create_txtimages_var
            )
            
        self.root.destroy()

    def on_mouse_move(self, event):
        """Changes the cursor when the mouse is over a bounding line."""
        # Chuyển đổi tọa độ chuột trên canvas về tọa độ trên video gốc
        x = int(event.x * (self.video_width / self.canvas_width)) if self.canvas_width > 0 else event.x
        y = int(event.y * (self.video_height / self.canvas_height)) if self.canvas_height > 0 else event.y

        line = self.get_clicked_line(x, y)
        if line in ("top", "bottom"):
            self.canvas.config(cursor="sb_v_double_arrow")  # Vertical double arrow
        elif line in ("left", "right"):
            self.canvas.config(cursor="sb_h_double_arrow")  # Horizontal double arrow
        else:
            self.canvas.config(cursor="arrow")  # Default cursor

# =============================================================================================
root = tk.Tk()
root.title("SEGG OCR Tool v1.37_Optimizer")
window_width = 622
window_height = 578
# Lấy kích thước màn hình
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()

# Tính toán vị trí để căn giữa
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)

# Đặt kích thước và vị trí cửa sổ
root.geometry(f"{window_width}x{window_height}+{x}+{y}")
button_width = 20

# Tên phụ đề
subtitle_frame = tk.Frame(root)
subtitle_frame.pack(pady=5, fill="x")  # Đặt frame để chứa các widget trên cùng một hàng
tk.Label(subtitle_frame, text="Tên file phụ đề:").pack(side="left", padx=5)
subtitle_entry = tk.Entry(subtitle_frame, width=58)  # Giảm kích thước width cho phù hợp
subtitle_entry.pack(side="left", padx=5)

subtitle_button = tk.Button(
    subtitle_frame,
    text="📂 Chọn nơi lưu sub",
    width=button_width,
    command=lambda: choose_subtitle_file(subtitle_entry),
)
subtitle_button.pack(side="right", padx=5)

# Thư mục hình ảnh
images_frame = tk.Frame(root)
images_frame.pack(pady=5, fill="x")  # Đặt frame để chứa các widget trên cùng một hàng
tk.Label(images_frame, text="Thư mục ảnh:").pack(side="left", padx=5)
images_entry = tk.Entry(images_frame, width=59)  # Giảm width cho Entry để vừa khung
images_entry.pack(side="left", padx=5)

images_button = tk.Button(
    images_frame,
    text="📂 Chọn thư mục ảnh",
    width=button_width,
    command=lambda: choose_images_directory(images_entry),
)
images_button.pack(side="right", padx=5)

# Khung nhập video
video_frame = tk.Frame(root)
video_frame.pack(
    pady=(0, 1), fill="x"
)  # Đặt frame để chứa các widget trên cùng một hàng
tk.Label(video_frame, text="Tệp tin video:").pack(side="left", padx=5)

entry_video = tk.Entry(video_frame, width=59)  # Giảm width cho Entry để vừa khung
entry_video.pack(side="left", padx=5)

# Tùy chọn crop video
crop_frame = tk.Frame(root)
crop_frame.pack(padx=10, pady=5, fill="x")

# Thêm nhãn và ô nhập liệu cho crop
crop_label = tk.Label(crop_frame, text="Crop (Top, Bottom, Left, Right):")
crop_label.pack(side="left", padx=5)

# Các biến lưu giá trị crop
crop_top_var = tk.StringVar(value="0")
crop_bottom_var = tk.StringVar(value="0")
crop_left_var = tk.StringVar(value="0")
crop_right_var = tk.StringVar(value="0")

validate_command = (root.register(validate_float_input), "%d", "%P")

entry_crop_top = tk.Entry(
    crop_frame,
    textvariable=crop_top_var,
    width=10,
    state="readonly",
    validate="key",
    validatecommand=validate_command,
)
entry_crop_top.pack(side="left", padx=2)

entry_crop_bottom = tk.Entry(
    crop_frame,
    textvariable=crop_bottom_var,
    width=10,
    state="readonly",
    validate="key",
    validatecommand=validate_command,
)
entry_crop_bottom.pack(side="left", padx=2)

entry_crop_left = tk.Entry(
    crop_frame,
    textvariable=crop_left_var,
    width=10,
    state="readonly",
    validate="key",
    validatecommand=validate_command,
)
entry_crop_left.pack(side="left", padx=2)

entry_crop_right = tk.Entry(
    crop_frame,
    textvariable=crop_right_var,
    width=10,
    state="readonly",
    validate="key",
    validatecommand=validate_command,
)
entry_crop_right.pack(side="left", padx=2)

# Thêm Combobox để chọn profile
profile_combobox = ttk.Combobox(
    crop_frame,
    values=["Chọn profile", "vlxx, javhd", "sextop", "phimKK", "titdam", "Tuỳ chỉnh"],
    state="readonly",
)
profile_combobox.pack(side="left", padx=5)
profile_combobox.set("Chọn profile")  # Đặt giá trị mặc định
profile_combobox.bind("<>", update_crop_values)

(
    FOLDER_ID,
    delete_raw_texts,
    delete_texts,
    nen_raw_texts,
    videosubfinder_path,
    threads,
    crop_profiles,
) = load_config()
# Checkbox Tùy chọn
delete_raw_texts_var = tk.BooleanVar(value=True if delete_raw_texts else False)
delete_texts_var = tk.BooleanVar(value=True if delete_texts else False)
nen_raw_texts_var = tk.BooleanVar(value=True if nen_raw_texts else False)
create_txtimages_var = tk.BooleanVar(value=False)
delete_options_frame = tk.Frame(root)
delete_options_frame.pack(pady=(0, 1), fill="x")  # Đặt frame

# Checkbox cho tùy chọn xóa thư mục raw_texts
tk.Checkbutton(
    delete_options_frame,
    text="Xóa folder raw_texts khi xong",
    variable=delete_raw_texts_var,
    anchor="w",
).pack(side="left", padx=5)

# Checkbox cho tùy chọn xóa thư mục texts
tk.Checkbutton(
    delete_options_frame,
    text="Xóa folder texts khi xong",
    variable=delete_texts_var,
    anchor="w",
).pack(side="left", padx=5)

# Checkbox cho tùy chọn nén thư mục raw_texts
tk.Checkbutton(
    delete_options_frame,
    text="Nén folder raw_texts",
    variable=nen_raw_texts_var,
    anchor="w",
).pack(side="left", padx=5)

# Checkbox cho tùy chọn tạo TXTImages
tk.Checkbutton(
    delete_options_frame,
    text="Tạo TXTImages",
    variable=create_txtimages_var,
    anchor="w",
).pack(side="left", padx=5)

# Tạo Frame chứa các nút Bắt đầu và Dừng
button_frame = tk.Frame(root)
button_frame.pack(pady=(0, 2), fill="x")
# Thêm nút Bắt đầu vào Frame
start_button = tk.Button(
    button_frame,
    text="🎬 Bắt đầu OCR",
    width=12,
    command=on_start_button_click,
    bg="#F50398",
)
start_button.pack(side="left", padx=5)
# Thêm nút Dừng vào Frame
stop_button = tk.Button(
    button_frame,
    text="❌ Dừng OCR",
    width=11,
    command=stop_processing,
    state=tk.DISABLED,
)
stop_button.pack(side="left", padx=2)

status_label = tk.Label(button_frame, text="Trạng thái chương trình: Sẵn sàng", fg="red")
status_label.pack(side="right", padx=2)  # Sắp xếp Label trạng thái gần nút

toado_button = tk.Button(
    video_frame,
    text="✨ Toạ độ",
    width=8,
    command=choose_video_for_crop,
)
toado_button.pack(side="right", padx=5)

VSF_button = tk.Button(
    video_frame,
    text="🚀 Chạy VSF",
    width=9,
    command=lambda: choose_video_file(
        entry_video,
        entry_crop_left,
        entry_crop_right,
        entry_crop_top,
        entry_crop_bottom,
        subtitle_entry,
        progress_bar,  # Thêm progress_bar
        root,  # Thêm root
        log_message,  # Thêm log_message
        videosubfinder_path,
        VSF_button,
        status_label,
        start_button,
        create_txtimages_var,
    ),
)
VSF_button.pack(side="right", padx=5)

progress_bar = ttk.Progressbar(
    root, orient="horizontal", length=612, mode="determinate"
)
progress_bar.pack(pady=(1, 0))

if __name__ == "__main__":
    (
        FOLDER_ID,
        delete_raw_texts,
        delete_texts,
        compress_raw_texts,
        videosubfinder_path,
        threads,
        crop_profiles,
    ) = load_config()
    create_gui()
    toado_button.config(state=tk.DISABLED)
    log_message(
        "|====================CHÚ Ý QUAN TRỌNG====================|\n\nVui lòng cấu hình trước API bằng tài khoản google của bạn.\nSau đó tải credentials.json đặt cùng thư mục chương trình.\n\n|=====================================Discord: ePubc#9826|\n"
    )
    # Gắn sự kiện đóng cửa sổ vào hàm xác nhận
    root.protocol("WM_DELETE_WINDOW", on_exit)
    root.mainloop()



Bài đăng nổi bật

Giới thiệu bộ lập trình giá rẻ CH341A và phần mềm NeoProgrammer 2.2.0.10 “quốc dân” cho dân điện tử

Trong lĩnh vực sửa chữa phần cứng, lập trình firmware hay nghiên cứu hệ thống nhúng, cái tên CH341A gần như đã trở thành “huyền thoại” tron...

Popular Posts