--[[

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