Compare commits

...

15 commits

Author SHA1 Message Date
c637ebd144 Add LICENSE 2019-11-02 11:37:15 +00:00
9ee3bff615 add changelog 2019-11-02 12:24:54 +01:00
a4f6194ea2 add link @ version read-out to sidebar 2019-11-02 12:24:54 +01:00
f9547511d8 update package.json 2019-11-02 12:24:54 +01:00
3e5b2ca502 get rid of ccapture.js, use MediaRecorder api instead
recording now works in Firefox
less dependencies
2019-11-02 12:24:54 +01:00
062fbf84df styling & improvements 2019-11-02 12:24:54 +01:00
0c1656b87e Revert "npm audit fix --force"
This reverts commit bd4f110936fcf2bbbf6f476ba3ac1e48b1aa1921.
2019-11-02 12:24:54 +01:00
4a208f6078 zoom has steps and allows < 1 2019-11-02 12:24:54 +01:00
907f323583 loop & fullscreen
refactor playing/recoridng/pausing functions
2019-11-02 12:24:54 +01:00
1707379e78 add option for discarding partial frames 2019-11-02 12:24:54 +01:00
7ef1778cad refactor, add slice offset
(centralize slice size and offset in App.vue)
2019-11-02 12:24:54 +01:00
089f7d56c0 allow selection of size by mouse 2019-11-02 12:24:54 +01:00
676c8f7fe0 autoformat && eslint change 2019-11-02 12:24:54 +01:00
4c3b984b21 npm audit fix --force 2019-11-02 12:24:54 +01:00
01f3e54f58 remove logo 2019-11-02 12:24:54 +01:00
17 changed files with 7050 additions and 4621 deletions

View file

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

View file

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

37
CHANGELOG.md Normal file
View file

@ -0,0 +1,37 @@
# 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 Normal file
View file

@ -0,0 +1,21 @@
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,4 +1,8 @@
'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)
};

10319
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
{ {
"name": "slitscan_vue", "name": "slitscan_vue",
"version": "1.0.0", "version": "1.1.1",
"description": "A Vue.js project", "description": "A video experiment for converting static images to strobe sequences.",
"author": "Tomáš Mládek <tmladek@inventati.org>", "homepage": "https://gitlab.com/tmladek/slitscan",
"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",
@ -11,12 +12,9 @@
"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",
@ -41,6 +39,7 @@
"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,62 +1,92 @@
<template> <template>
<div id="app"> <div id="app">
<div id="menu-wrap"> <div id="menu-wrap">
<Sidebar ref="sidebar" @loadImage="loadImage" @guideSize="guideSize"/> <Sidebar ref="sidebar" @params="setParams" :slice="slice" :offset="offset" @loadImage="loadImage"/>
</div> </div>
<div id="canvas-wrap"> <div id="canvas-wrap">
<Canvas ref="canvas"/> <Canvas ref="canvas" @params="setParams" :slice="slice" :offset="offset"/>
</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: {
loadImage: function (url) {
this.$refs.canvas.imageUrl = url;
}, },
methods: { setParams: function (coords) {
loadImage: function (url) { switch (coords.type) {
this.$refs.canvas.imageUrl = url case "slice":
}, this.slice = [Math.max(2, coords.x), Math.max(2, coords.y)];
guideSize: function (size) { break;
this.$refs.canvas.slice = size 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;
margin: 0; margin: 0;
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 { input, button {
font-size: .95em; font-family: Consolas, Inconsolata, monospace, serif;
} }
#menu-wrap, #canvas-wrap { input {
display: inline-block; font-size: .95em;
} padding: 4px 0;
background: white;
border: 1px solid black;
}
#menu-wrap { input[type="file"] {
width: 20%; border: none;
height: 100%; }
float: left;
}
#canvas-wrap { button {
width: 80%; background: white;
height: 100%; border: 1px solid black;
} box-shadow: 2px 2px #272727;
}
#menu-wrap, #canvas-wrap {
display: inline-block;
}
#menu-wrap {
width: 20%;
height: 100%;
float: left;
}
#canvas-wrap {
width: 80%;
height: 100%;
}
</style> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1,98 +1,129 @@
<template> <template>
<div id="canvas-container"> <div id="canvas-container">
<canvas ref="canvas" id="canvas"></canvas> <canvas ref="canvas" id="canvas" @mousedown="onMouse" @mousemove="onMouse" oncontextmenu="return false;"></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,
slice: [0, 0] ratio: 1,
} };
},
props: ["slice", "offset"],
computed: {},
watch: {
imageUrl: function (url) {
this.loadImage(url);
}, },
computed: {}, slice: function () {
watch: { this.refresh();
imageUrl: function (url) {
this.loadImage(url)
},
slice: function () {
this.refresh()
}
}, },
mounted: function () { offset: function () {
window.addEventListener('resize', this.handleResize) this.refresh();
this.handleResize()
}, },
methods: { },
loadImage: function (imageUrl) { mounted: function () {
this.image = new Image() window.addEventListener("resize", this.handleResize);
this.image.onload = () => { this.handleResize();
this.imageLoaded = true },
this.$bus.$emit('imageLoaded', this.image) methods: {
this.refresh() loadImage: function (imageUrl) {
this.image = new Image();
this.image.onload = () => {
this.imageLoaded = true;
this.$bus.$emit("imageLoaded", this.image);
this.refresh();
};
this.image.src = imageUrl;
},
refresh: function () {
if (!this.imageLoaded) return;
let image = this.image;
let canvas = this.$refs.canvas;
let ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
let dims = getDimsFit(image.width, image.height, canvas.width, canvas.height);
ctx.drawImage(image,
0, 0, image.width, image.height,
0, 0, dims.width, dims.height);
this.ratio = dims.width / image.width;
let sliceX = parseInt(this.slice[0]) * this.ratio;
let sliceY = parseInt(this.slice[1]) * this.ratio;
if (sliceX > 0 && sliceY > 0) {
for (let x = this.offset[0] * this.ratio; x < dims.width; x += sliceX) {
ctx.beginPath();
ctx.moveTo(x, this.offset[1] * this.ratio);
ctx.lineTo(x, dims.height);
ctx.strokeStyle = "red";
ctx.stroke();
} }
this.image.src = imageUrl for (let y = this.offset[1] * this.ratio; y < dims.height; y += sliceY) {
}, ctx.beginPath();
refresh: function () { ctx.moveTo(this.offset[0] * this.ratio, y);
if (!this.imageLoaded) return ctx.lineTo(dims.width, y);
let image = this.image ctx.strokeStyle = "red";
let canvas = this.$refs.canvas ctx.stroke();
let ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
let dims = getDimsFit(image.width, image.height, canvas.width, canvas.height)
ctx.drawImage(image,
0, 0, image.width, image.height,
0, 0, dims.width, dims.height)
let ratio = dims.width / image.width
let sliceX = parseInt(this.slice[0]) * ratio
let sliceY = parseInt(this.slice[1]) * ratio
if (sliceX > 0 && sliceY > 0) {
for (let x = 0; x < dims.width; x += sliceX) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, dims.height)
ctx.strokeStyle = 'red'
ctx.stroke()
}
for (let y = 0; y < dims.height; y += sliceY) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(dims.width, y)
ctx.strokeStyle = 'red'
ctx.stroke()
}
} }
},
handleResize: function () {
let canvasContainer = document.getElementById('canvas-container')
let canvas = document.getElementById('canvas')
canvas.width = canvasContainer.offsetWidth - 10
canvas.height = canvasContainer.offsetHeight - 10
this.refresh()
} }
} },
} handleResize: function () {
let canvasContainer = document.getElementById("canvas-container");
let canvas = document.getElementById("canvas");
canvas.width = canvasContainer.offsetWidth - 10;
canvas.height = canvasContainer.offsetHeight - 10;
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,26 +1,30 @@
<template> <template>
<div id="player" :class="{fullscreen: fullscreen}"> <div id="player">
<div> <div>
<label for="zoom">Zoom:</label> <label for="zoom">Zoom:</label>
<input type="number" min="1" :max="50" value="1" class="slider" v-model.number="zoom" id="zoom"> <input type="number" min="0.1" value="1" step="0.1" class="slider" v-model.number="zoom" id="zoom">
</div> </div>
<div id="player-canvas-container"> <div class="player-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=record> v-model.number="position" :disabled=recording>
</div>
</div>
<div class="controls">
<div>
<button @click="playOnceOrPause">{{!this.playing ? "PLAY ONCE" : "STOP"}}</button>
</div> </div>
<div> <div>
<button @click="playPause">{{buttonLabel}}</button> <button @click="recordSequence">RECORD</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">
@ -32,202 +36,270 @@
</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 () {
return {
image: null,
playing: false,
zoom: 1,
recording: false,
looping: false,
sortBySize: false,
sortDirection: false,
discardPartial: false,
position: 0,
tmp_ctx: null,
animation_id: null,
recorder: null,
};
},
computed: {
frames: function () {
if (this.image === null) return 0;
const roundFn = this.discardPartial ? Math.floor : Math.ceil;
return roundFn((this.image.width - this.offset[0]) / this.width) * roundFn((this.image.height - this.offset[1]) / this.height);
}, },
data: function () { canvas_height: function () {
return { return this.height * this.zoom;
image: null, },
playing: false, canvas_width: function () {
zoom: 1, return this.width * this.zoom;
fullscreen: false, },
sortBySize: false, frame_sequence: function () {
sortDirection: false, if (this.image === null) return [];
position: 0,
tmp_ctx: null, let sequence = [];
animation_id: null, for (let pos = 0; pos < this.frames; pos++) {
record: false, let wb = (this.discardPartial ? Math.floor : Math.ceil)((this.image.width - this.offset[0]) / this.width); // width_blocks
capturer: new CCapture({ let x = this.offset[0] + (pos % wb) * this.width; // x offset
format: 'webm', let w = x + this.width < this.image.width ? this.width : this.image.width - x; // frame width
verbose: true let y = this.offset[1] + Math.floor(pos / wb) * this.height; // y offset
}) let h = y + this.height < this.image.height ? this.height : this.image.height - y; // frame height
sequence.push([x, y, w, h]);
}
if (this.sortBySize) {
let tmpCanvas = document.createElement("canvas");
let ctx = tmpCanvas.getContext("2d");
let sizes = sequence.map((el, i) => {
if (tmpCanvas.width !== el[2] ||
tmpCanvas.height !== el[3]) {
tmpCanvas.width = el[2];
tmpCanvas.height = el[3];
}
ctx.drawImage(this.image, el[0], el[1], el[2], el[3],
0, 0, el[2], el[3]);
return {index: i, length: tmpCanvas.toDataURL("image/png").length};
});
sizes.sort(function (a, b) {
return a.length - b.length;
});
if (!this.sortDirection) {
sizes.reverse();
}
return sizes.map(function (el) {
return sequence[el.index];
});
} else {
return sequence;
} }
}, },
computed: { },
frames: function () { mounted: function () {
if (this.image === null) return 0 this.$bus.$on("imageLoaded", (image) => {
return Math.ceil(this.image.width / this.width) * this.image = image;
Math.ceil(this.image.height / this.height) });
}, this.clear("red");
canvas_height: function () { },
return this.height * this.zoom watch: {
}, canvas_width: function () {
canvas_width: function () { setTimeout(() => {
return this.width * this.zoom this.clear("red");
}, }, 0);
frame_sequence: function () { },
if (this.image === null) return [] canvas_height: function () {
setTimeout(() => {
let sequence = [] this.clear("red");
for (let pos = 0; pos < this.frames; pos++) { }, 0);
let wb = Math.ceil(this.image.width / this.width) // width_blocks },
let x = (pos % wb) * this.width // x offset frames: function () {
let w = x + this.width < this.image.width ? this.width : this.image.width - x // frame width this.$emit("frames", this.frames);
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 position: function () {
sequence.push([x, y, w, h]) if (this.playing || this.image === null) return;
} this.tmp_ctx = this.$refs.canvas.getContext("2d");
if (this.sortBySize) { this.$render();
let tmpCanvas = document.createElement('canvas') },
let ctx = tmpCanvas.getContext('2d') looping: function () {
let sizes = sequence.map((el, i) => { this.recording = false;
if (tmpCanvas.width !== el[2] || },
tmpCanvas.height !== el[3]) { recording: function () {
tmpCanvas.width = el[2] if (this.recording) {
tmpCanvas.height = el[3] this.looping = false;
} this.position = 0;
ctx.drawImage(this.image, el[0], el[1], el[2], el[3], }
0, 0, el[2], el[3]) },
return {index: i, length: tmpCanvas.toDataURL('image/png').length} },
}) methods: {
play: function () {
sizes.sort(function (a, b) { if (this.frames === 0) {
return a.length - b.length return;
}) }
if (!this.playing) {
if (!this.sortDirection) { // eslint-disable-next-line no-console
sizes.reverse() console.log("STARTED");
} this.tmp_ctx = this.$refs.canvas.getContext("2d");
this.$render_advance();
return sizes.map(function (el) { this.playing = true;
return sequence[el.index] }
}) },
} else { stop: function () {
return sequence if (this.playing) {
} // eslint-disable-next-line no-console
}, console.log("STOPPED");
buttonLabel: function () { cancelAnimationFrame(this.animation_id);
if (!this.playing) { this.playing = false;
if (this.record) { if (this.recording && this.recorder) {
return 'RECORD' this.recorder.stop();
} else { this.recording = false;
return 'PLAY'
}
} else {
return 'STOP'
} }
} }
}, },
mounted: function () { playOnceOrPause: function () {
this.$bus.$on('imageLoaded', (image) => { if (!this.playing) {
this.image = image this.looping = false;
}) this.recording = false;
this.clear('red') this.position = 0;
this.play();
} else {
this.stop();
}
}, },
watch: { recordSequence: function () {
canvas_width: 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) {
let ctx = this.$refs.canvas.getContext("2d");
ctx.fillStyle = style;
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 () {
let frame = this.frame_sequence[this.position];
if (frame === undefined) return;
let [x, y, w, h] = frame;
if (w !== this.width || h !== this.height) this.clear("black");
this.tmp_ctx.drawImage(this.image,
x, y, w, h,
0, 0, w * this.zoom, h * this.zoom);
},
createRecorder: function () {
const stream = this.$refs.canvas.captureStream();
this.recorder = new MediaRecorder(stream, {mimeType: "video/webm"});
if (!this.recorder) {
throw new Error("Unknown error, couldn't initialize recorder.");
}
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(() => { setTimeout(() => {
this.clear('red') document.body.removeChild(a);
}, 0) window.URL.revokeObjectURL(url);
}, }, 100);
canvas_height: function () { };
setTimeout(() => {
this.clear('red')
}, 0)
},
position: function () {
if (this.playing || this.image === null) return
this.tmp_ctx = this.$refs.canvas.getContext('2d')
this.$render()
},
record: function () {
if (this.record) {
this.position = 0
if (!bowser.chrome) {
alert('Recording only supported in Chrome :( \n' +
'https://github.com/spite/ccapture.js/#limitations')
}
}
}
}, },
methods: { },
playPause: function () { };
if (this.frames === 0) {
return
}
this.playing = !this.playing
if (this.playing) {
this.tmp_ctx = this.$refs.canvas.getContext('2d')
if (this.record) this.capturer.start()
this.$render_advance()
} else {
cancelAnimationFrame(this.animation_id)
}
},
clear: function (style) {
let ctx = this.$refs.canvas.getContext('2d')
ctx.fillStyle = style
ctx.fillRect(0, 0, parseInt(this.canvas_width), parseInt(this.canvas_height))
},
$render: function () {
let frame = this.frame_sequence[this.position]
if (frame === undefined) return
let [x, y, w, h] = frame
if (w !== this.width || h !== this.height) this.clear('black')
this.tmp_ctx.drawImage(this.image,
x, y, w, h,
0, 0, w * this.zoom, h * this.zoom)
},
$render_advance: function () {
this.$render()
if (this.position < this.frames) {
this.position += 1
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()
}
}
}
}
}
</script> </script>
<style scoped> <style scoped>
#player { #player {
text-align: center; text-align: center;
} }
.fullscreen { #zoom {
position: absolute; width: 3em;
top: 0; text-align: center;
left: 0; }
background-color: rgba(1, 1, 1, .9);
width: 100%;
height: 100%;
}
.fullscreen-controls { .player-container {
position: absolute; padding: 2em 0 1em 0;
bottom: 0; }
}
#zoom { .controls, .options {
width: 3em; 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,185 +1,247 @@
<template> <template>
<div id="sidebar-container"> <div id="sidebar-container">
<div> <div class="sidebar-section">
<input type="file" id="file" @change="loadImage($event)"> <div>
<input type="file" id="file" @change="loadImage($event)">
</div>
<div class="section-formlike">
<label>Image size:</label>
<div class="readout">{{imageSize[0]}} x {{imageSize[1]}}</div>
</div>
</div> </div>
<div> <div class="sidebar-section">
<label>Image size: {{imageSize[0]}} x {{imageSize[1]}}</label> <div class="section-formlike">
<label for="slice_x">Slice X: </label>
<input class="spinBox" id="slice_x" v-model.number="guideSizeX" type="number"
min="2" :max="imageSize[0]/2" step="1" value="64">
</div>
<div>
<label for="slice_x">X remainder: {{this.imageSize[0] % this.guideSizeX}}</label>
</div>
<div class="section-formlike">
<label for="slice_y">Slice Y: </label>
<input class="spinBox" id="slice_y" v-model.number="guideSizeY" type="number"
min="2" :max="imageSize[1]/2" step="1" value="64">
</div>
<div>
<label for="slice_x">Y remainder: {{this.imageSize[1] % this.guideSizeY}}</label>
</div>
<div class="section-formlike">
<label for="lockSize">Maintain square ratio: </label>
<input type="checkbox" id="lockSize" v-model="lockSize">
</div>
<div class="section-formlike">
<label for="cleanSize">Allow only clean sizes: </label>
<input type="checkbox" id="cleanSize" v-model="cleanSize">
</div>
</div> </div>
<hr> <div class="sidebar-section">
<div> <div class="section-formlike">
<label for="slice_x">Slice X: </label> <label>Offset: x={{offset[0]}}, y={{offset[1]}}</label>
<input class="spinBox" id="slice_x" v-model.number="guideSizeX" type="number" min="2" max="1024" step="1" <button @click="$emit('params', {type: 'offset', x: 0, y: 0})">RESET</button>
value="64"> </div>
</div> </div>
<div> <div class="sidebar-section">
<label for="slice_x">No X remainder: {{noXremainder}}</label> <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>
<input class="spinBox" id="fps" v-model="fps" type="number" min="1" max="60" step="1" value="60" disabled>
</div>
<div class="section-formlike">
<label>Est. length:</label>
<div class="readout">{{length}}</div>
</div>
</div> </div>
<div> <div class="sidebar-section player-section">
<label for="slice_y">Slice Y: </label> <player class="player" :width="guideSizeX" :height="guideSizeY" :offset="offset" @frames="(n)=>{slices=n}"/>
<input class="spinBox" id="slice_y" v-model.number="guideSizeY" type="number" min="2" max="1024" step="1"
value="64">
</div> </div>
<div> <div class="footer"><a :href="env.HOMEPAGE_URL">Version: {{env.VERSION}}</a></div>
<label for="slice_x">No Y remainder: {{noYremainder}}</label>
</div>
<div>
<label for="lockSize">Maintain square ratio: </label>
<input type="checkbox" id="lockSize" v-model="lockSize">
</div>
<div>
<label for="cleanSize">Allow only clean sizes: </label>
<input type="checkbox" id="cleanSize" v-model="cleanSize">
</div>
<hr>
<div>
<label>Total amount of slices: {{slices}}</label>
</div>
<div>
<label for="fps">FPS: </label>
<input class="spinBox" id="fps" v-model="fps" type="number" min="1" max="60" step="1" value="60" disabled>
</div>
<div>
<label>Est. length: {{length}}</label>
</div>
<hr>
<player :width="guideSizeX" :height="guideSizeY"/>
</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,
}, },
data: function () { props: ["slice", "offset"],
return { data: function () {
imageSize: [-1, -1], return {
guideSizeX: 64, imageSize: [-1, -1],
guideSizeY: 64, guideSizeX: 64,
lastGuideSizeX: 64, guideSizeY: 64,
lastGuideSizeY: 64, lastGuideSizeX: 64,
lockSize: true, lastGuideSizeY: 64,
cleanSize: false, lockSize: true,
fps: 60 cleanSize: false,
} slices: 0,
}, fps: 60,
watch: { };
guideSizeX (size) { },
if (this.lockSize) this.guideSizeY = size watch: {
if (this.cleanSize) { guideSizeX (size) {
let tmpSize = parseInt(size) if (this.lockSize) this.guideSizeY = size;
while (this.imageSize[0] % tmpSize !== 0 && if (this.cleanSize) {
tmpSize < this.imageSize[0] && let tmpSize = parseInt(size);
tmpSize > 2) { while (this.imageSize[0] % tmpSize !== 0 &&
if (this.lastGuideSizeX < size) { tmpSize < this.imageSize[0] &&
tmpSize += 1 tmpSize > 2) {
} else { if (this.lastGuideSizeX < size) {
tmpSize -= 1 tmpSize += 1;
} } else {
} tmpSize -= 1;
if (tmpSize !== 2 && tmpSize !== this.imageSize[0]) {
this.guideSizeX = tmpSize
} }
} }
this.$emit('guideSize', [this.guideSizeX, this.guideSizeY]) if (tmpSize !== 2 && tmpSize !== this.imageSize[0]) {
this.lastGuideSizeX = this.guideSizeX this.guideSizeX = tmpSize;
this.lastGuideSizeY = this.guideSizeY
},
guideSizeY (size) {
if (this.lockSize) this.guideSizeX = size
if (this.cleanSize) {
let tmpSize = parseInt(size)
while (this.imageSize[1] % tmpSize !== 0 &&
tmpSize < this.imageSize[1] &&
tmpSize > 2) {
if (this.lastGuideSizeY < size) {
tmpSize += 1
} else {
tmpSize -= 1
}
}
if (tmpSize !== 2 && tmpSize !== this.imageSize[1]) {
this.guideSizeY = tmpSize
}
}
this.$emit('guideSize', [this.guideSizeX, this.guideSizeY])
this.lastGuideSizeX = this.guideSizeX
this.lastGuideSizeY = this.guideSizeY
},
lockSize: function (locked) {
if (locked) {
if (this.guideSizeX < this.guideSizeY) this.guideSizeY = this.guideSizeX
else this.guideSizeX = this.guideSizeY
this.cleanSize = false
}
},
cleanSize: function (clean) {
if (clean) {
this.lockSize = false
} }
} }
this.$emit("params", {
type: "slice",
x: this.guideSizeX,
y: this.guideSizeY,
});
this.lastGuideSizeX = this.guideSizeX;
this.lastGuideSizeY = this.guideSizeY;
}, },
computed: { guideSizeY (size) {
slices: function () { if (this.lockSize) this.guideSizeX = size;
if (this.imageSize[0] === -1 || this.imageSize[1] === -1) { if (this.cleanSize) {
return '-' let tmpSize = parseInt(size);
while (this.imageSize[1] % tmpSize !== 0 &&
tmpSize < this.imageSize[1] &&
tmpSize > 2) {
if (this.lastGuideSizeY < size) {
tmpSize += 1;
} else {
tmpSize -= 1;
}
}
if (tmpSize !== 2 && tmpSize !== this.imageSize[1]) {
this.guideSizeY = tmpSize;
}
}
this.$emit("params", {
type: "slice",
x: this.guideSizeX,
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) {
if (locked) {
if (this.guideSizeX < this.guideSizeY) {
this.guideSizeY = this.guideSizeX;
} else { } else {
return Math.ceil(this.imageSize[0] / this.guideSizeX) * this.guideSizeX = this.guideSizeY;
Math.ceil(this.imageSize[1] / this.guideSizeY)
} }
}, this.cleanSize = false;
length: function () {
if (this.imageSize[0] === -1 || this.imageSize[1] === -1) {
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: { cleanSize: function (clean) {
loadImage (e) { if (clean) {
let url = URL.createObjectURL(e.target.files[0]) this.lockSize = false;
this.$emit('loadImage', url)
} }
}, },
mounted: function () { },
this.$bus.$on('imageLoaded', (image) => { computed: {
this.imageSize = [image.width, image.height] length: function () {
}) if (this.imageSize[0] === -1 || this.imageSize[1] === -1) {
this.$emit('guideSize', [this.guideSizeX, this.guideSizeY]) 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)") : "");
}
},
env: function () {
// eslint-disable-next-line no-undef
return process.env;
},
},
methods: {
loadImage (e) {
let url = URL.createObjectURL(e.target.files[0]);
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 () {
this.$bus.$on("imageLoaded", (image) => {
this.imageSize = [image.width, image.height];
});
this.$emit("guideSize", [this.guideSizeX, this.guideSizeY]);
},
};
</script> </script>
<style scoped> <style scoped>
#sidebar-container { #sidebar-container {
width: 100%; display: flex;
height: 100%; flex-direction: column;
width: 100%;
height: 100%;
border-right: 1px solid black; border-right: 1px solid black;
} }
.spinBox { .sidebar-section {
height: 1em; padding: .5em;
} border-bottom: 1px solid grey;
}
.green { .sidebar-section > div {
background-color: green; padding: .25em;
} }
.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},
}) });