Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

17 changed files with 4634 additions and 7063 deletions

View file

@ -1,22 +1,12 @@
{ {
"presets": [ "presets": [
[ ["env", {
"env",
{
"modules": false, "modules": false,
"targets": { "targets": {
"browsers": [ "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
"> 1%",
"last 2 versions",
"not ie <= 8"
]
} }
} }],
],
"stage-2" "stage-2"
], ],
"plugins": [ "plugins": ["transform-vue-jsx", "transform-runtime"]
"transform-vue-jsx",
"transform-runtime"
]
} }

View file

@ -1,35 +1,25 @@
// https://eslint.org/docs/user-guide/configuring
module.exports = { module.exports = {
"env": { root: true,
"browser": true, parser: 'babel-eslint',
"es6": true, parserOptions: {
sourceType: 'module'
}, },
"extends": [ env: {
"eslint:recommended", browser: true,
"plugin:vue/essential",
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
}, },
"parserOptions": { // https://github.com/standard/standard/blob/master/docs/RULES-en.md
"ecmaVersion": 2018, extends: 'standard',
"sourceType": "module", // required to lint *.vue files
}, plugins: [
"plugins": [ 'html'
"vue",
], ],
"rules": { // add your custom rules here
"linebreak-style": [ rules: {
"error", // allow async-await
"unix", 'generator-star-spacing': 'off',
], // allow debugger during development
"quotes": [ 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
"error", }
"double", }
],
"semi": [
"error",
"always",
],
},
};

View file

@ -5,6 +5,6 @@ module.exports = {
"postcss-import": {}, "postcss-import": {},
"postcss-url": {}, "postcss-url": {},
// to edit target browsers: use "browserslist" field in package.json // to edit target browsers: use "browserslist" field in package.json
"autoprefixer": {}, "autoprefixer": {}
}, }
}; }

View file

@ -1,37 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.1.1] - 2019-11-02
### Added
- Version label and link to gitlab repo to sidebar
### Changed
- Cleaned up UI
- Recording is now done through `MediaRecorder` API instead of `ccapture.js` - less dependencies, works on firefox
## [1.1.0] - 2019-11-01
### Added
- Loop & fullscreen button
- Discarding / filtering out partial frames
- Slices can have an offset from origin
- Slice size and offset can be set by clicking on canvas
## [1.0.0] - 2018-02-26
Initial version. Supports recording, basic slitscan functionality.
[unreleased]: https://gitlab.com/tmladek/slitscan/compare/v1.1.1...master
[1.1.1]: https://gitlab.com/tmladek/slitscan/compare/v1.1.0...v1.1.1
[1.1.0]: https://gitlab.com/tmladek/slitscan/compare/v1.0.0...v1.1.0
[1.0.0]: https://gitlab.com/tmladek/slitscan/-/tags/v1.0.0

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Tomáš Mládek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,7 +1,7 @@
"use strict"; 'use strict'
const merge = require("webpack-merge"); const merge = require('webpack-merge')
const prodEnv = require("./prod.env"); const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, { module.exports = merge(prodEnv, {
NODE_ENV: "\"development\"", NODE_ENV: '"development"'
}); })

View file

@ -1,19 +1,19 @@
"use strict"; 'use strict'
// Template version: 1.2.8 // Template version: 1.2.8
// see http://vuejs-templates.github.io/webpack for documentation. // see http://vuejs-templates.github.io/webpack for documentation.
const path = require("path"); const path = require('path')
module.exports = { module.exports = {
dev: { dev: {
// Paths // Paths
assetsSubDirectory: "static", assetsSubDirectory: 'static',
assetsPublicPath: "/", assetsPublicPath: '/',
proxyTable: {}, proxyTable: {},
// Various Dev Server settings // Various Dev Server settings
host: "localhost", // can be overwritten by process.env.HOST host: 'localhost', // can be overwritten by process.env.HOST
port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
autoOpenBrowser: false, autoOpenBrowser: false,
errorOverlay: true, errorOverlay: true,
@ -33,7 +33,7 @@ module.exports = {
*/ */
// https://webpack.js.org/configuration/devtool/#development // https://webpack.js.org/configuration/devtool/#development
devtool: "cheap-module-eval-source-map", devtool: 'cheap-module-eval-source-map',
// If you have problems debugging vue-files in devtools, // If you have problems debugging vue-files in devtools,
// set this to false - it *may* help // set this to false - it *may* help
@ -45,12 +45,12 @@ module.exports = {
build: { build: {
// Template for index.html // Template for index.html
index: path.resolve(__dirname, "../dist/index.html"), index: path.resolve(__dirname, '../dist/index.html'),
// Paths // Paths
assetsRoot: path.resolve(__dirname, "../dist"), assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: "static", assetsSubDirectory: 'static',
assetsPublicPath: "/tools/slitscan", assetsPublicPath: '/tools/slitscan',
/** /**
* Source Maps * Source Maps
@ -58,19 +58,19 @@ module.exports = {
productionSourceMap: true, productionSourceMap: true,
// https://webpack.js.org/configuration/devtool/#production // https://webpack.js.org/configuration/devtool/#production
devtool: "#source-map", devtool: '#source-map',
// Gzip off by default as many popular static hosts such as // Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you. // Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to: // Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin // npm install --save-dev compression-webpack-plugin
productionGzip: false, productionGzip: false,
productionGzipExtensions: ["js", "css"], productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to // Run the build command with an extra argument to
// View the bundle analyzer report after build finishes: // View the bundle analyzer report after build finishes:
// `npm run build --report` // `npm run build --report`
// Set to `true` or `false` to always turn it on or off // Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report, bundleAnalyzerReport: process.env.npm_config_report
}, }
}; }

View file

@ -1,8 +1,4 @@
"use strict"; 'use strict'
const package_json = require("../package.json")
module.exports = { module.exports = {
NODE_ENV: "\"production\"", NODE_ENV: '"production"'
VERSION: JSON.stringify(package_json.version), }
HOMEPAGE_URL: JSON.stringify(package_json.homepage)
};

10339
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,8 @@
{ {
"name": "slitscan_vue", "name": "slitscan_vue",
"version": "1.1.1", "version": "1.0.0",
"description": "A video experiment for converting static images to strobe sequences.", "description": "A Vue.js project",
"homepage": "https://gitlab.com/tmladek/slitscan", "author": "Tomáš Mládek <tmladek@inventati.org>",
"author": "Tomáš Mládek <t@mldk.cz>",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
@ -12,9 +11,12 @@
"build": "node build/build.js" "build": "node build/build.js"
}, },
"dependencies": { "dependencies": {
"bowser": "^1.9.2",
"ccapture.js": "^1.0.7",
"q": "^1.5.1", "q": "^1.5.1",
"sprintf-js": "^1.1.1", "sprintf-js": "^1.1.1",
"vue": "^2.5.2" "vue": "^2.5.2",
"whammy": "0.0.1"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^7.1.2", "autoprefixer": "^7.1.2",
@ -39,7 +41,6 @@
"eslint-plugin-node": "^5.2.0", "eslint-plugin-node": "^5.2.0",
"eslint-plugin-promise": "^3.4.0", "eslint-plugin-promise": "^3.4.0",
"eslint-plugin-standard": "^3.0.1", "eslint-plugin-standard": "^3.0.1",
"eslint-plugin-vue": "^5.2.3",
"extract-text-webpack-plugin": "^3.0.0", "extract-text-webpack-plugin": "^3.0.0",
"file-loader": "^1.1.4", "file-loader": "^1.1.4",
"friendly-errors-webpack-plugin": "^1.6.1", "friendly-errors-webpack-plugin": "^1.6.1",

View file

@ -1,49 +1,36 @@
<template> <template>
<div id="app"> <div id="app">
<div id="menu-wrap"> <div id="menu-wrap">
<Sidebar ref="sidebar" @params="setParams" :slice="slice" :offset="offset" @loadImage="loadImage"/> <Sidebar ref="sidebar" @loadImage="loadImage" @guideSize="guideSize"/>
</div> </div>
<div id="canvas-wrap"> <div id="canvas-wrap">
<Canvas ref="canvas" @params="setParams" :slice="slice" :offset="offset"/> <Canvas ref="canvas"/>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Canvas from "./components/Canvas"; import Canvas from './components/Canvas'
import Sidebar from "./components/Sidebar"; import Sidebar from './components/Sidebar'
export default { export default {
name: "app", name: 'app',
components: { components: {
Canvas, Sidebar, Canvas, Sidebar
},
data () {
return {
slice: [64, 64],
offset: [0, 0],
};
}, },
methods: { methods: {
loadImage: function (url) { loadImage: function (url) {
this.$refs.canvas.imageUrl = url; this.$refs.canvas.imageUrl = url
}, },
setParams: function (coords) { guideSize: function (size) {
switch (coords.type) { this.$refs.canvas.slice = size
case "slice": }
this.slice = [Math.max(2, coords.x), Math.max(2, coords.y)]; }
break;
case "offset":
this.offset = [coords.x, coords.y];
break;
} }
},
},
};
</script> </script>
<style> <style>
html, body, #app { html, body, #app {
height: 100%; height: 100%;
width: 100%; width: 100%;
border: 0; border: 0;
@ -52,41 +39,24 @@ html, body, #app {
font-family: Consolas, Inconsolata, monospace, serif; font-family: Consolas, Inconsolata, monospace, serif;
line-height: .97em; line-height: .97em;
font-size: .95em; font-size: .95em;
} }
input, button { input {
font-family: Consolas, Inconsolata, monospace, serif;
}
input {
font-size: .95em; font-size: .95em;
padding: 4px 0; }
background: white;
border: 1px solid black;
}
input[type="file"] { #menu-wrap, #canvas-wrap {
border: none;
}
button {
background: white;
border: 1px solid black;
box-shadow: 2px 2px #272727;
}
#menu-wrap, #canvas-wrap {
display: inline-block; display: inline-block;
} }
#menu-wrap { #menu-wrap {
width: 20%; width: 20%;
height: 100%; height: 100%;
float: left; float: left;
} }
#canvas-wrap { #canvas-wrap {
width: 80%; width: 80%;
height: 100%; height: 100%;
} }
</style> </style>

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1,129 +1,98 @@
<template> <template>
<div id="canvas-container"> <div id="canvas-container">
<canvas ref="canvas" id="canvas" @mousedown="onMouse" @mousemove="onMouse" oncontextmenu="return false;"></canvas> <canvas ref="canvas" id="canvas"></canvas>
</div> </div>
</template> </template>
<script> <script>
import {getDimsFit} from "../helpers/image.js"; import {getDimsFit} from '../helpers/image.js'
export default { export default {
name: "Canvas", name: 'Canvas',
data: function () { data: function () {
return { return {
imageUrl: "", imageUrl: '',
image: null, image: null,
imageLoaded: false, imageLoaded: false,
ratio: 1, slice: [0, 0]
}; }
}, },
props: ["slice", "offset"],
computed: {}, computed: {},
watch: { watch: {
imageUrl: function (url) { imageUrl: function (url) {
this.loadImage(url); this.loadImage(url)
}, },
slice: function () { slice: function () {
this.refresh(); this.refresh()
}, }
offset: function () {
this.refresh();
},
}, },
mounted: function () { mounted: function () {
window.addEventListener("resize", this.handleResize); window.addEventListener('resize', this.handleResize)
this.handleResize(); this.handleResize()
}, },
methods: { methods: {
loadImage: function (imageUrl) { loadImage: function (imageUrl) {
this.image = new Image(); this.image = new Image()
this.image.onload = () => { this.image.onload = () => {
this.imageLoaded = true; this.imageLoaded = true
this.$bus.$emit("imageLoaded", this.image); this.$bus.$emit('imageLoaded', this.image)
this.refresh(); this.refresh()
}; }
this.image.src = imageUrl; this.image.src = imageUrl
}, },
refresh: function () { refresh: function () {
if (!this.imageLoaded) return; if (!this.imageLoaded) return
let image = this.image; let image = this.image
let canvas = this.$refs.canvas; let canvas = this.$refs.canvas
let ctx = canvas.getContext("2d"); let ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height)
let dims = getDimsFit(image.width, image.height, canvas.width, canvas.height); let dims = getDimsFit(image.width, image.height, canvas.width, canvas.height)
ctx.drawImage(image, ctx.drawImage(image,
0, 0, image.width, image.height, 0, 0, image.width, image.height,
0, 0, dims.width, dims.height); 0, 0, dims.width, dims.height)
this.ratio = dims.width / image.width; let ratio = dims.width / image.width
let sliceX = parseInt(this.slice[0]) * this.ratio; let sliceX = parseInt(this.slice[0]) * ratio
let sliceY = parseInt(this.slice[1]) * this.ratio; let sliceY = parseInt(this.slice[1]) * ratio
if (sliceX > 0 && sliceY > 0) { if (sliceX > 0 && sliceY > 0) {
for (let x = this.offset[0] * this.ratio; x < dims.width; x += sliceX) { for (let x = 0; x < dims.width; x += sliceX) {
ctx.beginPath(); ctx.beginPath()
ctx.moveTo(x, this.offset[1] * this.ratio); ctx.moveTo(x, 0)
ctx.lineTo(x, dims.height); ctx.lineTo(x, dims.height)
ctx.strokeStyle = "red"; ctx.strokeStyle = 'red'
ctx.stroke(); ctx.stroke()
} }
for (let y = this.offset[1] * this.ratio; y < dims.height; y += sliceY) { for (let y = 0; y < dims.height; y += sliceY) {
ctx.beginPath(); ctx.beginPath()
ctx.moveTo(this.offset[0] * this.ratio, y); ctx.moveTo(0, y)
ctx.lineTo(dims.width, y); ctx.lineTo(dims.width, y)
ctx.strokeStyle = "red"; ctx.strokeStyle = 'red'
ctx.stroke(); ctx.stroke()
} }
} }
}, },
handleResize: function () { handleResize: function () {
let canvasContainer = document.getElementById("canvas-container"); let canvasContainer = document.getElementById('canvas-container')
let canvas = document.getElementById("canvas"); let canvas = document.getElementById('canvas')
canvas.width = canvasContainer.offsetWidth - 10; canvas.width = canvasContainer.offsetWidth - 10
canvas.height = canvasContainer.offsetHeight - 10; canvas.height = canvasContainer.offsetHeight - 10
this.refresh(); this.refresh()
},
onMouse: function (ev) {
if (ev.buttons === 0) {
return;
} }
ev.preventDefault();
const type = {
1: "slice",
2: "offset",
}[ev.buttons];
if (type !== undefined) {
let x, y;
switch (type) {
case "slice":
x = Math.round((ev.x - ev.target.offsetLeft) / this.ratio - this.offset[0]);
y = Math.round((ev.y - ev.target.offsetTop) / this.ratio - this.offset[1]);
break;
case "offset":
x = Math.round((ev.x - ev.target.offsetLeft) / this.ratio);
y = Math.round((ev.y - ev.target.offsetTop) / this.ratio);
break;
} }
this.$emit("params", {type, x, y});
} }
return false;
},
},
};
</script> </script>
<style scoped> <style scoped>
#canvas-container { #canvas-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
border: 0; border: 0;
padding: 0; padding: 0;
} }
</style> </style>

View file

@ -1,30 +1,26 @@
<template> <template>
<div id="player"> <div id="player" :class="{fullscreen: fullscreen}">
<div> <div>
<label for="zoom">Zoom:</label> <label for="zoom">Zoom:</label>
<input type="number" min="0.1" value="1" step="0.1" class="slider" v-model.number="zoom" id="zoom"> <input type="number" min="1" :max="50" value="1" class="slider" v-model.number="zoom" id="zoom">
</div> </div>
<div class="player-container"> <div id="player-canvas-container">
<canvas ref="canvas" id="player-canvas" <canvas ref="canvas" id="player-canvas"
:height="canvas_height" :width="canvas_width"> :height="canvas_height" :width="canvas_width">
</canvas> </canvas>
</div>
<div id="controls" class="{'fullscreen-controls': fullscreen}">
<div>
<label for="recordMode">Record output</label>
<input type="checkbox" v-model="record" id="recordMode">
</div>
<div> <div>
<input type="range" min="0" :max="frames - 1" value="0" class="slider" <input type="range" min="0" :max="frames - 1" value="0" class="slider"
v-model.number="position" :disabled=recording> v-model.number="position" :disabled=record>
</div>
</div>
<div class="controls">
<div>
<button @click="playOnceOrPause">{{!this.playing ? "PLAY ONCE" : "STOP"}}</button>
</div> </div>
<div> <div>
<button @click="recordSequence">RECORD</button> <button @click="playPause">{{buttonLabel}}</button>
</div> </div>
<div>
<button @click="loopFullscreen">LOOP & FULLSCREEN</button>
</div>
</div>
<div class="options">
<div> <div>
<label for="sort">Sort by size</label> <label for="sort">Sort by size</label>
<input type="checkbox" v-model="sortBySize" id="sort"> <input type="checkbox" v-model="sortBySize" id="sort">
@ -36,270 +32,202 @@
</label> </label>
<input type="checkbox" v-model="sortDirection" id="sortDirection"> <input type="checkbox" v-model="sortDirection" id="sortDirection">
</div> </div>
<div>
<label for="discard">Discard partial frames</label>
<input type="checkbox" v-model="discardPartial" id="discard">
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import CCapture from 'ccapture.js'
import bowser from 'bowser'
export default { export default {
name: "player", name: 'player',
props: { props: {
width: Number, width: Number,
height: Number, height: Number
offset: Array,
}, },
data: function () { data: function () {
return { return {
image: null, image: null,
playing: false, playing: false,
zoom: 1, zoom: 1,
recording: false, fullscreen: false,
looping: false,
sortBySize: false, sortBySize: false,
sortDirection: false, sortDirection: false,
discardPartial: false,
position: 0, position: 0,
tmp_ctx: null, tmp_ctx: null,
animation_id: null, animation_id: null,
recorder: null, record: false,
}; capturer: new CCapture({
format: 'webm',
verbose: true
})
}
}, },
computed: { computed: {
frames: function () { frames: function () {
if (this.image === null) return 0; if (this.image === null) return 0
const roundFn = this.discardPartial ? Math.floor : Math.ceil; return Math.ceil(this.image.width / this.width) *
return roundFn((this.image.width - this.offset[0]) / this.width) * roundFn((this.image.height - this.offset[1]) / this.height); Math.ceil(this.image.height / this.height)
}, },
canvas_height: function () { canvas_height: function () {
return this.height * this.zoom; return this.height * this.zoom
}, },
canvas_width: function () { canvas_width: function () {
return this.width * this.zoom; return this.width * this.zoom
}, },
frame_sequence: function () { frame_sequence: function () {
if (this.image === null) return []; if (this.image === null) return []
let sequence = []; let sequence = []
for (let pos = 0; pos < this.frames; pos++) { for (let pos = 0; pos < this.frames; pos++) {
let wb = (this.discardPartial ? Math.floor : Math.ceil)((this.image.width - this.offset[0]) / this.width); // width_blocks let wb = Math.ceil(this.image.width / this.width) // width_blocks
let x = this.offset[0] + (pos % wb) * this.width; // x offset let x = (pos % wb) * this.width // x offset
let w = x + this.width < this.image.width ? this.width : this.image.width - x; // frame width let w = x + this.width < this.image.width ? this.width : this.image.width - x // frame width
let y = this.offset[1] + Math.floor(pos / wb) * this.height; // y offset let y = Math.floor(pos / wb) * this.height // y offset
let h = y + this.height < this.image.height ? this.height : this.image.height - y; // frame height let h = y + this.height < this.image.height ? this.height : this.image.height - y // frame height
sequence.push([x, y, w, h]); sequence.push([x, y, w, h])
} }
if (this.sortBySize) { if (this.sortBySize) {
let tmpCanvas = document.createElement("canvas"); let tmpCanvas = document.createElement('canvas')
let ctx = tmpCanvas.getContext("2d"); let ctx = tmpCanvas.getContext('2d')
let sizes = sequence.map((el, i) => { let sizes = sequence.map((el, i) => {
if (tmpCanvas.width !== el[2] || if (tmpCanvas.width !== el[2] ||
tmpCanvas.height !== el[3]) { tmpCanvas.height !== el[3]) {
tmpCanvas.width = el[2]; tmpCanvas.width = el[2]
tmpCanvas.height = el[3]; tmpCanvas.height = el[3]
} }
ctx.drawImage(this.image, el[0], el[1], el[2], el[3], ctx.drawImage(this.image, el[0], el[1], el[2], el[3],
0, 0, el[2], el[3]); 0, 0, el[2], el[3])
return {index: i, length: tmpCanvas.toDataURL("image/png").length}; return {index: i, length: tmpCanvas.toDataURL('image/png').length}
}); })
sizes.sort(function (a, b) { sizes.sort(function (a, b) {
return a.length - b.length; return a.length - b.length
}); })
if (!this.sortDirection) { if (!this.sortDirection) {
sizes.reverse(); sizes.reverse()
} }
return sizes.map(function (el) { return sizes.map(function (el) {
return sequence[el.index]; return sequence[el.index]
}); })
} else { } else {
return sequence; return sequence
} }
}, },
buttonLabel: function () {
if (!this.playing) {
if (this.record) {
return 'RECORD'
} else {
return 'PLAY'
}
} else {
return 'STOP'
}
}
}, },
mounted: function () { mounted: function () {
this.$bus.$on("imageLoaded", (image) => { this.$bus.$on('imageLoaded', (image) => {
this.image = image; this.image = image
}); })
this.clear("red"); this.clear('red')
}, },
watch: { watch: {
canvas_width: function () { canvas_width: function () {
setTimeout(() => { setTimeout(() => {
this.clear("red"); this.clear('red')
}, 0); }, 0)
}, },
canvas_height: function () { canvas_height: function () {
setTimeout(() => { setTimeout(() => {
this.clear("red"); this.clear('red')
}, 0); }, 0)
},
frames: function () {
this.$emit("frames", this.frames);
}, },
position: function () { position: function () {
if (this.playing || this.image === null) return; if (this.playing || this.image === null) return
this.tmp_ctx = this.$refs.canvas.getContext("2d"); this.tmp_ctx = this.$refs.canvas.getContext('2d')
this.$render(); this.$render()
}, },
looping: function () { record: function () {
this.recording = false; if (this.record) {
}, this.position = 0
recording: function () { if (!bowser.chrome) {
if (this.recording) { alert('Recording only supported in Chrome :( \n' +
this.looping = false; 'https://github.com/spite/ccapture.js/#limitations')
this.position = 0; }
}
} }
},
}, },
methods: { methods: {
play: function () { playPause: function () {
if (this.frames === 0) { if (this.frames === 0) {
return; return
} }
if (!this.playing) { this.playing = !this.playing
// eslint-disable-next-line no-console
console.log("STARTED");
this.tmp_ctx = this.$refs.canvas.getContext("2d");
this.$render_advance();
this.playing = true;
}
},
stop: function () {
if (this.playing) { if (this.playing) {
// eslint-disable-next-line no-console this.tmp_ctx = this.$refs.canvas.getContext('2d')
console.log("STOPPED"); if (this.record) this.capturer.start()
cancelAnimationFrame(this.animation_id); this.$render_advance()
this.playing = false;
if (this.recording && this.recorder) {
this.recorder.stop();
this.recording = false;
}
}
},
playOnceOrPause: function () {
if (!this.playing) {
this.looping = false;
this.recording = false;
this.position = 0;
this.play();
} else { } else {
this.stop(); cancelAnimationFrame(this.animation_id)
} }
}, },
recordSequence: function () {
try {
this.createRecorder();
} catch (err) {
alert("Error initializing recording!\n" + err);
return;
}
this.looping = false;
this.recording = true;
this.stop();
this.position = 0;
this.recorder.start();
this.play();
},
loopFullscreen: function () {
this.play();
this.looping = true;
this.recording = false;
document.getElementById("player-canvas").requestFullscreen();
},
clear: function (style) { clear: function (style) {
let ctx = this.$refs.canvas.getContext("2d"); let ctx = this.$refs.canvas.getContext('2d')
ctx.fillStyle = style; ctx.fillStyle = style
ctx.fillRect(0, 0, parseInt(this.canvas_width), parseInt(this.canvas_height)); ctx.fillRect(0, 0, parseInt(this.canvas_width), parseInt(this.canvas_height))
},
$render_advance: function () {
this.$render();
if (this.position < this.frames) {
this.position += 1;
this.animation_id = requestAnimationFrame(this.$render_advance);
} else {
if (!this.looping) {
this.stop();
} else {
this.animation_id = requestAnimationFrame(this.$render_advance);
}
this.position = 0;
}
}, },
$render: function () { $render: function () {
let frame = this.frame_sequence[this.position]; let frame = this.frame_sequence[this.position]
if (frame === undefined) return; if (frame === undefined) return
let [x, y, w, h] = frame; let [x, y, w, h] = frame
if (w !== this.width || h !== this.height) this.clear("black"); if (w !== this.width || h !== this.height) this.clear('black')
this.tmp_ctx.drawImage(this.image, this.tmp_ctx.drawImage(this.image,
x, y, w, h, x, y, w, h,
0, 0, w * this.zoom, h * this.zoom); 0, 0, w * this.zoom, h * this.zoom)
}, },
createRecorder: function () { $render_advance: function () {
const stream = this.$refs.canvas.captureStream(); this.$render()
this.recorder = new MediaRecorder(stream, {mimeType: "video/webm"}); if (this.position < this.frames) {
if (!this.recorder) { this.position += 1
throw new Error("Unknown error, couldn't initialize recorder."); this.animation_id = requestAnimationFrame(this.$render_advance)
if (this.record) this.capturer.capture(this.$refs.canvas)
} else {
this.playPause()
this.position = 0
if (this.record) {
this.capturer.stop()
this.capturer.save()
}
}
}
} }
this.recorder.ondataavailable = function (event) {
if (!event.data) {
alert.error("Error during recording: No data available!");
return;
} }
const blob = new Blob([event.data], {type: "video/webm"});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = "slitscan.webm";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
};
},
},
};
</script> </script>
<style scoped> <style scoped>
#player { #player {
text-align: center; text-align: center;
} }
#zoom { .fullscreen {
position: absolute;
top: 0;
left: 0;
background-color: rgba(1, 1, 1, .9);
width: 100%;
height: 100%;
}
.fullscreen-controls {
position: absolute;
bottom: 0;
}
#zoom {
width: 3em; width: 3em;
text-align: center; }
}
.player-container {
padding: 2em 0 1em 0;
}
.controls, .options {
display: flex;
flex-direction: column;
}
.controls button {
width: 12em;
margin: .25em;
}
.options {
padding-top: 1em;
}
.options div {
display: flex;
justify-content: space-between;
}
</style> </style>

View file

@ -1,77 +1,61 @@
<template> <template>
<div id="sidebar-container"> <div id="sidebar-container">
<div class="sidebar-section">
<div> <div>
<input type="file" id="file" @change="loadImage($event)"> <input type="file" id="file" @change="loadImage($event)">
</div> </div>
<div class="section-formlike"> <div>
<label>Image size:</label> <label>Image size: {{imageSize[0]}} x {{imageSize[1]}}</label>
<div class="readout">{{imageSize[0]}} x {{imageSize[1]}}</div>
</div> </div>
</div> <hr>
<div class="sidebar-section"> <div>
<div class="section-formlike">
<label for="slice_x">Slice X: </label> <label for="slice_x">Slice X: </label>
<input class="spinBox" id="slice_x" v-model.number="guideSizeX" type="number" <input class="spinBox" id="slice_x" v-model.number="guideSizeX" type="number" min="2" max="1024" step="1"
min="2" :max="imageSize[0]/2" step="1" value="64"> value="64">
</div> </div>
<div> <div>
<label for="slice_x">X remainder: {{this.imageSize[0] % this.guideSizeX}}</label> <label for="slice_x">No X remainder: {{noXremainder}}</label>
</div> </div>
<div class="section-formlike"> <div>
<label for="slice_y">Slice Y: </label> <label for="slice_y">Slice Y: </label>
<input class="spinBox" id="slice_y" v-model.number="guideSizeY" type="number" <input class="spinBox" id="slice_y" v-model.number="guideSizeY" type="number" min="2" max="1024" step="1"
min="2" :max="imageSize[1]/2" step="1" value="64"> value="64">
</div> </div>
<div> <div>
<label for="slice_x">Y remainder: {{this.imageSize[1] % this.guideSizeY}}</label> <label for="slice_x">No Y remainder: {{noYremainder}}</label>
</div> </div>
<div class="section-formlike"> <div>
<label for="lockSize">Maintain square ratio: </label> <label for="lockSize">Maintain square ratio: </label>
<input type="checkbox" id="lockSize" v-model="lockSize"> <input type="checkbox" id="lockSize" v-model="lockSize">
</div> </div>
<div class="section-formlike"> <div>
<label for="cleanSize">Allow only clean sizes: </label> <label for="cleanSize">Allow only clean sizes: </label>
<input type="checkbox" id="cleanSize" v-model="cleanSize"> <input type="checkbox" id="cleanSize" v-model="cleanSize">
</div> </div>
<hr>
<div>
<label>Total amount of slices: {{slices}}</label>
</div> </div>
<div class="sidebar-section"> <div>
<div class="section-formlike">
<label>Offset: x={{offset[0]}}, y={{offset[1]}}</label>
<button @click="$emit('params', {type: 'offset', x: 0, y: 0})">RESET</button>
</div>
</div>
<div class="sidebar-section">
<div class="section-formlike">
<label>Total amount of slices:</label>
<div class="readout">{{slices}}</div>
</div>
<div class="section-formlike">
<label for="fps">FPS: </label> <label for="fps">FPS: </label>
<input class="spinBox" id="fps" v-model="fps" type="number" min="1" max="60" step="1" value="60" disabled> <input class="spinBox" id="fps" v-model="fps" type="number" min="1" max="60" step="1" value="60" disabled>
</div> </div>
<div class="section-formlike"> <div>
<label>Est. length:</label> <label>Est. length: {{length}}</label>
<div class="readout">{{length}}</div>
</div> </div>
</div> <hr>
<div class="sidebar-section player-section"> <player :width="guideSizeX" :height="guideSizeY"/>
<player class="player" :width="guideSizeX" :height="guideSizeY" :offset="offset" @frames="(n)=>{slices=n}"/>
</div>
<div class="footer"><a :href="env.HOMEPAGE_URL">Version: {{env.VERSION}}</a></div>
</div> </div>
</template> </template>
<!--suppress JSSuspiciousNameCombination --> <!--suppress JSSuspiciousNameCombination -->
<script> <script>
import Player from "@/components/Player"; import Player from '@/components/Player'
export default { export default {
name: "Sidebar", name: 'Sidebar',
components: { components: {
player: Player, player: Player
}, },
props: ["slice", "offset"],
data: function () { data: function () {
return { return {
imageSize: [-1, -1], imageSize: [-1, -1],
@ -81,167 +65,121 @@ export default {
lastGuideSizeY: 64, lastGuideSizeY: 64,
lockSize: true, lockSize: true,
cleanSize: false, cleanSize: false,
slices: 0, fps: 60
fps: 60, }
};
}, },
watch: { watch: {
guideSizeX (size) { guideSizeX (size) {
if (this.lockSize) this.guideSizeY = size; if (this.lockSize) this.guideSizeY = size
if (this.cleanSize) { if (this.cleanSize) {
let tmpSize = parseInt(size); let tmpSize = parseInt(size)
while (this.imageSize[0] % tmpSize !== 0 && while (this.imageSize[0] % tmpSize !== 0 &&
tmpSize < this.imageSize[0] && tmpSize < this.imageSize[0] &&
tmpSize > 2) { tmpSize > 2) {
if (this.lastGuideSizeX < size) { if (this.lastGuideSizeX < size) {
tmpSize += 1; tmpSize += 1
} else { } else {
tmpSize -= 1; tmpSize -= 1
} }
} }
if (tmpSize !== 2 && tmpSize !== this.imageSize[0]) { if (tmpSize !== 2 && tmpSize !== this.imageSize[0]) {
this.guideSizeX = tmpSize; this.guideSizeX = tmpSize
} }
} }
this.$emit("params", { this.$emit('guideSize', [this.guideSizeX, this.guideSizeY])
type: "slice", this.lastGuideSizeX = this.guideSizeX
x: this.guideSizeX, this.lastGuideSizeY = this.guideSizeY
y: this.guideSizeY,
});
this.lastGuideSizeX = this.guideSizeX;
this.lastGuideSizeY = this.guideSizeY;
}, },
guideSizeY (size) { guideSizeY (size) {
if (this.lockSize) this.guideSizeX = size; if (this.lockSize) this.guideSizeX = size
if (this.cleanSize) { if (this.cleanSize) {
let tmpSize = parseInt(size); let tmpSize = parseInt(size)
while (this.imageSize[1] % tmpSize !== 0 && while (this.imageSize[1] % tmpSize !== 0 &&
tmpSize < this.imageSize[1] && tmpSize < this.imageSize[1] &&
tmpSize > 2) { tmpSize > 2) {
if (this.lastGuideSizeY < size) { if (this.lastGuideSizeY < size) {
tmpSize += 1; tmpSize += 1
} else { } else {
tmpSize -= 1; tmpSize -= 1
} }
} }
if (tmpSize !== 2 && tmpSize !== this.imageSize[1]) { if (tmpSize !== 2 && tmpSize !== this.imageSize[1]) {
this.guideSizeY = tmpSize; this.guideSizeY = tmpSize
} }
} }
this.$emit("params", { this.$emit('guideSize', [this.guideSizeX, this.guideSizeY])
type: "slice", this.lastGuideSizeX = this.guideSizeX
x: this.guideSizeX, this.lastGuideSizeY = this.guideSizeY
y: this.guideSizeY,
});
this.lastGuideSizeX = this.guideSizeX;
this.lastGuideSizeY = this.guideSizeY;
},
slice: function (size) {
this.lockSize = size[0] === size[1];
this.guideSizeX = size[0];
this.guideSizeY = size[1];
}, },
lockSize: function (locked) { lockSize: function (locked) {
if (locked) { if (locked) {
if (this.guideSizeX < this.guideSizeY) { if (this.guideSizeX < this.guideSizeY) this.guideSizeY = this.guideSizeX
this.guideSizeY = this.guideSizeX; else this.guideSizeX = this.guideSizeY
} else { this.cleanSize = false
this.guideSizeX = this.guideSizeY;
}
this.cleanSize = false;
} }
}, },
cleanSize: function (clean) { cleanSize: function (clean) {
if (clean) { if (clean) {
this.lockSize = false; this.lockSize = false
}
} }
},
}, },
computed: { computed: {
length: function () { slices: function () {
if (this.imageSize[0] === -1 || this.imageSize[1] === -1) { if (this.imageSize[0] === -1 || this.imageSize[1] === -1) {
return "-"; return '-'
} else { } else {
let seconds = Math.round(this.slices / this.fps * 100) / 100; return Math.ceil(this.imageSize[0] / this.guideSizeX) *
Math.ceil(this.imageSize[1] / this.guideSizeY)
let mins = Math.floor(seconds / 60);
let secs = Math.round(seconds % 60);
return "~" + seconds + " seconds" + (mins > 0 ? (" (" + mins + "m " + secs + "s)") : "");
} }
}, },
env: function () { length: function () {
// eslint-disable-next-line no-undef if (this.imageSize[0] === -1 || this.imageSize[1] === -1) {
return process.env; return '-'
} else {
let seconds = Math.round(this.slices / this.fps * 100) / 100
let mins = Math.floor(seconds / 60)
let secs = Math.round(seconds % 60)
return '~' + seconds + ' seconds' + (mins > 0 ? (' (' + mins + 'm ' + secs + 's)') : '')
}
}, },
noXremainder: function () {
return this.imageSize[0] % this.guideSizeX === 0
},
noYremainder: function () {
return this.imageSize[1] % this.guideSizeY === 0
}
}, },
methods: { methods: {
loadImage (e) { loadImage (e) {
let url = URL.createObjectURL(e.target.files[0]); let url = URL.createObjectURL(e.target.files[0])
this.$emit("loadImage", url); this.$emit('loadImage', url)
},
setParams (coords) {
switch (coords.type) {
case "size":
this.lockSize = false;
this.guideSizeX = coords.x;
this.guideSizeY = coords.y;
break;
} }
}, },
},
mounted: function () { mounted: function () {
this.$bus.$on("imageLoaded", (image) => { this.$bus.$on('imageLoaded', (image) => {
this.imageSize = [image.width, image.height]; this.imageSize = [image.width, image.height]
}); })
this.$emit("guideSize", [this.guideSizeX, this.guideSizeY]); this.$emit('guideSize', [this.guideSizeX, this.guideSizeY])
}, }
}; }
</script> </script>
<style scoped> <style scoped>
#sidebar-container { #sidebar-container {
display: flex;
flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-right: 1px solid black; border-right: 1px solid black;
} }
.sidebar-section { .spinBox {
padding: .5em; height: 1em;
border-bottom: 1px solid grey; }
}
.sidebar-section > div { .green {
padding: .25em; background-color: green;
} }
.section-formlike {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.section-formlike input[type="number"] {
width: 4em;
text-align: center;
}
.section-formlike .readout {
min-width: 4em;
text-align: center;
}
.player-section {
border-bottom: none;
}
.footer {
position: absolute;
bottom: .5em;
left: .5em;
}
</style> </style>

View file

@ -1,16 +1,16 @@
export function getDimsFit (wOriginal, hOriginal, wContain, hContain) { export function getDimsFit (wOriginal, hOriginal, wContain, hContain) {
let origRatio = wOriginal / hOriginal; let origRatio = wOriginal / hOriginal
let containRatio = wContain / hContain; let containRatio = wContain / hContain
if (origRatio > containRatio) { if (origRatio > containRatio) {
return { return {
width: wContain, width: wContain,
height: wContain / origRatio, height: wContain / origRatio
}; }
} else { } else {
return { return {
width: origRatio * hContain, width: origRatio * hContain,
height: hContain, height: hContain
}; }
} }
} }

View file

@ -1,15 +1,15 @@
// The Vue build version to load with the `import` command // The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias. // (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from "vue"; import Vue from 'vue'
import App from "./App"; import App from './App'
Vue.config.productionTip = false; Vue.config.productionTip = false
Vue.prototype.$bus = new Vue({}); Vue.prototype.$bus = new Vue({})
/* eslint-disable no-new */ /* eslint-disable no-new */
new Vue({ new Vue({
el: "#app", el: '#app',
template: "<App/>", template: '<App/>',
components: {App}, components: { App }
}); })