Untitled

 avatar
unknown
swift
16 days ago
6.5 kB
5
Indexable
struct LyricsRenderer: TextRenderer {

    var animatableData: Double {
        get { currentTick }
        set { currentTick = newValue }
    }

    var currentTick: Double
    var line: MuseLandLyricsLine

    var inactiveOpacity: Double = 0.3
    var blendRadius: Double = 20

    var brightness: Double = 0.5

    // 计算每个slice对应的时间戳
    func groupByWord(layout: Text.Layout) -> [Text.Layout.RunSlice: (Int, Int)] {
        let slices = Array(layout.flattenedRunSlices)
        var result: [Text.Layout.RunSlice: (Int, Int)] = [:]
        var index = 0
        for string in line.blocks {
            for time in string.timestampByWord {
                result[slices[index]] = (time.0, time.1)
                index += 1
            }
        }
        return result
    }

    
    func groupRunsByLineAndColor(layout: Text.Layout) -> [CGFloat: [ColorAttribute?: [Text.Layout.Run]]] {
        var lineGroups: [CGFloat: [ColorAttribute?: [Text.Layout.Run]]] = [:]
        
        for run in layout.flattenedRuns {
            let lineY = run.typographicBounds.origin.y
            let colorAttr = run[ColorAttribute.self]
            
            if lineGroups[lineY] == nil {
                lineGroups[lineY] = [:]
            }
            
            if lineGroups[lineY]![colorAttr] == nil {
                lineGroups[lineY]![colorAttr] = []
            }
            
            lineGroups[lineY]![colorAttr]!.append(run)
        }
        
        return lineGroups
    }

    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
        
        /// TODO: 添加缓存避免每一帧都计算
        let group = self.groupByWord(layout: layout)
        let lineGroups = self.groupRunsByLineAndColor(layout: layout)
        
        var usedRuby : [String] = []
        
        // 渲染底层文字
        for run in layout.flattenedRuns {
            var copy = ctx
            copy.opacity = inactiveOpacity
            copy.draw(run)
        }
        
        // 渲染注音文字
        for run in layout.flattenedRuns {
            if let ruby = run[RubyAttribute.self] {
                if !usedRuby.contains(ruby.value) {
                    let rubyText = Text(ruby.value)
                        .font(.system(size: 10))
                    usedRuby.append(ruby.value)
                    ctx.draw(
                        rubyText,
                        at: CGPoint(
                            x: run.typographicBounds.origin.x + run.typographicBounds.width / 2,
                            y: run.typographicBounds.origin.y - run.typographicBounds.rect.height - 1
                        )
                    )
                }
            }
        }
        
        // 渲染上层行
        for (_, colorGroups) in lineGroups {
            for (colorAttr, runs) in colorGroups {
                // 渲染着色文字
                if let colorAttr = colorAttr {
                    // 计算这组runs的总边界
                    var combinedRect = runs[0].typographicBounds.rect
                    for run in runs.dropFirst() {
                        combinedRect = combinedRect.union(run.typographicBounds.rect)
                    }
                    
                    let path = Path(combinedRect)
                    
                    // 为整组runs应用单一渐变
                    ctx.drawLayer { context in
                        context.clipToLayer { c in
                            for run in runs {
                                drawMask(run, group: group, in: &c)
                            }
                        }
                        // 只为有多个颜色的文字应用渐变
                        if colorAttr.colors.count > 1 {
                            context.fill(
                                path,
                                with: .linearGradient(
                                    .init(colors: colorAttr.colors),
                                    startPoint: .init(x: combinedRect.minX + combinedRect.width / 3, y: 0),
                                    endPoint: .init(x: combinedRect.minX + combinedRect.width / 1.5, y: 0)
                                )
                            )
                        } else {
                            context.fill(path, with: .color(colorAttr.colors[0]))
                        }
                    }
                } else {
                    // 没有颜色属性
                    for run in runs {
                        drawMask(run, group: group, in: &ctx)
                    }
                }
            }
        }
    }

    func drawMask(_ run: Text.Layout.Run, group: [Text.Layout.RunSlice: (Int, Int)], in context: inout GraphicsContext) {
        for rs in run {
            if let lyric = group[rs] {
                let beginTime = Double(lyric.0)
                let endTime = Double(lyric.1)
                draw(
                    slice: rs,
                    begin: beginTime,
                    end: endTime,
                    in: &context
                )
            }
        }
    }

    func draw(slice: Text.Layout.RunSlice, begin: Double, end: Double, in context: inout GraphicsContext) {
        let elapsed = currentTick - begin
        let duration = end - begin
        let unclampedProgress = elapsed / duration
        let progress = max(0, min(1, unclampedProgress))
        if progress == 0 || (begin == 0 && end == 0) {
            return
        }
        if progress < 1 {
            var context = context
            let rect = slice.typographicBounds.rect
            let unclampedFilledWidth = rect.width * progress
            let filledWidth = rect.width * progress
            let mask = Path(
                .init(
                    x: rect.minX,
                    y: rect.minY,
                    width: filledWidth + blendRadius / 2,
                    height: rect.height
                )
            )
            context.clipToLayer { context in
                context.fill(
                    mask,
                    with: .linearGradient(
                        .init(colors: [.white, .clear]),
                        startPoint: .init(x: rect.minX + unclampedFilledWidth - blendRadius / 2, y: 0),
                        endPoint: .init(x: rect.minX + unclampedFilledWidth + blendRadius / 2, y: 0)
                    )
                )
            }
            context.draw(slice)
        } else {
            context.draw(slice)
        }
    }
}
Editor is loading...
Leave a Comment