ason / import_bubbles_to_capcut.py
chumdz97's picture
Add files using upload-large-folder tool
a151792 verified
Raw
History Blame Contribute Delete
37.3 kB
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_())