Commit ca7798bd by Jonathan Thomas

Adding new clip effects modal, and /info/effects/ endpoint to load all effects…

Adding new clip effects modal, and /info/effects/ endpoint to load all effects from the cloud api endpoint. This is still a work in progress.
parent 5025e0ea
<template>
<div
class="modal fade clip-effects-modal"
ref="clipEffectsModal"
tabindex="-1"
aria-labelledby="clipEffectsModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-lg clip-effects-dialog">
<div class="modal-content">
<div class="modal-header border-0 align-items-start">
<div>
<h5 class="modal-title" id="clipEffectsModalLabel">Clip Effects</h5>
<p v-if="clipDisplayLabel" class="clip-meta mb-0 text-muted">
{{ clipDisplayLabel }}
</p>
</div>
<button
type="button"
class="btn-close"
aria-label="Close"
@click="handleCancel"
></button>
</div>
<div class="modal-body">
<section class="effects-panel">
<header>Text Caption</header>
<div class="panel-body">
<div class="row g-3 align-items-end">
<div class="col-sm-6">
<label class="form-label" for="clipEffectsTextInput">Text</label>
<input
id="clipEffectsTextInput"
type="text"
class="form-control"
placeholder="Enter caption text"
ref="textInput"
v-model="form.text"
/>
</div>
<div class="col-sm-6">
<label class="form-label d-block">Position</label>
<div class="btn-group position-group w-100" role="group" aria-label="Text position">
<button
v-for="option in captionPositions"
:key="option.value"
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.position === option.value }"
@click="updatePosition(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
</section>
<section class="effects-panel">
<header>Audio</header>
<div class="panel-body">
<div class="audio-controls">
<span class="fw-semibold text-muted small">Level: {{ form.audioLevel }}%</span>
<input
type="range"
class="form-range audio-slider"
min="0"
max="100"
step="1"
v-model.number="form.audioLevel"
aria-label="Audio level"
/>
</div>
</div>
</section>
<section class="effects-panel">
<header>Motion</header>
<div class="panel-body motion-grid">
<div
v-for="preset in motionPresets"
:key="preset.key"
class="motion-preset"
>
<p class="mb-2 fw-semibold text-uppercase small">{{ preset.label }}</p>
<div class="btn-group w-100" role="group" :aria-label="`${preset.label} motion options`">
<button
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.motion[preset.inKey] }"
@click="toggleMotion(preset.inKey)"
>
In
</button>
<button
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.motion[preset.outKey] }"
@click="toggleMotion(preset.outKey)"
>
Out
</button>
</div>
</div>
</div>
</section>
<section class="effects-panel">
<header>Effects</header>
<div class="panel-body">
<div class="row g-2 align-items-end">
<div class="col-md-8 col-sm-12">
<label class="form-label" for="availableEffects">Add effect</label>
<select
id="availableEffects"
class="form-select"
v-model="form.selectedEffectId"
>
<option value="" disabled>Select effect</option>
<option
v-for="effect in normalizedEffectOptions"
:key="effect.value"
:value="effect.value"
>
{{ effect.label }}
</option>
</select>
</div>
<div class="col-md-4 col-sm-12 d-grid">
<label class="form-label invisible">Add</label>
<button
type="button"
class="btn btn-outline-primary"
:disabled="!form.selectedEffectId"
@click="appendSelectedEffect"
>
<span class="me-2">Add</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5H2.5a.5.5 0 0 1 0-1H7V2.5A.5.5 0 0 1 8 2z"/>
</svg>
</button>
</div>
</div>
<div class="applied-effects mt-4">
<ul v-if="form.appliedEffects.length" class="list-unstyled mb-0">
<li
v-for="effect in form.appliedEffects"
:key="effect.value"
class="applied-item"
>
<span>{{ effect.label }}</span>
<button
type="button"
class="btn btn-link text-decoration-none text-danger p-0"
@click="removeEffect(effect.value)"
>
Remove
</button>
</li>
</ul>
</div>
</div>
</section>
</div>
<div class="modal-footer justify-content-between border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" @click="handleReset">
Reset
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-light" @click="handleCancel">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="handleSave">
Save
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
import { mapState } from "vuex";
export default {
name: "ClipEffectsModal",
data() {
return {
modalInstance: null,
modalShownHandler: null,
clipContext: {
clipIndex: null,
clipName: "",
fileName: ""
},
form: this.createEmptyForm(),
snapshot: null,
sampleEffectOptions: [
{ value: "blur", label: "Blur" },
{ value: "glow", label: "Glow" },
{ value: "pop", label: "Pop" },
{ value: "bounce", label: "Bounce" }
],
captionPositions: [
{ value: "top", label: "Top" },
{ value: "center", label: "Center" },
{ value: "bottom", label: "Bottom" }
],
motionPresets: [
{ key: "slide", label: "Slide", inKey: "slideIn", outKey: "slideOut" },
{ key: "fade", label: "Fade", inKey: "fadeIn", outKey: "fadeOut" },
{ key: "zoom", label: "Zoom", inKey: "zoomIn", outKey: "zoomOut" }
]
}
},
computed: {
...mapState(['effectCatalog']),
clipDisplayLabel() {
if (!this.clipContext.clipName && !this.clipContext.fileName) {
return ""
}
const label = this.clipContext.clipName || this.clipContext.fileName
if (this.clipContext.clipIndex) {
return `${label} (Clip ${this.clipContext.clipIndex})`
}
return label
},
normalizedEffectOptions() {
if (this.effectCatalog && this.effectCatalog.length) {
return this.effectCatalog.map((effect, index) => this.normalizeEffectOption(effect, index))
}
return this.sampleEffectOptions
}
},
methods: {
createEmptyForm() {
return {
text: "",
position: "bottom",
audioLevel: 100,
motion: {
slideIn: false,
slideOut: false,
fadeIn: false,
fadeOut: false,
zoomIn: false,
zoomOut: false
},
selectedEffectId: "",
appliedEffects: []
}
},
ensureModalInstance() {
if (!this.modalInstance) {
this.modalInstance = new Modal(this.$refs.clipEffectsModal, {
backdrop: "static"
})
this.modalShownHandler = () => this.focusTextInput()
this.$refs.clipEffectsModal.addEventListener('shown.bs.modal', this.modalShownHandler)
}
return this.modalInstance
},
normalizeEffectOption(effect, fallbackIndex) {
const label = effect.title || effect.name || effect.display_name || effect.slug || `Effect ${fallbackIndex + 1}`
const value = effect.id || effect.slug || effect.value || `effect-${fallbackIndex}`
return { label, value }
},
normalizeAppliedEffect(effect, fallbackIndex) {
if (typeof effect === 'string') {
return { label: effect, value: effect }
}
const normalized = this.normalizeEffectOption(effect, fallbackIndex)
return normalized
},
open(payload = {}) {
this.clipContext = {
clipIndex: payload.clipIndex ?? null,
clipName: payload.clip?.name || payload.clipName || "",
fileName: payload.fileName || payload.clip?.name || ""
}
const defaults = this.createEmptyForm()
this.form = {
...defaults,
text: payload.text ?? payload.clip?.json?.text ?? defaults.text,
position: payload.position || 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,
selectedEffectId: ""
}
this.snapshot = JSON.parse(JSON.stringify(this.form))
this.ensureModalInstance().show()
this.$nextTick(() => {
this.focusTextInput()
})
},
close() {
if (this.modalInstance) {
this.modalInstance.hide()
}
},
handleReset() {
if (this.snapshot) {
this.form = JSON.parse(JSON.stringify(this.snapshot))
} else {
this.form = this.createEmptyForm()
}
this.$emit('reset', { clip: this.clipContext })
},
handleCancel() {
if (this.snapshot) {
this.form = JSON.parse(JSON.stringify(this.snapshot))
}
this.close()
this.$emit('cancel', { clip: this.clipContext })
},
handleSave() {
const payload = {
clip: this.clipContext,
form: JSON.parse(JSON.stringify(this.form))
}
this.$emit('save', payload)
this.close()
},
updatePosition(value) {
this.form.position = value
},
toggleMotion(key) {
this.form.motion[key] = !this.form.motion[key]
},
appendSelectedEffect() {
if (!this.form.selectedEffectId) {
return
}
const exists = this.form.appliedEffects.some(effect => effect.value === this.form.selectedEffectId)
if (exists) {
this.form.selectedEffectId = ""
return
}
const selected = this.normalizedEffectOptions.find(effect => effect.value === this.form.selectedEffectId)
if (selected) {
this.form.appliedEffects.push(selected)
}
this.form.selectedEffectId = ""
},
removeEffect(value) {
this.form.appliedEffects = this.form.appliedEffects.filter(effect => effect.value !== value)
},
focusTextInput() {
if (this.$refs.textInput) {
this.$refs.textInput.focus()
this.$refs.textInput.select()
}
}
},
mounted() {
this.ensureModalInstance()
},
unmounted() {
if (this.modalInstance) {
this.modalInstance.hide()
this.modalInstance = null
}
if (this.modalShownHandler && this.$refs.clipEffectsModal) {
this.$refs.clipEffectsModal.removeEventListener('shown.bs.modal', this.modalShownHandler)
}
}
}
</script>
<style scoped>
.clip-effects-modal .modal-dialog {
max-width: 720px;
}
.clip-effects-modal .modal-content {
border-radius: 0.85rem;
}
.clip-effects-modal .modal-body {
padding: 1rem 1.25rem;
}
.clip-effects-modal header {
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.08em;
color: #6c757d;
}
.clip-effects-modal .effects-panel + .effects-panel {
margin-top: 1.25rem;
}
.clip-effects-modal .panel-body {
margin-top: 0.5rem;
background: #f8f9fa;
border-radius: 0.65rem;
padding: 0.9rem 1rem;
}
.clip-effects-modal .motion-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.clip-effects-modal .motion-preset {
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 0.6rem;
padding: 0.85rem;
background-color: #fff;
}
.clip-effects-modal .applied-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.55rem 0.8rem;
border-radius: 0.45rem;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 0.35rem;
}
.clip-effects-modal .position-group .btn {
min-width: 80px;
}
.clip-effects-modal .modal-footer {
background: #fdfdfd;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding: 0.75rem 1.25rem 1.25rem;
}
.clip-effects-modal .clip-meta {
font-size: 0.85rem;
}
.clip-effects-modal .audio-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.clip-effects-modal .audio-slider {
min-width: 180px;
flex: 1 1 160px;
}
.clip-effects-modal .applied-effects {
margin-top: 0.75rem;
}
.clip-effects-modal .effects-panel select,
.clip-effects-modal .effects-panel input[type="text"] {
font-size: 0.95rem;
}
</style>
...@@ -37,23 +37,15 @@ ...@@ -37,23 +37,15 @@
<!-- Clip sequence # badge --> <!-- Clip sequence # badge -->
<span class="clip-badge badge">{{ index + 1 }}</span> <span class="clip-badge badge">{{ index + 1 }}</span>
<!-- Clip text icon --> <!-- Clip effects icon -->
<div class="context-menu" @mousedown.stop> <div class="context-menu" @mousedown.stop>
<span @click.prevent="toggleText(clip)" <span
class="menu-icon toggle-icon" @click.prevent="openClipEffectsModal(clip, index)"
:class="{ active: isTextActive(clip) }" class="menu-icon toggle-icon effects-trigger"
title="Add Text" :class="{ active: hasClipEnhancements(clip) }"
draggable="false"> title="Effects and Text Captions"
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-textarea-t" viewBox="0 0 16 16"> draggable="false"
<path d="M1.5 2.5A1.5 1.5 0 0 1 3 1h10a1.5 1.5 0 0 1 1.5 1.5v3.563a2 2 0 0 1 0 3.874V13.5A1.5 1.5 0 0 1 13 15H3a1.5 1.5 0 0 1-1.5-1.5V9.937a2 2 0 0 1 0-3.874V2.5zm1 3.563a2 2 0 0 1 0 3.874V13.5a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V9.937a2 2 0 0 1 0-3.874V2.5A.5.5 0 0 0 13 2H3a.5.5 0 0 0-.5.5v3.563zM2 7a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/> >
<path d="M11.434 4H4.566L4.5 5.994h.386c.21-1.252.612-1.446 2.173-1.495l.343-.011v6.343c0 .537-.116.665-1.049.748V12h3.294v-.421c-.938-.083-1.054-.21-1.054-.748V4.488l.348.01c1.56.05 1.963.244 2.173 1.496h.386L11.434 4z"/>
</svg>
</span>
<span @click.prevent="toggleFx(clip)"
class="menu-icon toggle-icon"
:class="{ active: isFxActive(clip) }"
title="Add FX"
draggable="false">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16">
<path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/> <path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/> <path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/>
...@@ -132,16 +124,25 @@ ...@@ -132,16 +124,25 @@
</div> </div>
</div> </div>
</div> </div>
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
/>
</template> </template>
<script> <script>
import {mapActions, mapState, mapMutations, mapGetters} from "vuex" import {mapActions, mapState, mapMutations, mapGetters} from "vuex"
import { Modal } from "bootstrap" import { Modal } from "bootstrap"
import caption from "../data/caption.json" import caption from "../data/caption.json"
import ClipEffectsModal from "./ClipEffectsModal"
export default { export default {
name: "Clips.vue", name: "Clips.vue",
props: ['project'], props: ['project'],
components: {
ClipEffectsModal
},
data() { data() {
return { return {
show_spinner: false, show_spinner: false,
...@@ -152,45 +153,42 @@ export default { ...@@ -152,45 +153,42 @@ export default {
dragOverPosition: null, dragOverPosition: null,
autoScrollDirection: 0, autoScrollDirection: 0,
autoScrollFrame: null, autoScrollFrame: null,
dragListenersActive: false dragListenersActive: false,
pendingClipEffects: null
} }
}, },
methods: { methods: {
toggleText(clipObj) { openClipEffectsModal(clipObj, index) {
if (Object.prototype.hasOwnProperty.call(clipObj.json, 'text')) { const fileName = clipObj?.name || clipObj?.file_name || ""
delete clipObj.json.text const appliedEffects = Array.isArray(clipObj?.json?.effects) ? clipObj.json.effects : []
} else { if (this.$refs.clipEffectsModal) {
clipObj.json.text = "" this.$refs.clipEffectsModal.open({
if (clipObj.json.fx_enabled) { clip: clipObj,
delete clipObj.json.fx_enabled clipIndex: index + 1,
} clipName: clipObj?.name,
fileName,
text: clipObj?.json?.text || "",
appliedEffects
})
} }
let payload = { data: clipObj }
this.editClip(payload)
}, },
toggleFx(clipObj) { hasClipEnhancements(clipObj) {
if (clipObj.json.fx_enabled) { if (!clipObj || !clipObj.json) {
delete clipObj.json.fx_enabled return false
} else { }
clipObj.json.fx_enabled = true const hasText = !!clipObj.json.text
if (Object.prototype.hasOwnProperty.call(clipObj.json, 'text')) { const hasFxFlag = !!clipObj.json.fx_enabled
delete clipObj.json.text const hasAppliedEffects = Array.isArray(clipObj.json.effects) && clipObj.json.effects.length > 0
} return hasText || hasFxFlag || hasAppliedEffects
} },
let payload = { data: clipObj } handleClipEffectsSave(payload) {
this.editClip(payload) this.pendingClipEffects = payload
}, },
updateTextInput(e, clipObj) { updateTextInput(e, clipObj) {
clipObj.json.text = e.target.value clipObj.json.text = e.target.value
let payload = { data: clipObj } let payload = { data: clipObj }
this.editClip(payload) this.editClip(payload)
}, },
isTextActive(clipObj) {
return Object.prototype.hasOwnProperty.call(clipObj.json, 'text')
},
isFxActive(clipObj) {
return !!clipObj.json.fx_enabled
},
onDragStart(event, clipObj, index) { onDragStart(event, clipObj, index) {
if (this.thumbnailedClips.length <= 1) { if (this.thumbnailedClips.length <= 1) {
event.preventDefault() event.preventDefault()
...@@ -609,6 +607,16 @@ export default { ...@@ -609,6 +607,16 @@ export default {
color: #0d6efd; color: #0d6efd;
background-color: rgba(13, 110, 253, 0.25); background-color: rgba(13, 110, 253, 0.25);
} }
.effects-trigger {
padding: 4px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.45);
transition: background-color 0.15s ease, color 0.15s ease;
}
.effects-trigger:hover {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.menu-icon:hover { .menu-icon:hover {
color: #0d6efd; color: #0d6efd;
} }
......
...@@ -9,6 +9,18 @@ ...@@ -9,6 +9,18 @@
<div class="row preview-stage-row" v-if="hasPreviewFile || show_spinner"> <div class="row preview-stage-row" v-if="hasPreviewFile || show_spinner">
<div class="col-12" ref="previewStageWrapper"> <div class="col-12" ref="previewStageWrapper">
<div class="preview-stage" :class="{'is-resizing': isResizingStage}" :style="previewStageStyle"> <div class="preview-stage" :class="{'is-resizing': isResizingStage}" :style="previewStageStyle">
<button
v-if="canOpenClipEffects"
type="button"
class="preview-effects-button effects-trigger"
title="Effects and Text Captions"
@click.stop="openPreviewEffects"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16">
<path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
<div v-if="show_spinner" class="preview-stage-spinner"> <div v-if="show_spinner" class="preview-stage-spinner">
<div class="spinner-border" role="status"> <div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
...@@ -89,11 +101,16 @@ ...@@ -89,11 +101,16 @@
</div> </div>
</div> </div>
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
/>
</template> </template>
<script> <script>
import {mapState, mapActions, mapGetters, mapMutations} from "vuex"; import {mapState, mapActions, mapGetters, mapMutations} from "vuex";
import {fixImageDuration} from "../store/axios"; import {fixImageDuration} from "../store/axios";
import ClipEffectsModal from "./ClipEffectsModal";
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif', 'svg', 'tif', 'tiff', 'heic', 'heif']; 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']; const AUDIO_FILE_EXTENSIONS = ['mp3', 'aac', 'm4a', 'wav', 'oga', 'ogg', 'flac', 'opus', 'wma', 'aif', 'aiff'];
...@@ -123,6 +140,9 @@ const MEDIA_MIME_TYPES = { ...@@ -123,6 +140,9 @@ const MEDIA_MIME_TYPES = {
export default { export default {
name: "Preview.vue", name: "Preview.vue",
props: ['project'], props: ['project'],
components: {
ClipEffectsModal
},
data() { data() {
return { return {
dragging_marker: null, dragging_marker: null,
...@@ -148,7 +168,8 @@ export default { ...@@ -148,7 +168,8 @@ export default {
stageResizeStartHeight: 0, stageResizeStartHeight: 0,
isResizingStage: false, isResizingStage: false,
stageMinHeight: 220, stageMinHeight: 220,
stageMaxHeight: 720 stageMaxHeight: 720,
pendingClipEffects: null
} }
}, },
methods: { methods: {
...@@ -511,6 +532,28 @@ export default { ...@@ -511,6 +532,28 @@ export default {
this.stageHeightManual = null this.stageHeightManual = null
this.scheduleStageMeasure() this.scheduleStageMeasure()
}, },
openPreviewEffects() {
if (!this.$refs.clipEffectsModal || !this.preview.file) {
return
}
const appliedEffects = Array.isArray(this.preview.clip?.json?.effects) ? this.preview.clip.json.effects : []
this.$refs.clipEffectsModal.open({
clip: this.preview.clip || null,
clipName: this.preview.clip?.name || this.getPreviewFileName(),
fileName: this.getPreviewFileName(),
text: this.preview.clip?.json?.text || "",
appliedEffects
})
},
getPreviewFileName() {
if (!this.preview.file) {
return 'Current Clip'
}
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
},
...mapActions(['createClip', 'editClip', 'moveClip']), ...mapActions(['createClip', 'editClip', 'moveClip']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile']) ...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile'])
}, },
...@@ -536,6 +579,9 @@ export default { ...@@ -536,6 +579,9 @@ export default {
return false return false
} }
}, },
canOpenClipEffects() {
return this.hasPreviewFile
},
hasAnyFiles() { hasAnyFiles() {
return Array.isArray(this.files) && this.files.length > 0 return Array.isArray(this.files) && this.files.length > 0
}, },
...@@ -664,6 +710,30 @@ export default { ...@@ -664,6 +710,30 @@ export default {
</script> </script>
<style scoped> <style scoped>
.preview-effects-button {
position: absolute;
top: 10px;
right: 10px;
border: none;
padding: 6px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.5);
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-effects-button:hover {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.preview-effects-button:focus-visible {
outline: 2px solid #0d6efd;
}
.preview-effects-button svg {
pointer-events: none;
}
.preview-stage-row { .preview-stage-row {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
......
...@@ -14,6 +14,7 @@ export default createStore({ ...@@ -14,6 +14,7 @@ export default createStore({
effects: [], effects: [],
project: null, project: null,
current_export: { progress: 0.0 }, current_export: { progress: 0.0 },
effectCatalog: [],
user: null, user: null,
preview: { preview: {
file: null, file: null,
...@@ -74,6 +75,9 @@ export default createStore({ ...@@ -74,6 +75,9 @@ export default createStore({
effectObj effectObj
] ]
}, },
setEffectCatalog(state, effects) {
state.effectCatalog = effects || []
},
updateProjectThumbnail(state, payload) { updateProjectThumbnail(state, payload) {
state.projects = [ state.projects = [
...state.projects = state.projects.filter(p => p.id != payload.obj.id), ...state.projects = state.projects.filter(p => p.id != payload.obj.id),
...@@ -517,6 +521,20 @@ export default createStore({ ...@@ -517,6 +521,20 @@ export default createStore({
commit('addError', err.response.data) commit('addError', err.response.data)
} }
}, },
async loadEffectCatalog({state, commit}) {
if (state.effectCatalog && state.effectCatalog.length > 0) {
return state.effectCatalog
}
try {
const response = await instance.get('/info/effects/')
const data = response.data && response.data.results ? response.data.results : response.data
commit('setEffectCatalog', data || [])
return data
} catch(err) {
commit('addError', err.response?.data || err.message)
return []
}
},
async createEffect({commit}, payload) { async createEffect({commit}, payload) {
try { try {
const response = await instance.post('/effects/', payload) const response = await instance.post('/effects/', payload)
......
...@@ -37,7 +37,7 @@ export default { ...@@ -37,7 +37,7 @@ export default {
this.checkExportProgress(this.activeExports[0]) this.checkExportProgress(this.activeExports[0])
} }
}, },
...mapActions(['getProject', 'loadExports', 'checkExportProgress', 'loadEffects', 'attachThumbnail']), ...mapActions(['getProject', 'loadExports', 'checkExportProgress', 'loadEffects', 'loadEffectCatalog', 'attachThumbnail']),
...mapMutations(['addError', 'setProject', 'setExports']) ...mapMutations(['addError', 'setProject', 'setExports'])
}, },
computed: { computed: {
...@@ -55,6 +55,7 @@ export default { ...@@ -55,6 +55,7 @@ export default {
let project_id = this.$route.params.id let project_id = this.$route.params.id
this.loadExports(project_id) this.loadExports(project_id)
this.loadEffects(project_id) this.loadEffects(project_id)
this.loadEffectCatalog()
let results = await this.getProject(project_id) let results = await this.getProject(project_id)
this.setProject(results.data) this.setProject(results.data)
this.hasData = true this.hasData = true
...@@ -80,4 +81,4 @@ export default { ...@@ -80,4 +81,4 @@ export default {
<style scoped> <style scoped>
</style> </style>
\ No newline at end of file
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