用 PySide6 打造可视化 ASS 字幕样式编辑器:从需求到实现

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

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

但问题在于:

  • Aegisub 等专业工具门槛高,普通用户不敢碰;
  • 命令行工具无法实时预览

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

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

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


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

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

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

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

为什么不用 QSS 而是 QGraphicsView?

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


核心模块拆解

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

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

python 复制代码
&HFF0000FF&  → 蓝色,不透明
&H80FF0000&  → 红色,半透明

难点:

  • Qt 使用 ARGB
  • 需要支持 6 位和 8 位 十六进制
  • 用户选择后要实时回写 ASS 字符串

解决方案:ColorPicker 封装

python 复制代码
@staticmethod
def 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 反转

易错点

  • 忘记 255 - a 导致透明度反了
  • 未处理 &HFFFFFF&(6 位)情况
  • 直接用 QColor.fromString 会失败

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

python 复制代码
path = QPainterPath()
path.addText(0, 0, font, text)

实现步骤:

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

关键代码:

python 复制代码
stroker = QPainterPathStroker()
stroker.setWidth(outline * 2)
outline_path = stroker.createStroke(path)

为什么乘 2?

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

易错点:

  • 阴影也需要描边,否则会"漏白"
  • 变换要作用在 所有图层(含阴影),否则错位
  • setPos(x, y) 必须在变换后,否则坐标系混乱

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

设计目标:

  • 一键导入 → 显示文件名
  • 点击保存 → 自动生成 {原名}-edit.ass(避免覆盖可能的同名字幕)
  • 样式写入 [V4+ Styles]

实现思路:

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

关键转换函数:

python 复制代码
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 文件

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

生成 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,,你好啊

注意:

  • 样式只写一个Default),所有对话引用它
  • 编码统一 UTF-8

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

布局结构:

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

小技巧:

  • QGroupBox + margin-top 控制标题间距
  • QPushButton().setCheckable(True) 实现 9 宫格对齐
  • QLabel 显示导入文件名 + setToolTip 显示路径

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

pyinstaller -w -F appedit.py

python 复制代码
IS_FROZEN = getattr(sys, 'frozen', False)
ROOT_DIR = Path(sys.executable).parent if IS_FROZEN else Path(__file__).parent

关键:

  • preview.pngass.json 放在可执行文件同目录
  • 使用 PyInstaller --onefile --add-data "assets;assets" 打包

七、易错点总结

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

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

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

  • 跨平台 GUI 开发
  • 复杂格式解析
  • 实时图形渲染
  • 文件 I/O 与编码处理
  • 打包部署

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

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

python 复制代码
import sys
import json
import os
from 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,QIcon
from PySide6.QtCore import Qt, Signal,QSize
from pathlib import Path
from PySide6.QtWidgets import QFileDialog  

IS_FROZEN = True if getattr(sys, 'frozen', False) else False
ROOT_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())
相关推荐
笨鸟先飞,勤能补拙2 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能3 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂4 小时前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study4 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境5 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境5 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1235 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102166 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb006 小时前
一个项目带你入门AI应用开发01
python
0566466 小时前
Python康复训练——常用标准库
开发语言·python·学习