버전 3.11.4로 업데이트하며, 메모리 상황에 따른 동적 재시작 기능을 추가하고, GPT5 응답 처리 로직을 개선하였습니다. DLL 누락 문제를 해결하였으며, 업데이트 로그를 수정하여 변경 사항을 반영하였습니다.
This commit is contained in:
parent
6b9ac3321f
commit
672c5a3e4f
|
|
@ -0,0 +1,328 @@
|
||||||
|
; AutoPercenty3 Inno Setup Script
|
||||||
|
; 이 스크립트는 cx_Freeze로 빌드된 결과물이 있는 "build\exe.win-amd64-3.11" 폴더를 기반으로 인스톨러를 제작합니다.
|
||||||
|
; 20250823_090708에 생성됨
|
||||||
|
|
||||||
|
#define AppId "autopercenty"
|
||||||
|
#define MyAppName "Edit_PartTimer"
|
||||||
|
#define MyAppVersion "3.11.3"
|
||||||
|
#define MyAppPublisher "WhenRideMyCar"
|
||||||
|
#define MyAppProgramName "편집알바생"
|
||||||
|
#define MyAppDescription "편집알바생"
|
||||||
|
#define MyAppCopyright "Copyright 2024"
|
||||||
|
#define MyAppExeName "Edit_PartTimer3"
|
||||||
|
#define MySetupName "Edit_PartTimer Setup"
|
||||||
|
#define MySetupIcon "src/Edit_PartTimer3.ico"
|
||||||
|
#define MySetupOutputDir "dist/installer"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; 기본 설정
|
||||||
|
AppId={#AppId}
|
||||||
|
AppName={#MyAppProgramName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DefaultGroupName={#MyAppPublisher}
|
||||||
|
OutputDir={#MySetupOutputDir}
|
||||||
|
OutputBaseFilename={#MySetupName}
|
||||||
|
SetupIconFile={#MySetupIcon}
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
|
||||||
|
; 업데이트 관련 설정 - 권한 최적화
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
PrivilegesRequiredOverridesAllowed=dialog
|
||||||
|
UpdateUninstallLogAppName=yes
|
||||||
|
AppMutex={#MyAppName}
|
||||||
|
CloseApplications=yes
|
||||||
|
RestartApplications=no
|
||||||
|
|
||||||
|
; 보안 및 호환성 설정
|
||||||
|
ArchitecturesAllowed=x64
|
||||||
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
|
AllowNoIcons=yes
|
||||||
|
|
||||||
|
; 버전 정보
|
||||||
|
VersionInfoVersion={#MyAppVersion}
|
||||||
|
VersionInfoCompany={#MyAppPublisher}
|
||||||
|
VersionInfoDescription={#MyAppDescription}
|
||||||
|
VersionInfoCopyright={#MyAppCopyright}
|
||||||
|
VersionInfoProductName={#MyAppProgramName}
|
||||||
|
VersionInfoProductVersion={#MyAppVersion}
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
; 설치 시 {app}\logs 폴더를 생성하고,
|
||||||
|
; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여
|
||||||
|
Name: "{app}\logs"; Permissions: users-modify
|
||||||
|
; 설치 시 {app}\user_data 폴더를 생성하고,
|
||||||
|
; Users 그룹에 'modify' 권한(=쓰기 가능)을 부여
|
||||||
|
Name: "{app}\user_data"; Permissions: users-modify
|
||||||
|
; Playwright 브라우저 폴더를 Program Files 내부에 생성
|
||||||
|
Name: "{app}\lib\src\browsers\chromium-1155"; Permissions: users-modify
|
||||||
|
; Playwright 브라우저 사용자폴더를 Program Files 내부에 생성
|
||||||
|
Name: "{app}\lib\src\browsers\user_data"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
; 프로그램 파일만 설치 (항상 덮어쓰기)
|
||||||
|
Source: "build\exe.win-amd64-3.11\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
; VC++ 재배포 패키지 파일을 임시 폴더({tmp})에 복사
|
||||||
|
Source: "VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
; Playwright 브라우저 경로를 Program Files 내부로 설정
|
||||||
|
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "PLAYWRIGHT_BROWSERS_PATH"; ValueData: "{app}\lib\src\browsers"; Flags: preservestringtype
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
; 시작 메뉴 바로가기
|
||||||
|
Name: "{group}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"
|
||||||
|
; 바탕화면 바로가기
|
||||||
|
Name: "{autodesktop}\{#MyAppProgramName}"; Filename: "{app}\{#MyAppExeName}.exe"; Tasks: desktopicon
|
||||||
|
; 프로그램 제거 바로가기
|
||||||
|
Name: "{group}\{#MyAppProgramName} 제거"; Filename: "{uninstallexe}"
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
; VC++ 재배포 패키지 설치 (필요할 경우)
|
||||||
|
Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /passive /norestart"; StatusMsg: "VC++ 재배포 패키지 설치 중..."; Check: NeedsVCredist
|
||||||
|
; 설치 후 프로그램 실행 (원할 경우)
|
||||||
|
Filename: "{app}\{#MyAppExeName}.exe"; Description: "{cm:LaunchProgram,{#MyAppProgramName}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
function CompareVersion(V1, V2: string): Integer;
|
||||||
|
var
|
||||||
|
P1, P2, N1, N2: Integer;
|
||||||
|
begin
|
||||||
|
P1 := 1;
|
||||||
|
P2 := 1;
|
||||||
|
Result := 0;
|
||||||
|
while (Result = 0) and ((P1 <= Length(V1)) or (P2 <= Length(V2))) do begin
|
||||||
|
while (P1 <= Length(V1)) and (V1[P1] = '.') do Inc(P1);
|
||||||
|
while (P2 <= Length(V2)) and (V2[P2] = '.') do Inc(P2);
|
||||||
|
if (P1 <= Length(V1)) and (P2 <= Length(V2)) then begin
|
||||||
|
N1 := 0; while (P1 <= Length(V1)) and (V1[P1] >= '0') and (V1[P1] <= '9') do begin N1 := N1 * 10 + Ord(V1[P1]) - Ord('0'); Inc(P1); end;
|
||||||
|
N2 := 0; while (P2 <= Length(V2)) and (V2[P2] >= '0') and (V2[P2] <= '9') do begin N2 := N2 * 10 + Ord(V2[P2]) - Ord('0'); Inc(P2); end;
|
||||||
|
if N1 < N2 then Result := -1 else if N1 > N2 then Result := 1;
|
||||||
|
end else begin
|
||||||
|
if P1 <= Length(V1) then Result := 1 else if P2 <= Length(V2) then Result := -1;
|
||||||
|
end;
|
||||||
|
while (P1 <= Length(V1)) and (V1[P1] <> '.') do Inc(P1);
|
||||||
|
while (P2 <= Length(V2)) and (V2[P2] <> '.') do Inc(P2);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 파일 또는 폴더 복사 함수
|
||||||
|
procedure CopyDir(const SourcePath, DestPath: string);
|
||||||
|
var
|
||||||
|
FindRec: TFindRec;
|
||||||
|
SourceFilePath: string;
|
||||||
|
DestFilePath: string;
|
||||||
|
begin
|
||||||
|
ForceDirectories(DestPath);
|
||||||
|
|
||||||
|
if FindFirst(SourcePath + '\*', FindRec) then
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
repeat
|
||||||
|
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
|
||||||
|
begin
|
||||||
|
SourceFilePath := SourcePath + '\' + FindRec.Name;
|
||||||
|
DestFilePath := DestPath + '\' + FindRec.Name;
|
||||||
|
|
||||||
|
if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then
|
||||||
|
begin
|
||||||
|
if FileCopy(SourceFilePath, DestFilePath, False) then
|
||||||
|
Log('파일 복사 성공: ' + SourceFilePath + ' -> ' + DestFilePath)
|
||||||
|
else
|
||||||
|
Log('파일 복사 실패: ' + SourceFilePath);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
CopyDir(SourceFilePath, DestFilePath);
|
||||||
|
end;
|
||||||
|
until not FindNext(FindRec);
|
||||||
|
finally
|
||||||
|
FindClose(FindRec);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 디렉토리 삭제 함수
|
||||||
|
procedure DeleteDir(const DirPath: string);
|
||||||
|
var
|
||||||
|
FindRec: TFindRec;
|
||||||
|
FilePath: string;
|
||||||
|
begin
|
||||||
|
if not DirExists(DirPath) then Exit;
|
||||||
|
|
||||||
|
if FindFirst(DirPath + '\*', FindRec) then
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
repeat
|
||||||
|
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
|
||||||
|
begin
|
||||||
|
FilePath := DirPath + '\' + FindRec.Name;
|
||||||
|
|
||||||
|
if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then
|
||||||
|
begin
|
||||||
|
if DeleteFile(FilePath) then
|
||||||
|
Log('파일 삭제 성공: ' + FilePath)
|
||||||
|
else
|
||||||
|
Log('파일 삭제 실패: ' + FilePath);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
DeleteDir(FilePath);
|
||||||
|
end;
|
||||||
|
until not FindNext(FindRec);
|
||||||
|
finally
|
||||||
|
FindClose(FindRec);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if RemoveDir(DirPath) then
|
||||||
|
Log('디렉토리 삭제 성공: ' + DirPath)
|
||||||
|
else
|
||||||
|
Log('디렉토리 삭제 실패: ' + DirPath);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 프로그램 실행 여부 확인
|
||||||
|
function IsAppRunning(const FileName: string): Boolean;
|
||||||
|
var
|
||||||
|
Handle: THandle;
|
||||||
|
begin
|
||||||
|
Handle := FindWindowByWindowName('{#MyAppProgramName}'); // 프로그램의 윈도우 타이틀로 찾기
|
||||||
|
Result := (Handle <> 0);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 프로그램 종료
|
||||||
|
procedure CloseApplication(const FileName: string);
|
||||||
|
var
|
||||||
|
Handle: THandle;
|
||||||
|
begin
|
||||||
|
Handle := FindWindowByWindowName('{#MyAppProgramName}');
|
||||||
|
if Handle <> 0 then
|
||||||
|
begin
|
||||||
|
PostMessage(Handle, 18, 0, 0); // WM_QUIT
|
||||||
|
Sleep(1000); // 종료 대기
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// VC++ 재배포 패키지 필요 여부 확인
|
||||||
|
function NeedsVCredist: Boolean;
|
||||||
|
begin
|
||||||
|
if RegKeyExists(HKEY_LOCAL_MACHINE, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') then
|
||||||
|
Result := False // 이미 설치됨
|
||||||
|
else
|
||||||
|
Result := True; // 미설치 -> 설치 필요
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 설치 완료 후 실행 여부 확인
|
||||||
|
function InitializeFinish(): Boolean;
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
if MsgBox('설치가 완료되었습니다. 프로그램을 실행하시겠습니까?' + #13#10 +
|
||||||
|
'(실행 시 서버와 동기화하여 설정이 업데이트됩니다)',
|
||||||
|
mbConfirmation, MB_YESNO) = IDYES then
|
||||||
|
begin
|
||||||
|
Exec(ExpandConstant('{app}\{#MyAppExeName}.exe'), '', '', SW_SHOW, ewNoWait, ResultCode);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function InitializeSetup(): Boolean;
|
||||||
|
var
|
||||||
|
OldVersion: String;
|
||||||
|
NewVersion: String;
|
||||||
|
OldAppPath: String;
|
||||||
|
UserDataSourcePath, UserDataBackupPath: String;
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
NewVersion := '{#MyAppVersion}';
|
||||||
|
UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup');
|
||||||
|
|
||||||
|
// 현재 프로그램 버전 확인
|
||||||
|
if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1',
|
||||||
|
'DisplayVersion', OldVersion) then
|
||||||
|
begin
|
||||||
|
// 같은 버전이거나 더 높은 버전이 설치되어 있는 경우
|
||||||
|
if CompareVersion(OldVersion, NewVersion) >= 0 then
|
||||||
|
begin
|
||||||
|
MsgBox('현재 설치된 버전(' + OldVersion + ')이 이 설치 프로그램의 버전(' +
|
||||||
|
NewVersion + ')과 같거나 더 높습니다.' + #13#10 +
|
||||||
|
'설치를 계속할 수 없습니다.', mbInformation, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 이전 버전이 설치되어 있는 경우 업데이트 진행
|
||||||
|
if CompareVersion(OldVersion, NewVersion) < 0 then
|
||||||
|
begin
|
||||||
|
Log('업데이트 설치 진행: ' + OldVersion + ' -> ' + NewVersion);
|
||||||
|
|
||||||
|
// 프로그램이 실행 중인지 확인하고 종료 요청
|
||||||
|
if IsAppRunning('{#MyAppExeName}.exe') then
|
||||||
|
begin
|
||||||
|
if MsgBox('프로그램을 업데이트하기 위해 실행 중인 프로그램을 종료해야 합니다.' + #13#10 +
|
||||||
|
'계속하시겠습니까?', mbConfirmation, MB_YESNO) = IDNO then
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
exit;
|
||||||
|
end;
|
||||||
|
CloseApplication('{#MyAppExeName}.exe');
|
||||||
|
Sleep(2000); // 프로세스 종료 대기
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 레지스트리에서 설치 경로 확인
|
||||||
|
if RegQueryStringValue(HKLM, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppName}_is1',
|
||||||
|
'InstallLocation', OldAppPath) then
|
||||||
|
begin
|
||||||
|
Log('기존 설치 경로: ' + OldAppPath);
|
||||||
|
|
||||||
|
// lib/src/user_data 폴더 백업
|
||||||
|
UserDataSourcePath := OldAppPath + '\lib\src\user_data';
|
||||||
|
if DirExists(UserDataSourcePath) then
|
||||||
|
begin
|
||||||
|
Log('사용자 데이터 백업 중: ' + UserDataSourcePath + ' -> ' + UserDataBackupPath);
|
||||||
|
ForceDirectories(UserDataBackupPath);
|
||||||
|
CopyDir(UserDataSourcePath, UserDataBackupPath);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
Log('사용자 데이터 폴더가 존재하지 않음: ' + UserDataSourcePath);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 기존 설치 폴더 완전 삭제
|
||||||
|
if DirExists(OldAppPath) then
|
||||||
|
begin
|
||||||
|
Log('기존 설치 폴더 삭제 중: ' + OldAppPath);
|
||||||
|
DeleteDir(OldAppPath);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
var
|
||||||
|
UserDataBackupPath, UserDataDestPath: String;
|
||||||
|
begin
|
||||||
|
// 설치 완료 후
|
||||||
|
if CurStep = ssPostInstall then
|
||||||
|
begin
|
||||||
|
UserDataBackupPath := ExpandConstant('{tmp}\user_data_backup');
|
||||||
|
UserDataDestPath := ExpandConstant('{app}\lib\src\user_data');
|
||||||
|
|
||||||
|
// 백업한 사용자 데이터 폴더가 있으면 복원
|
||||||
|
if DirExists(UserDataBackupPath) then
|
||||||
|
begin
|
||||||
|
Log('사용자 데이터 복원 중: ' + UserDataBackupPath + ' -> ' + UserDataDestPath);
|
||||||
|
ForceDirectories(UserDataDestPath);
|
||||||
|
CopyDir(UserDataBackupPath, UserDataDestPath);
|
||||||
|
Log('사용자 데이터 복원 완료');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
@ -4083,6 +4083,13 @@ class ImageWorkerManager:
|
||||||
self.restart_count = 0
|
self.restart_count = 0
|
||||||
self.max_restart = 5
|
self.max_restart = 5
|
||||||
self.image_worker_fatal = image_worker_fatal
|
self.image_worker_fatal = image_worker_fatal
|
||||||
|
# 정책 로드(토글 기반)
|
||||||
|
self._success_count = 0
|
||||||
|
self._consecutive_mem_errors = 0
|
||||||
|
self._periodic_restart_every = 0
|
||||||
|
self._mem_restart_threshold = 0
|
||||||
|
self._mem_error_escalate_after = 2
|
||||||
|
self._load_worker_policies()
|
||||||
# ─── 프로세스/큐 생성 ───────────────────────────────────
|
# ─── 프로세스/큐 생성 ───────────────────────────────────
|
||||||
self.task_q = Queue()
|
self.task_q = Queue()
|
||||||
self.result_q = Queue()
|
self.result_q = Queue()
|
||||||
|
|
@ -4143,6 +4150,30 @@ class ImageWorkerManager:
|
||||||
timeout = 120 if self.first_call else 60
|
timeout = 120 if self.first_call else 60
|
||||||
result = await self._call_worker("process_single_image", timeout=timeout, **kwargs)
|
result = await self._call_worker("process_single_image", timeout=timeout, **kwargs)
|
||||||
self.first_call = False
|
self.first_call = False
|
||||||
|
# 성공 시 연속 메모리 오류 카운터 초기화
|
||||||
|
try:
|
||||||
|
self._consecutive_mem_errors = 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 메모리 임계치 기반 재시작
|
||||||
|
try:
|
||||||
|
if self._mem_restart_threshold and self._mem_restart_threshold > 0:
|
||||||
|
vm = psutil.virtual_memory()
|
||||||
|
if vm.percent >= self._mem_restart_threshold:
|
||||||
|
self.logger.log(f"메모리 임계치 초과({vm.percent}% >= {self._mem_restart_threshold}%) → 워커 재시작", level=logging.WARNING)
|
||||||
|
self.restart(cause="error")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 주기적 재시작 트리거: 성공으로 간주되는 경우에만 카운트
|
||||||
|
try:
|
||||||
|
if self._periodic_restart_every and isinstance(result, dict) and result.get("status") in {"translated", "original", "exclude", "success"}:
|
||||||
|
self._success_count += 1
|
||||||
|
if self._success_count % self._periodic_restart_every == 0:
|
||||||
|
self.restart(cause="periodic")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def safe_process_single_image(self, **kwargs):
|
async def safe_process_single_image(self, **kwargs):
|
||||||
|
|
@ -4197,6 +4228,20 @@ class ImageWorkerManager:
|
||||||
self.restart(cause="error")
|
self.restart(cause="error")
|
||||||
raise # 상위에서 필요하다면 재시도 로직을 추가
|
raise # 상위에서 필요하다면 재시도 로직을 추가
|
||||||
except Exception as e: # ← RuntimeError 포함
|
except Exception as e: # ← RuntimeError 포함
|
||||||
|
# primitive/메모리 오류 에스컬레이션 처리
|
||||||
|
msg = str(e).lower()
|
||||||
|
if any(s in msg for s in ["memory", "primitive", "out of memory", "unable to allocate", "cv::outofmemoryerror"]):
|
||||||
|
try:
|
||||||
|
self._consecutive_mem_errors += 1
|
||||||
|
except Exception:
|
||||||
|
self._consecutive_mem_errors = 1
|
||||||
|
self.logger.log(f"메모리/primitive 오류 {self._consecutive_mem_errors}회: {e}", level=logging.WARNING)
|
||||||
|
if self._consecutive_mem_errors >= max(1, self._mem_error_escalate_after):
|
||||||
|
self.logger.log("연속 메모리 오류 기준 충족 → 워커 재시작", level=logging.WARNING)
|
||||||
|
self.restart(cause="error")
|
||||||
|
self._consecutive_mem_errors = 0
|
||||||
|
else:
|
||||||
|
# 일반 오류도 재시작
|
||||||
self.logger.log(f"워커 내부 오류 → 재시작: {e}", level=logging.WARNING)
|
self.logger.log(f"워커 내부 오류 → 재시작: {e}", level=logging.WARNING)
|
||||||
self.restart(cause="error")
|
self.restart(cause="error")
|
||||||
raise
|
raise
|
||||||
|
|
@ -4274,8 +4319,25 @@ class ImageWorkerManager:
|
||||||
if toggle_states is not None:
|
if toggle_states is not None:
|
||||||
self.toggle_states = toggle_states
|
self.toggle_states = toggle_states
|
||||||
self.logger.log("ImageWorker toggle_states 업데이트됨", level=logging.DEBUG)
|
self.logger.log("ImageWorker toggle_states 업데이트됨", level=logging.DEBUG)
|
||||||
|
self._load_worker_policies()
|
||||||
|
|
||||||
if unwanted_words is not None:
|
if unwanted_words is not None:
|
||||||
self.unwanted_words = unwanted_words
|
self.unwanted_words = unwanted_words
|
||||||
self.logger.log("ImageWorker unwanted_words 업데이트됨", level=logging.DEBUG)
|
self.logger.log("ImageWorker unwanted_words 업데이트됨", level=logging.DEBUG)
|
||||||
|
|
||||||
|
def _load_worker_policies(self):
|
||||||
|
"""toggle_states 로부터 워커 재시작 정책을 로드한다."""
|
||||||
|
try:
|
||||||
|
every = int(self.toggle_states.get("image_worker_periodic_restart_every", 0) or 0)
|
||||||
|
mem_thr = int(self.toggle_states.get("image_worker_mem_restart_threshold", 0) or 0)
|
||||||
|
esc_after = int(self.toggle_states.get("image_worker_mem_error_escalate_after", 2) or 2)
|
||||||
|
self._periodic_restart_every = max(0, every)
|
||||||
|
self._mem_restart_threshold = max(0, mem_thr)
|
||||||
|
self._mem_error_escalate_after = max(1, esc_after)
|
||||||
|
self.logger.log(
|
||||||
|
f"워커 정책 로드: periodic={self._periodic_restart_every}, mem_thr={self._mem_restart_threshold}%, escalate_after={self._mem_error_escalate_after}",
|
||||||
|
level=logging.INFO,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"워커 정책 로드 실패: {e}", level=logging.WARNING)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1212,7 +1212,9 @@ class MAIN_GUI(QMainWindow):
|
||||||
"migan_use_cuda": False, # 3050 4GB면 True 권장(실패시 자동 CPU 폴백)
|
"migan_use_cuda": False, # 3050 4GB면 True 권장(실패시 자동 CPU 폴백)
|
||||||
"migan_intra_threads": 0,
|
"migan_intra_threads": 0,
|
||||||
"migan_inter_threads": 0,
|
"migan_inter_threads": 0,
|
||||||
|
"migan_use_tensorrt": True,
|
||||||
|
"migan_trt_fp16_enable": True,
|
||||||
|
"migan_max_image_size": 2048
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
21
setup.py
21
setup.py
|
|
@ -113,6 +113,8 @@ dll_files = [
|
||||||
system32_path = "C:/Windows/System32/downlevel"
|
system32_path = "C:/Windows/System32/downlevel"
|
||||||
dll_include_files = [(os.path.join(system32_path, dll), dll) for dll in dll_files]
|
dll_include_files = [(os.path.join(system32_path, dll), dll) for dll in dll_files]
|
||||||
|
|
||||||
|
dll_include_files = [] # 충돌 가능성으로 인해 빈 리스트로 초기화
|
||||||
|
|
||||||
# 패들 라이브러리 경로 설정
|
# 패들 라이브러리 경로 설정
|
||||||
# 경로 수정: scripts/Lib -> Lib로 변경
|
# 경로 수정: scripts/Lib -> Lib로 변경
|
||||||
base_path = os.path.dirname(os.path.abspath(__file__))
|
base_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
@ -124,6 +126,7 @@ paddleocr_path = os.path.join(site_packages, "paddleocr")
|
||||||
|
|
||||||
paddle_includes = []
|
paddle_includes = []
|
||||||
|
|
||||||
|
|
||||||
# 경로가 존재하는 경우에만 포함
|
# 경로가 존재하는 경우에만 포함
|
||||||
if os.path.exists(paddle_path):
|
if os.path.exists(paddle_path):
|
||||||
paddle_includes.append((paddle_path, 'lib/paddle'))
|
paddle_includes.append((paddle_path, 'lib/paddle'))
|
||||||
|
|
@ -133,8 +136,23 @@ if os.path.exists(paddleocr_path):
|
||||||
paddle_includes.append((paddleocr_path, 'lib/paddleocr'))
|
paddle_includes.append((paddleocr_path, 'lib/paddleocr'))
|
||||||
print(f"paddleocr 경로 추가: {paddleocr_path}")
|
print(f"paddleocr 경로 추가: {paddleocr_path}")
|
||||||
|
|
||||||
|
onnxruntime_capi = os.path.join(site_packages, 'onnxruntime', 'capi')
|
||||||
|
if os.path.exists(onnxruntime_capi):
|
||||||
|
onnxruntime_includes.append((onnxruntime_capi, 'lib/onnxruntime/capi'))
|
||||||
|
|
||||||
|
vc_runtime_files = [
|
||||||
|
('C:/Windows/System32/vcruntime140.dll', 'vcruntime140.dll'),
|
||||||
|
('C:/Windows/System32/vcruntime140_1.dll', 'vcruntime140_1.dll'),
|
||||||
|
('C:/Windows/System32/msvcp140.dll', 'msvcp140.dll'),
|
||||||
|
('C:/Windows/System32/msvcp140_1.dll', 'msvcp140_1.dll'),
|
||||||
|
('C:/Windows/System32/msvcp140_2.dll', 'msvcp140_2.dll'),
|
||||||
|
('C:/Windows/System32/concrt140.dll', 'concrt140.dll'),
|
||||||
|
('C:/Windows/System32/vcomp140.dll', 'vcomp140.dll'),
|
||||||
|
]
|
||||||
|
include_files.extend([f for f in vc_runtime_files if os.path.exists(f[0])])
|
||||||
|
|
||||||
# ✅ 기존 포함 파일 + DLL 추가
|
# ✅ 기존 포함 파일 + DLL 추가
|
||||||
include_files = dll_include_files + paddle_includes + [
|
include_files = dll_include_files + paddle_includes + onnxruntime_includes + vc_runtime_files + [
|
||||||
# include_files = dll_include_files + [
|
# include_files = dll_include_files + [
|
||||||
# 나머지 파일들
|
# 나머지 파일들
|
||||||
('src/ppocr/PP_Models', 'lib/src/ppocr/PP_Models'),
|
('src/ppocr/PP_Models', 'lib/src/ppocr/PP_Models'),
|
||||||
|
|
@ -176,6 +194,7 @@ for src, dest in include_files:
|
||||||
|
|
||||||
# 사용된 패키지 정의
|
# 사용된 패키지 정의
|
||||||
build_exe_options = {
|
build_exe_options = {
|
||||||
|
'include_msvcr': True, # VC++ 런타임 자동 포함
|
||||||
'packages': [
|
'packages': [
|
||||||
'ctypes', 'asyncio',
|
'ctypes', 'asyncio',
|
||||||
'subprocess', 'pyperclip', 'numpy',
|
'subprocess', 'pyperclip', 'numpy',
|
||||||
|
|
|
||||||
|
|
@ -1131,52 +1131,63 @@ class OptionHandler:
|
||||||
async def adjust_options_by_index(self, filtered, options_items, max_option_count):
|
async def adjust_options_by_index(self, filtered, options_items, max_option_count):
|
||||||
"""
|
"""
|
||||||
인덱스 기반 옵션 체크/해제. 상태 span을 재조회해서 실제 반영 확인.
|
인덱스 기반 옵션 체크/해제. 상태 span을 재조회해서 실제 반영 확인.
|
||||||
filtered: [{'index':i, ...}, ...] (선택대상)
|
filtered: [{'index':i, 'original_name': str, ...}, ...] (선택대상)
|
||||||
options_items: option_type_1의 items 리스트
|
options_items: option_type_1의 items 리스트
|
||||||
"""
|
"""
|
||||||
selected_count = 0
|
selected_count = 0
|
||||||
filtered_idx_set = set([opt["index"] for opt in filtered])
|
# 재정렬되는 환경 대응: 인덱스 대신 원본 옵션명으로 대상으로 판단
|
||||||
|
filtered_name_set = set([opt.get("original_name") for opt in filtered if opt.get("original_name")])
|
||||||
|
|
||||||
|
# option_type_1 컨테이너 Locator (첫 번째 콜랩스 박스)
|
||||||
|
option_type_1_container = self.page.locator("div#productMainContentContainerId div.ant-collapse-content-box").first
|
||||||
|
|
||||||
for idx, item in enumerate(options_items):
|
for idx, item in enumerate(options_items):
|
||||||
element = item.get("checkbox_elem")
|
original_name = item.get("original_name")
|
||||||
|
if not original_name:
|
||||||
|
continue
|
||||||
|
|
||||||
# 현재 상태 확인 (제외된 옵션 span)
|
# 현재 상태 재조회: 원본 옵션명으로 해당 아이템을 찾아 검사
|
||||||
span_elems = await element.query_selector_all("span")
|
item_locator = option_type_1_container.locator(
|
||||||
|
"li.ant-list-item[aria-roledescription='sortable']"
|
||||||
|
).filter(has_text=original_name)
|
||||||
|
|
||||||
|
# '제외된 옵션' 여부 확인
|
||||||
|
try:
|
||||||
|
excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count()
|
||||||
|
is_excluded = excluded_count > 0
|
||||||
|
except Exception:
|
||||||
is_excluded = False
|
is_excluded = False
|
||||||
for span in span_elems:
|
|
||||||
text = (await span.inner_text()).strip()
|
|
||||||
if text == "제외된 옵션":
|
|
||||||
is_excluded = True
|
|
||||||
break
|
|
||||||
item["is_excluded"] = is_excluded
|
|
||||||
|
|
||||||
should_checked = idx in filtered_idx_set and selected_count < max_option_count
|
# 목표 상태 산정 (이름 기준)
|
||||||
|
within_limit = (max_option_count == 0) or (selected_count < max_option_count)
|
||||||
|
should_checked = (original_name in filtered_name_set) and within_limit
|
||||||
should_excluded = not should_checked
|
should_excluded = not should_checked
|
||||||
|
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
f"[{idx}] {item.get('original_name')} | 현재상태: {'제외됨' if is_excluded else '체크됨'} | 목표: {'체크' if should_checked else '제외'}",
|
f"[{idx}] {original_name} | 현재상태: {'제외됨' if is_excluded else '체크됨'} | 목표: {'체크' if should_checked else '제외'}",
|
||||||
level=logging.DEBUG,
|
level=logging.DEBUG,
|
||||||
)
|
)
|
||||||
|
|
||||||
if should_checked and is_excluded:
|
# 클릭 대상 라벨 Locator (Locator는 재해결되어 분리 오류를 피함)
|
||||||
label_elem = await element.query_selector("label.ant-checkbox-wrapper")
|
label_locator = item_locator.locator("label.ant-checkbox-wrapper")
|
||||||
if label_elem:
|
|
||||||
await label_elem.click()
|
|
||||||
await self.page.wait_for_timeout(50) # 50~100ms
|
|
||||||
|
|
||||||
# 상태 변화 기다리며, 최대 1초 대기
|
if should_checked and is_excluded:
|
||||||
|
try:
|
||||||
|
await label_locator.click()
|
||||||
|
await self.page.wait_for_timeout(50)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"옵션[{idx}] 체크 시도 클릭 오류: {e}", level=logging.WARNING)
|
||||||
|
|
||||||
|
# 상태 변화 대기 및 재확인 (재정렬 대비 매번 재조회)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
span_elems = await element.query_selector_all("span")
|
item_locator = option_type_1_container.locator(
|
||||||
after_is_excluded = False
|
"li.ant-list-item[aria-roledescription='sortable']"
|
||||||
for span in span_elems:
|
).filter(has_text=original_name)
|
||||||
text = (await span.inner_text()).strip()
|
excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count()
|
||||||
if text == "제외된 옵션":
|
after_is_excluded = excluded_count > 0
|
||||||
after_is_excluded = True
|
|
||||||
break
|
|
||||||
if not after_is_excluded:
|
if not after_is_excluded:
|
||||||
break
|
break
|
||||||
item["is_excluded"] = after_is_excluded
|
|
||||||
|
|
||||||
if after_is_excluded:
|
if after_is_excluded:
|
||||||
self.logger.log(f"옵션[{idx}] 체크 시도 후에도 '제외된 옵션' 상태임! 재시도 필요", level=logging.ERROR)
|
self.logger.log(f"옵션[{idx}] 체크 시도 후에도 '제외된 옵션' 상태임! 재시도 필요", level=logging.ERROR)
|
||||||
else:
|
else:
|
||||||
|
|
@ -1184,25 +1195,22 @@ class OptionHandler:
|
||||||
self.logger.log(f"옵션[{idx}] 체크 성공", level=logging.INFO)
|
self.logger.log(f"옵션[{idx}] 체크 성공", level=logging.INFO)
|
||||||
|
|
||||||
elif should_excluded and not is_excluded:
|
elif should_excluded and not is_excluded:
|
||||||
label_elem = await element.query_selector("label.ant-checkbox-wrapper")
|
try:
|
||||||
if label_elem:
|
await label_locator.click()
|
||||||
await label_elem.click()
|
await self.page.wait_for_timeout(50)
|
||||||
await self.page.wait_for_timeout(50) # 50~100ms
|
except Exception as e:
|
||||||
|
self.logger.log(f"옵션[{idx}] 체크해제 시도 클릭 오류: {e}", level=logging.WARNING)
|
||||||
|
|
||||||
# 상태 변화 기다리며, 최대 1초 대기
|
# 상태 변화 대기 및 재확인 (재정렬 대비 매번 재조회)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
span_elems = await element.query_selector_all("span")
|
item_locator = option_type_1_container.locator(
|
||||||
after_is_excluded = False
|
"li.ant-list-item[aria-roledescription='sortable']"
|
||||||
for span in span_elems:
|
).filter(has_text=original_name)
|
||||||
text = (await span.inner_text()).strip()
|
excluded_count = await item_locator.locator("span:has-text('제외된 옵션')").count()
|
||||||
if text == "제외된 옵션":
|
after_is_excluded = excluded_count > 0
|
||||||
after_is_excluded = True
|
|
||||||
break
|
|
||||||
if after_is_excluded:
|
if after_is_excluded:
|
||||||
break
|
break
|
||||||
item["is_excluded"] = after_is_excluded
|
|
||||||
|
|
||||||
if not after_is_excluded:
|
if not after_is_excluded:
|
||||||
self.logger.log(f"옵션[{idx}] 체크해제 시도 후에도 체크상태임! 재시도 필요", level=logging.ERROR)
|
self.logger.log(f"옵션[{idx}] 체크해제 시도 후에도 체크상태임! 재시도 필요", level=logging.ERROR)
|
||||||
else:
|
else:
|
||||||
|
|
@ -1212,7 +1220,7 @@ class OptionHandler:
|
||||||
if should_checked:
|
if should_checked:
|
||||||
selected_count += 1
|
selected_count += 1
|
||||||
|
|
||||||
if selected_count >= max_option_count:
|
if max_option_count and selected_count >= max_option_count:
|
||||||
self.logger.log(f"최대 선택 옵션 수 {max_option_count} 도달, 이후 옵션은 제외처리", level=logging.DEBUG)
|
self.logger.log(f"최대 선택 옵션 수 {max_option_count} 도달, 이후 옵션은 제외처리", level=logging.DEBUG)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
|
|
@ -0,0 +1,276 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
GPU 유틸리티 모듈 - CUDA 지원 및 GPU 상태 관리
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- GPU 사용 가능성 검사
|
||||||
|
- CUDA 지원 여부 확인
|
||||||
|
- 전역 GPU 상태 관리
|
||||||
|
- CPU 폴백 처리
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class GPUManager:
|
||||||
|
"""GPU 상태 관리 및 CUDA 지원 확인"""
|
||||||
|
|
||||||
|
def __init__(self, logger: Optional[object] = None):
|
||||||
|
self.logger = logger or self._create_dummy_logger()
|
||||||
|
|
||||||
|
# GPU 상태 전역 변수들
|
||||||
|
self.can_use_cuda = False
|
||||||
|
self.cuda_available = False
|
||||||
|
self.gpu_info = {}
|
||||||
|
self.initialization_attempted = False
|
||||||
|
|
||||||
|
def _create_dummy_logger(self):
|
||||||
|
"""로거가 없을 때 사용할 더미 로거"""
|
||||||
|
class DummyLogger:
|
||||||
|
def log(self, msg, level=logging.INFO, exc_info=False):
|
||||||
|
print(f"[GPU] {msg}")
|
||||||
|
return DummyLogger()
|
||||||
|
|
||||||
|
def initialize_gpu_state(self, toggle_states: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
GPU 상태를 초기화하고 전역 변수에 저장
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toggle_states: 설정 딕셔너리
|
||||||
|
"""
|
||||||
|
if self.initialization_attempted:
|
||||||
|
return # 이미 초기화됨
|
||||||
|
|
||||||
|
self.initialization_attempted = True
|
||||||
|
|
||||||
|
# 사용자가 CUDA 사용을 원하는지 확인
|
||||||
|
use_cuda_requested = toggle_states.get("use_cuda", False)
|
||||||
|
|
||||||
|
self.logger.log("=== GPU 상태 초기화 시작 ===", level=logging.INFO)
|
||||||
|
self.logger.log(f"사용자 CUDA 사용 요청: {use_cuda_requested}", level=logging.INFO)
|
||||||
|
|
||||||
|
if not use_cuda_requested:
|
||||||
|
self.logger.log("CUDA 사용이 비활성화됨 (toggle_states['use_cuda'] = False)", level=logging.INFO)
|
||||||
|
self.can_use_cuda = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# GPU 하드웨어 검사
|
||||||
|
gpu_detected = self._detect_gpu_hardware()
|
||||||
|
|
||||||
|
if not gpu_detected:
|
||||||
|
self.logger.log("GPU 하드웨어가 감지되지 않음 - CPU 모드로 전환", level=logging.WARNING)
|
||||||
|
self.can_use_cuda = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# CUDA 설치 및 작동 상태 확인
|
||||||
|
cuda_working = self._check_cuda_installation()
|
||||||
|
|
||||||
|
if not cuda_working:
|
||||||
|
self.logger.log("CUDA가 정상적으로 작동하지 않음 - CPU 모드로 전환", level=logging.WARNING)
|
||||||
|
self.can_use_cuda = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# PyTorch/ONNXRuntime CUDA 지원 확인
|
||||||
|
framework_cuda_support = self._check_framework_cuda_support()
|
||||||
|
|
||||||
|
if not framework_cuda_support:
|
||||||
|
self.logger.log("ML 프레임워크에서 CUDA를 지원하지 않음 - CPU 모드로 전환", level=logging.WARNING)
|
||||||
|
self.can_use_cuda = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# 모든 검사 통과
|
||||||
|
self.can_use_cuda = True
|
||||||
|
self.cuda_available = True
|
||||||
|
|
||||||
|
self.logger.log("✅ CUDA 사용 가능 - GPU 가속 모드로 동작", level=logging.INFO)
|
||||||
|
self.logger.log(f"GPU 정보: {self.gpu_info}", level=logging.INFO)
|
||||||
|
self.logger.log("=== GPU 상태 초기화 완료 ===", level=logging.INFO)
|
||||||
|
|
||||||
|
def _detect_gpu_hardware(self) -> bool:
|
||||||
|
"""GPU 하드웨어 감지"""
|
||||||
|
try:
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
self.logger.log("현재 Windows만 지원됨", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# nvidia-smi 명령어로 GPU 확인
|
||||||
|
result = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
gpu_lines = result.stdout.strip().split('\n')
|
||||||
|
for i, line in enumerate(gpu_lines):
|
||||||
|
if line.strip():
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) >= 3:
|
||||||
|
self.gpu_info[f'gpu_{i}'] = {
|
||||||
|
'name': parts[0],
|
||||||
|
'memory_mb': parts[1],
|
||||||
|
'driver_version': parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.log(f"GPU 하드웨어 감지됨: {len(self.gpu_info)}개", level=logging.INFO)
|
||||||
|
for gpu_id, info in self.gpu_info.items():
|
||||||
|
self.logger.log(f" {gpu_id}: {info['name']} ({info['memory_mb']}MB, 드라이버 {info['driver_version']})", level=logging.INFO)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.log(f"nvidia-smi 실행 실패: {result.stderr}", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError) as e:
|
||||||
|
self.logger.log(f"GPU 하드웨어 감지 실패: {e}", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"GPU 하드웨어 감지 중 예외: {e}", level=logging.ERROR, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_cuda_installation(self) -> bool:
|
||||||
|
"""CUDA 설치 및 작동 상태 확인"""
|
||||||
|
try:
|
||||||
|
# nvcc 버전 확인
|
||||||
|
result = subprocess.run(
|
||||||
|
["nvcc", "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
version_output = result.stdout
|
||||||
|
self.logger.log(f"CUDA 컴파일러 감지됨", level=logging.INFO)
|
||||||
|
|
||||||
|
# 버전 정보 추출
|
||||||
|
for line in version_output.split('\n'):
|
||||||
|
if 'release' in line.lower():
|
||||||
|
self.logger.log(f"CUDA 버전: {line.strip()}", level=logging.INFO)
|
||||||
|
break
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.log("CUDA 컴파일러(nvcc)를 찾을 수 없음", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||||
|
self.logger.log(f"CUDA 설치 확인 실패: {e}", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"CUDA 설치 확인 중 예외: {e}", level=logging.ERROR, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_framework_cuda_support(self) -> bool:
|
||||||
|
"""ML 프레임워크의 CUDA 지원 확인"""
|
||||||
|
support_count = 0
|
||||||
|
|
||||||
|
# ONNXRuntime CUDA 지원 확인
|
||||||
|
try:
|
||||||
|
import onnxruntime as ort
|
||||||
|
providers = ort.get_available_providers()
|
||||||
|
if "CUDAExecutionProvider" in providers:
|
||||||
|
self.logger.log("✅ ONNXRuntime CUDA 지원 확인됨", level=logging.INFO)
|
||||||
|
support_count += 1
|
||||||
|
else:
|
||||||
|
self.logger.log("❌ ONNXRuntime CUDA 지원 없음", level=logging.WARNING)
|
||||||
|
self.logger.log(f"사용 가능한 providers: {providers}", level=logging.DEBUG)
|
||||||
|
except ImportError:
|
||||||
|
self.logger.log("ONNXRuntime가 설치되지 않음", level=logging.WARNING)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"ONNXRuntime CUDA 지원 확인 실패: {e}", level=logging.WARNING)
|
||||||
|
|
||||||
|
# PyTorch CUDA 지원 확인 (선택적)
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
device_count = torch.cuda.device_count()
|
||||||
|
current_device = torch.cuda.current_device()
|
||||||
|
device_name = torch.cuda.get_device_name(current_device)
|
||||||
|
self.logger.log(f"✅ PyTorch CUDA 지원 확인됨 ({device_count}개 디바이스, 현재: {device_name})", level=logging.INFO)
|
||||||
|
support_count += 1
|
||||||
|
else:
|
||||||
|
self.logger.log("❌ PyTorch CUDA 지원 없음", level=logging.WARNING)
|
||||||
|
except ImportError:
|
||||||
|
self.logger.log("PyTorch가 설치되지 않음 (선택적)", level=logging.DEBUG)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"PyTorch CUDA 지원 확인 실패: {e}", level=logging.WARNING)
|
||||||
|
|
||||||
|
# 최소 하나의 프레임워크에서 CUDA를 지원해야 함
|
||||||
|
if support_count > 0:
|
||||||
|
self.logger.log(f"ML 프레임워크 CUDA 지원: {support_count}개 확인됨", level=logging.INFO)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.log("ML 프레임워크에서 CUDA를 지원하지 않음", level=logging.WARNING)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_cuda_status(self) -> Dict[str, Any]:
|
||||||
|
"""현재 CUDA 상태 정보 반환"""
|
||||||
|
return {
|
||||||
|
"can_use_cuda": self.can_use_cuda,
|
||||||
|
"cuda_available": self.cuda_available,
|
||||||
|
"gpu_info": self.gpu_info.copy(),
|
||||||
|
"initialization_attempted": self.initialization_attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
def force_cpu_mode(self) -> None:
|
||||||
|
"""강제로 CPU 모드로 전환"""
|
||||||
|
self.can_use_cuda = False
|
||||||
|
self.logger.log("강제로 CPU 모드로 전환됨", level=logging.WARNING)
|
||||||
|
|
||||||
|
def log_gpu_memory_usage(self) -> None:
|
||||||
|
"""현재 GPU 메모리 사용량 로깅"""
|
||||||
|
if not self.can_use_cuda:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
lines = result.stdout.strip().split('\n')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip():
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
used_mb = int(parts[0])
|
||||||
|
total_mb = int(parts[1])
|
||||||
|
usage_percent = (used_mb / total_mb) * 100
|
||||||
|
self.logger.log(
|
||||||
|
f"GPU {i} 메모리 사용량: {used_mb}MB/{total_mb}MB ({usage_percent:.1f}%)",
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log(f"GPU 메모리 사용량 확인 실패: {e}", level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
# 전역 GPU 관리자 인스턴스 (선택적 사용)
|
||||||
|
_global_gpu_manager = None
|
||||||
|
|
||||||
|
def get_global_gpu_manager(logger=None) -> GPUManager:
|
||||||
|
"""전역 GPU 관리자 인스턴스 반환"""
|
||||||
|
global _global_gpu_manager
|
||||||
|
if _global_gpu_manager is None:
|
||||||
|
_global_gpu_manager = GPUManager(logger)
|
||||||
|
return _global_gpu_manager
|
||||||
|
|
||||||
|
|
||||||
|
def check_cuda_simple() -> bool:
|
||||||
|
"""간단한 CUDA 사용 가능성 확인 (캐시 없음)"""
|
||||||
|
try:
|
||||||
|
import onnxruntime as ort
|
||||||
|
providers = ort.get_available_providers()
|
||||||
|
return "CUDAExecutionProvider" in providers
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
@ -25,6 +25,7 @@ from translatepy.translators.google import GoogleTranslate
|
||||||
# from src.modules.background_removal_module_pp import PPMattingBackgroundRemovalModule # (변경)
|
# from src.modules.background_removal_module_pp import PPMattingBackgroundRemovalModule # (변경)
|
||||||
|
|
||||||
from src.modules.request_inpaint import Request_AI_Server
|
from src.modules.request_inpaint import Request_AI_Server
|
||||||
|
from src.modules.gpu_utils import GPUManager
|
||||||
|
|
||||||
class ImageProcessor3:
|
class ImageProcessor3:
|
||||||
"""이미지 다운로드, OCR, 번역 처리를 담당하는 클래스"""
|
"""이미지 다운로드, OCR, 번역 처리를 담당하는 클래스"""
|
||||||
|
|
@ -37,6 +38,10 @@ class ImageProcessor3:
|
||||||
self.unwanted_texts = unwanted_words
|
self.unwanted_texts = unwanted_words
|
||||||
self.authenticated_by_admin = authenticated_by_admin
|
self.authenticated_by_admin = authenticated_by_admin
|
||||||
|
|
||||||
|
# GPU 관리자 초기화
|
||||||
|
self.gpu_manager = GPUManager(logger=logger)
|
||||||
|
self.gpu_manager.initialize_gpu_state(toggle_states)
|
||||||
|
|
||||||
self.logger.log(f"ImageProcessor3 Init toggle_states: {self.toggle_states}", level=logging.DEBUG)
|
self.logger.log(f"ImageProcessor3 Init toggle_states: {self.toggle_states}", level=logging.DEBUG)
|
||||||
|
|
||||||
self.papago_translator = papago_translator
|
self.papago_translator = papago_translator
|
||||||
|
|
@ -94,7 +99,13 @@ class ImageProcessor3:
|
||||||
if not self.is_frozen():
|
if not self.is_frozen():
|
||||||
self.request_rembg_server_url = self.toggle_states.get("request_rembg_server_url_local", None)
|
self.request_rembg_server_url = self.toggle_states.get("request_rembg_server_url_local", None)
|
||||||
|
|
||||||
self.request_ai_server = Request_AI_Server(logger=self.logger, inpaint_server_url=self.request_inpainting_server_url, rembg_server_url=self.request_rembg_server_url)
|
# Request_AI_Server에도 GPU 상태 전달
|
||||||
|
self.request_ai_server = Request_AI_Server(
|
||||||
|
logger=self.logger,
|
||||||
|
inpaint_server_url=self.request_inpainting_server_url,
|
||||||
|
rembg_server_url=self.request_rembg_server_url,
|
||||||
|
gpu_manager=self.gpu_manager
|
||||||
|
)
|
||||||
|
|
||||||
self.gtranslate = GoogleTranslate()
|
self.gtranslate = GoogleTranslate()
|
||||||
|
|
||||||
|
|
@ -102,7 +113,16 @@ class ImageProcessor3:
|
||||||
try:
|
try:
|
||||||
from src.modules.migan_module import build_migan_from_toggle
|
from src.modules.migan_module import build_migan_from_toggle
|
||||||
if self.toggle_states.get("migan_onnx_path"):
|
if self.toggle_states.get("migan_onnx_path"):
|
||||||
self.migan = build_migan_from_toggle(self.toggle_states, logger=self.logger)
|
# GPU 상태에 따라 CUDA 사용 여부 결정
|
||||||
|
enhanced_toggle_states = self.toggle_states.copy()
|
||||||
|
if self.gpu_manager.can_use_cuda:
|
||||||
|
enhanced_toggle_states["migan_use_cuda"] = enhanced_toggle_states.get("use_cuda", False)
|
||||||
|
self.logger.log(f"MIGAN CUDA 사용 설정: {enhanced_toggle_states['migan_use_cuda']}", level=logging.INFO)
|
||||||
|
else:
|
||||||
|
enhanced_toggle_states["migan_use_cuda"] = False
|
||||||
|
self.logger.log("MIGAN CUDA 사용 불가 - CPU 모드로 설정", level=logging.INFO)
|
||||||
|
|
||||||
|
self.migan = build_migan_from_toggle(enhanced_toggle_states, logger=self.logger)
|
||||||
else:
|
else:
|
||||||
self.migan = None
|
self.migan = None
|
||||||
self.logger.log("migan_onnx_path 미설정: MIGAN 비활성", level=logging.INFO)
|
self.logger.log("migan_onnx_path 미설정: MIGAN 비활성", level=logging.INFO)
|
||||||
|
|
@ -1026,10 +1046,11 @@ class ImageProcessor3:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
# 메모리 / primitive 관련 오류 → OCR 모듈 재초기화 후 1회 재시도
|
# 메모리 / primitive 관련 오류 → OCR 모듈 재초기화 후 1회 재시도
|
||||||
if any(err in msg for err in ["create a primitive", "memory object", "unable to allocate"]):
|
if any(err in msg for err in ["create a primitive", "memory object", "unable to allocate", "out of memory", "cv::outofmemoryerror"]):
|
||||||
ok = self.reset_ocr_module()
|
ok = self.reset_ocr_module()
|
||||||
if ok:
|
if ok:
|
||||||
return self.ocr_module.detect_text(img_path)
|
# 재시도 시 실패하면 MemoryError를 재전파하여 상위에서 워커 재시작 트리거
|
||||||
|
return self.ocr_module.detect_text(img_path, raise_on_memory_error=True)
|
||||||
# 그 외 예외는 그대로 상위로 전달
|
# 그 외 예외는 그대로 상위로 전달
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ class OCRModule:
|
||||||
|
|
||||||
# CPU만 사용하도록 환경 변수 설정
|
# CPU만 사용하도록 환경 변수 설정
|
||||||
os.environ['CUDA_VISIBLE_DEVICES'] = ''
|
os.environ['CUDA_VISIBLE_DEVICES'] = ''
|
||||||
|
# OpenCV OpenCL 비활성화 (메모리 primitive 오류 예방)
|
||||||
|
try:
|
||||||
|
import cv2 as _cv2
|
||||||
|
_cv2.ocl.setUseOpenCL(False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 멀티 스레드 사용 시 오류 방지
|
# 멀티 스레드 사용 시 오류 방지
|
||||||
os.environ["OMP_NUM_THREADS"] = "1"
|
os.environ["OMP_NUM_THREADS"] = "1"
|
||||||
|
|
@ -45,13 +51,16 @@ class OCRModule:
|
||||||
try:
|
try:
|
||||||
from paddleocr import PaddleOCR
|
from paddleocr import PaddleOCR
|
||||||
|
|
||||||
|
# 메모리 사용량을 줄이기 위해 배치/스레드 수를 제한
|
||||||
ocr = PaddleOCR(
|
ocr = PaddleOCR(
|
||||||
use_gpu=False,
|
use_gpu=False,
|
||||||
use_angle_cls=True, # 텍스트 방향 분류 활성화
|
use_angle_cls=True, # 텍스트 방향 분류 활성화
|
||||||
lang="ch",
|
lang="ch",
|
||||||
det_model_dir=self.det_model_dir,
|
det_model_dir=self.det_model_dir,
|
||||||
rec_model_dir=self.rec_model_dir,
|
rec_model_dir=self.rec_model_dir,
|
||||||
cls_model_dir=self.cls_model_dir
|
cls_model_dir=self.cls_model_dir,
|
||||||
|
rec_batch_num=4, # 기본(6)보다 낮춰 메모리 사용 축소
|
||||||
|
cpu_threads=2 # 스레드 수 제한으로 피크 메모리 완화
|
||||||
)
|
)
|
||||||
return ocr
|
return ocr
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -60,7 +69,7 @@ class OCRModule:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def detect_text(self, image_path: str, method: str = 'polygon') -> List[Dict[str, Any]]:
|
def detect_text(self, image_path: str, method: str = 'polygon', raise_on_memory_error: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
이미지에서 텍스트를 감지하고 다양한 방식으로 영역 반환
|
이미지에서 텍스트를 감지하고 다양한 방식으로 영역 반환
|
||||||
|
|
||||||
|
|
@ -110,20 +119,37 @@ class OCRModule:
|
||||||
try:
|
try:
|
||||||
ocr_raw_results = self.ocr.ocr(image)
|
ocr_raw_results = self.ocr.ocr(image)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 메모리 오류나 기타 예외가 발생할 경우 1/2로 추가 축소 후 재시도
|
# 메모리 오류나 기타 예외가 발생할 경우 1/2 → 1/4로 단계적 축소 재시도
|
||||||
if 'unable to allocate' in str(e) or isinstance(e, MemoryError):
|
err_msg = str(e).lower()
|
||||||
|
mem_signals = [
|
||||||
|
'unable to allocate',
|
||||||
|
'out of memory',
|
||||||
|
'could not create a memory object',
|
||||||
|
'create a primitive',
|
||||||
|
'cv::outofmemoryerror',
|
||||||
|
]
|
||||||
|
if isinstance(e, MemoryError) or any(s in err_msg for s in mem_signals):
|
||||||
try:
|
try:
|
||||||
h, w = image.shape[:2]
|
h, w = image.shape[:2]
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
f"🔁 OCR 메모리 오류 재시도: 이미지 1/2 축소 ({w}x{h}) → ({w//2}x{h//2})",
|
f"🔁 OCR 메모리 오류 재시도: 이미지 1/2 축소 ({w}x{h}) → ({w//2}x{h//2})",
|
||||||
level=logging.WARNING,
|
level=logging.WARNING,
|
||||||
)
|
)
|
||||||
image_small = cv2.resize(image, (w // 2, h // 2), interpolation=cv2.INTER_AREA)
|
image_small = cv2.resize(image, (max(1, w // 2), max(1, h // 2)), interpolation=cv2.INTER_AREA)
|
||||||
ocr_raw_results = self.ocr.ocr(image_small)
|
ocr_raw_results = self.ocr.ocr(image_small)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
|
try:
|
||||||
self.logger.log(
|
self.logger.log(
|
||||||
f"❌ OCR 재시도 실패: {e2}", level=logging.ERROR, exc_info=True,
|
f"🔁 2차 재시도(1/4 축소): {e2}", level=logging.WARNING,
|
||||||
)
|
)
|
||||||
|
image_small2 = cv2.resize(image, (max(1, w // 4), max(1, h // 4)), interpolation=cv2.INTER_AREA)
|
||||||
|
ocr_raw_results = self.ocr.ocr(image_small2)
|
||||||
|
except Exception as e3:
|
||||||
|
self.logger.log(
|
||||||
|
f"❌ OCR 재시도 실패: {e3}", level=logging.ERROR, exc_info=True,
|
||||||
|
)
|
||||||
|
if raise_on_memory_error:
|
||||||
|
raise MemoryError(f"OCR memory/primitive failure after retries: {e3}")
|
||||||
return []
|
return []
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
from PIL import ImageFont
|
|
||||||
font_path = r"D:\py\AutoPercenty3_311\src\modules\fonts\HakgyoansimDunggeunmisoTTFB.ttf"
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(font_path, 24)
|
|
||||||
print("폰트 로드 성공")
|
|
||||||
except Exception as e:
|
|
||||||
print("폰트 로드 실패:", e)
|
|
||||||
|
|
@ -52,14 +52,31 @@ class GPTClient:
|
||||||
"【": "[",
|
"【": "[",
|
||||||
"】": "]",
|
"】": "]",
|
||||||
"(": "(",
|
"(": "(",
|
||||||
")": ")"
|
")": ")",
|
||||||
# 주의: 콤마(,)나 기타 JSON 구조 문자는 변환하지 않음
|
"“": '"',
|
||||||
|
"”": '"',
|
||||||
|
"„": '"',
|
||||||
|
"‘": "'",
|
||||||
|
"’": "'",
|
||||||
|
"\ufeff": "", # BOM 제거
|
||||||
|
"\u200b": "", # zero width space
|
||||||
|
"\u200c": "",
|
||||||
|
"\u200d": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
cleaned = content
|
cleaned = content
|
||||||
for old_char, new_char in json_replacements.items():
|
for old_char, new_char in json_replacements.items():
|
||||||
cleaned = cleaned.replace(old_char, new_char)
|
cleaned = cleaned.replace(old_char, new_char)
|
||||||
|
|
||||||
|
# 키의 닫는 따옴표 앞에 잘못 삽입된 백슬래시를 제거: "1\": → "1":
|
||||||
|
cleaned = re.sub(r'\\"(?=\s*:)', '"', cleaned)
|
||||||
|
|
||||||
|
# 객체/배열 종료 직전의 트레일링 콤마 제거
|
||||||
|
cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned)
|
||||||
|
|
||||||
|
# 앞뒤 공백 및 개행 정리
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
def _create_gpt5_response(self, prompt: str) -> dict:
|
def _create_gpt5_response(self, prompt: str) -> dict:
|
||||||
|
|
@ -90,7 +107,16 @@ class GPTClient:
|
||||||
# 추가 로깅으로 디버깅 개선
|
# 추가 로깅으로 디버깅 개선
|
||||||
self.logger.log(f'JSON 파싱 시도 전 정리된 내용: {cleaned_content}', level=logging.DEBUG)
|
self.logger.log(f'JSON 파싱 시도 전 정리된 내용: {cleaned_content}', level=logging.DEBUG)
|
||||||
|
|
||||||
|
try:
|
||||||
return json.loads(cleaned_content)
|
return json.loads(cleaned_content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 추가 보정 1차: 키 끝의 이스케이프 제거 및 트레일링 콤마 제거 후 재시도
|
||||||
|
repaired_once = re.sub(r'\\"(?=\s*:)', '"', cleaned_content)
|
||||||
|
repaired_once = re.sub(r',\s*([}\]])', r'\1', repaired_once)
|
||||||
|
if repaired_once != cleaned_content:
|
||||||
|
self.logger.log(f'JSON 1차 보정 후 재시도: {repaired_once}', level=logging.DEBUG)
|
||||||
|
return json.loads(repaired_once)
|
||||||
|
raise
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
self.logger.log(f'JSON 디코딩 실패: {e}. 원본 응답: {content}', level=logging.ERROR, exc_info=True)
|
self.logger.log(f'JSON 디코딩 실패: {e}. 원본 응답: {content}', level=logging.ERROR, exc_info=True)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -7,7 +7,7 @@ import logging
|
||||||
""" 프로그램 기본 정보 """
|
""" 프로그램 기본 정보 """
|
||||||
__title__ = "Edit_PartTimer"
|
__title__ = "Edit_PartTimer"
|
||||||
__description__ = "편집알바생"
|
__description__ = "편집알바생"
|
||||||
__version__ = "3.11.2"
|
__version__ = "3.11.4"
|
||||||
__build__ = "1" # 빌드 번호
|
__build__ = "1" # 빌드 번호
|
||||||
__author__ = "WhenRideMyCar"
|
__author__ = "WhenRideMyCar"
|
||||||
__author_email__ = "abc@gmail.com"
|
__author_email__ = "abc@gmail.com"
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 3.11.2 업데이트 로그
|
# 3.11.4 업데이트 로그
|
||||||
|
|
||||||
|
### 패치 4 오류수정
|
||||||
|
- GPT5 응답처리 변경
|
||||||
|
- DLL 누락 추가
|
||||||
|
### 패치 4 기능변경
|
||||||
|
- 메모리 상황에 따른 동적재시작 추가
|
||||||
|
|
||||||
|
### 패치 3 기능변경
|
||||||
|
- 옵션 선택방식 변경(업데이트 대응)
|
||||||
|
|
||||||
### 패치 2 오류수정
|
### 패치 2 오류수정
|
||||||
- 누끼서버 폴백기능 오류 수정
|
- 누끼서버 폴백기능 오류 수정
|
||||||
|
|
||||||
### 패치 2 기능추가
|
### 패치 2 기능추가
|
||||||
<!-- - CPU 이미지번역 개선(품질/속도개선) -->
|
<!-- - CPU 이미지번역 개선(품질/속도개선) -->
|
||||||
- 이미지번역 폴백2 추가
|
- 이미지번역 폴백2 추가
|
||||||
|
|
@ -17,16 +24,15 @@
|
||||||
- 폰트누락 수정 (죄송합니다. 기본폰트 이외의 폰트가 누락되었었습니다)
|
- 폰트누락 수정 (죄송합니다. 기본폰트 이외의 폰트가 누락되었었습니다)
|
||||||
- 이미지워커 재시작 오류 수정
|
- 이미지워커 재시작 오류 수정
|
||||||
- 네이버검색 API 오류 수정
|
- 네이버검색 API 오류 수정
|
||||||
|
|
||||||
### 패치 1 기능추가
|
### 패치 1 기능추가
|
||||||
- 누끼서버 폴백기능 추가(Self수행)
|
- 누끼서버 폴백기능 추가(Self수행)
|
||||||
|
|
||||||
### 3.10 마이너 업데이트 오류수정
|
### 3.11 마이너 업데이트 오류수정
|
||||||
- 기존버전 사용자 중 상세페이지 번역 누락 수정
|
- 기존버전 사용자 중 상세페이지 번역 누락 수정
|
||||||
- 이미지워커 중복큐 제거
|
- 이미지워커 중복큐 제거
|
||||||
- 이미지폰트가 저장되지 않던 문제 수정
|
- 이미지폰트가 저장되지 않던 문제 수정
|
||||||
|
|
||||||
### 3.10 마이너 업데이트 기능 추가
|
### 3.11 마이너 업데이트 기능 추가
|
||||||
- gpt5의 특수문자 처리 로직 추가
|
- gpt5의 특수문자 처리 로직 추가
|
||||||
- gpt5 폴백 개선
|
- gpt5 폴백 개선
|
||||||
- 이미지 처리방식 webp 모드 추가(기본적용)
|
- 이미지 처리방식 webp 모드 추가(기본적용)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue