Aegisub/devel/OverLua/docs/sample3.lua

646 lines
20 KiB
Lua

--[[
Sample script for OverLua
- advanced karaoke effect, first version of Mendoi-Conclave Gundam 00 OP 1
Given into the public domain.
(You can do anything you want with this file, with no restrictions whatsoever.
You don't get any warranties of any kind either, though.)
Originally authored by Niels Martin Hansen.
While I can't prevent you from it, please don't use this effect script
verbatim or almost-verbatim for own productions. It's mainly intended for
showing techniques, just using it without modifications or with only light
modifications is what I'd consider "cheap".
Be aware that this effect is very slow at rendering, at full 720p resolution
it takes around 3 hours to render on my dual 2.2 GHz Opteron.
This effect is called "OH NOES" by the way. No special meaning to that.
It's best read from bottom to top.
]]
-- Virtual resolution, 720p
local virtual_res_x = 1280
local virtual_res_y = 720
-- Font names
--local latin_font = "Eras Bold ITC"
local latin_font = "Briem Akademi Std Semibold"
local latin_weight = ""
local kanji_font = "DFGSoGei-W9"
-- Font sizes
local romaji_size = 34
local engrish_size = 36
local kanji_size = 30
local tl_size = 36
-- Text positions (vertical only, assumed centered)
local romaji_pos_y = 55
local tl_pos_y = virtual_res_y - 38
local kanji_pos_y = virtual_res_y - 27
local kanji_pos_x = virtual_res_x - 55
local engrish_pos_y = virtual_res_y - 38
timing_input_file = overlua_datastring
assert(timing_input_file, "OH NOES! Missing timing input file.")
-- Here's some mostly standard input file parsing functions
function parsenum(str)
return tonumber(str) or 0
end
function parse_ass_time(ass)
local h, m, s, cs = ass:match("(%d+):(%d+):(%d+)%.(%d+)")
return parsenum(cs)/100 + parsenum(s) + parsenum(m)*60 + parsenum(h)*3600
end
function parse_k_timing(text)
local syls = {}
local cleantext = ""
local i = 1
for timing, syltext in text:gmatch("{\\k(%d+)}([^{]*)") do
local syl = {dur = parsenum(timing)/100, text = syltext, i = i}
local maintext, furitext = syltext:match("(.-)|(.+)")
-- Note that there is a light support for Auto4 style furigana
-- in this script, but I haven't maintained it since it ended up being
-- unused.
if maintext and furitext and furitext ~= "" then
syl.text = maintext
syl.furi = furitext
end
table.insert(syls, syl)
cleantext = cleantext .. syl.text
i = i + 1
end
return syls, cleantext
end
function read_input_file(name)
for line in io.lines(name) do
local start_time, end_time, style, fx, text = line:match("Dialogue: 0,(.-),(.-),(.-),,0000,0000,0000,(.-),(.*)")
if text then
local ls = {}
ls.start_time = parse_ass_time(start_time)
ls.end_time = parse_ass_time(end_time)
ls.style = style
ls.fx = fx
ls.rawtext = text
ls.kara, ls.cleantext = parse_k_timing(text)
table.insert(lines, ls)
end
end
end
function init()
if inited then return end
inited = true
lines = {}
read_input_file(timing_input_file)
end
-- Calculate size and position of a line and its syllables
-- Only for horizontal lines, not vertical
function calc_line_metrics(ctx, line, font_name, font_size, pos_y)
if line.pos_x then return end
ctx.select_font_face(font_name, "", latin_weight)
ctx.set_font_size(font_size)
line.te = ctx.text_extents(line.cleantext)
line.fe = ctx.font_extents()
line.pos_x = (virtual_res_x - line.te.width) / 2 - line.te.x_bearing
line.pos_y = pos_y
if #line.kara < 2 then return end
local curx = line.pos_x
for i, syl in pairs(line.kara) do
syl.te = ctx.text_extents(syl.text)
syl.pos_x = curx
syl.center_x = curx + syl.te.x_bearing + syl.te.width/2
syl.center_y = pos_y - line.fe.ascent/2 + line.fe.descent/2
curx = curx + syl.te.x_advance
if syl.furi then
ctx.set_font_size(font_size/2)
syl.furite = ctx.text_extents(syl.furi)
syl.furife = ctx.font_extents()
ctx.set_font_size(font_size)
syl.furi_x = syl.center_x - syl.furite.width/2 - syl.furite.x_bearing
syl.furi_y = pos_y - line.fe.height
end
end
end
-- Paint the image of a line of text to a cairo context
-- Assumes the current path in the context is of the text to be painted
function paint_text(surf, ctx)
ctx.set_line_join("round")
ctx.set_source_rgba(0, 0.2, 0.3, 0.8)
ctx.set_line_width(3)
ctx.stroke_preserve()
raster.gaussian_blur(surf, 1.7)
ctx.set_source_rgba(1, 1, 1, 0.95)
ctx.fill()
end
-- Render one of the zoomed circles with some parameters
-- width and height are of the source area to be visible in the zoomed image
-- Some of this is a bit hacked, I just changed stuff around until it worked,
-- honestly. Analyse it if you want, it still doesn't fully make sense to me ;)
function make_zoomed_ellipsis(srcsurf, center_x, center_y, width, height)
local factor = 0.7
local target_width, target_height = math.ceil(width/factor), math.ceil(height/factor)
local target = cairo.image_surface_create(target_width, target_height, "argb32")
local targetctx = target.create_context()
local src_x, src_y = center_x - width/2, center_y - height/2
-- The basic premise is just taking the source surface, making an upscaling
-- pattern of it and fill a circle with the correct portion of it.
-- Actually pretty simple, it's just getting the numbers right.
local srcpat = cairo.pattern_create_for_surface(srcsurf)
srcpat.set_extend("none")
local srcpatmatrix = cairo.matrix_create()
srcpatmatrix.init_translate(src_x, src_y)
srcpatmatrix.scale(factor, factor)
srcpat.set_matrix(srcpatmatrix)
targetctx.scale(target_width, target_height)
targetctx.arc(0.5, 0.5, 0.5, 0, math.pi*2)
targetctx.scale(1/target_width, 1/target_height)
targetctx.set_source(srcpat)
targetctx.fill()
return target, target_width, target_height
end
-- Duration in seconds for the fade-in/-outs
local fadeinoutdur = 1.2
-- Paint a complete line of karaoke text with all effects, except the
-- zoom circles, to a context. It depends on l.textsurf containing the line
-- image.
-- The main attraction here is the fade-over effect.
function paint_kara_text(f, ctx, t, l)
local fade, fademask, fadetype
-- Check if we're fading in?
if t < l.start_time + fadeinoutdur and l.fx ~= "nofadein" then
-- Calculate the position of the fade
fade = 1 - (l.start_time - t + fadeinoutdur/2) / fadeinoutdur
-- Create a gradient pattern that shows only the relevant part of
-- the line for the fade.
fademask = cairo.pattern_create_linear(virtual_res_x*fade, virtual_res_y/2, virtual_res_x*fade - 100, virtual_res_y/2-30)
fademask.add_color_stop_rgba(0, 1, 1, 1, 0)
fademask.add_color_stop_rgba(0.05, 1, 1, 1, 1)
fademask.add_color_stop_rgba(0.3, 1, 1, 1, 0.2)
fademask.add_color_stop_rgba(1, 1, 1, 1, 1)
fadetype = "in"
end
-- Or fading out?
if l.end_time - fadeinoutdur <= t and l.fx ~= "last" and l.fx~= "nofadeout" then
-- Pretty much the same as for fade in, except that a different part of
-- the line is shown by the produced pattern
fade = (t - l.end_time + fadeinoutdur/2) / fadeinoutdur
fademask = cairo.pattern_create_linear(virtual_res_x*fade, virtual_res_y/2, virtual_res_x*fade + 100, virtual_res_y/2+30)
fademask.add_color_stop_rgba(0, 1, 1, 1, 0)
fademask.add_color_stop_rgba(0.05, 1, 1, 1, 1)
fademask.add_color_stop_rgba(0.3, 1, 1, 1, 0.2)
fademask.add_color_stop_rgba(1, 1, 1, 1, 1)
fadetype = "out"
end
-- Is the line even visible?!
if not fade and (t < l.start_time or l.end_time <= t) then return end
-- A function that calculates the distance between a point and the fade
-- The distance is calculated only along the X axis, so it's not the
-- shortest distance from the point to the "fade line".
-- Used to determine which side of the fade a point is on.
local function fadedist(x, y) -- on X axis
local fade_x_at_y = virtual_res_x*fade - (y - virtual_res_y/2) * 3/10
if fadetype == "in" then
return fade_x_at_y - x
else
return x - fade_x_at_y
end
end
-- We'll be painting the surface with the image of the text
ctx.set_source_surface(l.textsurf, 0, 0)
if fade then
-- So first paint the text with the fading-mask
ctx.mask(fademask)
-- Now generate a slightly different mask for the bloom effect
-- This one goes "both ways", it's not restricted to just one direction;
-- it gets limited later
local bloommask = cairo.pattern_create_linear(virtual_res_x*fade - 200, virtual_res_y/2-60, virtual_res_x*fade + 200, virtual_res_y/2+60)
bloommask.add_color_stop_rgba(0, 1, 1, 1, 0)
bloommask.add_color_stop_rgba(0.5, 1, 1, 1, 1)
bloommask.add_color_stop_rgba(1, 1, 1, 1, 0)
local bloom = cairo.image_surface_create(virtual_res_x, virtual_res_y, "argb32")
local bc = bloom.create_context()
bc.set_source_surface(l.textsurf, 0, 0)
bc.mask(fademask)
-- Ok, this could be done in a faster way I bet... modify the colour of
-- the bloom effect depending on whether it's a fade in or out,
-- by running a pixel value mapping program over them.
if fadetype == "out" then
raster.pixel_value_map(bloom, "R 0.9 * =R G 0.1 * =G B 0.4 * =B")
else
raster.pixel_value_map(bloom, "R 0.22 * =R G 0.45 * =G B 0.44 * =B")
end
-- Now, three times, do an additive blending of a successively more
-- blurred version of the masked text.
-- Exploit that the text border is very dark, so it won't contribute
-- much at all to the overall result.
-- If the border was brighter a different image of the text would need
-- to be used instead.
-- This is what *really* kills the rendering speed!
ctx.set_operator("add")
raster.gaussian_blur(bloom, 3)
ctx.set_source_surface(bloom, 0, 0)
ctx.mask(bloommask)
raster.gaussian_blur(bloom, 3)
ctx.set_source_surface(bloom, 0, 0)
ctx.mask(bloommask)
raster.gaussian_blur(bloom, 3)
ctx.set_source_surface(bloom, 0, 0)
ctx.mask(bloommask)
ctx.set_operator("over")
else
-- We aren't fading, just do a plain paint of the text image
ctx.paint()
end
return fade, fademask, fadetype, fadedist
end
-- Line style processing functions
-- The entries in this table are matched with the line Style fields to pick
-- an appropriate handling function for the line.
stylefunc = {}
-- This is a generic handling function called by other functions
function stylefunc.generic(f, ctx, t, l, font_name, font_size, pos_y)
-- Fast return for irrelevant lines
if t < l.start_time - fadeinoutdur/2 then return end
if l.end_time + fadeinoutdur/2 <= t then return end
-- Make sure we have the positioning information for the line
calc_line_metrics(ctx, l, font_name, font_size, pos_y)
-- If it's the first time this line is processed, generate the image of it
if not l.textsurf then
-- Create surface for the text image
local textsurf = cairo.image_surface_create(virtual_res_x, virtual_res_y, "argb32")
local c = textsurf.create_context()
-- Fill it with a path of the text
c.select_font_face(font_name, "", latin_weight)
c.set_font_size(font_size)
c.move_to(l.pos_x, l.pos_y)
c.text_path(l.cleantext)
for i, syl in pairs(l.kara) do
if syl.furi then
c.set_font_size(kanji_size/2)
c.move_to(syl.furi_x, syl.furi_y)
c.text_path(syl.furi)
end
end
paint_text(textsurf, c)
l.textsurf = textsurf
end
-- Check if we're on the last line which needs the "fade all out" effect
if l.fx == "last" and t > l.end_time - 1.5 then
fade_all_out = (l.end_time - t) / 1.5
else
fade_all_out = nil
end
-- Put the actual text onto the video image
local fade, fademask, fadetype, fadedist = paint_kara_text(f, ctx, t, l)
-- Search for a currently highlighted syllable in the text
local sumdur = l.start_time
local cursyl = -1
for i, syl in pairs(l.kara) do
syl.start_time = sumdur
if t >= sumdur and t < sumdur+syl.dur then
cursyl = i
end
sumdur = sumdur + syl.dur
end
if cursyl >= 1 then
-- There is a current syllable
-- Figure out where to put the zoom circle
local syl = l.kara[cursyl]
-- Assume it's at the center of the syllable for now
local zoompoint = {
cx = syl.center_x,
cy = syl.center_y,
size = math.max(syl.te.width, syl.te.height)
}
-- But check if we're time-wise close enough to the previous syllable
-- (if there is one) to do a transition from it
local prevsyl
if cursyl >= 2 then
local prevsyli = cursyl - 1
repeat
prevsyl = l.kara[prevsyli]
prevsyli = prevsyli - 1
until (prevsyl.dur > 0)
if syl.dur > 0.100 and t - syl.start_time < 0.100 then
local pcx, pcy = prevsyl.center_x, prevsyl.center_y
local psize = math.max(prevsyl.te.width, prevsyl.te.height)
local v = (t - syl.start_time) / 0.100
local iv = 1 - v
zoompoint.cx = iv * pcx + v * zoompoint.cx
zoompoint.cy = iv * pcy + v * zoompoint.cy
zoompoint.size = iv * psize + v * zoompoint.size
end
elseif cursyl == 1 and syl.dur > 0.100 and t - syl.start_time < 0.100 then
zoompoint.size = zoompoint.size * (t - syl.start_time) / 0.100
end
zoompoint.size = zoompoint.size * 1.1
-- Check that we aren't fading over and that the center of the zoom is
-- not outside the visible part of the line.
if not fade or fadedist(zoompoint.cx, zoompoint.cy) > 0 then
-- Insert (enable) the zoom point then
table.insert(zoompoints, zoompoint)
end
end
end
-- The Romaji and Engrish styles are both the same generic thing
function stylefunc.Romaji(f, ctx, t, l)
stylefunc.generic(f, ctx, t, l, latin_font, romaji_size, romaji_pos_y)
end
-- Engrish was used for the somewhat-English lines in the original lyrics
-- (I.e. not for the translation.)
function stylefunc.Engrish(f, ctx, t, l)
stylefunc.generic(f, ctx, t, l, latin_font, engrish_size, engrish_pos_y)
end
-- The vertical kanji need a rather different handling
function stylefunc.Kanji(f, ctx, t, l)
-- Again, check for fast skip
if t < l.start_time - fadeinoutdur/2 then return end
if l.end_time + fadeinoutdur/2 <= t then return end
-- Mostly the same as for the generic handling, except that we also
-- calculate the metrics here.
if not l.textsurf then
local textsurf = cairo.image_surface_create(virtual_res_x, virtual_res_y, "argb32")
local c = textsurf.create_context()
c.select_font_face("@"..kanji_font)
c.set_font_size(kanji_size)
l.te = c.text_extents(l.cleantext)
l.fe = c.font_extents()
l.pos_x = kanji_pos_x
l.pos_y = (virtual_res_y - l.te.width) / 2 - l.te.x_bearing
local cury = l.pos_y
for i, syl in pairs(l.kara) do
syl.te = c.text_extents(syl.text)
syl.pos_y = cury
syl.center_y = cury + syl.te.x_bearing + syl.te.width/2
syl.center_x = kanji_pos_x + l.fe.ascent/2 - l.fe.descent/2
cury = cury + syl.te.x_advance
end
c.translate(l.pos_x, l.pos_y)
c.rotate(math.pi/2)
c.move_to(0,0)
c.text_path(l.cleantext)
paint_text(textsurf, c)
l.textsurf = textsurf
end
local fade, fademask, fadetype, fadedist = paint_kara_text(f, ctx, t, l)
-- Lots of copy-paste (code re-use!) here, slightly adapted for vertical
-- text rather than horizontal stuff.
local sumdur = l.start_time
local cursyl = -1
for i, syl in pairs(l.kara) do
syl.start_time = sumdur
if t >= sumdur and t < sumdur+syl.dur then
cursyl = i
end
sumdur = sumdur + syl.dur
end
if cursyl >= 1 then
local syl = l.kara[cursyl]
local zoompoint = {
cx = syl.center_x,
cy = syl.center_y,
size = math.max(syl.te.width, syl.te.height)
}
local prevsyl
if cursyl >= 2 then
local prevsyli = cursyl - 1
repeat
prevsyl = l.kara[prevsyli]
prevsyli = prevsyli - 1
until (prevsyl.dur > 0)
if syl.dur > 0.100 and t - syl.start_time < 0.100 then
local pcx, pcy = prevsyl.center_x, prevsyl.center_y
local psize = math.max(prevsyl.te.width, prevsyl.te.height)
local v = (t - syl.start_time) / 0.100
local iv = 1 - v
zoompoint.cx = iv * pcx + v * zoompoint.cx
zoompoint.cy = iv * pcy + v * zoompoint.cy
zoompoint.size = iv * psize + v * zoompoint.size
end
elseif cursyl == 1 and syl.dur > 0.100 and t - syl.start_time < 0.100 then
zoompoint.size = zoompoint.size * (t - syl.start_time) / 0.100
end
zoompoint.size = zoompoint.size * 1.1
if not fade or fadedist(zoompoint.cx, zoompoint.cy) > 0 then
table.insert(zoompoints, zoompoint)
end
end
end
-- The translation lines get a somewhat simplified handling again.
-- Originally separated out because some translated lines were split into two
-- stacked lines, but that was dropped again.
function stylefunc.TL(f, ctx, t, l)
if t < l.start_time - fadeinoutdur/2 then return end
if l.end_time + fadeinoutdur/2 <= t then return end
local line1, line2 = l.rawtext, l.rawtext:find("\\n", 1, true)
if line2 then
line1 = l.rawtext:sub(line2+2)
line2 = l.rawtext:sub(1, line2-1)
else
line2 = ""
end
if not l.textsurf then
local textsurf = cairo.image_surface_create(virtual_res_x, virtual_res_y, "argb32")
local c = textsurf.create_context()
c.select_font_face(latin_font, "", latin_weight)
c.set_font_size(tl_size)
l.te1 = c.text_extents(line1)
l.te2 = c.text_extents(line2)
l.fe = c.font_extents()
l.pos1_x = (virtual_res_x - l.te1.width) / 2 - l.te1.x_bearing
l.pos2_x = (virtual_res_x - l.te2.width) / 2 - l.te2.x_bearing
l.pos1_y = tl_pos_y
l.pos2_y = tl_pos_y - l.fe.height
c.move_to(l.pos1_x, l.pos1_y)
c.text_path(line1)
c.move_to(l.pos2_x, l.pos2_y)
c.text_path(line2)
paint_text(textsurf, c)
l.textsurf = textsurf
end
paint_kara_text(f, ctx, t, l)
end
-- Paint a zoom circle onto the video
-- zp is one of the zoompoint structures generated in the style functions
function draw_zoompoint(surf, ctx, t, zp)
if zp.size < 5 then return end
local zoom, zoom_width, zoom_height = make_zoomed_ellipsis(surf, zp.cx, zp.cy, zp.size*1.2, zp.size*1.2)
local glow = cairo.image_surface_create(zoom_width+50, zoom_height+50, "argb32")
local gc = glow.create_context()
-- Hue-rotation
-- Based on HSL-to-RGB code from Aegisub
local r, g, b
local cspeed = 1/5
local sat = 69
local q = math.floor((cspeed*t) % 6)
local qf = ((cspeed*t) % 6 - q) * (255-sat)
if q == 0 then
r = 255
g = sat + qf
b = sat
elseif q == 1 then
r = sat + 255 - qf
g = 255
b = sat
elseif q == 2 then
r = sat
g = 255
b = sat + qf
elseif q == 3 then
r = sat
g = sat + 255 - qf
b = 255
elseif q == 4 then
r = sat + qf
g = qf
b = 255
elseif q == 5 then
r = 255
g = sat
b = qf + 255 - qf
end
-- Circle-tail-chaser thing
-- Just a bunch of increasingly opaque lines drawn from a center
-- and overlapping enough to create a sense of continuity.
gc.set_line_width(6)
for a = 0, 1, 1/zoom_height do
gc.set_source_rgba(r/255, g/255, b/255, a)
gc.move_to(zoom_width/2+25, zoom_height/2+25)
gc.rel_line_to((zoom_width/2+5) * math.sin(-t*8-a*math.pi*2), (zoom_height/2+5) * math.cos(-t*8-a*math.pi*2))
gc.stroke()
end
-- Love gaussian blur!
raster.gaussian_blur(glow, 2)
-- Use additive blend to put the tail-chaser onto the video
ctx.set_source_surface(glow, zp.cx-zoom_width/2-25, zp.cy-zoom_height/2-25)
local oldop = ctx.get_operator()
ctx.set_operator("add")
ctx.paint()
ctx.set_operator(oldop)
-- And regular blend for the zoom circle
ctx.set_source_surface(zoom, zp.cx-zoom_width/2, zp.cy-zoom_height/2)
ctx.paint()
end
function render_frame(f, t)
-- Make sure we're initialised
init()
-- Clear the list of zoom points
zoompoints = {}
-- Create a surface and context from the video
local worksurf = f.create_cairo_surface()
local workctx = worksurf.create_context()
-- This should make it possible to render on different resolution videos,
-- but I don't think it works
workctx.scale(f.width / virtual_res_x, f.height / virtual_res_y)
-- Run over each input line, processing it
-- This will draw the main text and transition effects
for i, line in pairs(lines) do
if stylefunc[line.style] then
stylefunc[line.style](worksurf, workctx, t, line)
end
end
-- Then go over the zoom points and draw those on top
-- If this isn't done after all lines have been drawn, lines that are close
-- to each other could end up overlapping each others' zoom circles.
for i, zp in pairs(zoompoints) do
draw_zoompoint(worksurf, workctx, t, zp)
end
-- If we're fading it all out, make the karaoke less visible by doing
-- an alpha paint over with the original video frame.
if fade_all_out then
local vidsurf = f.create_cairo_surface()
workctx.set_source_surface(vidsurf, 0, 0)
workctx.paint_with_alpha(1-fade_all_out)
end
-- Finally put the video frame back
f.overlay_cairo_surface(worksurf, 0, 0)
end