Files
questshock/modding/hacked/editor/movies/View.go
T

616 lines
19 KiB
Go

package movies
import (
"fmt"
"image"
"image/color"
"image/gif"
"os"
"path/filepath"
"time"
"github.com/asticode/go-astisub"
"github.com/inkyblackness/imgui-go/v3"
"github.com/inkyblackness/hacked/editor/external"
"github.com/inkyblackness/hacked/editor/graphics"
"github.com/inkyblackness/hacked/editor/render"
"github.com/inkyblackness/hacked/ss1/content/audio"
"github.com/inkyblackness/hacked/ss1/content/bitmap"
"github.com/inkyblackness/hacked/ss1/content/movie"
"github.com/inkyblackness/hacked/ss1/edit/undoable"
"github.com/inkyblackness/hacked/ss1/edit/undoable/cmd"
"github.com/inkyblackness/hacked/ss1/resource"
"github.com/inkyblackness/hacked/ss1/world"
"github.com/inkyblackness/hacked/ss1/world/ids"
"github.com/inkyblackness/hacked/ui/gui"
)
type movieInfo struct {
title string
multilang bool
}
var knownMovies = map[resource.ID]movieInfo{
ids.MovieIntro: {title: "Intro", multilang: true},
ids.MovieDeath: {title: "Death", multilang: false},
ids.MovieEnd: {title: "End", multilang: false},
}
var knownMoviesOrder = []resource.ID{ids.MovieIntro, ids.MovieDeath, ids.MovieEnd}
// View provides edit controls for animations.
type View struct {
mod *world.Mod
frameCache *graphics.FrameCache
frameCacheKey graphics.FrameCacheKey
movieService undoable.MovieService
modalStateMachine gui.ModalStateMachine
guiScale float32
commander cmd.Commander
model viewModel
}
// NewMoviesView returns a new instance.
func NewMoviesView(mod *world.Mod, frameCache *graphics.FrameCache,
movieService undoable.MovieService,
modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View {
view := &View{
mod: mod,
frameCache: frameCache,
frameCacheKey: frameCache.AllocateKey(),
movieService: movieService,
modalStateMachine: modalStateMachine,
guiScale: guiScale,
commander: commander,
model: freshViewModel(),
}
return view
}
// WindowOpen returns the flag address, to be used with the main menu.
func (view *View) WindowOpen() *bool {
return &view.model.windowOpen
}
// Render renders the view.
func (view *View) Render() {
if view.model.restoreFocus {
imgui.SetNextWindowFocus()
view.model.restoreFocus = false
view.model.windowOpen = true
}
if view.model.windowOpen {
imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver)
if imgui.BeginV("Movies", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) {
view.renderContent()
}
imgui.End()
}
}
func (view *View) renderContent() {
if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) {
imgui.PushItemWidth(-150 * view.guiScale)
if imgui.BeginCombo("Movie", knownMovies[view.model.currentKey.ID].title) {
for _, id := range knownMoviesOrder {
if imgui.SelectableV(knownMovies[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) {
view.model.currentKey.ID = id
view.model.currentKey.Index = 0
view.model.currentScene = 0
view.model.currentFrame = 0
view.model.frameTimeFraction = -1
}
}
imgui.EndCombo()
}
if knownMovies[view.model.currentKey.ID].multilang {
if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) {
languages := resource.Languages()
for _, lang := range languages {
if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) {
view.model.currentKey.Lang = lang
}
}
imgui.EndCombo()
}
} else {
imgui.LabelText("Language", "(not localized)")
}
imgui.Separator()
view.renderProperties()
imgui.PopItemWidth()
}
imgui.EndChild()
scenes := view.movieService.Video(view.model.currentKey)
imgui.SameLine()
imgui.BeginGroup()
imgui.BeginChildV("Scenes", imgui.Vec2{X: 200 * view.guiScale, Y: -60 * view.guiScale}, true,
imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsAlwaysVerticalScrollbar)
for index, scene := range scenes {
sceneInfo := fmt.Sprintf("Scene %02d (%d frames)", index, len(scene.Frames))
if imgui.SelectableV(sceneInfo, view.model.currentScene == index, 0, imgui.Vec2{}) {
view.model.currentScene = index
view.model.currentFrame = 0
view.model.frameTimeFraction = -1
}
}
imgui.EndChild()
if imgui.Button("Up") {
view.requestMoveSceneEarlier()
}
imgui.SameLine()
if imgui.Button("Down") {
view.requestMoveSceneLater()
}
imgui.SameLine()
if imgui.Button("Remove") {
view.requestRemoveScene()
}
if imgui.Button("Import") {
view.requestImportScene("")
}
imgui.SameLine()
if imgui.Button("Export") {
view.requestExportScene()
}
imgui.EndGroup()
imgui.SameLine()
if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) {
var scene *movie.Scene
if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) {
scene = &scenes[view.model.currentScene]
}
if scene != nil {
var frame *movie.Frame
if (view.model.currentFrame >= 0) && (view.model.currentFrame < len(scene.Frames)) {
frame = &scene.Frames[view.model.currentFrame]
}
if frame != nil {
// This code updates the texture every render cycle. In case of performance loss, this is a point to optimize.
view.frameCache.SetTexture(view.frameCacheKey, 600, 300, frame.Pixels, &scene.Palette)
render.FrameImage("Frame", view.frameCache, view.frameCacheKey,
imgui.Vec2{
X: float32(movie.HighResDefaultWidth) * view.guiScale,
Y: float32(movie.HighResDefaultHeight) * view.guiScale,
})
}
imgui.PushItemWidth(-150 * view.guiScale)
gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, len(scene.Frames)-1)
imgui.PopItemWidth()
if (frame != nil) && (frame.DisplayTime < (time.Second / 4)) {
imgui.Separator()
if view.model.frameTimeFraction < 0 {
view.model.frameTimeFraction = int((frame.DisplayTime * 0x10000) / time.Second)
}
imgui.PushItemWidth(-150 * view.guiScale)
gui.StepSliderInt("Frame time fraction", &view.model.frameTimeFraction, 0, 0x3FFF)
imgui.PopItemWidth()
if imgui.Button("Set for scene") {
displayTime := (time.Duration(view.model.frameTimeFraction) * time.Second) / 0x10000
view.requestSetFramesDisplayTime(displayTime)
}
}
}
}
imgui.EndChild()
}
func (view *View) renderProperties() {
view.renderAudioProperties()
view.renderSubtitlesProperties()
if view.movieService.SizeWarning(view.model.currentKey) {
view.renderSizeWarning()
}
}
func (view *View) renderAudioProperties() {
imgui.PushID("audio")
imgui.Separator()
sound := view.currentSound()
imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration()))
if imgui.Button("Import") {
view.requestImportAudio()
}
if !sound.Empty() {
imgui.SameLine()
if imgui.Button("Export") {
view.requestExportAudio(sound)
}
imgui.SameLine()
if imgui.Button("Clear") {
view.requestClearAudio()
}
}
imgui.PopID()
}
func (view *View) renderSubtitlesProperties() {
imgui.PushID("subtitles")
imgui.Separator()
if imgui.BeginCombo("Sub Language", view.model.currentSubtitleLang.String()) {
languages := resource.Languages()
for _, lang := range languages {
if imgui.SelectableV(lang.String(), lang == view.model.currentSubtitleLang, 0, imgui.Vec2{}) {
view.model.currentSubtitleLang = lang
}
}
imgui.EndCombo()
}
sub := view.currentSubtitles()
imgui.Text(fmt.Sprintf("%d lines", len(sub.Entries)))
if imgui.Button("Import") {
view.requestImportSubtitles()
}
if len(sub.Entries) > 0 {
imgui.SameLine()
if imgui.Button("Export") {
view.requestExportSubtitles()
}
imgui.SameLine()
if imgui.Button("Clear") {
view.requestClearSubtitles()
}
}
imgui.PopID()
}
func (view *View) renderSizeWarning() {
imgui.Separator()
imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1})
imgui.Text("Size warning - reduce content!")
imgui.PopStyleColor()
if imgui.IsItemHovered() {
imgui.BeginTooltip()
imgui.SetTooltip("The movie has reached a size larger than officially supported.\n" +
"While the game might still show the movie, a future update may stop supporting it.")
imgui.EndTooltip()
}
}
func (view *View) restoreFunc() func() {
return view.restoreFuncWithScene(view.model.currentScene)
}
func (view *View) restoreFuncWithScene(oldScene int) func() {
oldKey := view.model.currentKey
oldSubtitlesLang := view.model.currentSubtitleLang
oldFrame := view.model.currentFrame
return func() {
view.model.restoreFocus = true
view.model.currentKey = oldKey
view.model.currentSubtitleLang = oldSubtitlesLang
view.model.currentScene = oldScene
view.model.currentFrame = oldFrame
view.model.frameTimeFraction = -1
}
}
func (view *View) currentSound() audio.L8 {
return view.movieService.Audio(view.model.currentKey)
}
func (view *View) requestExportAudio(sound audio.L8) {
filename := fmt.Sprintf("%s_%s.wav", knownMovies[view.model.currentKey.ID].title, view.model.currentKey.Lang.String())
external.ExportAudio(view.modalStateMachine, filename, sound)
}
func (view *View) requestImportAudio() {
external.ImportAudio(view.modalStateMachine, func(sound audio.L8) {
view.movieService.RequestSetAudio(view.model.currentKey, sound, view.restoreFunc())
})
}
func (view *View) requestClearAudio() {
view.movieService.RequestSetAudio(view.model.currentKey, audio.L8{}, view.restoreFunc())
}
func (view *View) currentSubtitles() movie.SubtitleList {
return view.movieService.Subtitles(view.model.currentKey, view.model.currentSubtitleLang)
}
func (view View) requestExportSubtitles() {
filename := fmt.Sprintf("%s_%s.srt", knownMovies[view.model.currentKey.ID].title, view.model.currentSubtitleLang.String())
info := "File to be written: " + filename
var exportTo func(string)
currentSubtitles := view.currentSubtitles()
exportTo = func(dirname string) {
writer, err := os.Create(filepath.Join(dirname, filename))
if err != nil {
external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true)
return
}
defer func() { _ = writer.Close() }()
sub := astisub.NewSubtitles()
var lastItem *astisub.Item
for _, entry := range currentSubtitles.Entries {
var item astisub.Item
var line astisub.Line
line.Items = append(line.Items, astisub.LineItem{Text: entry.Text})
item.Lines = []astisub.Line{line}
item.StartAt = entry.Timestamp
item.EndAt = item.StartAt
if lastItem != nil {
lastItem.EndAt = item.StartAt
}
lastItem = &item
sub.Items = append(sub.Items, lastItem)
}
err = sub.WriteToSRT(writer)
if err != nil {
external.Export(view.modalStateMachine, "Could not export subtitles.\n"+info, exportTo, true)
return
}
}
external.Export(view.modalStateMachine, info, exportTo, false)
}
func (view *View) requestImportSubtitles() {
info := "File must be an .SRT file."
types := []external.TypeInfo{{Title: "Subtitle files (*.srt)", Extensions: []string{"srt"}}}
var fileHandler func(string)
fileHandler = func(filename string) {
reader, err := os.Open(filename)
if err != nil {
external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true)
return
}
defer func() { _ = reader.Close() }()
subtitles, err := astisub.ReadFromSRT(reader)
if err != nil {
external.Import(view.modalStateMachine, "File not recognized as SRT.\n"+info, types, fileHandler, true)
return
}
var newSubtitles movie.SubtitleList
for _, item := range subtitles.Items {
var newEntry movie.Subtitle
newEntry.Timestamp = item.StartAt
for _, line := range item.Lines {
for _, lineItem := range line.Items {
if len(newEntry.Text) > 0 {
newEntry.Text += "\n"
}
newEntry.Text += lineItem.Text
}
}
newSubtitles.Entries = append(newSubtitles.Entries, newEntry)
}
view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang,
newSubtitles, view.restoreFunc())
}
external.Import(view.modalStateMachine, info, types, fileHandler, false)
}
func (view *View) requestClearSubtitles() {
view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang,
movie.SubtitleList{}, view.restoreFunc())
}
func (view *View) requestImportScene(returningInfo string) {
info := fmt.Sprintf("File must be an animated GIF file in size %dx%d.",
movie.HighResDefaultWidth, movie.HighResDefaultHeight)
types := []external.TypeInfo{{Title: "Animation files (*.gif)", Extensions: []string{"gif"}}}
var fileHandler func(string)
fileHandler = func(filename string) {
reader, err := os.Open(filename)
if err != nil {
external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true)
return
}
defer func() { _ = reader.Close() }()
data, err := gif.DecodeAll(reader)
if err != nil {
external.Import(view.modalStateMachine, "File not recognized as GIF.\n"+info, types, fileHandler, true)
return
}
if (data.Config.Width != movie.HighResDefaultWidth) || (data.Config.Height != movie.HighResDefaultHeight) {
external.Import(view.modalStateMachine, info, types, fileHandler, true)
return
}
var scene movie.Scene
scene.Frames = make([]movie.Frame, len(data.Image))
var palette bitmap.Palette
if gifPalette, isPal := data.Config.ColorModel.(color.Palette); isPal {
for index, clr := range gifPalette {
r, g, b, _ := clr.RGBA()
palette[index].Red = byte(r >> 8)
palette[index].Green = byte(g >> 8)
palette[index].Blue = byte(b >> 8)
}
}
framebuffer := make([]byte, data.Config.Width*data.Config.Height)
framebufferSnapshot := func() []byte {
buf := make([]byte, len(framebuffer))
copy(buf, framebuffer)
return buf
}
var usedColors [256]bool
for _, img := range data.Image {
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
colorIndex := img.ColorIndexAt(x, y)
usedColors[colorIndex] = true
}
}
}
var indexMap [256]byte
for i := 0; i < 256; i++ {
indexMap[byte(i)] = byte(i)
}
// Ensure color index 0 is not used.
if usedColors[0] && (data.BackgroundIndex != 0) {
if data.BackgroundIndex == 255 {
indexMap[0] = palette.IndexClosestTo(palette[0], []byte{0, 255})
} else {
palette[0], palette[data.BackgroundIndex] = palette[data.BackgroundIndex], palette[0]
indexMap[0] = data.BackgroundIndex
indexMap[data.BackgroundIndex] = 0
}
}
palette[0] = bitmap.RGB{Red: 0x00, Green: 0x00, Blue: 0x00} // default color for background
// In case the last index is in use by the animation, use a similar color as this entry is reserved for subtitles.
if usedColors[255] && (data.BackgroundIndex != 255) {
indexMap[255] = palette.IndexClosestTo(palette[255], []byte{0, 255})
}
palette[255] = bitmap.RGB{Red: 0x9A, Green: 0x35, Blue: 0x35} // default color in intro
scene.Palette = palette
for index, img := range data.Image {
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
colorIndex := img.ColorIndexAt(x, y)
if colorIndex != data.BackgroundIndex {
framebuffer[y*data.Config.Width+x] = indexMap[colorIndex]
}
}
}
scene.Frames[index].DisplayTime = time.Duration(data.Delay[index]) * 10 * time.Millisecond // nolint: durationcheck
scene.Frames[index].Pixels = framebufferSnapshot()
}
view.compressAndAddScene(scene, data.Config.Width, data.Config.Height)
}
external.Import(view.modalStateMachine, returningInfo+info, types, fileHandler, false)
}
func (view *View) compressAndAddScene(scene movie.Scene, width, height int) {
view.modalStateMachine.SetState(&compressingStartState{
machine: view.modalStateMachine,
view: view,
width: width,
height: height,
input: scene,
listener: view.onCompressionResult,
})
}
func (view *View) onCompressionResult(result compressionResult) {
switch typedResult := result.(type) {
case compressionAborted:
case compressionFinished:
view.requestAddScene(typedResult.scene)
case compressionFailed:
view.requestImportScene("Could not compress. Follow recommendations and retry.\n" +
"Technical details:\n" + typedResult.err.Error() + "\n\n")
}
}
func (view *View) requestAddScene(scene movie.HighResScene) {
view.movieService.RequestAddScene(view.model.currentKey, scene, view.restoreFunc())
}
func (view *View) requestExportScene() {
filename := fmt.Sprintf("%s_Scene%02d_%s.gif",
knownMovies[view.model.currentKey.ID].title,
view.model.currentScene,
view.model.currentKey.Lang.String())
info := "File to be written: " + filename
var exportTo func(string)
scenes := view.movieService.Video(view.model.currentKey)
var scene *movie.Scene
if view.model.currentScene >= 0 && view.model.currentScene < len(scenes) {
scene = &scenes[view.model.currentScene]
}
if (scene == nil) || len(scene.Frames) == 0 {
return
}
exportTo = func(dirname string) {
writer, err := os.Create(filepath.Join(dirname, filename))
if err != nil {
external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true)
return
}
defer func() { _ = writer.Close() }()
colorPalette := scene.Palette.ColorPalette(false)
data := gif.GIF{
Config: image.Config{
Width: 600,
Height: 300,
ColorModel: colorPalette,
},
LoopCount: -1,
}
imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height)
for _, frame := range scene.Frames {
frameImg := image.NewPaletted(imageRect, colorPalette)
frameImg.Pix = frame.Pixels
frameImg.Stride = data.Config.Width
data.Image = append(data.Image, frameImg)
data.Delay = append(data.Delay, int(((frame.DisplayTime/time.Millisecond)+5)/10))
}
err = gif.EncodeAll(writer, &data)
if err != nil {
external.Export(view.modalStateMachine, info, exportTo, true)
return
}
}
external.Export(view.modalStateMachine, info, exportTo, false)
}
func (view *View) requestMoveSceneEarlier() {
scenes := view.movieService.Video(view.model.currentKey)
if (view.model.currentScene > 0) && (view.model.currentScene < len(scenes)) {
view.movieService.RequestMoveSceneEarlier(view.model.currentKey, view.model.currentScene,
view.restoreFuncWithScene(view.model.currentScene-1))
}
}
func (view *View) requestMoveSceneLater() {
scenes := view.movieService.Video(view.model.currentKey)
if (view.model.currentScene >= 0) && (view.model.currentScene < (len(scenes) - 1)) {
view.movieService.RequestMoveSceneLater(view.model.currentKey, view.model.currentScene,
view.restoreFuncWithScene(view.model.currentScene+1))
}
}
func (view *View) requestRemoveScene() {
scenes := view.movieService.Video(view.model.currentKey)
if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) {
view.movieService.RequestRemoveScene(view.model.currentKey, view.model.currentScene, view.restoreFunc())
}
}
func (view *View) requestSetFramesDisplayTime(displayTime time.Duration) {
view.movieService.RequestSetSceneFramesDisplayTime(view.model.currentKey, view.model.currentScene, displayTime, view.restoreFunc())
}