import sys import os import uuid import json import time import shutil import cv2 import subprocess import random from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QFormLayout, QTextEdit, QCheckBox) from PyQt5.QtCore import QThread, pyqtSignal class BubbleCapCutWorker(QThread): log_msg = pyqtSignal(str) finished_sig = pyqtSignal(bool) def __init__(self, video_path, json_path, frames_dir, scenes_json_path, scenes_dir, project_name, use_transition=True): super().__init__() self.video_path = video_path.replace("\\", "/") self.json_path = json_path.replace("\\", "/") self.frames_dir = frames_dir.replace("\\", "/") self.scenes_json_path = scenes_json_path.replace("\\", "/") self.scenes_dir = scenes_dir.replace("\\", "/") self.project_name = project_name self.use_transition = use_transition self.local_appdata = os.getenv('LOCALAPPDATA') self.draft_dir = os.path.join(self.local_appdata, 'CapCut', 'User Data', 'Projects', 'com.lveditor.draft') def log(self, message): self.log_msg.emit(message) def run(self): try: self.execute() self.finished_sig.emit(True) except Exception as e: import traceback self.log(f"LỖI: {str(e)}") self.log(traceback.format_exc()) self.finished_sig.emit(False) def gen_uuid(self): return str(uuid.uuid4()).upper() def make_video_material(self, mat_id, path, duration_us, is_photo=False, width=1920, height=1080): return { "aigc_history_id": "", "aigc_item_id": "", "aigc_type": "none", "audio_fade": None, "beauty_body_preset_id": "", "beauty_face_auto_preset": {"name": "", "preset_id": "", "rate_map": ""}, "beauty_face_auto_preset_infos": [], "beauty_face_preset_infos": [], "cartoon_path": "", "category_id": "", "category_name": "local", "check_flag": 62978047, "crop": { "lower_left_x": 0.0, "lower_left_y": 1.0, "lower_right_x": 1.0, "lower_right_y": 1.0, "upper_left_x": 0.0, "upper_left_y": 0.0, "upper_right_x": 1.0, "upper_right_y": 0.0 }, "crop_ratio": "free", "crop_scale": 1.0, "duration": duration_us, "extra_type_option": 0, "formula_id": "", "freeze": None, "has_audio": not is_photo, "has_sound_separated": False, "height": height, "id": mat_id, "intensifies_audio_path": "", "intensifies_path": "", "is_ai_generate_content": False, "is_copyright": False, "is_text_edit_overdub": False, "is_unified_beauty_mode": False, "live_photo_cover_path": "", "live_photo_timestamp": -1, "local_id": "", "local_material_from": "", "local_material_id": str(uuid.uuid4()), "material_id": "", "material_name": os.path.basename(path), "material_url": "", "matting": { "custom_matting_id": "", "enable_matting_stroke": False, "expansion": 0, "feather": 0, "flag": 0, "has_use_quick_brush": False, "has_use_quick_eraser": False, "interactiveTime": [], "path": "", "reverse": False, "strokes": [] }, "media_path": "", "multi_camera_info": None, "object_locked": None, "origin_material_id": "", "path": path, "picture_from": "none", "picture_set_category_id": "", "picture_set_category_name": "", "request_id": "", "reverse_intensifies_path": "", "reverse_path": "", "smart_match_info": None, "smart_motion": None, "source": 0, "source_platform": 0, "stable": {"matrix_path": "", "stable_level": 0, "time_range": {"duration": 0, "start": 0}}, "team_id": "", "type": "photo" if is_photo else "video", "video_algorithm": { "ai_background_configs": [], "ai_expression_driven": None, "ai_motion_driven": None, "aigc_generate": None, "algorithms": [], "complement_frame_config": None, "deflicker": None, "gameplay_configs": [], "image_interpretation": None, "motion_blur_config": None, "mouth_shape_driver": None, "noise_reduction": None, "path": "", "quality_enhance": None, "smart_complement_frame": None, "super_resolution": None, "time_range": None }, "width": width } def make_segment(self, seg_id, mat_id, start_us, duration_us, extra_refs=None, track_idx=0, is_video=True, clip=None, common_keyframes=None): if clip is None: clip = { "alpha": 1.0, "flip": {"horizontal": False, "vertical": False}, "rotation": 0.0, "scale": {"x": 1.0, "y": 1.0}, "transform": {"x": 0.0, "y": 0.0} } if common_keyframes is None: common_keyframes = [] return { "caption_info": None, "cartoon": False, "clip": clip, "color_correct_alg_result": "", "common_keyframes": common_keyframes, "desc": "", "digital_human_template_group_id": "", "enable_adjust": True, "enable_adjust_mask": False, "enable_color_correct_adjust": False, "enable_color_curves": True, "enable_color_match_adjust": False, "enable_color_wheels": True, "enable_hsl": False, "enable_lut": True, "enable_smart_color_adjust": False, "enable_video_mask": True, "extra_material_refs": extra_refs or [], "group_id": "", "hdr_settings": {"intensity": 1.0, "mode": 1, "nits": 1000}, "id": seg_id, "intensifies_audio": False, "is_loop": False, "is_placeholder": False, "is_tone_modify": False, "keyframe_refs": [], "last_nonzero_volume": 1.0, "lyric_keyframes": None, "material_id": mat_id, "raw_segment_id": "", "render_index": 0, "render_timerange": {"duration": 0, "start": 0}, "responsive_layout": { "enable": False, "horizontal_pos_layout": 0, "size_layout": 0, "target_follow": "", "vertical_pos_layout": 0 }, "reverse": False, "source": "segmentsourcenormal", "source_timerange": {"duration": duration_us, "start": 0}, "speed": 1.0, "state": 0, "target_timerange": {"duration": duration_us, "start": start_us}, "template_id": "", "template_scene": "default", "track_attribute": 0, "track_render_index": track_idx, "uniform_scale": {"on": True, "value": 1.0}, "visible": True, "volume": 1.0 } def make_audio_material(self, mat_id, path, duration_us): return { "app_id": 0, "category_id": "", "category_name": "local", "check_flag": 1, "duration": duration_us, "effect_id": "", "formula_id": "", "id": mat_id, "intensifies_path": "", "local_material_id": self.gen_uuid(), "music_id": self.gen_uuid(), "name": os.path.basename(path), "path": path, "request_id": "", "source_platform": 0, "team_id": "", "text_id": "", "tone_category_id": "", "tone_category_name": "", "tone_effect_id": "", "tone_effect_name": "", "tone_speaker": "", "tone_type": "", "type": "extract_music", "video_id": "" } def make_audio_segment(self, seg_id, mat_id, start_us, duration_us, speed_id): return { "caption_info": None, "cartoon": False, "clip": None, "color_correct_alg_result": "", "common_keyframes": [], "desc": "", "enable_adjust": False, "enable_color_curves": False, "enable_color_wheels": False, "enable_lut": False, "enable_smart_color_adjust": False, "extra_material_refs": [speed_id], "group_id": "", "id": seg_id, "intensifies_audio": False, "is_loop": False, "is_placeholder": False, "is_tone_modify": False, "keyframe_refs": [], "last_nonzero_volume": 1.0, "lyric_keyframes": None, "material_id": mat_id, "render_index": 0, "render_timerange": {"duration": duration_us, "start": 0}, "reverse": False, "source": "", "source_timerange": {"duration": duration_us, "start": 0}, "speed": 1.0, "state": 0, "target_timerange": {"duration": duration_us, "start": start_us}, "template_id": "", "template_scene": "default", "track_attribute": 0, "track_render_index": 0, "visible": True, "volume": 1.0 } def make_speed_material(self, speed_id): return {"curve_speed": None, "id": speed_id, "mode": 0, "speed": 1.0, "type": "speed"} def make_canvas_material(self, canvas_id): return {"album_image": "", "blur": 0.0, "color": "", "id": canvas_id, "image": "", "image_id": "", "image_name": "", "source_platform": 0, "team_id": "", "type": "canvas_color"} def make_vocal_separation(self, vs_id): return {"choice": 0, "enter_from": "", "final_algorithm": "", "id": vs_id, "production_path": "", "removed_sounds": [], "time_range": None, "type": "vocal_separation"} def make_transition_material(self, trans_id, effect_id, name, local_appdata): effect_dir = os.path.join(local_appdata, f"CapCut/User Data/Cache/effect/{effect_id}").replace("\\", "/") path = "" if os.path.exists(effect_dir): subdirs = os.listdir(effect_dir) if subdirs: path = f"{effect_dir}/{subdirs[0]}" return { "category_id": "25835", "category_name": "Đang thịnh hành", "duration": 466666, "effect_id": effect_id, "id": trans_id, "is_ai_transition": False, "is_overlap": True, "name": name, "path": path, "platform": "all", "request_id": "", "resource_id": effect_id, "source_platform": 1, "task_id": "", "third_resource_id": effect_id, "type": "transition", "video_path": "" } def get_video_duration(self, vpath): cap = cv2.VideoCapture(vpath) if not cap.isOpened(): return 10_000_000 fps = cap.get(cv2.CAP_PROP_FPS) frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) cap.release() if fps <= 0: return 10_000_000 return int((frame_count / fps) * 1_000_000) def generate_random_scene_keyframes(self, duration_us): scale_start, scale_end = 1.0, 1.0 x_start, x_end = 0.0, 0.0 y_start, y_end = 0.0, 0.0 effect = random.choice(["zoom_in", "zoom_out", "pan_left", "pan_right", "pan_up", "pan_down", "static", "zoom_in"]) if effect == "zoom_in": scale_start, scale_end = 1.0, 1.1 elif effect == "zoom_out": scale_start, scale_end = 1.15, 1.05 elif effect == "pan_left": scale_start, scale_end = 1.1, 1.1 x_start, x_end = 0.05, -0.05 elif effect == "pan_right": scale_start, scale_end = 1.1, 1.1 x_start, x_end = -0.05, 0.05 elif effect == "pan_up": scale_start, scale_end = 1.1, 1.1 y_start, y_end = -0.05, 0.05 elif effect == "pan_down": scale_start, scale_end = 1.1, 1.1 y_start, y_end = 0.05, -0.05 common_keyframes = [] if scale_start != scale_end: common_keyframes.append({ "id": self.gen_uuid(), "keyframe_list": [ { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": 0, "values": [scale_start] }, { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": duration_us, "values": [scale_end] } ], "material_id": "", "property_type": "KFTypeScaleX" }) if x_start != x_end or y_start != y_end: # Phát sinh đủ hệ tọa độ X common_keyframes.append({ "id": self.gen_uuid(), "keyframe_list": [ { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": 0, "values": [x_start] }, { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": duration_us, "values": [x_end] } ], "material_id": "", "property_type": "KFTypePositionX" }) # Buộc phải phát sinh thêm hệ tọa độ Y kể cả khi không đổi, nếu không CapCut sẽ lỗi giao diện UI keyframe! common_keyframes.append({ "id": self.gen_uuid(), "keyframe_list": [ { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": 0, "values": [y_start] }, { "curveType": "Line", "graphID": "", "id": self.gen_uuid(), "left_control": {"x": 0.0, "y": 0.0}, "right_control": {"x": 0.0, "y": 0.0}, "string_value": "", "time_offset": duration_us, "values": [y_end] } ], "material_id": "", "property_type": "KFTypePositionY" }) clip = { "alpha": 1.0, "flip": {"horizontal": False, "vertical": False}, "rotation": 0.0, "scale": {"x": scale_start, "y": scale_start}, "transform": {"x": x_start, "y": y_start} } return clip, common_keyframes def create_empty_draft(self): return { "canvas_config": {"background": None, "height": 1080, "ratio": "16:9", "width": 1920}, "color_space": 0, "config": { "adjust_max_index": 1, "attachment_info": [], "combination_max_index": 1, "export_range": None, "extract_audio_last_index": 1, "lyrics_recognition_id": "", "lyrics_sync": True, "lyrics_taskinfo": [], "maintrack_adsorb": True, "material_save_mode": 0, "multi_language_current": "none", "multi_language_list": [], "multi_language_main": "none", "multi_language_mode": "none", "original_sound_last_index": 1, "record_audio_last_index": 1, "sticker_max_index": 1, "subtitle_keywords_config": None, "subtitle_recognition_id": "", "subtitle_sync": True, "subtitle_taskinfo": [], "system_font_list": [], "use_float_render": False, "video_mute": False, "zoom_info_params": None }, "cover": None, "create_time": 0, "duration": 0, "extra_info": None, "fps": 30.0, "free_render_index_mode_on": False, "group_container": None, "id": "", "is_drop_frame_timecode": False, "keyframe_graph_list": [], "keyframes": { "adjusts": [], "audios": [], "effects": [], "filters": [], "handwrites": [], "stickers": [], "texts": [], "videos": [] }, "last_modified_platform": { "app_id": 359289, "app_source": "cc", "app_version": "6.7.0", "device_id": "", "hard_disk_id": "", "mac_address": "", "os": "windows", "os_version": "10.0.19045" }, "lyrics_effects": [], "materials": { "ai_translates": [], "audio_balances": [], "audio_effects": [], "audio_fades": [], "audio_track_indexes": [], "audios": [], "beats": [], "canvases": [], "chromas": [], "color_curves": [], "common_mask": [], "digital_humans": [], "drafts": [], "effects": [], "flowers": [], "green_screens": [], "handwrites": [], "hsl": [], "images": [], "log_color_wheels": [], "loudnesses": [], "manual_beautys": [], "manual_deformations": [], "material_animations": [], "material_colors": [], "multi_language_refs": [], "placeholder_infos": [], "placeholders": [], "plugin_effects": [], "primary_color_wheels": [], "realtime_denoises": [], "shapes": [], "smart_crops": [], "smart_relights": [], "sound_channel_mappings": [], "speeds": [], "stickers": [], "tail_leaders": [], "text_templates": [], "texts": [], "time_marks": [], "transitions": [], "video_effects": [], "video_trackings": [], "videos": [], "vocal_beautifys": [], "vocal_separations": [] }, "mutable_config": None, "name": "", "new_version": "140.0.0", "path": "", "platform": { "app_id": 359289, "app_source": "cc", "app_version": "6.7.0", "device_id": "", "hard_disk_id": "", "mac_address": "", "os": "windows", "os_version": "10.0.19045" }, "relationships": [], "render_index_track_mode_on": True, "retouch_cover": None, "source": "default", "static_cover_image_path": "", "time_marks": None, "tracks": [], "uneven_animation_template_info": { "composition": "", "content": "", "order": "", "sub_template_info_list": [] }, "update_time": 0, "version": 360000 } def execute(self): self.log(f"Bắt đầu khởi tạo Project: {self.project_name}") project_folder = os.path.join(self.draft_dir, self.project_name) if os.path.exists(project_folder): shutil.rmtree(project_folder) os.makedirs(project_folder, exist_ok=True) # Tạo thư mục cần thiết for d in ['common_attachment', 'matting', 'smart_crop', 'adjust_mask', 'qr_upload', 'Resources', 'subdraft']: os.makedirs(os.path.join(project_folder, d), exist_ok=True) vid_duration_us = self.get_video_duration(self.video_path) self.log(f"Độ dài video gốc: {vid_duration_us / 1_000_000:.2f}s") def get_time_seconds(item): if "timestamp_seconds" in item: return float(item["timestamp_seconds"]) t_str = item.get("time_formatted", "0:00:00.000") h, m, s = t_str.split(':') s, ms = s.split('.') return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 # Đọc JSON bong bóng with open(self.json_path, 'r', encoding='utf-8') as f: bubbles_data = json.load(f) bubbles_data.sort(key=get_time_seconds) self.log(f"Tìm thấy {len(bubbles_data)} định dạng thời gian bong bóng.") # Đọc JSON scenes with open(self.scenes_json_path, 'r', encoding='utf-8') as f: scenes_data = json.load(f) scenes_data.sort(key=get_time_seconds) self.log(f"Tìm thấy {len(scenes_data)} định dạng thời gian frame ảnh gốc (Scenes).") draft = self.create_empty_draft() draft["duration"] = vid_duration_us draft["id"] = self.gen_uuid() draft["tracks"] = [] # EXTRACT MP3 TỪ VIDEO GỐC BẰNG FFMPEG (Để tạo base track chuẩn âm thanh sạch) base_name = os.path.splitext(os.path.basename(self.video_path))[0] dir_name = os.path.dirname(self.video_path) audio_path = os.path.join(dir_name, f"{base_name}.mp3").replace("\\", "/") if not os.path.exists(audio_path): self.log(f"Đang tự động tách âm thanh (FFMPEG) -> {base_name}.mp3...") # --- TÍCH HỢP ĐƯỜNG DẪN FFMPEG KHI BUNDLE PYINSTALLER --- if getattr(sys, 'frozen', False): # Nếu đang chạy file .exe, tìm ffmpeg.exe trong thư mục tạm giải nén (_MEIPASS) ffmpeg_exe = os.path.join(sys._MEIPASS, "ffmpeg.exe") # Dự phòng: người dùng có thể để chung thư mục file exe if not os.path.exists(ffmpeg_exe): ffmpeg_exe = os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe") else: # Đang chạy qua file .py thông thường ffmpeg_exe = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ffmpeg.exe") if not os.path.exists(ffmpeg_exe): ffmpeg_exe = "ffmpeg" subprocess.run([ffmpeg_exe, "-y", "-i", self.video_path, "-q:a", "0", "-map", "a", audio_path], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) self.log("Tách âm thanh bằng FFMPEG hoàn tất!") else: self.log(f"Đã có sẳn file Audio ({base_name}.mp3), không cần chạy lại FFMPEG.") # TRACK 0: BACKGROUND VIDEO (Giữ lại MP4 như yêu cầu) bg_vid_mat_id = self.gen_uuid() bg_vid_seg_id = self.gen_uuid() bg_vid_spd_id, bg_vid_cvs_id, bg_vid_voc_id = self.gen_uuid(), self.gen_uuid(), self.gen_uuid() draft["materials"]["videos"].append(self.make_video_material(bg_vid_mat_id, self.video_path, vid_duration_us, is_photo=False)) draft["materials"]["speeds"].append(self.make_speed_material(bg_vid_spd_id)) draft["materials"]["canvases"].append(self.make_canvas_material(bg_vid_cvs_id)) draft["materials"]["vocal_separations"].append(self.make_vocal_separation(bg_vid_voc_id)) bg_vid_track = { "attribute": 0, "flag": 0, "id": self.gen_uuid(), "is_default_name": True, "name": "", "type": "video", "segments": [self.make_segment(bg_vid_seg_id, bg_vid_mat_id, 0, vid_duration_us, [bg_vid_spd_id, bg_vid_cvs_id, bg_vid_voc_id], track_idx=0)] } draft["tracks"].append(bg_vid_track) # ADD: BACKGROUND AUDIO TRACK (File MP3 tách từ FFMPEG) bg_aud_mat_id = self.gen_uuid() bg_aud_seg_id = self.gen_uuid() bg_aud_spd_id = self.gen_uuid() draft["materials"]["audios"].append(self.make_audio_material(bg_aud_mat_id, audio_path, vid_duration_us)) draft["materials"]["speeds"].append(self.make_speed_material(bg_aud_spd_id)) bg_audio_track = { "attribute": 0, "flag": 0, "id": self.gen_uuid(), "is_default_name": True, "name": "", "type": "audio", "segments": [self.make_audio_segment(bg_aud_seg_id, bg_aud_mat_id, 0, vid_duration_us, bg_aud_spd_id)] } draft["tracks"].append(bg_audio_track) img_duration_default_us = 10800000000000 # CapCut photo duration constant # TRACK 1: MASK SCENES scenes_track = { "attribute": 0, "flag": 0, "id": self.gen_uuid(), "is_default_name": True, "name": "", "type": "video", "segments": [] } for i, item in enumerate(scenes_data): img_file = item.get("image_filename", item.get("image_file", "")) start_sec = get_time_seconds(item) start_us = int(start_sec * 1_000_000) if i < len(scenes_data) - 1: next_start_us = int(get_time_seconds(scenes_data[i+1]) * 1_000_000) dur_us = max(next_start_us - start_us, 100_000) else: dur_us = max(vid_duration_us - start_us, 100_000) img_path = os.path.join(self.scenes_dir, img_file).replace("\\", "/") if not os.path.exists(img_path): self.log(f"CẢNH BÁO: Không tìm thấy ảnh scene {img_file}") continue mat_id, seg_id, spd_id, cvs_id = self.gen_uuid(), self.gen_uuid(), self.gen_uuid(), self.gen_uuid() draft["materials"]["videos"].append(self.make_video_material(mat_id, img_path, img_duration_default_us, is_photo=True)) draft["materials"]["speeds"].append(self.make_speed_material(spd_id)) draft["materials"]["canvases"].append(self.make_canvas_material(cvs_id)) # Khởi tạo hiệu ứng chuyển cảnh "Kết hợp" cho frame extra_refs = [spd_id, cvs_id] if self.use_transition and i < len(scenes_data) - 1: trans_id = self.gen_uuid() draft["materials"]["transitions"].append(self.make_transition_material(trans_id, "6724845717472416269", "Kết hợp", self.local_appdata)) extra_refs.append(trans_id) # Sinh toán học animation keyframe ngẫu nhiên cho frame ảnh này dyn_clip, dyn_kfs = self.generate_random_scene_keyframes(dur_us) scenes_track["segments"].append(self.make_segment( seg_id, mat_id, start_us, dur_us, extra_refs, track_idx=1, is_video=False, clip=dyn_clip, common_keyframes=dyn_kfs )) draft["tracks"].append(scenes_track) self.log(f"Đã lên timeline cho {len(scenes_track['segments'])} scenes (hình gốc).") # TRACK 2: PIP BUBBLES pip_track = { "attribute": 0, "flag": 0, "id": self.gen_uuid(), "is_default_name": True, "name": "", "type": "video", "segments": [] } for i, item in enumerate(bubbles_data): img_file = item.get("image_file") start_sec = get_time_seconds(item) start_us = int(start_sec * 1_000_000) if i < len(bubbles_data) - 1: next_start_us = int(get_time_seconds(bubbles_data[i+1]) * 1_000_000) dur_us = max(next_start_us - start_us, 100_000) else: dur_us = max(vid_duration_us - start_us, 100_000) img_path = os.path.join(self.frames_dir, img_file).replace("\\", "/") if not os.path.exists(img_path): self.log(f"CẢNH BÁO: Không tìm thấy ảnh bong bóng {img_file}") continue mat_id, seg_id, spd_id, cvs_id = self.gen_uuid(), self.gen_uuid(), self.gen_uuid(), self.gen_uuid() draft["materials"]["videos"].append(self.make_video_material(mat_id, img_path, img_duration_default_us, is_photo=True)) draft["materials"]["speeds"].append(self.make_speed_material(spd_id)) draft["materials"]["canvases"].append(self.make_canvas_material(cvs_id)) pip_track["segments"].append(self.make_segment(seg_id, mat_id, start_us, dur_us, [spd_id, cvs_id], track_idx=2)) draft["tracks"].append(pip_track) self.log(f"Đã lên timeline cho {len(pip_track['segments'])} bong bóng.") # LƯU FILE draft_path = os.path.join(project_folder, "draft_content.json") with open(draft_path, "w", encoding="utf-8") as f: json.dump(draft, f, ensure_ascii=False) # Cập nhật root_meta_info.json để CapCut nhận ra timestamp = int(time.time() * 1_000_000) self.register_into_root_meta(draft["id"], project_folder.replace("\\", "/"), self.draft_dir.replace("\\", "/"), timestamp, vid_duration_us) # Meta info meta_path = os.path.join(project_folder, "draft_meta_info.json") meta = { "draft_fold_path": project_folder.replace("\\", "/"), "draft_id": draft["id"], "draft_name": self.project_name, "draft_root_path": self.draft_dir.replace("\\", "/"), "tm_draft_create": timestamp, "tm_draft_modified": timestamp, "tm_duration": vid_duration_us } with open(meta_path, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False) self.log("HOÀN TẤT! Project đã được tạo trong hệ thống của CapCut.") def register_into_root_meta(self, draft_id, project_folder, draft_dir, timestamp, max_time_us): root_meta_path = os.path.join(self.draft_dir, "root_meta_info.json") if not os.path.exists(root_meta_path): return with open(root_meta_path, "r", encoding="utf-8") as f: root_meta = json.load(f) existing = root_meta.get("all_draft_store", []) existing = [e for e in existing if e.get("draft_fold_path") != project_folder and e.get("draft_name") != self.project_name] new_entry = { "draft_cover": "", "draft_fold_path": project_folder, "draft_id": draft_id, "draft_json_file": f"{project_folder}/draft_content.json", "draft_name": self.project_name, "draft_root_path": draft_dir, "tm_draft_create": timestamp, "tm_draft_modified": timestamp, "tm_duration": max_time_us } existing.insert(0, new_entry) root_meta["all_draft_store"] = existing with open(root_meta_path, "w", encoding="utf-8") as f: json.dump(root_meta, f, ensure_ascii=False) # --- GUI --- class BubbleImporterGUI(QWidget): def __init__(self): super().__init__() self.setWindowTitle('Auto CapCut - Bubble Sync to Timeline') self.resize(600, 350) # --- Bật Icon cho Taskbar và Cửa sổ --- try: import ctypes myappid = 'manga.bubble.capcut.1.0' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except: pass from PyQt5.QtGui import QIcon if getattr(sys, 'frozen', False): icon_path = os.path.join(sys._MEIPASS, "icon.ico") else: icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon.ico") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) self.initUI() def initUI(self): layout = QVBoxLayout() form = QFormLayout() self.vid_input = QLineEdit() btn_vid = QPushButton("Chọn Video Gốc") btn_vid.clicked.connect(self.select_video) h1 = QHBoxLayout() h1.addWidget(self.vid_input) h1.addWidget(btn_vid) form.addRow("Video Gốc:", h1) self.json_input = QLineEdit() btn_json = QPushButton("Chọn file JSON Bong Bóng") btn_json.clicked.connect(self.select_json) h2 = QHBoxLayout() h2.addWidget(self.json_input) h2.addWidget(btn_json) form.addRow("Bubbles JSON:", h2) self.folder_input = QLineEdit() btn_folder = QPushButton("Chọn Thư mục chứa bong bóng") btn_folder.clicked.connect(self.select_folder) h3 = QHBoxLayout() h3.addWidget(self.folder_input) h3.addWidget(btn_folder) form.addRow("Thư mục Bubbles:", h3) self.sjsn_input = QLineEdit() btn_sjsn = QPushButton("Chọn JSON Scenes") btn_sjsn.clicked.connect(self.select_sjsn) h4 = QHBoxLayout() h4.addWidget(self.sjsn_input) h4.addWidget(btn_sjsn) form.addRow("Scenes JSON:", h4) self.sf_input = QLineEdit() btn_sf = QPushButton("Chọn Thư mục Scenes") btn_sf.clicked.connect(self.select_sf) h5 = QHBoxLayout() h5.addWidget(self.sf_input) h5.addWidget(btn_sf) form.addRow("Thư mục Scenes:", h5) self.proj_input = QLineEdit("Manga_Vietsub_Auto") form.addRow("Tên Project CapCut:", self.proj_input) self.trans_checkbox = QCheckBox("Thêm hiệu ứng chuyển tiếp (Kết hợp)") self.trans_checkbox.setChecked(True) self.trans_checkbox.setStyleSheet("font-weight: bold; color: #2980b9;") form.addRow("", self.trans_checkbox) layout.addLayout(form) self.btn_run = QPushButton("⚡ CHUYỂN TOÀN BỘ BONG BÓNG LÊN CAPCUT") self.btn_run.setStyleSheet("background-color: #27AE60; color: white; padding: 15px; font-weight: bold; font-size: 15px;") self.btn_run.clicked.connect(self.run_process) layout.addWidget(self.btn_run) self.log_txt = QTextEdit() self.log_txt.setReadOnly(True) layout.addWidget(self.log_txt) self.setLayout(layout) def select_video(self): f, _ = QFileDialog.getOpenFileName(self, 'Chọn Video', '', 'Video (*.mp4 *.mkv)') if f: self.vid_input.setText(f) # Tự động điền các đường dẫn nếu tồn tại như yêu cầu mới dir_name = os.path.dirname(f) base_name = os.path.splitext(os.path.basename(f))[0] b_json = os.path.join(dir_name, f"{base_name}_bubbles.json").replace("\\", "/") if os.path.exists(b_json): self.json_input.setText(b_json) b_dir = os.path.join(dir_name, f"{base_name}_bubbles").replace("\\", "/") if os.path.exists(b_dir): self.folder_input.setText(b_dir) s_json = os.path.join(dir_name, f"{base_name}_scenes.json").replace("\\", "/") if os.path.exists(s_json): self.sjsn_input.setText(s_json) s_dir = os.path.join(dir_name, f"{base_name}_frames").replace("\\", "/") if os.path.exists(s_dir): self.sf_input.setText(s_dir) self.proj_input.setText(f"{base_name}_project") def select_json(self): f, _ = QFileDialog.getOpenFileName(self, 'Chọn file JSON của bong bóng', '', 'JSON (*.json)') if f: self.json_input.setText(f) def select_folder(self): f = QFileDialog.getExistingDirectory(self, 'Chọn Thư mục chứa bong bóng') if f: self.folder_input.setText(f) def select_sjsn(self): f, _ = QFileDialog.getOpenFileName(self, 'Chọn file JSON của Scenes', '', 'JSON (*.json)') if f: self.sjsn_input.setText(f) def select_sf(self): f = QFileDialog.getExistingDirectory(self, 'Chọn Thư mục chứa Scenes') if f: self.sf_input.setText(f) def run_process(self): vid = self.vid_input.text() jsn = self.json_input.text() fld = self.folder_input.text() sjsn = self.sjsn_input.text() sfld = self.sf_input.text() prj = self.proj_input.text() use_trans = self.trans_checkbox.isChecked() if not vid or not jsn or not fld or not sjsn or not sfld: self.log_txt.append("Vui lòng điền đủ tất cả các trường!") return self.btn_run.setEnabled(False) self.log_txt.clear() self.worker = BubbleCapCutWorker(vid, jsn, fld, sjsn, sfld, prj, use_trans) self.worker.log_msg.connect(self.log_txt.append) self.worker.finished_sig.connect(lambda s: self.btn_run.setEnabled(True)) self.worker.start() if __name__ == '__main__': app = QApplication(sys.argv) ex = BubbleImporterGUI() ex.show() sys.exit(app.exec_())