用 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())
相关推荐
清空mega3 小时前
从零开始搭建 flask 博客实验(2)
后端·python·flask
jiushun_suanli3 小时前
PyTorch CV模型实战全流程(二)
人工智能·pytorch·python
咚咚王者3 小时前
人工智能之编程基础 Python 入门:第三章 基础语法
人工智能·python
小白黑科技测评4 小时前
2025 年编程工具实测:零基础学习平台适配性全面解析!
java·开发语言·python
ejinxian4 小时前
Python 3.14 发布
java·开发语言·python
gfdgd xi5 小时前
GXDE For deepin 25:deepin25 能用上 GXDE 了!
linux·运维·python·ubuntu·架构·bug·deepin
小Pawn爷5 小时前
构建Django的Web镜像
前端·python·docker·django
dont worry about it5 小时前
使用亮数据爬虫API零门槛快速爬取Tiktok数据
开发语言·爬虫·python
软件开发技术深度爱好者6 小时前
python使用Pygame库实现避障小人行走游戏
python·游戏·pygame