掘金 人工智能 10月31日 09:59
Python ASS 字幕样式编辑器:赋能视频翻译与制作
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文介绍了如何使用 Python 和 PySide6 开发一个轻量级的 ASS 字幕样式编辑器。该编辑器旨在降低专业工具的使用门槛,提供实时预览功能,并支持 SRT、ASS、VTT 等多种格式的导入导出。文章详细阐述了项目的整体架构(MVC)、核心模块(颜色系统、实时预览、字幕导入)的设计思路,并重点攻克了 ASS 颜色格式的“坑”以及 QGraphicsView 相较于 QSS 的优势。同时,文章还分享了处理多格式字幕解析、保存逻辑以及 UI 设计的经验,并提供了 PyInstaller 打包部署的建议。最终目标是帮助用户“所见即所得”地调整字幕样式,并可嵌入至视频处理流程。

🎯 **目标与定位**:该项目旨在创建一个轻量级、易于使用的 ASS 字幕样式编辑器,解决 Aegisub 等专业工具门槛高、命令行工具无实时预览的问题。它通过 Python + PySide6 实现,支持导入 SRT/ASS/VTT,提供实时预览,并能导出带样式的 ASS 文件,服务于视频翻译、字幕制作和 AI 配音后处理等场景。

🎨 **核心技术与设计**:编辑器采用 MVC 架构,UI 层(PySide6)负责界面交互和实时预览,业务逻辑层处理样式控件同步与导入导出,数据模型层负责 JSON 持久化和 ASS 格式生成。特别地,选择了 QGraphicsView + QPainterPath 来实现 ASS 字幕复杂的描边、阴影、旋转等效果,这比 QSS 提供了更精细的像素级控制。

⚙️ **关键模块解析**:项目攻克了 ASS 颜色格式(BGR+Alpha,`&HAA BB GG RR&`)与 Qt ARGB 的转换难点,通过 `ColorPicker` 封装实现双向转换和实时更新。实时预览模块利用 `QPainterPathItem` 绘制文字、描边、阴影等,并处理了变换(缩放、旋转)和定位。字幕导入功能支持 SRT、ASS、VTT 三种格式的解析,并进行了时间戳的标准化处理(保留 centisecond)。

💾 **文件处理与部署**:编辑器支持导入多种字幕格式,并能生成新的 ASS 文件,避免覆盖原文件。样式信息通过 JSON 文件持久化。打包部署方面,利用 PyInstaller `-w -F` 命令将项目冻结为单个可执行文件,并讲解了资源文件(如 `preview.png`, `ass.json`)的正确打包方法,解决了路径问题。

💡 **经验与教训**:文章总结了开发过程中遇到的易错点,如颜色透明度反转、描边宽度计算错误、预览更新不及时、打包后资源文件找不到、中文路径乱码等,并提供了相应的解决方案,强调了代码沟通与设计思考的重要性。

为什么要做一个 ASS 样式编辑器?

在视频翻译、字幕制作、AI 配音后处理等场景中,ASS(Advanced SubStation Alpha) 是事实上的工业标准字幕格式。它不仅支持丰富样式(颜色、描边、阴影、旋转、缩放),还支持卡拉 OK、动画等高级效果。

但问题在于:

于是,我决定用 Python + PySide6 做一个:

轻量、可冻结、可嵌入、实时预览、支持导入 SRT/ASS/VTT 并一键导出带样式的 ASS 文件 的编辑器。

这篇文章将带你完整走一遍这个项目的 设计思路、核心代码、难点攻克与经验总结


整体架构:MVC 思维下的分层设计

┌─────────────────┐│   UI 层 (PySide6)│ ← 表单 + 预览 + 按钮├─────────────────┤│  业务逻辑层      │ ← 样式 ↔ 控件同步、导入/导出├─────────────────┤│  数据模型层       │ ← JSON 持久化、ASS 格式生成└─────────────────┘

我没有使用复杂框架,而是用 清晰的类职责划分 实现可维护性:

职责
ASSStyleDialog主窗口、控件管理、事件协调
ColorPickerASS 颜色字符串 ↔ QColor 双向转换
PreviewWidget使用 QGraphicsScene 实时绘制字幕效果
convert_and_save_ass()字幕格式解析 + ASS 结构生成

为什么不用 QSS 而是 QGraphicsView?
因为 ASS 字幕需要 描边、阴影、旋转、缩放、字母间距 等复杂排版,QLabel/QSS 无法精确控制。因此选择 QPainterPath + QGraphicsPathItem 实现像素级渲染。


核心模块拆解

1. 颜色系统:ASS 颜色格式的“坑”

ASS 颜色是 BGR + Alpha,且以 &HAA BB GG RR& 表示:

&HFF0000FF&  → 蓝色,不透明&H80FF0000&  → 红色,半透明

难点:

解决方案:ColorPicker 封装

@staticmethoddef parse_color(color_str):    hex_str = color_str[2:-1].upper()  # 去掉 &H 和 &    if len(hex_str) == 6:        a, b, g, r = 0, *map(lambda x: int(x, 16), [hex_str[i:i+2] for i in range(0,6,2)])    elif len(hex_str) == 8:        a, b, g, r = map(lambda x: int(x, 16), [hex_str[i:i+2] for i in range(0,8,2)])    return QColor(r, g, b, 255 - a)  # Alpha 反转

易错点


2. 实时预览:用 QGraphicsScene 画出“真·ASS 效果”

path = QPainterPath()path.addText(0, 0, font, text)

实现步骤:

    填充文字QGraphicsPathItem(fill_item)描边QPainterPathStroker().createStroke() → 再填充阴影 → 复制描边+填充,偏移 (Shadow, Shadow)不透明框(BorderStyle=3)QGraphicsRectItem变换QTransform().scale().rotate()

关键代码:

stroker = QPainterPathStroker()stroker.setWidth(outline * 2)outline_path = stroker.createStroke(path)

为什么乘 2?
因为 createStroke 是向外扩展 width/2,所以要 outline * 2 才等于 ASS 的视觉描边宽度。

易错点:


3. 导入字幕:支持 SRT / ASS / VTT 三种格式

设计目标:

实现思路:

格式解析策略
.ass直接复制 [Events] 中的 Dialogue:
.srt按数字块解析时间戳(,.
.vtt--> 分隔,时间格式类似 SRT

关键转换函数:

def parse_time_srt(t):  # "00:00:02,034" → "0:00:02.03"    h, m, sms = t.split(':')    s, ms = sms.replace(',', '.').split('.')    return f"{int(h):d}:{int(m):d}:{int(s):d}.{ms.ljust(2,'0')[:2]}"

为什么不保留毫秒第三位?
ASS 只支持 centisecond(两位),多余的截断即可。


保存逻辑:样式 + 字幕 → 新 ASS 文件

def save_settings(self):    style = self.get_current_style()    # 1. 保存到 JSON    # 2. 若有导入字幕 → 生成 -edit.ass

生成 ASS 文件结构:

[Script Info]ScriptType: v4.00+[V4+ Styles]Format: Name, Fontname, ...Style: Default,Arial,16,&H00FFFFFF&,...[Events]Format: Layer, Start, End, Style, ...Dialogue: 0,0:00:01.23,0:00:03.45,Default,,0,0,0,,你好啊

注意:


UI 设计:不好看但凑合的界面

布局结构:

┌─ 左侧表单 (QFormLayout) ─┐│ 字体 | 尺寸 | 颜色 ×4     ││ 粗体 | 斜体 | 下划线      ││ 对齐 9宫格               ││ 边距 | 缩放 | 旋转       │└────────────────────────┘┌─ 右侧预览 (QGraphicsView) ─┐│  [导入字幕] 文件名         │ │  ┌─────────────────────┐  ││  │     实时预览区域      │  ││  └─────────────────────┘  │└──────────────────────────┘

小技巧:


打包与部署:冻结为单个 exe

pyinstaller -w -F appedit.py

IS_FROZEN = getattr(sys, 'frozen', False)ROOT_DIR = Path(sys.executable).parent if IS_FROZEN else Path(__file__).parent

关键:


七、易错点总结

问题原因解决方案
颜色透明度反了ASS Alpha 是 255 - Qt.alpha255 - a
描边太细/太粗stroker.setWidth(outline) 应为 outline * 2乘 2
导入后预览空白update_preview 未触发valueChanged.connect 确保连接
打包后找不到 preview.png路径写死 __file__sys.executable
SRT 时间戳格式错误, 未转 .正则替换
中文路径乱码打开文件未指定编码utf-8 + try gbk

九、写在最后:代码是沟通,设计是思考

这个项目虽小,但包含了:


你也可以 fork 这份代码,嵌入到你的视频处理流程中,让用户“所见即所得”地调字幕样式。

全部源码都在这一个单文件中,pip install pyside6 后即可启动

import sysimport jsonimport osfrom PySide6.QtWidgets import (    QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFormLayout,    QFontComboBox, QSpinBox, QCheckBox, QComboBox, QColorDialog, QGridLayout,    QGroupBox, QApplication, QWidget, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,    QGraphicsTextItem, QGraphicsRectItem, QGraphicsPathItem,QSpacerItem,QSizePolicy)from PySide6.QtGui import QColor, QPixmap, QFont, QPen, QBrush, QPainterPath, QTransform, QPainterPathStroker,QIconfrom PySide6.QtCore import Qt, Signal,QSizefrom pathlib import Pathfrom PySide6.QtWidgets import QFileDialog  IS_FROZEN = True if getattr(sys, 'frozen', False) else FalseROOT_DIR= Path(sys.executable).parent.as_posix() if IS_FROZEN else Path(__file__).parent.as_posix()os.environ['PATH'] = ROOT_DIR +os.pathsep+f'{ROOT_DIR}/ffmpeg'+os.pathsep + os.environ.get("PATH", "")JSON_FILE = f'{ROOT_DIR}/ass.json'PREVIEW_IMAGE = f'{ROOT_DIR}/preview.png'DEFAULT_STYLE = {    'Name': 'Default',    'Fontname': 'Arial',    'Fontsize': 16,    'PrimaryColour': '&H00FFFFFF&',    'SecondaryColour': '&H00FFFFFF&',    'OutlineColour': '&H00000000&',    'BackColour': '&H00000000&',    'Bold': 0,    'Italic': 0,    'Underline': 0,    'StrikeOut': 0,    'ScaleX': 100,    'ScaleY': 100,    'Spacing': 0,    'Angle': 0,    'BorderStyle': 1,    'Outline': 1,    'Shadow': 0,    'Alignment': 2,    'MarginL': 10,    'MarginR': 10,    'MarginV': 10,    'Encoding': 1}class ColorPicker(QWidget):    colorChanged = Signal()    def __init__(self, color_str, parent=None):        super().__init__(parent)        self.setLayout(QHBoxLayout())        self.layout().setContentsMargins(0, 0, 0, 0)        self.color_swatch = QLabel()        self.color_swatch.setFixedSize(30, 20)        self.color_swatch.setStyleSheet("border: 1px solid black;")        self.button = QPushButton('选择颜色')        self.layout().addWidget(self.color_swatch)        self.layout().addWidget(self.button)        self.color = self.parse_color(color_str)        self.update_swatch()        self.button.clicked.connect(self.choose_color)    @staticmethod    def parse_color(color_str):        if not color_str.startswith('&H') or not color_str.endswith('&'):            return QColor(255, 255, 255, 255)        hex_str = color_str[2:-1].upper()        if len(hex_str) == 6:            a = 0            b = int(hex_str[0:2], 16)            g = int(hex_str[2:4], 16)            r = int(hex_str[4:6], 16)        elif len(hex_str) == 8:            a = int(hex_str[0:2], 16)            b = int(hex_str[2:4], 16)            g = int(hex_str[4:6], 16)            r = int(hex_str[6:8], 16)        else:            return QColor(255, 255, 255, 255)        return QColor(r, g, b, 255 - a)    def to_ass_color(self):        r = self.color.red()        g = self.color.green()        b = self.color.blue()        a = 255 - self.color.alpha()        return f'&H{a:02X}{b:02X}{g:02X}{r:02X}&'    def choose_color(self):        dialog = QColorDialog(self.color, self)        dialog.setOption(QColorDialog.ShowAlphaChannel, True)        if dialog.exec():            self.color = dialog.currentColor()            self.update_swatch()            self.colorChanged.emit()    def update_swatch(self):        self.color_swatch.setStyleSheet(f"background-color: rgba({self.color.red()},{self.color.green()},{self.color.blue()},{self.color.alpha()}); border: 1px solid black;")class PreviewWidget(QGraphicsView):    def __init__(self, parent=None):        super().__init__(parent)        self.scene = QGraphicsScene()        self.setScene(self.scene)        self.background_item = None        self.items = []        self.load_background()                          def load_background(self):        if Path(PREVIEW_IMAGE).exists():            pixmap = QPixmap(PREVIEW_IMAGE)            self.background_item = QGraphicsPixmapItem(pixmap)            self.background_item.setZValue(-10)            self.scene.addItem(self.background_item)            self.setSceneRect(self.background_item.boundingRect())        else:            self.setSceneRect(0, 0, 640, 360)  # Default size if no image    def clear_items(self):        for item in self.items:            self.scene.removeItem(item)        self.items = []    def update_preview(self, style):        self.clear_items()        text =  '你好啊,亲爱的朋友们!'        font = QFont(style['Fontname'], style['Fontsize'])        font.setBold(bool(style['Bold']))        font.setItalic(bool(style['Italic']))        font.setUnderline(bool(style['Underline']))        font.setStrikeOut(bool(style['StrikeOut']))        font.setLetterSpacing(QFont.AbsoluteSpacing, style['Spacing'])        if isinstance(style['PrimaryColour'], str):            primary_color = ColorPicker.parse_color(style['PrimaryColour'])        else:            primary_color = style['PrimaryColour']        if isinstance(style['OutlineColour'], str):            outline_color = ColorPicker.parse_color(style['OutlineColour'])        else:            outline_color = style['OutlineColour']        if isinstance(style['BackColour'], str):            back_color = ColorPicker.parse_color(style['BackColour'])        else:            back_color = style['BackColour']        path = QPainterPath()        path.addText(0, 0, font, text)        text_rect = path.boundingRect()        effective_outline = style['Outline'] if style['BorderStyle'] == 1 else 0        shadow_item = None        back_rect = None        outline_item = None        fill_item = QGraphicsPathItem(path)        fill_item.setPen(Qt.NoPen)        fill_item.setBrush(QBrush(primary_color))        self.scene.addItem(fill_item)        self.items.append(fill_item)        main_item = fill_item        if effective_outline > 0:            stroker = QPainterPathStroker()            stroker.setWidth(effective_outline * 2)            stroker.setCapStyle(Qt.RoundCap)            stroker.setJoinStyle(Qt.RoundJoin)            outline_path = stroker.createStroke(path)            outline_item = QGraphicsPathItem(outline_path)            outline_item.setPen(Qt.NoPen)            outline_item.setBrush(QBrush(outline_color))            self.scene.addItem(outline_item)            self.items.append(outline_item)            outline_item.setZValue(-1)            main_item = outline_item        if style['BorderStyle'] == 3:            box_padding = style['Outline']            box_rect = text_rect.adjusted(-box_padding, -box_padding, box_padding, box_padding)            back_rect = QGraphicsRectItem(box_rect)            back_rect.setBrush(QBrush(outline_color))  # BorderStyle 3 使用 OutlineColour 作为背景框颜色            back_rect.setPen(Qt.NoPen)            self.scene.addItem(back_rect)            self.items.append(back_rect)            back_rect.setZValue(-1)            fill_item.setZValue(1)        # Shadow        if style['Shadow'] > 0:            if style['BorderStyle'] == 1:                shadow_path = QPainterPath()                shadow_path.addText(0, 0, font, text)                stroker = QPainterPathStroker()                stroker.setWidth(effective_outline * 2)                stroker.setCapStyle(Qt.RoundCap)                stroker.setJoinStyle(Qt.RoundJoin)                widened_shadow = stroker.createStroke(shadow_path) + shadow_path                shadow_item = QGraphicsPathItem(widened_shadow)                shadow_item.setPen(Qt.NoPen)                shadow_item.setBrush(QBrush(back_color))                self.scene.addItem(shadow_item)                self.items.append(shadow_item)                shadow_item.setZValue(-2)            elif style['BorderStyle'] == 3:                box_padding = style['Outline']                shadow_rect = text_rect.adjusted(-box_padding, -box_padding, box_padding, box_padding)                shadow_item = QGraphicsRectItem(shadow_rect)                shadow_item.setBrush(QBrush(back_color))                shadow_item.setPen(Qt.NoPen)                self.scene.addItem(shadow_item)                self.items.append(shadow_item)                shadow_item.setZValue(-2)        # Transformations        transform = QTransform()        transform.scale(style['ScaleX'] / 100.0, style['ScaleY'] / 100.0)        transform.rotate(style['Angle'])        # Apply transform to main items        for item in self.items:            if shadow_item is None or item != shadow_item:                item.setTransform(transform)        if style['Shadow'] > 0:            shadow_item.setTransform(transform)        # Position        scene_rect = self.sceneRect()        # Use text bounding for alignment        text_bounding = fill_item.mapToScene(fill_item.boundingRect()).boundingRect()        width = text_bounding.width()        height = text_bounding.height()        align = style['Alignment']        margin_l = style['MarginL']        margin_r = style['MarginR']        margin_v = style['MarginV']        if align in [1, 4, 7]:  # Left            x = margin_l        elif align in [3, 6, 9]:  # Right            x = scene_rect.width() - width - margin_r        else:  # Center            x = (scene_rect.width() - width) / 2        if align in [7, 8, 9]:  # Top            y = margin_v        elif align in [1, 2, 3]:  # Bottom            y = scene_rect.height() - height - margin_v        else:  # Middle            y = (scene_rect.height() - height) / 2        # Set pos for main items        fill_item.setPos(x, y)        if outline_item:            outline_item.setPos(x, y)        if back_rect:            back_rect.setPos(x, y)        if style['Shadow'] > 0:            shadow_item.setPos(x + style['Shadow'], y + style['Shadow'])class ASSStyleDialog(QDialog):    def __init__(self, parent=None):        super().__init__(parent)        self.setWindowTitle('ASS 字幕样式编辑器 - pyVideoTrans.com')        self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint)        self.resize(1000, 600)        self.setModal(True)                self.subtitle_path = None          # 导入的字幕文件完整路径        self.subtitle_lines = []          # 原始字幕行(list[str])                self.main_layout = QVBoxLayout(self)        # Split layout for form and preview        content_layout = QHBoxLayout()        # Form for style properties        self.form_group = QGroupBox('')        self.form_layout = QFormLayout()        # Font        self.font_combo = QFontComboBox()        self.font_combo.currentFontChanged.connect(self.update_preview)        self.form_layout.addRow('字体', self.font_combo)        # Font size        self.font_size_spin = QSpinBox()        self.font_size_spin.setRange(1, 200)        self.font_size_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('字体尺寸', self.font_size_spin)        # Colors        self.primary_color_picker = ColorPicker(DEFAULT_STYLE['PrimaryColour'])        self.primary_color_picker.colorChanged.connect(self.update_preview)        self.form_layout.addRow("主要颜色", self.primary_color_picker)        self.secondary_color_picker = ColorPicker(DEFAULT_STYLE['SecondaryColour'])        self.secondary_color_picker.colorChanged.connect(self.update_preview)        self.form_layout.addRow("次要颜色", self.secondary_color_picker)        self.outline_color_picker = ColorPicker(DEFAULT_STYLE['OutlineColour'])        self.outline_color_picker.colorChanged.connect(self.update_preview)        self.form_layout.addRow('轮廓颜色', self.outline_color_picker)        self.back_color_picker = ColorPicker(DEFAULT_STYLE['BackColour'])        self.back_color_picker.colorChanged.connect(self.update_preview)        self.form_layout.addRow('背景颜色', self.back_color_picker)        # Bold, Italic, Underline, StrikeOut        self.bold_check = QCheckBox()        self.bold_check.stateChanged.connect(self.update_preview)        self.form_layout.addRow('粗体', self.bold_check)        self.italic_check = QCheckBox()        self.italic_check.stateChanged.connect(self.update_preview)        self.form_layout.addRow('斜体', self.italic_check)        self.underline_check = QCheckBox()        self.underline_check.stateChanged.connect(self.update_preview)        self.form_layout.addRow('下划线', self.underline_check)        self.strikeout_check = QCheckBox()        self.strikeout_check.stateChanged.connect(self.update_preview)        self.form_layout.addRow('删除线', self.strikeout_check)        # ScaleX, ScaleY        self.scale_x_spin = QSpinBox()        self.scale_x_spin.setRange(1, 1000)        self.scale_x_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('X缩放', self.scale_x_spin)        self.scale_y_spin = QSpinBox()        self.scale_y_spin.setRange(1, 1000)        self.scale_y_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow("Y缩放", self.scale_y_spin)        # Spacing        self.spacing_spin = QSpinBox()        self.spacing_spin.setRange(-100, 100)        self.spacing_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow("间距", self.spacing_spin)        # Angle        self.angle_spin = QSpinBox()        self.angle_spin.setRange(-360, 360)        self.angle_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('角度', self.angle_spin)        # BorderStyle        self.border_style_combo = QComboBox()        self.border_style_combo.addItems(['轮廓 (1)', '不透明框 (3)'])        self.border_style_combo.currentIndexChanged.connect(self.update_preview)        self.form_layout.addRow('边框样式', self.border_style_combo)        # Outline (border size)        self.outline_spin = QSpinBox()        self.outline_spin.setRange(0, 10)        self.outline_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('轮廓大小', self.outline_spin)        # Shadow        self.shadow_spin = QSpinBox()        self.shadow_spin.setRange(0, 10)        self.shadow_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('阴影大小', self.shadow_spin)        # Alignment        self.alignment_group = QGroupBox('对齐')        self.alignment_group.setStyleSheet("QGroupBox { margin-bottom: 12px;margin-top:12px }")   # 标题上留空        self.alignment_layout = QGridLayout()        self.alignment_buttons = []        for i in range(1, 10):            btn = QPushButton(str(i))            btn.setCheckable(True)            btn.clicked.connect(lambda checked, val=i: self.set_alignment(val))            self.alignment_buttons.append(btn)        positions = [(0,0,7), (0,1,8), (0,2,9),                     (1,0,4), (1,1,5), (1,2,6),                     (2,0,1), (2,1,2), (2,2,3)]        for row, col, val in positions:            self.alignment_layout.addWidget(self.alignment_buttons[val-1], row, col)        self.alignment_group.setLayout(self.alignment_layout)        self.alignment_group.setStyleSheet("""    QGroupBox {        margin-top: 14px;            padding-top: 10px;     }    QGroupBox::title {        subcontrol-origin: margin;        subcontrol-position: top left;        left: 10px;        padding: 0 3px;    }""")        self.form_layout.addRow(self.alignment_group)        # Margins        self.margin_l_spin = QSpinBox()        self.margin_l_spin.setRange(0, 1000)        self.margin_l_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('左边距', self.margin_l_spin)        self.margin_r_spin = QSpinBox()        self.margin_r_spin.setRange(0, 1000)        self.margin_r_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('右边距', self.margin_r_spin)        self.margin_v_spin = QSpinBox()        self.margin_v_spin.setRange(0, 1000)        self.margin_v_spin.valueChanged.connect(self.update_preview)        self.form_layout.addRow('垂直边距', self.margin_v_spin)        self.form_group.setLayout(self.form_layout)        content_layout.addWidget(self.form_group)        # Preview        self.preview_group = QGroupBox('')        preview_layout = QVBoxLayout()        # ---------- 顶部一行(导入按钮 + 文件名) ----------        top_bar = QHBoxLayout()        self.import_btn = QPushButton('导入字幕')        self.import_btn.setCursor(Qt.PointingHandCursor)        self.import_btn.clicked.connect(self.import_subtitle)        top_bar.addWidget(self.import_btn)        self.subtitle_label = QLabel('未导入')        self.subtitle_label.setStyleSheet('color: #555;')        top_bar.addWidget(self.subtitle_label)        top_bar.addStretch()                     # 右侧留空        preview_layout.addLayout(top_bar)        # -----------------------------------------------------                self.preview_widget = PreviewWidget()        preview_layout.addWidget(self.preview_widget)        self.preview_group.setLayout(preview_layout)        content_layout.addWidget(self.preview_group)        self.main_layout.addLayout(content_layout)        # Buttons        self.buttons_layout = QHBoxLayout()        self.save_btn = QPushButton('保存设置')        self.save_btn.setCursor(Qt.PointingHandCursor)        self.save_btn.clicked.connect(self.save_settings)        self.save_btn.setMinimumSize(QSize(200, 35))        self.restore_btn = QPushButton('恢复默认')        self.restore_btn.clicked.connect(self.restore_defaults)        self.restore_btn.setMaximumSize(QSize(150, 20))        self.restore_btn.setCursor(Qt.PointingHandCursor)        self.close_btn = QPushButton('关闭')        self.close_btn.clicked.connect(self.close)        self.close_btn.setMaximumSize(QSize(150, 20))        self.close_btn.setCursor(Qt.PointingHandCursor)        self.buttons_layout.addWidget(self.save_btn)        self.buttons_layout.addWidget(self.restore_btn)        self.buttons_layout.addWidget(self.close_btn)        self.main_layout.addLayout(self.buttons_layout)        # Load settings if exist        self.load_settings()        self.update_preview()    def import_subtitle(self):        """打开文件对话框,选择 .srt / .ass / .vtt 文件并读取内容"""        file_path, _ = QFileDialog.getOpenFileName(            self,            '导入字幕文件',            '',            'Subtitle Files (*.srt *.ass *.vtt);;All Files (*)'        )        if not file_path:            return        try:            with open(file_path, 'r', encoding='utf-8') as f:                lines = f.readlines()        except UnicodeDecodeError:            # 有些文件可能是 gbk 编码            with open(file_path, 'r', encoding='gbk') as f:                lines = f.readlines()        self.subtitle_path = file_path        self.subtitle_lines = [line.rstrip('\n') for line in lines]        # 更新按钮文字 & 标签        file_name = Path(file_path).name        self.import_btn.setText('重新导入')        self.subtitle_label.setText(file_name)        self.subtitle_label.setToolTip(file_path)    def set_alignment(self, value):        for btn in self.alignment_buttons:            btn.setChecked(False)        self.alignment_buttons[value-1].setChecked(True)        self.update_preview()    def get_alignment(self):        for i, btn in enumerate(self.alignment_buttons):            if btn.isChecked():                return i + 1        return 2  # Default    def load_settings(self):        self.blockSignals(True)        try:            if Path(JSON_FILE).exists():                with open(JSON_FILE, 'r') as f:                    style = json.load(f)            else:                style = DEFAULT_STYLE            self.font_combo.setCurrentFont(style.get('Fontname', 'Arial'))            self.font_size_spin.setValue(style.get('Fontsize', 16))            self.primary_color_picker.color = ColorPicker.parse_color(style.get('PrimaryColour', '&H00FFFFFF&'))            self.primary_color_picker.update_swatch()            self.secondary_color_picker.color = ColorPicker.parse_color(style.get('SecondaryColour', '&H00FFFFFF&'))            self.secondary_color_picker.update_swatch()            self.outline_color_picker.color = ColorPicker.parse_color(style.get('OutlineColour', '&H00000000&'))            self.outline_color_picker.update_swatch()            self.back_color_picker.color = ColorPicker.parse_color(style.get('BackColour', '&H00000000&'))            self.back_color_picker.update_swatch()            self.bold_check.setChecked(bool(style.get('Bold', 0)))            self.italic_check.setChecked(bool(style.get('Italic', 0)))              self.underline_check.setChecked(bool(style.get('Underline', 0)))            self.strikeout_check.setChecked(bool(style.get('StrikeOut', 0)))            self.scale_x_spin.setValue(style.get('ScaleX', 100))            self.scale_y_spin.setValue(style.get('ScaleY', 100))            self.spacing_spin.setValue(style.get('Spacing', 0))            self.angle_spin.setValue(style.get('Angle', 0))            self.border_style_combo.setCurrentIndex(0 if style.get('BorderStyle', 1) == 1 else 1)            self.outline_spin.setValue(style.get('Outline', 1))            self.shadow_spin.setValue(style.get('Shadow', 0))            self.set_alignment(style.get('Alignment', 2))            self.margin_l_spin.setValue(style.get('MarginL', 10))            self.margin_r_spin.setValue(style.get('MarginR', 10))            self.margin_v_spin.setValue(style.get('MarginV', 10))        finally:            self.blockSignals(False)    def save_settings(self):        style = self.get_current_style()        with open(JSON_FILE, 'w') as f:            json.dump(style, f, indent=4)        # 如果已经导入字幕 → 转换为 ASS 并保存为 “原名-edit.ass”        if self.subtitle_path and self.subtitle_lines:            self.convert_and_save_ass(style)    def convert_and_save_ass(self, style: dict):        """        将 self.subtitle_lines(srt/ass/vtt)转换为 ASS 格式,        把 style 写入 [V4+ Styles],保存为 {原文件名}-edit.ass        """        import re        from datetime import timedelta        src_path = Path(self.subtitle_path)        new_path = src_path.parent / f'{src_path.stem}-edit.ass'        ass_lines = [            '[Script Info]',            '; Script generated by pyVideoTrans ASS Style Editor',            'Title: Edited Subtitle',            'ScriptType: v4.00+',            'Collisions: Normal',            'PlayResX: 640',            'PlayResY: 360',            '',            '[V4+ Styles]',            'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, '            'Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, '            'Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',            f'Style: {style["Name"]},{style["Fontname"]},{style["Fontsize"]},'            f'{style["PrimaryColour"]},{style["SecondaryColour"]},'            f'{style["OutlineColour"]},{style["BackColour"]},'            f'{"-1" if style["Bold"] else "0"},'            f'{"-1" if style["Italic"] else "0"},'            f'{"-1" if style["Underline"] else "0"},'            f'{"-1" if style["StrikeOut"] else "0"},'            f'{style["ScaleX"]},{style["ScaleY"]},{style["Spacing"]},{style["Angle"]},'            f'{style["BorderStyle"]},{style["Outline"]},{style["Shadow"]},'            f'{style["Alignment"]},{style["MarginL"]},{style["MarginR"]},'            f'{style["MarginV"]},{style["Encoding"]}',            '',            '[Events]',            'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',        ]        ext = src_path.suffix.lower()        if ext == '.ass':            # ASS 直接复制 Events 部分(只保留 Dialogue 行)            in_events = False            for line in self.subtitle_lines:                line = line.strip()                if line.startswith('[Events]'):                    in_events = True                    continue                if in_events and line.startswith('Dialogue:'):                    ass_lines.append(line)                # 跳过其它段落        else:            # SRT / VTT → 统一解析为时间戳 + 文本            def parse_time_srt(t: str) -> str:                """00:00:02,034 → 0:00:02.03"""                h, m, s_ms = t.split(':')                s, ms = s_ms.replace(',', '.').split('.')                return f'{int(h):d}:{int(m):d}:{int(s):d}.{ms.ljust(2, "0")[:2]}'            def parse_time_vtt(t: str) -> str:                """00:00:02.034 → 0:00:02.03"""                return t.split('.')[0] + '.' + t.split('.')[1][:2].ljust(2, '0')            i = 0            while i < len(self.subtitle_lines):                line = self.subtitle_lines[i].strip()                # SRT: 数字行                # VTT: --> 分隔行                if ext == '.srt' and line.isdigit():                    i += 1                    time_line = self.subtitle_lines[i].strip()                    start_end = re.split(r'\s*-->\s*', time_line)                    if len(start_end) != 2:                        i += 1                        continue                    start = parse_time_srt(start_end[0])                    end = parse_time_srt(start_end[1])                    i += 1                    text = []                    while i < len(self.subtitle_lines) and self.subtitle_lines[i].strip():                        text.append(self.subtitle_lines[i].strip())                        i += 1                    dialogue = f'Dialogue: 0,{start},{end},{style["Name"]},,0,0,0,,{" ".join(text)}'                    ass_lines.append(dialogue)                elif ext == '.vtt' and '-->' in line:                    start_end = re.split(r'\s*-->\s*', line)                    start = parse_time_vtt(start_end[0])                    end = parse_time_vtt(start_end[1])                    i += 1                    text = []                    while i < len(self.subtitle_lines) and self.subtitle_lines[i].strip():                        text.append(self.subtitle_lines[i].strip())                        i += 1                    dialogue = f'Dialogue: 0,{start},{end},{style["Name"]},,0,0,0,,{" ".join(text)}'                    ass_lines.append(dialogue)                else:                    i += 1        # 4. 写入文件        with open(new_path, 'w', encoding='utf-8') as f:            f.write('\n'.join(ass_lines) + '\n')        # 弹窗提示        from PySide6.QtWidgets import QMessageBox        QMessageBox.information(self, '保存成功',                                f'字幕已保存为:\n{new_path}')    def restore_defaults(self):        style = DEFAULT_STYLE        self.blockSignals(True)        try:            self.font_combo.setCurrentFont(style['Fontname'])            self.font_size_spin.setValue(style['Fontsize'])            self.primary_color_picker.color = ColorPicker.parse_color(style['PrimaryColour'])            self.primary_color_picker.update_swatch()            self.secondary_color_picker.color = ColorPicker.parse_color(style['SecondaryColour'])            self.secondary_color_picker.update_swatch()            self.outline_color_picker.color = ColorPicker.parse_color(style['OutlineColour'])            self.outline_color_picker.update_swatch()            self.back_color_picker.color = ColorPicker.parse_color(style['BackColour'])            self.back_color_picker.update_swatch()            self.bold_check.setChecked(bool(style['Bold']))            self.italic_check.setChecked(bool(style['Italic']))            self.underline_check.setChecked(bool(style['Underline']))            self.strikeout_check.setChecked(bool(style['StrikeOut']))            self.scale_x_spin.setValue(style['ScaleX'])            self.scale_y_spin.setValue(style['ScaleY'])            self.spacing_spin.setValue(style['Spacing'])            self.angle_spin.setValue(style['Angle'])            self.border_style_combo.setCurrentIndex(0 if style['BorderStyle'] == 1 else 1)            self.outline_spin.setValue(style['Outline'])            self.shadow_spin.setValue(style['Shadow'])            self.set_alignment(style['Alignment'])            self.margin_l_spin.setValue(style['MarginL'])            self.margin_r_spin.setValue(style['MarginR'])            self.margin_v_spin.setValue(style['MarginV'])        finally:            self.blockSignals(False)                self.update_preview()        with open(JSON_FILE, 'w') as f:            json.dump(style, f, indent=4)        # 恢复默认时清掉已导入的字幕        self.subtitle_path = None        self.subtitle_lines = []        self.import_btn.setText('导入字幕')        self.subtitle_label.setText('未导入')    def get_current_style(self):        style = {            'Name': 'Default',            'Fontname': self.font_combo.currentText(),            'Fontsize': self.font_size_spin.value(),            'PrimaryColour': self.primary_color_picker.to_ass_color(),            'SecondaryColour': self.secondary_color_picker.to_ass_color(),            'OutlineColour': self.outline_color_picker.to_ass_color(),            'BackColour': self.back_color_picker.to_ass_color(),            'Bold': 1 if self.bold_check.isChecked() else 0,            'Italic': 1 if self.italic_check.isChecked() else 0,            'Underline': 1 if self.underline_check.isChecked() else 0,            'StrikeOut': 1 if self.strikeout_check.isChecked() else 0,            'ScaleX': self.scale_x_spin.value(),            'ScaleY': self.scale_y_spin.value(),            'Spacing': self.spacing_spin.value(),            'Angle': self.angle_spin.value(),            'BorderStyle': 1 if self.border_style_combo.currentIndex() == 0 else 3,            'Outline': self.outline_spin.value(),            'Shadow': self.shadow_spin.value(),            'Alignment': self.get_alignment(),            'MarginL': self.margin_l_spin.value(),            'MarginR': self.margin_r_spin.value(),            'MarginV': self.margin_v_spin.value(),            'Encoding': 1        }        return style    def update_preview(self):        style = self.get_current_style()        self.preview_widget.update_preview(style)if __name__=='__main__':    app = QApplication(sys.argv)    win=ASSStyleDialog()    win.show()    sys.exit(app.exec())

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

ASS 字幕编辑器 Python PySide6 视频翻译 字幕制作 GUI 开发 实时预览 SRT VTT PyInstaller Subtitle Editor Python PySide6 Video Translation Subtitle Production GUI Development Real-time Preview PyInstaller
相关文章