| 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: |
| |
| 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" |
| }) |
| |
| 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) |
| |
| |
| 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 |
|
|
| |
| 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.") |
|
|
| |
| 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"] = [] |
|
|
| |
| 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...") |
| |
| |
| if getattr(sys, 'frozen', False): |
| |
| ffmpeg_exe = os.path.join(sys._MEIPASS, "ffmpeg.exe") |
| |
| if not os.path.exists(ffmpeg_exe): |
| ffmpeg_exe = os.path.join(os.path.dirname(sys.executable), "ffmpeg.exe") |
| else: |
| |
| 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.") |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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)) |
| |
| |
| 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) |
|
|
| |
| 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).") |
|
|
| |
| 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.") |
|
|
| |
| 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) |
| |
| |
| 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_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) |
|
|
|
|
| |
| class BubbleImporterGUI(QWidget): |
| def __init__(self): |
| super().__init__() |
| self.setWindowTitle('Auto CapCut - Bubble Sync to Timeline') |
| self.resize(600, 350) |
| |
| |
| 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) |
| |
| |
| 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_()) |
|
|