Object Preview service
This commit is contained in:
@@ -27,13 +27,13 @@ workflows:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
test-test:
|
||||
cristi-ci-test:
|
||||
jobs:
|
||||
- docker_build_and_deploy:
|
||||
context: main-builds
|
||||
filters:
|
||||
branches:
|
||||
only: cristi/ci-test-deployment
|
||||
only: cristi/ci-test
|
||||
|
||||
jobs:
|
||||
test_server:
|
||||
@@ -86,7 +86,10 @@ jobs:
|
||||
name: Build Server
|
||||
command: env SPECKLE_SERVER_PACKAGE=server ./.circleci/build.sh
|
||||
- run:
|
||||
name: Deploy Frontend and Server
|
||||
name: Build Object Preview Service
|
||||
command: env SPECKLE_SERVER_PACKAGE=preview-service ./.circleci/build.sh
|
||||
- run:
|
||||
name: Deploy
|
||||
command: ./.circleci/deploy.sh
|
||||
- run:
|
||||
name: Test deployment
|
||||
|
||||
@@ -28,6 +28,13 @@ echo "$K8S_CLUSTER_CERTIFICATE" | base64 --decode > k8s_cert.crt
|
||||
--token=$K8S_TOKEN \
|
||||
set image deployment/$TARGET_SPECKLE_DEPLOYMENT-server main=$DOCKER_IMAGE_TAG-server:$IMAGE_VERSION_TAG
|
||||
|
||||
./kubectl \
|
||||
--kubeconfig=/dev/null \
|
||||
--server=$K8S_SERVER \
|
||||
--certificate-authority=k8s_cert.crt \
|
||||
--token=$K8S_TOKEN \
|
||||
set image deployment/$TARGET_SPECKLE_DEPLOYMENT-preview-service main=$DOCKER_IMAGE_TAG-preview-service:$IMAGE_VERSION_TAG
|
||||
|
||||
|
||||
# Wait for rollout to complete
|
||||
./kubectl \
|
||||
@@ -43,3 +50,10 @@ echo "$K8S_CLUSTER_CERTIFICATE" | base64 --decode > k8s_cert.crt
|
||||
--certificate-authority=k8s_cert.crt \
|
||||
--token=$K8S_TOKEN \
|
||||
rollout status -w deployment/$TARGET_SPECKLE_DEPLOYMENT-server --timeout=1m
|
||||
|
||||
./kubectl \
|
||||
--kubeconfig=/dev/null \
|
||||
--server=$K8S_SERVER \
|
||||
--certificate-authority=k8s_cert.crt \
|
||||
--token=$K8S_TOKEN \
|
||||
rollout status -w deployment/$TARGET_SPECKLE_DEPLOYMENT-preview-service --timeout=1m
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es2020": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11
|
||||
},
|
||||
"ignorePatterns": ["node_modules/*"],
|
||||
"rules": {
|
||||
"arrow-spacing": [
|
||||
2,
|
||||
{
|
||||
"before": true,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"array-bracket-spacing": [2, "always"],
|
||||
"object-curly-spacing": [1, "always"],
|
||||
"block-spacing": [2, "always"],
|
||||
"camelcase": [
|
||||
1,
|
||||
{
|
||||
"properties": "always"
|
||||
}
|
||||
],
|
||||
"space-in-parens": [2, "always"],
|
||||
"keyword-spacing": 2,
|
||||
"semi": [1, "never"],
|
||||
"quotes": [1, "single"],
|
||||
"indent": ["error", 2],
|
||||
"space-unary-ops": [
|
||||
2,
|
||||
{
|
||||
"words": true,
|
||||
"nonwords": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# NOTE: Docker context should be set to git root directory, to include the viewer
|
||||
|
||||
FROM node:14.16.0-buster-slim as node
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# chromium dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
ARG NODE_ENV=production
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
WORKDIR /opt/objectloader
|
||||
COPY packages/objectloader/package*.json ./
|
||||
RUN npm install --production=false
|
||||
COPY packages/objectloader .
|
||||
# RUN npm run build
|
||||
|
||||
WORKDIR /opt/viewer
|
||||
COPY packages/viewer/package*.json ./
|
||||
RUN npm install ../objectloader
|
||||
# Install dependencies and devDependencies
|
||||
RUN npm install --production=false
|
||||
COPY packages/viewer .
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /opt/preview-service
|
||||
COPY packages/preview-service/package*.json ./
|
||||
RUN npm install ../objectloader
|
||||
RUN npm install ../viewer
|
||||
RUN npm ci --production=false
|
||||
COPY packages/preview-service .
|
||||
RUN npm run build-fe
|
||||
|
||||
|
||||
ENTRYPOINT [ "tini", "--" ]
|
||||
CMD ["node", "bin/www"]
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict'
|
||||
|
||||
var createError = require( 'http-errors' )
|
||||
var express = require( 'express' )
|
||||
var path = require( 'path' )
|
||||
var cookieParser = require( 'cookie-parser' )
|
||||
var logger = require( 'morgan' )
|
||||
|
||||
var indexRouter = require( './routes/index' )
|
||||
var previewRouter = require( './routes/preview' )
|
||||
var objectsRouter = require( './routes/objects' )
|
||||
|
||||
var app = express()
|
||||
|
||||
app.use( logger( 'dev' ) )
|
||||
app.use( express.json() )
|
||||
app.use( express.urlencoded( { extended: false } ) )
|
||||
app.use( cookieParser() )
|
||||
app.use( express.static( path.join( __dirname, 'public' ) ) )
|
||||
|
||||
app.use( '/', indexRouter )
|
||||
app.use( '/preview', previewRouter )
|
||||
app.use( '/objects', objectsRouter )
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use( function( req, res, next ) {
|
||||
next( createError( 404 ) )
|
||||
} )
|
||||
|
||||
// error handler
|
||||
app.use( function( err, req, res, next ) {
|
||||
let errorText = err.message
|
||||
if ( req.app.get( 'env' ) === 'development' ) {
|
||||
errorText = `<html><body><pre>${err.message}: ${err.status}\n${err.stack}</pre></body></html>`
|
||||
}
|
||||
res.status( err.status || 500 )
|
||||
res.send( errorText )
|
||||
} )
|
||||
|
||||
module.exports = app
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict'
|
||||
|
||||
const crypto = require( 'crypto' )
|
||||
const knex = require( '../knex' )
|
||||
const fetch = require( 'node-fetch' )
|
||||
const ObjectPreview = ( ) => knex( 'object_preview' )
|
||||
const Previews = ( ) => knex( 'previews' )
|
||||
|
||||
async function startTask() {
|
||||
let { rows } = await knex.raw( `
|
||||
UPDATE object_preview
|
||||
SET
|
||||
"previewStatus" = 1,
|
||||
"lastUpdate" = NOW()
|
||||
FROM (
|
||||
SELECT "streamId", "objectId" FROM object_preview
|
||||
WHERE "previewStatus" = 0 OR ("previewStatus" = 1 AND "lastUpdate" < NOW() - INTERVAL '1 HOUR')
|
||||
ORDER BY "priority" ASC, "lastUpdate" ASC
|
||||
LIMIT 1
|
||||
) as task
|
||||
WHERE object_preview."streamId" = task."streamId" AND object_preview."objectId" = task."objectId"
|
||||
RETURNING object_preview."streamId", object_preview."objectId"
|
||||
` )
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function doTask( task ) {
|
||||
|
||||
let previewUrl = `http://127.0.0.1:3001/preview/${task.streamId}/${task.objectId}`
|
||||
let res = await fetch( previewUrl )
|
||||
res = await res.json()
|
||||
// let imgBuffer = await res.buffer() // this gets the binary response body
|
||||
|
||||
let metadata = {}
|
||||
|
||||
for ( let angle in res ) {
|
||||
const imgBuffer = new Buffer.from( res[angle].replace( /^data:image\/\w+;base64,/, '' ), 'base64' )
|
||||
let previewId = crypto.createHash( 'md5' ).update( imgBuffer ).digest( 'hex' )
|
||||
|
||||
// Save preview image
|
||||
let insertionObject = { id: previewId, data: imgBuffer }
|
||||
//await Previews().insert( insertionObject )
|
||||
//let dbQuery = Previews().insert( insertionObject ).toString( ) + ' on conflict do nothing'
|
||||
await knex.raw( 'INSERT INTO "previews" (id, data) VALUES (?, ?) ON CONFLICT DO NOTHING', [ previewId, imgBuffer ] )
|
||||
|
||||
metadata[angle] = previewId
|
||||
}
|
||||
|
||||
// Update preview metadata
|
||||
await knex.raw( `
|
||||
UPDATE object_preview
|
||||
SET
|
||||
"previewStatus" = 2,
|
||||
"lastUpdate" = NOW(),
|
||||
"preview" = ?
|
||||
WHERE "streamId" = ? AND "objectId" = ?
|
||||
`, [ metadata, task.streamId, task.objectId ] )
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
let task = await startTask()
|
||||
if ( !task ) {
|
||||
setTimeout( tick, 1000 )
|
||||
return
|
||||
}
|
||||
|
||||
doTask( task )
|
||||
|
||||
// Check for another task very soon
|
||||
setTimeout( tick, 10 )
|
||||
} catch ( err ) {
|
||||
console.log( 'Error executing task: ', err )
|
||||
setTimeout( tick, 5000 )
|
||||
}
|
||||
}
|
||||
|
||||
async function startPreviewService() {
|
||||
console.log( '📸 Started Preview Service' )
|
||||
|
||||
tick()
|
||||
}
|
||||
|
||||
module.exports = { startPreviewService }
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
var app = require( '../app' )
|
||||
var debug = require( 'debug' )( 'preview-service:server' )
|
||||
var http = require( 'http' )
|
||||
var { startPreviewService } = require( '../bg_service' )
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
|
||||
var port = normalizePort( process.env.PORT || '3001' )
|
||||
app.set( 'port', port )
|
||||
|
||||
/**
|
||||
* Create HTTP server.
|
||||
*/
|
||||
|
||||
var server = http.createServer( app )
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
|
||||
server.listen( port, '127.0.0.1' )
|
||||
server.on( 'error', onError )
|
||||
server.on( 'listening', onListening )
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
*/
|
||||
|
||||
function normalizePort( val ) {
|
||||
var port = parseInt( val, 10 )
|
||||
|
||||
if ( isNaN( port ) ) {
|
||||
// named pipe
|
||||
return val
|
||||
}
|
||||
|
||||
if ( port >= 0 ) {
|
||||
// port number
|
||||
return port
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
|
||||
function onError( error ) {
|
||||
if ( error.syscall !== 'listen' ) {
|
||||
throw error
|
||||
}
|
||||
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch ( error.code ) {
|
||||
case 'EACCES':
|
||||
console.error( bind + ' requires elevated privileges' )
|
||||
process.exit( 1 )
|
||||
break
|
||||
case 'EADDRINUSE':
|
||||
console.error( bind + ' is already in use' )
|
||||
process.exit( 1 )
|
||||
break
|
||||
default:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
var addr = server.address()
|
||||
var bind = typeof addr === 'string'
|
||||
? 'pipe ' + addr
|
||||
: 'port ' + addr.port
|
||||
debug( 'Listening on ' + bind )
|
||||
|
||||
|
||||
startPreviewService()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = require( 'knex' )( {
|
||||
client: 'pg',
|
||||
connection: process.env.PG_CONNECTION_STRING || 'postgres://speckle:speckle@localhost/speckle',
|
||||
pool: { min: 1, max: 1 }
|
||||
// migrations are in managed in the server package
|
||||
} )
|
||||
+17388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@speckle/preview-service",
|
||||
"version": "2.0.0",
|
||||
"description": "Generate PNG previews of Speckle objects by using a headless viewer",
|
||||
"main": "index.js",
|
||||
"homepage": "https://speckle.systems",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/specklesystems/speckle-server.git",
|
||||
"directory": "packages/preview-service"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "DEBUG='preview-service:*' nodemon ./bin/www",
|
||||
"build-fe": "webpack --env dev --config webpack.config.render_page.js && webpack --env build --config webpack.config.render_page.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@speckle/objectloader": "file:../objectloader",
|
||||
"@speckle/viewer": "file:../viewer",
|
||||
"cookie-parser": "~1.4.4",
|
||||
"crypto": "^1.0.1",
|
||||
"debug": "~2.6.9",
|
||||
"express": "~4.16.1",
|
||||
"file-type": "^16.3.0",
|
||||
"http-errors": "~1.6.3",
|
||||
"jade": "~1.11.0",
|
||||
"knex": "^0.95.4",
|
||||
"morgan": "~1.9.1",
|
||||
"node-fetch": "^2.6.1",
|
||||
"pg": "^8.6.0",
|
||||
"pg-query-stream": "^4.1.0",
|
||||
"puppeteer": "^9.0.0",
|
||||
"zlib": "^1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.16",
|
||||
"babel-loader": "^8.2.2",
|
||||
"clean-webpack-plugin": "^4.0.0-alpha.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"nodemon": "^2.0.7",
|
||||
"webpack": "^5.35.0",
|
||||
"webpack-cli": "^4.6.0",
|
||||
"webpack-dev-server": "^3.11.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Speckle Object Preview Service
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"/><title>Speckle Viewer</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
margin: 0px;
|
||||
}
|
||||
button {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
border-color: #0A66FF;
|
||||
}
|
||||
#renderer{
|
||||
height: 400px;
|
||||
width: 700px;
|
||||
}</style><script defer="defer" src="viewer.min.js"></script></head><body><div id="renderer"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
/*! For license information please see Speckle.js.LICENSE.txt */
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
|
||||
import { Viewer, Converter } from '@speckle/viewer'
|
||||
import ObjectLoader from '@speckle/objectloader'
|
||||
|
||||
let v = new Viewer( { container: document.getElementById( 'renderer' ), showStats: false } )
|
||||
// v.on( 'load-progress', args => console.log( args ) )
|
||||
|
||||
window.v = v
|
||||
|
||||
window.LoadData = async function LoadData( url ) {
|
||||
await v.loadObject( url, token )
|
||||
}
|
||||
|
||||
window.onload = (event) => {
|
||||
let testUrl = window.location.hash.substr(1);
|
||||
if (testUrl) {
|
||||
LoadData(testUrl);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Speckle Viewer</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" integrity="sha512-EZLkOqwILORob+p0BXZc+Vm3RgJBOe1Iq/0fiI7r/wJgzOFZMlsqTa29UEl6v6U6gsV4uIpsNZoV32YZqrCRCQ==" crossorigin="anonymous" />
|
||||
-->
|
||||
<style type="text/css">
|
||||
body{
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
margin: 0px;
|
||||
}
|
||||
button {
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
border-color: #0A66FF;
|
||||
}
|
||||
#renderer{
|
||||
height: 400px;
|
||||
width: 700px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="renderer"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict'
|
||||
|
||||
var express = require( 'express' )
|
||||
var router = express.Router()
|
||||
|
||||
router.get( '/', function( req, res, next ) {
|
||||
res.send( 'Speckle Object Preview Service' )
|
||||
} )
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,103 @@
|
||||
'use strict'
|
||||
|
||||
const zlib = require( 'zlib' )
|
||||
var express = require( 'express' )
|
||||
var { getObject, getObjectChildrenStream } = require( './services/objects_utils' )
|
||||
|
||||
var router = express.Router()
|
||||
|
||||
|
||||
// This method was copy-pasted from the server method, without authentication/authorization (this web service is an internal one)
|
||||
router.get( '/:streamId/:objectId', async function( req, res, next ) {
|
||||
|
||||
// Populate first object (the "commit")
|
||||
let obj = await getObject( { streamId: req.params.streamId, objectId: req.params.objectId } )
|
||||
|
||||
if ( !obj ) {
|
||||
return res.status( 404 ).send( `Failed to find object ${req.params.objectId}.` )
|
||||
}
|
||||
|
||||
obj = obj.data
|
||||
|
||||
let simpleText = req.headers.accept === 'text/plain'
|
||||
|
||||
let dbStream = await getObjectChildrenStream( { streamId: req.params.streamId, objectId: req.params.objectId } )
|
||||
|
||||
let currentChunkSize = 0
|
||||
let maxChunkSize = 50000
|
||||
let chunk = simpleText ? '' : [ ]
|
||||
let isFirst = true
|
||||
|
||||
res.writeHead( 200, { 'Content-Encoding': 'gzip', 'Content-Type': simpleText ? 'text/plain' : 'application/json' } )
|
||||
|
||||
const gzip = zlib.createGzip( )
|
||||
|
||||
if ( !simpleText ) gzip.write( '[' )
|
||||
|
||||
// helper func to flush the gzip buffer
|
||||
const writeBuffer = ( addTrailingComma ) => {
|
||||
// console.log( `writing buff ${currentChunkSize}` )
|
||||
if ( simpleText ) {
|
||||
gzip.write( chunk )
|
||||
} else {
|
||||
gzip.write( chunk.join( ',' ) )
|
||||
if ( addTrailingComma ) {
|
||||
gzip.write( ',' )
|
||||
}
|
||||
}
|
||||
gzip.flush( )
|
||||
chunk = simpleText ? '' : [ ]
|
||||
}
|
||||
|
||||
var objString = JSON.stringify( obj )
|
||||
if ( simpleText ) {
|
||||
chunk += `${obj.id}\t${objString}\n`
|
||||
} else {
|
||||
chunk.push( objString )
|
||||
}
|
||||
writeBuffer( true )
|
||||
|
||||
let k = 0
|
||||
let requestDropped = false
|
||||
dbStream.on( 'data', row => {
|
||||
try {
|
||||
let data = JSON.stringify( row.data )
|
||||
currentChunkSize += Buffer.byteLength( data, 'utf8' )
|
||||
if ( simpleText ) {
|
||||
chunk += `${row.data.id}\t${data}\n`
|
||||
} else {
|
||||
chunk.push( data )
|
||||
}
|
||||
if ( currentChunkSize >= maxChunkSize ) {
|
||||
currentChunkSize = 0
|
||||
writeBuffer( true )
|
||||
}
|
||||
k++
|
||||
} catch ( e ) {
|
||||
requestDropped = true
|
||||
debug( 'speckle:error' )( `'Failed to find object, or object is corrupted.' ${req.params.objectId}` )
|
||||
return
|
||||
}
|
||||
} )
|
||||
|
||||
dbStream.on( 'error', err => {
|
||||
console.log("DB ERROR ",err)
|
||||
debug( 'speckle:error' )( `Error in streaming object children for ${req.params.objectId}: ${err}` )
|
||||
requestDropped = true
|
||||
return
|
||||
} )
|
||||
|
||||
dbStream.on( 'end', ( ) => {
|
||||
if ( currentChunkSize !== 0 ) {
|
||||
writeBuffer( false )
|
||||
if ( !simpleText ) gzip.write( ']' )
|
||||
}
|
||||
gzip.end( )
|
||||
} )
|
||||
|
||||
// 🚬
|
||||
gzip.pipe( res )
|
||||
|
||||
} )
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict'
|
||||
|
||||
var express = require( 'express' )
|
||||
var router = express.Router()
|
||||
const puppeteer = require( 'puppeteer' )
|
||||
|
||||
function sleep( ms ) {
|
||||
return new Promise( ( resolve ) => {
|
||||
setTimeout( resolve, ms )
|
||||
} )
|
||||
}
|
||||
|
||||
async function getScreenshot( objectUrl ) {
|
||||
const browser = await puppeteer.launch( { args: [ '--no-sandbox', '--disable-setuid-sandbox' ] } )
|
||||
const page = await browser.newPage()
|
||||
await page.goto( 'http://127.0.0.1:3001/render/' )
|
||||
|
||||
console.log("Page loaded")
|
||||
|
||||
console.time( 'lo' )
|
||||
const scr = await page.evaluate( async ( objectUrl ) => {
|
||||
waitForAnimation = async ( ms=70 ) => await new Promise( ( resolve ) => {
|
||||
setTimeout( resolve, ms )
|
||||
} )
|
||||
let scr = {}
|
||||
let stepAngle = 0.261799
|
||||
v.postprocessing = false
|
||||
v.sceneManager.skipPostLoad = true
|
||||
await v.loadObject( objectUrl, '' )
|
||||
v.interactions.zoomExtents( 0.95, false )
|
||||
await waitForAnimation()
|
||||
scr['0'] = v.interactions.screenshot()
|
||||
|
||||
for ( let i = 1; i < 3; i++ ) {
|
||||
v.interactions.rotateCamera( stepAngle, undefined, false )
|
||||
await waitForAnimation()
|
||||
scr[( -1 * i ) + ''] = v.interactions.screenshot()
|
||||
}
|
||||
v.interactions.rotateCamera( -2 * stepAngle, undefined, false )
|
||||
await waitForAnimation()
|
||||
for ( let i = 1; i < 3; i++ ) {
|
||||
v.interactions.rotateCamera( -1 * stepAngle, undefined, false )
|
||||
await waitForAnimation()
|
||||
scr[i + ''] = v.interactions.screenshot()
|
||||
}
|
||||
/*
|
||||
v.interactions.rotateCamera( 2 * stepAngle, transition=false )
|
||||
await waitForAnimation( 500 )
|
||||
|
||||
let dirArray = [ 'top', 'bottom', 'front', 'back', 'left', 'right' ]
|
||||
for ( let i in dirArray ) {
|
||||
let d = dirArray[i]
|
||||
v.interactions.rotateTo( d )
|
||||
await waitForAnimation()
|
||||
scr[d] = v.interactions.screenshot()
|
||||
}
|
||||
*/
|
||||
return scr
|
||||
}, objectUrl )
|
||||
|
||||
|
||||
return scr
|
||||
|
||||
return `
|
||||
<html><body>
|
||||
<img height="200px" src="${scr['-2']}" /><br />
|
||||
<img height="200px" src="${scr['-1']}" /><br />
|
||||
<img height="200px" src="${scr['0']}" /><br />
|
||||
<img height="200px" src="${scr['1']}" /><br />
|
||||
<img height="200px" src="${scr['2']}" /><br />
|
||||
</body></html>
|
||||
`
|
||||
|
||||
const imageBuffer = new Buffer.from( b64Image.replace( /^data:image\/\w+;base64,/, '' ), 'base64' )
|
||||
|
||||
console.timeEnd( 'lo' )
|
||||
|
||||
// await page.waitForTimeout(500);
|
||||
//var response = await page.screenshot({
|
||||
// type: 'png',
|
||||
// clip: {x: 0, y: 0, width: 800, height: 800}
|
||||
//});
|
||||
|
||||
// Don't await for cleanup
|
||||
browser.close()
|
||||
|
||||
return imageBuffer
|
||||
};
|
||||
|
||||
|
||||
router.get( '/:streamId/:objectId', async function( req, res, next ) {
|
||||
let objectUrl = `http://127.0.0.1:3001/streams/${req.params.streamId}/objects/${req.params.objectId}`
|
||||
/*
|
||||
let authToken = ''
|
||||
let authorizationHeader = req.header( 'Authorization' )
|
||||
if ( authorizationHeader && authorizationHeader.toLowerCase().startsWith( 'bearer ' ) ) {
|
||||
authToken = authorizationHeader.Substring( 'Bearer '.Length ).Trim()
|
||||
}
|
||||
// useful for testing (not the recommended way of passing the auth token)
|
||||
if ( req.query.authToken ) {
|
||||
authToken = req.query.authToken
|
||||
}
|
||||
*/
|
||||
|
||||
console.log( objectUrl )
|
||||
console.time( 'test' )
|
||||
let scr = await getScreenshot( objectUrl )
|
||||
console.timeEnd( 'test' )
|
||||
// res.setHeader( 'content-type', 'image/png' )
|
||||
res.send( scr )
|
||||
} )
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
|
||||
let debug = require( 'debug' )( 'speckle:services' )
|
||||
const knex = require( '../../knex' )
|
||||
|
||||
const Objects = ( ) => knex( 'objects' )
|
||||
const Closures = ( ) => knex( 'object_children_closure' )
|
||||
|
||||
module.exports = {
|
||||
|
||||
async getObject( { streamId, objectId } ) {
|
||||
let res = await Objects( ).where( { streamId: streamId, id: objectId } ).select( '*' ).first( )
|
||||
if ( !res ) return null
|
||||
res.data.totalChildrenCount = res.totalChildrenCount
|
||||
delete res.streamId
|
||||
return res
|
||||
},
|
||||
|
||||
async getObjectChildrenStream( { streamId, objectId } ) {
|
||||
let q = Closures( )
|
||||
q.select( 'id' )
|
||||
q.select( 'data' )
|
||||
q.rightJoin( 'objects', function() {
|
||||
this.on( 'objects.streamId', '=', 'object_children_closure.streamId' )
|
||||
.andOn( 'objects.id', '=', 'object_children_closure.child' )
|
||||
} )
|
||||
.where( knex.raw( 'object_children_closure."streamId" = ? AND parent = ?', [ streamId, objectId ] ) )
|
||||
.orderBy( 'objects.id' )
|
||||
return q.stream( )
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* global __dirname, require, module*/
|
||||
const HtmlWebpackPlugin = require( 'html-webpack-plugin' )
|
||||
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' )
|
||||
const path = require( 'path' )
|
||||
const yargs = require( 'yargs' )
|
||||
const env = yargs.argv.env
|
||||
|
||||
let filename = 'viewer'
|
||||
|
||||
let outputFile, mode
|
||||
|
||||
if ( env === 'build' ) {
|
||||
mode = 'production'
|
||||
outputFile = filename + '.min.js'
|
||||
} else {
|
||||
mode = 'development'
|
||||
outputFile = filename + '.js'
|
||||
}
|
||||
|
||||
const config = {
|
||||
mode: mode,
|
||||
entry: path.resolve( __dirname + '/render_page/src/app.js' ),
|
||||
target: 'web',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
path: path.resolve( __dirname + '/public/render' ) ,
|
||||
filename: outputFile,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /(\.jsx|\.js|\.ts|\.tsx)$/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
},
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin( { cleanStaleWebpackAssets: false } ),
|
||||
new HtmlWebpackPlugin( { title: 'Speckle Viewer Example', template: 'render_page/src/example.html', filename: 'index.html' } )
|
||||
],
|
||||
resolve: {
|
||||
modules: [ path.resolve( './node_modules' ), path.resolve( '.render_page/src' ) ],
|
||||
extensions: [ '.json', '.js' ],
|
||||
},
|
||||
devServer: {
|
||||
contentBase: path.join( __dirname, 'example' ),
|
||||
compress: false,
|
||||
port: 9000,
|
||||
serveIndex: true,
|
||||
writeToDisk: true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
@@ -56,6 +56,7 @@ module.exports = {
|
||||
connection: connectionUri,
|
||||
migrations: {
|
||||
directory: migrationDirs
|
||||
}
|
||||
},
|
||||
pool: { min: 2, max: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const { scalarResolvers, scalarSchemas } = require( './core/graph/scalars' )
|
||||
exports.init = async ( app ) => {
|
||||
let dirs = fs.readdirSync( `${appRoot}/modules` )
|
||||
|
||||
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites' ] // TODO: add './invites'
|
||||
let moduleDirs = [ './core', './auth', './apiexplorer', './emails', './pwdreset', './serverinvites', './previews' ] // TODO: add './invites'
|
||||
|
||||
// Stage 1: initialise all modules
|
||||
for ( let dir of moduleDirs ){
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,102 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
|
||||
const debug = require( 'debug' )
|
||||
const express = require( 'express' )
|
||||
const appRoot = require( 'app-root-path' )
|
||||
|
||||
const cors = require( 'cors' )
|
||||
const { matomoMiddleware } = require( `${appRoot}/logging/matomoHelper` )
|
||||
const { contextMiddleware, validateScopes, authorizeResolver } = require( `${appRoot}/modules/shared` )
|
||||
|
||||
const { getStream } = require( '../core/services/streams' )
|
||||
const { getObject } = require( '../core/services/objects' )
|
||||
const { getPreviewImage, createObjectPreview, getObjectPreviewInfo } = require( './services/previews' )
|
||||
|
||||
exports.init = ( app, options ) => {
|
||||
if ( process.env.DISABLE_PREVIEWS ) {
|
||||
debug( 'speckle:modules' )( '📸 Object preview module is DISABLED' )
|
||||
} else {
|
||||
debug( 'speckle:modules' )( '📸 Init object preview module' )
|
||||
}
|
||||
|
||||
let sendObjectPreview = async ( req, res, streamId, objectId ) => {
|
||||
if ( process.env.DISABLE_PREVIEWS ) {
|
||||
return res.sendFile( `${appRoot}/modules/previews/assets/no_preview.png` )
|
||||
}
|
||||
|
||||
// Check if objectId is valid
|
||||
const dbObj = await getObject( { streamId, objectId } )
|
||||
if ( !dbObj ) {
|
||||
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
|
||||
}
|
||||
|
||||
// Get existing preview metadata
|
||||
let previewInfo = await getObjectPreviewInfo( { streamId, objectId } )
|
||||
if ( !previewInfo ) {
|
||||
await createObjectPreview( { streamId, objectId, priority: 0 } )
|
||||
}
|
||||
|
||||
let timestampStart = Date.now()
|
||||
|
||||
// Try for 10 sec of wall-clock to get the image (wait for preview generation)
|
||||
while ( Date.now() < timestampStart + 10*1000 ) {
|
||||
previewInfo = await getObjectPreviewInfo( { streamId, objectId } )
|
||||
if ( previewInfo.previewStatus == 2 && previewInfo.preview ) {
|
||||
break
|
||||
}
|
||||
await new Promise( ( resolve ) => {
|
||||
setTimeout( resolve, 500 )
|
||||
} )
|
||||
}
|
||||
|
||||
if ( previewInfo.previewStatus != 2 || !previewInfo.preview ) {
|
||||
return res.sendFile( `${appRoot}/modules/previews/assets/no_preview.png` )
|
||||
}
|
||||
|
||||
let previewImgId = previewInfo.preview[req.params.angle]
|
||||
if ( !previewImgId ) {
|
||||
debug( 'speckle:errors' )( `Error: Preview angle '${req.params.angle}' not found for object ${streamId}:${objectId}` )
|
||||
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
|
||||
}
|
||||
let previewImg = await getPreviewImage( { previewId: previewImgId } )
|
||||
if ( !previewImg ) {
|
||||
debug( 'speckle:errors' )( `Error: Preview image not found: ${previewImgId}` )
|
||||
return res.sendFile( `${appRoot}/modules/previews/assets/preview_error.png` )
|
||||
}
|
||||
|
||||
res.contentType( 'image/png' )
|
||||
res.send( previewImg )
|
||||
}
|
||||
|
||||
app.get( '/preview/:streamId/objects/:objectId/:angle', contextMiddleware, matomoMiddleware, async ( req, res ) => {
|
||||
|
||||
const stream = await getStream( { streamId: req.params.streamId, userId: req.context.userId } )
|
||||
|
||||
if ( !stream ) {
|
||||
return res.status( 404 ).end()
|
||||
}
|
||||
|
||||
if ( !stream.isPublic && req.context.auth === false ) {
|
||||
return res.status( 401 ).end( )
|
||||
}
|
||||
|
||||
if ( !stream.isPublic ) {
|
||||
try {
|
||||
await validateScopes( req.context.scopes, 'streams:read' )
|
||||
} catch ( err ) {
|
||||
return res.status( 401 ).end( )
|
||||
}
|
||||
|
||||
try {
|
||||
await authorizeResolver( req.context.userId, req.params.streamId, 'stream:reviewer' )
|
||||
} catch ( err ) {
|
||||
return res.status( 401 ).end( )
|
||||
}
|
||||
}
|
||||
|
||||
return sendObjectPreview( req, res, req.params.streamId, req.params.objectId )
|
||||
} )
|
||||
}
|
||||
|
||||
exports.finalize = () => {}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
|
||||
exports.up = async knex => {
|
||||
await knex.schema.createTable( 'object_preview', table => {
|
||||
table.string( 'streamId', 10 ).references( 'id' ).inTable( 'streams' ).onDelete( 'cascade' )
|
||||
table.string( 'objectId' ).notNullable( )
|
||||
table.integer( 'previewStatus' ).notNullable( ).defaultTo( 0 )
|
||||
table.integer( 'priority' ).notNullable( ).defaultTo( 1 )
|
||||
table.timestamp( 'lastUpdate' ).notNullable( ).defaultTo( knex.fn.now( ) )
|
||||
table.jsonb( 'preview' )
|
||||
table.primary( [ 'streamId', 'objectId' ] )
|
||||
table.index( [ 'previewStatus', 'priority', 'lastUpdate' ] )
|
||||
} )
|
||||
|
||||
await knex.schema.createTable( 'previews', table => {
|
||||
table.string( 'id' ).primary( )
|
||||
table.binary( 'data' )
|
||||
} )
|
||||
}
|
||||
|
||||
exports.down = async knex => {
|
||||
await knex.schema.dropTableIfExists( 'object_preview' )
|
||||
await knex.schema.dropTableIfExists( 'previews' )
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* istanbul ignore file */
|
||||
'use strict'
|
||||
|
||||
const appRoot = require( 'app-root-path' )
|
||||
const { async } = require('crypto-random-string')
|
||||
const knex = require( `${appRoot}/db/knex` )
|
||||
|
||||
const ObjectPreview = ( ) => knex( 'object_preview' )
|
||||
const Previews = ( ) => knex( 'previews' )
|
||||
|
||||
module.exports = {
|
||||
|
||||
async getObjectPreviewInfo( { streamId, objectId } ) {
|
||||
return await ObjectPreview( ).select( '*' ).where( { streamId, objectId } ).first( )
|
||||
},
|
||||
|
||||
async createObjectPreview ( { streamId, objectId, priority } ) {
|
||||
let insertionObject = {
|
||||
streamId,
|
||||
objectId,
|
||||
priority,
|
||||
previewStatus: 0
|
||||
}
|
||||
let sqlQuery = ObjectPreview( ).insert( insertionObject ).toString( ) + ' on conflict do nothing'
|
||||
await knex.raw( sqlQuery )
|
||||
},
|
||||
|
||||
async getPreviewImage( { previewId } ) {
|
||||
let previewRow = await Previews( ).where( { id: previewId } ).first( ).select( '*' )
|
||||
if ( !previewRow ) {
|
||||
return null
|
||||
}
|
||||
return previewRow.data
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user