Commit 57c793c5 by Jonathan Thomas

Removing single caption effect for entire project, and replacing with per-clip…

Removing single caption effect for entire project, and replacing with per-clip caption effects - which are easier to edit in openshot after the project is created. Also, fixing some issues with gaps left behind after rearranging/deleting clips.
parent ca7798bd
......@@ -186,6 +186,12 @@
<script>
import { Modal } from "bootstrap";
import { mapState } from "vuex";
import {
DEFAULT_CAPTION_POSITION,
getClipCaptionState,
normalizeCaptionPosition,
topValueToCaptionPosition
} from "../utils/captions";
export default {
name: "ClipEffectsModal",
......@@ -196,8 +202,11 @@ export default {
clipContext: {
clipIndex: null,
clipName: "",
fileName: ""
fileName: "",
clipId: null,
clipUrl: null
},
activeClip: null,
form: this.createEmptyForm(),
snapshot: null,
sampleEffectOptions: [
......@@ -231,8 +240,10 @@ export default {
return label
},
normalizedEffectOptions() {
if (this.effectCatalog && this.effectCatalog.length) {
return this.effectCatalog.map((effect, index) => this.normalizeEffectOption(effect, index))
if (Array.isArray(this.effectCatalog) && this.effectCatalog.length) {
return this.effectCatalog
.filter(effect => !this.isCaptionEffect(effect))
.map((effect, index) => this.normalizeEffectOption(effect, index))
}
return this.sampleEffectOptions
}
......@@ -241,7 +252,7 @@ export default {
createEmptyForm() {
return {
text: "",
position: "bottom",
position: DEFAULT_CAPTION_POSITION,
audioLevel: 100,
motion: {
slideIn: false,
......@@ -277,25 +288,51 @@ export default {
const normalized = this.normalizeEffectOption(effect, fallbackIndex)
return normalized
},
isCaptionEffect(effect) {
if (!effect) {
return false
}
if (typeof effect === 'string') {
return effect.toLowerCase() === 'caption'
}
const rawLabel = effect.title || effect.name || effect.display_name || effect.slug || effect.type || effect.class_name || ""
return typeof rawLabel === 'string' && rawLabel.toLowerCase().includes('caption')
},
open(payload = {}) {
this.clipContext = {
clipIndex: payload.clipIndex ?? null,
clipName: payload.clip?.name || payload.clipName || "",
fileName: payload.fileName || payload.clip?.name || ""
fileName: payload.fileName || payload.clip?.name || "",
clipId: payload.clip?.id ?? payload.clipId ?? null,
clipUrl: payload.clip?.url ?? payload.clipUrl ?? null
}
this.activeClip = payload.clip || null
const defaults = this.createEmptyForm()
const clipCaptionState = getClipCaptionState(payload.clip)
const overridePosition = typeof payload.position === "string"
? payload.position
: (typeof payload.captionPosition === "string" ? payload.captionPosition : null)
const positionFromTop = typeof payload.captionTop === "number"
? topValueToCaptionPosition(payload.captionTop)
: clipCaptionState.position
const resolvedPosition = overridePosition
? normalizeCaptionPosition(overridePosition)
: positionFromTop
const appliedEffects = Array.isArray(payload.appliedEffects)
? payload.appliedEffects
.filter(effect => !this.isCaptionEffect(effect))
.map((effect, index) => this.normalizeAppliedEffect(effect, index))
: defaults.appliedEffects
this.form = {
...defaults,
text: payload.text ?? payload.clip?.json?.text ?? defaults.text,
position: payload.position || defaults.position,
text: payload.text ?? clipCaptionState.text ?? defaults.text,
position: resolvedPosition || defaults.position,
audioLevel: typeof payload.audioLevel === "number" ? payload.audioLevel : defaults.audioLevel,
motion: {
...defaults.motion,
...(payload.motion || {})
},
appliedEffects: Array.isArray(payload.appliedEffects)
? payload.appliedEffects.map((effect, index) => this.normalizeAppliedEffect(effect, index))
: defaults.appliedEffects,
appliedEffects,
selectedEffectId: ""
}
this.snapshot = JSON.parse(JSON.stringify(this.form))
......@@ -310,12 +347,18 @@ export default {
}
},
handleReset() {
if (this.snapshot) {
this.form = JSON.parse(JSON.stringify(this.snapshot))
} else {
this.form = this.createEmptyForm()
this.form = this.createEmptyForm()
const payload = {
clip: this.activeClip,
clipId: this.clipContext.clipId,
clipUrl: this.clipContext.clipUrl,
clipIndex: this.clipContext.clipIndex,
clipName: this.clipContext.clipName,
fileName: this.clipContext.fileName,
form: JSON.parse(JSON.stringify(this.form)),
hasCustomizations: false
}
this.$emit('reset', { clip: this.clipContext })
this.$emit('reset', payload)
},
handleCancel() {
if (this.snapshot) {
......@@ -326,8 +369,14 @@ export default {
},
handleSave() {
const payload = {
clip: this.clipContext,
form: JSON.parse(JSON.stringify(this.form))
clip: this.activeClip,
clipId: this.clipContext.clipId,
clipUrl: this.clipContext.clipUrl,
clipIndex: this.clipContext.clipIndex,
clipName: this.clipContext.clipName,
fileName: this.clipContext.fileName,
form: JSON.parse(JSON.stringify(this.form)),
hasCustomizations: this.formHasCustomizations(this.form)
}
this.$emit('save', payload)
this.close()
......@@ -361,6 +410,30 @@ export default {
this.$refs.textInput.focus()
this.$refs.textInput.select()
}
},
formHasCustomizations(form) {
const defaults = this.createEmptyForm()
if ((form?.text ?? "") !== defaults.text) {
return true
}
if (normalizeCaptionPosition(form?.position) !== defaults.position) {
return true
}
if (typeof form?.audioLevel === "number" && form.audioLevel !== defaults.audioLevel) {
return true
}
const motion = form?.motion || {}
const motionDefaults = defaults.motion
const motionKeys = Object.keys(motionDefaults)
for (const key of motionKeys) {
if (!!motion[key] !== !!motionDefaults[key]) {
return true
}
}
if (Array.isArray(form?.appliedEffects) && form.appliedEffects.length > 0) {
return true
}
return false
}
},
mounted() {
......
......@@ -67,11 +67,6 @@
</span>
</div>
<!-- Clip text input -->
<div v-if="clip.json.hasOwnProperty('text')" class="mb-3 clip-text">
<input @blur="updateTextInput($event, clip)" @mousedown.stop type="text" class="form-control" maxlength="35" placeholder="Enter text here" :value="clip.json.text">
</div>
<!-- Clip length label -->
<div ref="clip" class="clip-label">
{{ (clip.end - clip.start).toFixed(1) }} Seconds
......@@ -128,14 +123,15 @@
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
@reset="handleClipEffectsReset"
/>
</template>
<script>
import {mapActions, mapState, mapMutations, mapGetters} from "vuex"
import { Modal } from "bootstrap"
import caption from "../data/caption.json"
import ClipEffectsModal from "./ClipEffectsModal"
import { createClipWithCaptionForm, hasCaptionChanges } from "../utils/captions"
export default {
name: "Clips.vue",
......@@ -153,8 +149,7 @@ export default {
dragOverPosition: null,
autoScrollDirection: 0,
autoScrollFrame: null,
dragListenersActive: false,
pendingClipEffects: null
dragListenersActive: false
}
},
methods: {
......@@ -167,7 +162,6 @@ export default {
clipIndex: index + 1,
clipName: clipObj?.name,
fileName,
text: clipObj?.json?.text || "",
appliedEffects
})
}
......@@ -176,18 +170,40 @@ export default {
if (!clipObj || !clipObj.json) {
return false
}
const hasText = !!clipObj.json.text
const hasFxFlag = !!clipObj.json.fx_enabled
const hasAppliedEffects = Array.isArray(clipObj.json.effects) && clipObj.json.effects.length > 0
return hasText || hasFxFlag || hasAppliedEffects
return hasFxFlag || hasAppliedEffects
},
handleClipEffectsSave(payload) {
this.pendingClipEffects = payload
async handleClipEffectsSave(payload) {
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj || !payload.form) {
return
}
await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
},
updateTextInput(e, clipObj) {
clipObj.json.text = e.target.value
let payload = { data: clipObj }
this.editClip(payload)
async handleClipEffectsReset(payload) {
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj || !payload.form) {
return
}
await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
},
async persistClipEffects(clipObj, form, fxEnabled) {
if (!clipObj || !form) {
return
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
return
}
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
return
}
const editPayload = { data: updatedClip, latest: false, thumbnail: false }
await this.editClip(editPayload)
Object.assign(clipObj, updatedClip)
},
onDragStart(event, clipObj, index) {
if (this.thumbnailedClips.length <= 1) {
......@@ -408,54 +424,6 @@ export default {
},
async startExport() {
this.exporting = true
// Search for existing caption effect (we can re-use it)
let caption_results = this.effects.filter(e => e.layer == 999 && e.position == 0.0 && e.type == 'Caption')
let effect_payload = {}
let is_effect_new = false
// Get or create caption effect object
if (caption_results.length == 1) {
effect_payload = caption_results[0]
effect_payload.end = 0
effect_payload.json = caption
} else {
is_effect_new = true
effect_payload = {
position: 0,
start: 0,
end: 0,
layer: 999,
project: this.project.url,
title: 'Caption',
type: 'Caption',
json: caption
}
}
// Generate caption text (w/ timestamps)
caption.caption_text = ""
for (let clip of this.thumbnailedClips) {
if (clip.json.text) {
let clip_right_edge = clip.position + (clip.end - clip.start)
if (clip_right_edge > effect_payload.end) {
effect_payload.end = clip_right_edge
}
let start_timestamp = new Date(clip.position * 1000).toISOString().substr(11, 12)
let end_timestamp = new Date((clip.position + (clip.end - clip.start)) * 1000).toISOString().substr(11, 12)
caption.caption_text += `${start_timestamp} --> ${end_timestamp}\n${clip.json.text}\n\n`
}
}
// Save caption effect (if any text is needed)
if (effect_payload.end > 0) {
if (is_effect_new) {
await this.createEffect(effect_payload)
} else {
await this.updateEffect(effect_payload)
}
}
// Create export
let payload = {
"export_type": "video",
"video_format": "mp4",
......@@ -504,12 +472,12 @@ export default {
let myModal = new Modal(this.$refs.exportModal)
myModal.show()
},
...mapActions(['loadClips', 'createClip', 'deleteClip', 'editClip', 'moveClip', 'createExport', 'createEffect', 'updateEffect']),
...mapActions(['loadClips', 'createClip', 'deleteClip', 'editClip', 'moveClip', 'createExport']),
...mapMutations((['setPreviewClip', 'setClips', 'setScrollToClip']))
},
computed: {
...mapGetters(['totalClipDuration', 'thumbnailedClips']),
...mapState(['clips', 'preview', 'scrollToClip', 'current_export', 'effects']),
...mapState(['clips', 'preview', 'scrollToClip', 'current_export']),
isDragging() {
return !!this.draggingClip
}
......@@ -611,6 +579,7 @@ export default {
padding: 4px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.45);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
transition: background-color 0.15s ease, color 0.15s ease;
}
.effects-trigger:hover {
......@@ -684,21 +653,4 @@ export default {
background-color: #000000;
width: 100%;
}
.clip-text {
position: absolute;
width: 75%;
top: 0%;
padding-left: 5%;
padding-top: 10px;
}
input.form-control {
background-color: rgba(255, 255, 255, 0.20);
color: #fff;
border-color: #0d6efd;
filter: drop-shadow(0px 0px 1px #000000);
}
.form-control::placeholder {
filter: none;
color: #e3e3e3;
}
</style>
......@@ -13,6 +13,7 @@
v-if="canOpenClipEffects"
type="button"
class="preview-effects-button effects-trigger"
:class="{ active: hasPreviewEffectsChanges }"
title="Effects and Text Captions"
@click.stop="openPreviewEffects"
>
......@@ -104,6 +105,7 @@
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
@reset="handleClipEffectsReset"
/>
</template>
......@@ -111,6 +113,7 @@
import {mapState, mapActions, mapGetters, mapMutations} from "vuex";
import {fixImageDuration} from "../store/axios";
import ClipEffectsModal from "./ClipEffectsModal";
import { createClipWithCaptionForm, hasCaptionChanges, syncCaptionEffectWithClip } from "../utils/captions";
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif', 'svg', 'tif', 'tiff', 'heic', 'heif'];
const AUDIO_FILE_EXTENSIONS = ['mp3', 'aac', 'm4a', 'wav', 'oga', 'ogg', 'flac', 'opus', 'wma', 'aif', 'aiff'];
......@@ -186,7 +189,14 @@ export default {
"json": {}
}
let payload = {project_id: this.project.id, project_url: this.project.url, data}
await this.createClip(payload)
const newClip = await this.createClip(payload)
if (newClip && this.pendingClipEffects && this.pendingClipEffects.form) {
await this.persistClipEffects(newClip, this.pendingClipEffects.form, !!this.pendingClipEffects.hasCustomizations)
}
this.pendingClipEffects = null
if (newClip) {
await this.normalizeClipPositions()
}
}
else
{
......@@ -194,12 +204,10 @@ export default {
let clipObj = this.preview.clip
clipObj.start = this.preview.start * fixImageDuration(this.preview.file.json.duration)
clipObj.end = this.preview.end * fixImageDuration(this.preview.file.json.duration)
syncCaptionEffectWithClip(clipObj)
let payload = { data: clipObj, latest: false, thumbnail: true }
await this.editClip(payload)
// Re-order clips (and fix 'position' to be valid again)
let move_payload = {direction: null, clip: clipObj, current: -1, dest: -1 }
await this.moveClip(move_payload)
await this.normalizeClipPositions()
}
this.setPreviewFile(null)
},
......@@ -536,13 +544,21 @@ export default {
if (!this.$refs.clipEffectsModal || !this.preview.file) {
return
}
const clipIndex = Array.isArray(this.clips) && this.preview.clip
? this.clips.findIndex(clip => clip.id === this.preview.clip.id)
: -1
const appliedEffects = Array.isArray(this.preview.clip?.json?.effects) ? this.preview.clip.json.effects : []
const pendingForm = !this.preview.clip && this.pendingClipEffects ? this.pendingClipEffects.form : null
this.$refs.clipEffectsModal.open({
clip: this.preview.clip || null,
clipIndex: clipIndex >= 0 ? clipIndex + 1 : null,
clipName: this.preview.clip?.name || this.getPreviewFileName(),
fileName: this.getPreviewFileName(),
text: this.preview.clip?.json?.text || "",
appliedEffects
text: pendingForm?.text,
position: pendingForm?.position,
audioLevel: pendingForm?.audioLevel,
motion: pendingForm?.motion,
appliedEffects: this.preview.clip ? appliedEffects : (pendingForm?.appliedEffects || [])
})
},
getPreviewFileName() {
......@@ -551,11 +567,59 @@ export default {
}
return this.preview.file.name || this.preview.file.title || this.preview.file.filename || this.preview.file.media?.split('/').pop() || 'Current Clip'
},
handleClipEffectsSave(payload) {
this.pendingClipEffects = payload
async handleClipEffectsSave(payload) {
if (!payload.form) {
return
}
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj) {
this.pendingClipEffects = {
form: JSON.parse(JSON.stringify(payload.form)),
hasCustomizations: !!payload.hasCustomizations
}
return
}
this.pendingClipEffects = null
const updatedClip = await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
if (updatedClip && this.preview.clip && this.preview.clip.id === updatedClip.id) {
this.setPreviewClip(updatedClip)
}
},
async handleClipEffectsReset(payload) {
if (!payload.form) {
return
}
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj) {
this.pendingClipEffects = null
return
}
const updatedClip = await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
if (updatedClip && this.preview.clip && this.preview.clip.id === updatedClip.id) {
this.setPreviewClip(updatedClip)
}
},
async persistClipEffects(clipObj, form, fxEnabled) {
if (!clipObj || !form) {
return null
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
clipObj.json.fx_enabled = fxEnabled
return clipObj
}
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
return null
}
const editPayload = { data: updatedClip, latest: false, thumbnail: false }
await this.editClip(editPayload)
Object.assign(clipObj, updatedClip)
return clipObj
},
...mapActions(['createClip', 'editClip', 'moveClip']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile'])
...mapActions(['createClip', 'editClip', 'normalizeClipPositions']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile', 'setPreviewClip'])
},
computed: {
...mapState(['preview', 'files', 'clips']),
......@@ -582,6 +646,12 @@ export default {
canOpenClipEffects() {
return this.hasPreviewFile
},
hasPreviewEffectsChanges() {
if (this.preview.clip) {
return !!this.preview.clip.json?.fx_enabled
}
return !!(this.pendingClipEffects && this.pendingClipEffects.hasCustomizations)
},
hasAnyFiles() {
return Array.isArray(this.files) && this.files.length > 0
},
......@@ -718,12 +788,17 @@ export default {
padding: 6px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-effects-button.active {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.preview-effects-button:hover {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
......
......@@ -330,9 +330,11 @@ export default createStore({
let fps = clipObj.json.reader.fps.num / clipObj.json.reader.fps.den
let thumbnail_payload = { obj: clipObj, frame: clipObj.start * fps, latest: true }
dispatch('attachThumbnail', thumbnail_payload)
await dispatch('attachThumbnail', thumbnail_payload)
return clipObj
} catch(err) {
commit('addError', err.response.data)
return null
}
},
async editClip({dispatch, commit}, payload) {
......@@ -375,13 +377,15 @@ export default createStore({
pos += (clip.end - clip.start)
}
for (let edit_payload of updates) {
dispatch('editClip', edit_payload)
await dispatch('editClip', edit_payload)
}
await dispatch('normalizeClipPositions')
},
async deleteClip({commit}, clip_id) {
async deleteClip({commit, dispatch}, clip_id) {
try {
await instance.delete(`clips/${clip_id}/`)
commit('deleteClip', clip_id)
await dispatch('normalizeClipPositions')
} catch(err) {
commit('addError', err.response.data)
}
......@@ -552,6 +556,25 @@ export default createStore({
commit('addError', err.response.data)
}
},
async normalizeClipPositions({getters, dispatch}) {
const clips = [...getters.thumbnailedClips]
if (!Array.isArray(clips) || clips.length === 0) {
return
}
let position = 0
const updates = []
for (let clip of clips) {
const duration = Math.max(0, (clip.end - clip.start))
if (Math.abs((clip.position ?? 0) - position) > 0.0001) {
clip.position = position
updates.push({ data: clip, latest: false, thumbnail: false })
}
position += duration
}
for (let payload of updates) {
await dispatch('editClip', payload)
}
},
},
modules: {
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment