diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5474a06..526595c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -46,7 +46,7 @@ jobs: target: google_apis profile: pixel_6 disable-animations: true - emulator-options: -no-window -gpu software -memory 2048 -cores 2 -no-snapshot -noaudio -no-boot-anim -camera-back none + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: bash android/emulator-check.sh - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() diff --git a/README.md b/README.md index b09509f..73038e3 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ ScreenNote lets you record or import a short explanation, review it privately, a ## Limits that matter -Clips are limited to ten minutes and 50 MB. Shared storage uses a 1,000,000,000-byte budget with temporary upload reservations; there is no fixed 16-slot cap. Available clip count depends on sizes, active reservations and provider quotas. Each clip allows up to 100 comments. Names are unverified; owner and author controls depend on private browser capabilities. +Clips are limited to five minutes and 25 MiB. Shared storage uses a 1,000,000,000-byte budget with temporary upload reservations; there is no fixed 16-slot cap. Available clip count depends on sizes, active reservations and provider quotas. Each clip allows up to 100 comments. Names are unverified; owner and author controls depend on private browser capabilities. -Local recordings are browser data, not a backup. Recovery-key files restore management capabilities, not video files. Issued media URLs may continue working for ten minutes after sharing stops. Playback bubbles do not appear in native fullscreen or picture-in-picture. Phones without browser screen capture can import a device recording. +Local recordings are browser data, not a backup. Recovery-key files restore management capabilities, not video files. Issued media URLs may continue working for five minutes after sharing stops. Playback bubbles do not appear in native fullscreen or picture-in-picture. Phones without browser screen capture can import a device recording. ## Quick start diff --git a/android/app/build.gradle b/android/app/build.gradle index 91a81da..d3f6e6e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,7 +2,7 @@ plugins { id 'com.android.application' } android { namespace 'com.buttercode.mobile' compileSdk 36 - defaultConfig { applicationId 'za.co.buttercode.screennote'; minSdk 26; targetSdk 36; versionCode 4; versionName '0.5.0-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + defaultConfig { applicationId 'za.co.buttercode.screennote'; minSdk 26; targetSdk 36; versionCode 2; versionName '0.1.1-internal'; testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' buildConfigField 'String','APP_ORIGIN','"https://screennote-woad.vercel.app"' buildConfigField 'boolean','SCREEN_CAPTURE','true' buildConfigField 'boolean','BUNDLED_OFFLINE','false' diff --git a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java index 615552a..f1f5c4b 100644 --- a/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java +++ b/android/app/src/androidTest/java/com/buttercode/mobile/LaunchTest.java @@ -2,7 +2,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;import androidx.test.platform.app.InstrumentationRegistry;import androidx.test.core.app.ActivityScenario;import android.content.*;import android.graphics.Bitmap;import java.io.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import org.junit.Test;import org.junit.runner.RunWith;import static org.junit.Assert.*; @RunWith(AndroidJUnit4.class) public class LaunchTest { private void nativeText(String text,boolean tap)throws Exception{for(int n=0;n<50;n++){android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();if(root!=null){java.util.List found=root.findAccessibilityNodeInfosByText(text);for(android.view.accessibility.AccessibilityNodeInfo node:found){if(node.getText()==null||!text.equalsIgnoreCase(node.getText().toString()))continue;if(!tap)return;while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null&&node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK))return;}}Thread.sleep(100);}fail("Native control unavailable: "+text);} - private String nativeTree(android.view.accessibility.AccessibilityNodeInfo n){if(n==null)return "none";StringBuilder s=new StringBuilder();s.append("["+n.getClassName()+" text="+n.getText()+" description="+n.getContentDescription()+" clickable="+n.isClickable()+"]");for(int i=0;i launch(){Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();Intent intent=context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());assertNotNull(intent);return ActivityScenario.launch(intent);} private String js(ActivityScenario scenario,String expression)throws Exception{CountDownLatch ready=new CountDownLatch(1);AtomicReference value=new AtomicReference<>();scenario.onActivity(a->a.web.evaluateJavascript(expression,v->{value.set(v);ready.countDown();}));assertTrue("WebView evaluation timed out",ready.await(10,TimeUnit.SECONDS));return value.get();} private void ready(ActivityScenario scenario)throws Exception{for(int n=0;n<80;n++){String v=js(scenario,"document.readyState==='complete'&&!!document.querySelector('#app h1,#root h1')&&document.body.innerText.length>120");if("true".equals(v))return;Thread.sleep(250);}fail("The packaged app did not render usable content.");} @@ -26,7 +25,7 @@ private void saveScreenshot(ActivityScenario scenario,Context cont @androidx.test.filters.SdkSuppress(minSdkVersion=29) @Test public void integratedChromeTracksThemeAndDoesNotStealContentSpace()throws Exception{ Context context=InstrumentationRegistry.getInstrumentation().getTargetContext(); try(ActivityScenario scenario=launch()){ - ready(scenario);js(scenario,"(()=>{const d=document.getElementById('record-setup');if(d)d.open=true})()"); + ready(scenario); for(int n=0;n<60&&!"1".equals(js(scenario,"document.querySelectorAll('[data-android-menu]').length"));n++)Thread.sleep(100); assertEquals("1",js(scenario,"document.querySelectorAll('[data-android-menu]').length")); scenario.onActivity(a->{android.view.ViewGroup parent=(android.view.ViewGroup)a.web.getParent();assertSame("No native toolbar may sit below the product",a.web,parent.getChildAt(parent.getChildCount()-1));}); @@ -60,48 +59,11 @@ private void saveScreenshot(ActivityScenario scenario,Context cont nativeText("Refresh page",true);nativeText("Refresh this page?",false);nativeText("Keep editing",true); ready(scenario); if(BuildConfig.SCREEN_CAPTURE){ - js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Recording quality",false);nativeText("Continue",true);nativeText("Record your screen",false);nativeText("Cancel",true); + js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Record your screen",false);nativeText("Cancel",true); assertEquals("true",js(scenario,"!!document.querySelector('[data-android-capture]')")); } } } @Test public void nativeExportRequiresExactSequentialBytes()throws Exception{Context context=InstrumentationRegistry.getInstrumentation().getTargetContext();ExportStore store=new ExportStore(context.getCacheDir());String token=store.begin("invoice.pdf","application/pdf",4);try{store.chunk(token,1,"JVBERg==");fail("Out-of-order export accepted");}catch(IOException expected){}store.chunk(token,0,"JVBERg==");File file=store.finish(token);assertEquals(4,file.length());try(InputStream in=new FileInputStream(file)){assertEquals('%',in.read());}assertTrue(file.delete());store.abort();} - @androidx.test.filters.SdkSuppress(minSdkVersion=35) @Test public void nativeScreenRecordingSurvivesOneMinuteAndImports()throws Exception{ - Context context=InstrumentationRegistry.getInstrumentation().getTargetContext(); - android.content.res.Configuration cfg=context.getResources().getConfiguration(); - org.junit.Assume.assumeTrue("Long native capture runs once, in phone portrait mode",cfg.smallestScreenWidthDp<600&&cfg.orientation==android.content.res.Configuration.ORIENTATION_PORTRAIT); - try(ActivityScenario scenario=launch()){ - ready(scenario);context.getSharedPreferences("capture",Context.MODE_PRIVATE).edit().putBoolean("notificationsAsked",true).apply();js(scenario,"(()=>{const d=document.getElementById('record-setup');if(d)d.open=true;document.querySelector('[data-android-action=recordScreen]').click()})()"); - nativeText("Recording quality",false);nativeText("Continue",true);nativeText("Screen only",true); - // Read the actual system dialog. Android 15 offers a scope selector; choose the entire test screen. - boolean consent=false; - for(int n=0;n<50&&!consent;n++){ - android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow(); - if(root!=null){ - java.util.List scope=root.findAccessibilityNodeInfosByText("A single app"); - if(!scope.isEmpty()){android.view.accessibility.AccessibilityNodeInfo node=scope.get(0);while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null)node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK);Thread.sleep(300);nativeText("Entire screen",true);Thread.sleep(300);} - root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow(); - if(root!=null)for(String label:new String[]{"Start","Start now","Start recording"})for(android.view.accessibility.AccessibilityNodeInfo node:root.findAccessibilityNodeInfosByText(label))if(node.getText()!=null&&label.equalsIgnoreCase(node.getText().toString())){while(node!=null&&!node.isClickable())node=node.getParent();if(node!=null&&node.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK))consent=true;} - } - if(!consent)Thread.sleep(200); - } - if(!consent){saveScreenshot(scenario,context,"capture-consent-blocked");android.view.accessibility.AccessibilityNodeInfo root=InstrumentationRegistry.getInstrumentation().getUiAutomation().getRootInActiveWindow();System.out.println("CAPTURE_CONSENT_UI "+nativeTree(root));}assertTrue("Native screen-sharing consent was not completed",consent); - for(int n=0;n<60&&!ScreenRecordingService.active;n++)Thread.sleep(200); - assertTrue("Native recorder did not start",ScreenRecordingService.active); - String name=ScreenRecordingService.activeFileName; - // Real elapsed capture on the emulator. No synthetic MediaRecorder or file-size mocks. - Thread.sleep(65000);assertTrue("Recorder stopped before 65 seconds",ScreenRecordingService.active); - js(scenario,"document.querySelector('[data-android-action=recordScreen]').click()");nativeText("Stop recording",true); - for(int n=0;n<60&&ScreenRecordingService.active;n++)Thread.sleep(200); - File video=new File(new File(context.getFilesDir(),"recordings"),name);assertTrue(video.exists());assertTrue(video.length()>0); - android.media.MediaMetadataRetriever media=new android.media.MediaMetadataRetriever();media.setDataSource(video.getAbsolutePath());long duration=Long.parseLong(media.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION));assertTrue("Native video shorter than a minute",duration>=60000);assertNotNull(media.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));media.release(); - js(scenario,"document.querySelector('[data-android-action=openRecordings]').click()");nativeText("Recordings on this device",false); - // Import via the same WebView route the user opens, then verify a decoded, persistent local copy. - nativeText("Close",true);String id=name.substring(0,name.length()-4);scenario.onActivity(a->a.web.loadUrl(BuildConfig.APP_ORIGIN+"/?nativeRecording="+id)); - boolean imported=false;for(int n=0;n<100;n++){if("true".equals(js(scenario,"!!document.querySelector('#player')&&document.querySelector('#player').duration>=60"))){imported=true;break;}Thread.sleep(200);}assertTrue("Native video did not import into the editor",imported); - assertEquals("false",js(scenario,"document.documentElement.scrollWidth>innerWidth+2"));saveScreenshot(scenario,context,"native-recording-import"); - }finally{context.startService(new Intent(context,ScreenRecordingService.class).setAction(ScreenRecordingService.STOP));} - } - } diff --git a/android/app/src/main/java/com/buttercode/mobile/ExportStore.java b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java index 981a214..28584e6 100644 --- a/android/app/src/main/java/com/buttercode/mobile/ExportStore.java +++ b/android/app/src/main/java/com/buttercode/mobile/ExportStore.java @@ -6,7 +6,7 @@ final class ExportStore { String name,mime; ExportStore(File root){directory=new File(root,"exports");directory.mkdirs();} synchronized String begin(String fileName,String contentType,long size)throws IOException{ - if(output!=null)throw new IOException("Finish the current export first.");if(size<1||size>OriginPolicy.exportLimit(contentType)||!OriginPolicy.exportMime(contentType)||!contentType.matches("[a-zA-Z0-9!#$&^_.+/-]+(?:;[a-zA-Z0-9=_.+ -]+)?"))throw new IOException("Unsupported export type or size."); + if(output!=null)throw new IOException("Finish the current export first.");if(size<1||size>OriginPolicy.MAX_EXPORT_BYTES||!OriginPolicy.exportMime(contentType)||!contentType.matches("[a-zA-Z0-9!#$&^_.+/-]+(?:;[a-zA-Z0-9=_.+ -]+)?"))throw new IOException("Unsupported export type or size."); long total=0;for(File f:directory.listFiles()==null?new File[0]:directory.listFiles()){if(System.currentTimeMillis()-f.lastModified()>7L*86400000)f.delete();else total+=f.length();}if(total+size>100L*1024*1024)throw new IOException("Export cache is full. Save your files and clear the export cache from Android settings."); token=UUID.randomUUID().toString();name=OriginPolicy.safeName(fileName);mime=contentType;active=new File(directory,token+"-"+name);expected=size;written=0;sequence=0;output=new FileOutputStream(active);return token; } diff --git a/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java index 73616a3..4d5516c 100644 --- a/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java +++ b/android/app/src/main/java/com/buttercode/mobile/OriginPolicy.java @@ -4,12 +4,11 @@ import java.util.UUID; /** Native capabilities are available only to the packaged app's exact HTTPS origin. */ public final class OriginPolicy { - public static final long MAX_EXPORT_BYTES=250_000_000L; + public static final long MAX_EXPORT_BYTES=30L*1024*1024; public static boolean trusted(String candidate,String origin){try{URI u=new URI(candidate),o=new URI(origin);return "https".equals(u.getScheme())&&u.getUserInfo()==null&&u.getPort()==o.getPort()&&o.getHost()!=null&&o.getHost().equalsIgnoreCase(u.getHost());}catch(Exception e){return false;}} public static boolean recordingId(String id){try{return UUID.fromString(id).toString().equals(id);}catch(Exception e){return false;}} public static String assetPath(String path){if(path==null||path.isEmpty()||path.equals("/"))return "index.html";String p=path.startsWith("/")?path.substring(1):path;if(p.contains("..")||p.contains("\\")||p.contains("%")||p.indexOf('\0')>=0)return null;return p;} public static String safeName(String value){String clean=value==null?"export":value.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]","_").trim();if(clean.isEmpty()||clean.equals(".")||clean.equals(".."))clean="export";return clean.substring(0,Math.min(clean.length(),120));} - public static long exportLimit(String mime){return "video/mp4".equals(mime)||"video/webm".equals(mime)?MAX_EXPORT_BYTES:30L*1024*1024;} public static boolean exportMime(String value){return value!=null&&value.length()<=100&&!value.matches("(?s).*[\\r\\n\\x00].*")&&(value.equals("application/pdf")||value.equals("application/json")||value.equals("application/octet-stream")||value.equals("video/mp4")||value.equals("video/webm")||java.util.Set.of("text/plain","text/csv","text/markdown","text/vtt").contains(value.split(";",2)[0]));} private OriginPolicy(){} } diff --git a/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java b/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java index 7c41884..514b040 100644 --- a/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java +++ b/android/app/src/main/java/com/buttercode/mobile/ScreenNoteActivity.java @@ -1,16 +1,14 @@ package com.buttercode.mobile; import android.Manifest;import android.app.*;import android.os.*;import android.content.*;import android.content.pm.PackageManager;import android.media.*;import android.media.projection.*;import android.net.Uri;import android.widget.*;import androidx.core.content.FileProvider;import java.io.*;import java.util.*; public class ScreenNoteActivity extends MainActivity { - private boolean mic;private String quality="balanced";private static final int AUDIO=81,SCREEN=82,NOTIFICATIONS=83; + private boolean mic;private static final int AUDIO=81,SCREEN=82,NOTIFICATIONS=83; @Override protected void chooseRecording(){if(ScreenRecordingService.active){dialog().setTitle("Recording in progress").setMessage("Stop and save your recording?").setPositiveButton("Stop recording",(d,w)->startService(new Intent(this,ScreenRecordingService.class).setAction(ScreenRecordingService.STOP))).setNegativeButton("Keep recording",null).show();return;} - dialog().setTitle("Recording quality").setSingleChoiceItems(new String[]{"Data saver · smaller files","Balanced · everyday walkthroughs","Clear text · larger files"},1,(d,w)->quality=new String[]{"saver","balanced","clear"}[w]).setPositiveButton("Continue",(d,w)->chooseRecordingAudio()).setNegativeButton("Cancel",null).show(); - } - private void chooseRecordingAudio(){dialog().setTitle("Record your screen").setMessage("Android will ask which screen or app to share. Recordings stay on this device until you publish. Local: up to 30 minutes / 250 MB. Shared clips: up to 10 minutes / 50 MB. System audio and a camera bubble are not included in this first Android recorder.").setPositiveButton("Screen + microphone",(d,w)->{mic=true;if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO},AUDIO);else requestScreen();}).setNeutralButton("Screen only",(d,w)->{mic=false;requestScreen();}).setNegativeButton("Cancel",null).show(); + dialog().setTitle("Record your screen").setMessage("Android will ask which screen or app to share. Recordings stay on this device until you publish. Up to 5 minutes / 25 MB. System audio and a camera bubble are not included in this first Android recorder.").setPositiveButton("Screen + microphone",(d,w)->{mic=true;if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)requestPermissions(new String[]{Manifest.permission.RECORD_AUDIO},AUDIO);else requestScreen();}).setNeutralButton("Screen only",(d,w)->{mic=false;requestScreen();}).setNegativeButton("Cancel",null).show(); } private void requestScreen(){if(Build.VERSION.SDK_INT>=33&&checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)!=PackageManager.PERMISSION_GRANTED&&!getSharedPreferences("capture",MODE_PRIVATE).getBoolean("notificationsAsked",false)){getSharedPreferences("capture",MODE_PRIVATE).edit().putBoolean("notificationsAsked",true).apply();requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},NOTIFICATIONS);return;}startActivityForResult(getSystemService(MediaProjectionManager.class).createScreenCaptureIntent(),SCREEN);} @Override public void onRequestPermissionsResult(int code,String[] names,int[] results){super.onRequestPermissionsResult(code,names,results);if(code==NOTIFICATIONS){requestScreen();return;}if(code==AUDIO&&results.length>0&&results[0]==PackageManager.PERMISSION_GRANTED)requestScreen();else if(code==AUDIO)Toast.makeText(this,"Microphone permission declined. You can choose Screen only.",Toast.LENGTH_LONG).show();} - @Override protected void onActivityResult(int code,int result,Intent data){super.onActivityResult(code,result,data);if(code==SCREEN&&result==RESULT_OK&&data!=null){Intent start=new Intent(this,ScreenRecordingService.class).putExtra("result",result).putExtra("consent",data).putExtra("microphone",mic).putExtra("quality",quality);startForegroundService(start);Toast.makeText(this,"Recording starts now. Use the recording notification or Record screen to stop.",Toast.LENGTH_LONG).show();}} - @Override protected void onResume(){super.onResume();String notice=getSharedPreferences("capture",MODE_PRIVATE).getString("notice",null);if(notice!=null){getSharedPreferences("capture",MODE_PRIVATE).edit().remove("notice").apply();Toast.makeText(this,notice,Toast.LENGTH_LONG).show();}String error=getSharedPreferences("capture",MODE_PRIVATE).getString("error",null);if(error!=null){getSharedPreferences("capture",MODE_PRIVATE).edit().remove("error").apply();Toast.makeText(this,error,Toast.LENGTH_LONG).show();}} + @Override protected void onActivityResult(int code,int result,Intent data){super.onActivityResult(code,result,data);if(code==SCREEN&&result==RESULT_OK&&data!=null){Intent start=new Intent(this,ScreenRecordingService.class).putExtra("result",result).putExtra("consent",data).putExtra("microphone",mic);startForegroundService(start);Toast.makeText(this,"Recording starts now. Use the recording notification or Record screen to stop.",Toast.LENGTH_LONG).show();}} + @Override protected void onResume(){super.onResume();String error=getSharedPreferences("capture",MODE_PRIVATE).getString("error",null);if(error!=null){getSharedPreferences("capture",MODE_PRIVATE).edit().remove("error").apply();Toast.makeText(this,error,Toast.LENGTH_LONG).show();}} @Override protected void recordings(){File dir=new File(getFilesDir(),"recordings");File[] values=dir.listFiles((d,n)->n.endsWith(".mp4")&&OriginPolicy.recordingId(n.substring(0,n.length()-4))&&!n.equals(ScreenRecordingService.activeFileName));if(values==null||values.length==0){Toast.makeText(this,"No native recordings yet. Stop a recording to save it here.",Toast.LENGTH_LONG).show();return;}Arrays.sort(values,(a,b)->Long.compare(b.lastModified(),a.lastModified()));String[] labels=Arrays.stream(values).map(f->new java.text.SimpleDateFormat("dd MMM HH:mm",Locale.getDefault()).format(new Date(f.lastModified()))+" · "+String.format(Locale.getDefault(),"%.1f MB",f.length()/1000000.0)).toArray(String[]::new);dialog().setTitle("Recordings on this device").setItems(labels,(d,n)->openRecording(values[n])).setNegativeButton("Close",null).show();} private void openRecording(File file){dialog().setTitle("Your recording").setItems(new String[]{"Review and share in ScreenNote","Play recording","Share original MP4","Delete recording"},(d,n)->{String id=file.getName().substring(0,file.getName().length()-4);Uri uri=FileProvider.getUriForFile(this,BuildConfig.APPLICATION_ID+".files",file);if(n==0)web.loadUrl(BuildConfig.APP_ORIGIN+"/?nativeRecording="+id);else if(n==3)dialog().setMessage("Permanently delete this native recording? Imported copies and published videos are separate.").setPositiveButton("Delete",(a,b)->{if(!file.delete())Toast.makeText(this,"The recording could not be deleted.",Toast.LENGTH_LONG).show();}).setNegativeButton("Keep recording",null).show();else try{Intent intent=n==1?new Intent(Intent.ACTION_VIEW).setDataAndType(uri,"video/mp4"):new Intent(Intent.ACTION_SEND).setType("video/mp4").putExtra(Intent.EXTRA_STREAM,uri);intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);startActivity(n==1?intent:Intent.createChooser(intent,"Share recording"));}catch(ActivityNotFoundException e){Toast.makeText(this,"No app can open this video.",Toast.LENGTH_LONG).show();}}).show();} } diff --git a/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java b/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java index 054c015..706b1a0 100644 --- a/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java +++ b/android/app/src/main/java/com/buttercode/mobile/ScreenRecordingService.java @@ -8,19 +8,19 @@ public class ScreenRecordingService extends Service { @Override public IBinder onBind(Intent intent){return null;} @Override public int onStartCommand(Intent intent,int flags,int startId){ if(intent==null||STOP.equals(intent.getAction())){finish();return START_NOT_STICKY;}if(active)return START_NOT_STICKY; - boolean mic=intent.getBooleanExtra("microphone",false);String quality=intent.getStringExtra("quality");Intent consent=Build.VERSION.SDK_INT>=33?intent.getParcelableExtra("consent",Intent.class):intent.getParcelableExtra("consent");int result=intent.getIntExtra("result",Activity.RESULT_CANCELED); + boolean mic=intent.getBooleanExtra("microphone",false);Intent consent=Build.VERSION.SDK_INT>=33?intent.getParcelableExtra("consent",Intent.class):intent.getParcelableExtra("consent");int result=intent.getIntExtra("result",Activity.RESULT_CANCELED); if(consent==null||result!=Activity.RESULT_OK){stopSelf();return START_NOT_STICKY;} try{ NotificationManager nm=getSystemService(NotificationManager.class);nm.createNotificationChannel(new NotificationChannel("recording","Screen recording",NotificationManager.IMPORTANCE_LOW)); PendingIntent stop=PendingIntent.getService(this,1,new Intent(this,ScreenRecordingService.class).setAction(STOP),PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); PendingIntent open=PendingIntent.getActivity(this,2,new Intent(this,ScreenNoteActivity.class),PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE); - Notification notice=new Notification.Builder(this,"recording").setContentTitle("ScreenNote is recording").setContentText("Tap Stop when you have finished. Local recording: up to 30 minutes / 250 MB.").setSmallIcon(android.R.drawable.presence_video_online).setOngoing(true).setContentIntent(open).addAction(new Notification.Action.Builder(null,"Stop recording",stop).build()).build(); + Notification notice=new Notification.Builder(this,"recording").setContentTitle("ScreenNote is recording").setContentText("Tap Stop when you have finished. Up to 5 minutes / 25 MB.").setSmallIcon(android.R.drawable.presence_video_online).setOngoing(true).setContentIntent(open).addAction(new Notification.Action.Builder(null,"Stop recording",stop).build()).build(); int types=0;if(Build.VERSION.SDK_INT>=29)types=ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;if(mic&&Build.VERSION.SDK_INT>=30)types|=ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; if(Build.VERSION.SDK_INT>=29)startForeground(40,notice,types);else startForeground(40,notice); MediaProjectionManager manager=getSystemService(MediaProjectionManager.class);projection=manager.getMediaProjection(result,consent);projection.registerCallback(new MediaProjection.Callback(){@Override public void onStop(){finish();}},new Handler(getMainLooper())); File folder=new File(getFilesDir(),"recordings");folder.mkdirs();output=new File(folder,UUID.randomUUID()+".mp4");activeFileName=output.getName(); - Point size=new Point();((WindowManager)getSystemService(WINDOW_SERVICE)).getDefaultDisplay().getRealSize(size);float longest="clear".equals(quality)?1920f:"saver".equals(quality)?960f:1280f;float scale=Math.min(1f,longest/Math.max(size.x,size.y));int width=Math.max(2,Math.round(size.x*scale)/2*2),height=Math.max(2,Math.round(size.y*scale)/2*2); - recorder=Build.VERSION.SDK_INT>=31?new MediaRecorder(this):new MediaRecorder();if(mic)recorder.setAudioSource(MediaRecorder.AudioSource.MIC);recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);recorder.setVideoSize(width,height);recorder.setVideoFrameRate("saver".equals(quality)?15:24);recorder.setVideoEncodingBitRate("clear".equals(quality)?1600000:"saver".equals(quality)?350000:600000);if(mic){recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);recorder.setAudioEncodingBitRate(64000);recorder.setAudioSamplingRate(44100);}recorder.setOutputFile(output.getAbsolutePath());recorder.setMaxDuration(1800000);recorder.setMaxFileSize(250000000L-65536);recorder.setOnInfoListener((r,what,extra)->{if(what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED||what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED){getSharedPreferences("capture",MODE_PRIVATE).edit().putString("notice",what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED?"Saved: the 30-minute local recording limit was reached.":"Saved: the 250 MB local recording limit was reached.").apply();finish();}});recorder.setOnErrorListener((r,what,extra)->finish());recorder.prepare(); + Point size=new Point();((WindowManager)getSystemService(WINDOW_SERVICE)).getDefaultDisplay().getRealSize(size);float scale=Math.min(1f,1280f/Math.max(size.x,size.y));int width=Math.max(2,Math.round(size.x*scale)/2*2),height=Math.max(2,Math.round(size.y*scale)/2*2); + recorder=Build.VERSION.SDK_INT>=31?new MediaRecorder(this):new MediaRecorder();if(mic)recorder.setAudioSource(MediaRecorder.AudioSource.MIC);recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);recorder.setVideoSize(width,height);recorder.setVideoFrameRate(24);recorder.setVideoEncodingBitRate(1000000);if(mic){recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);recorder.setAudioEncodingBitRate(64000);recorder.setAudioSamplingRate(44100);}recorder.setOutputFile(output.getAbsolutePath());recorder.setMaxDuration(300000);recorder.setMaxFileSize(25L*1024*1024-65536);recorder.setOnInfoListener((r,what,extra)->{if(what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED||what==MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED)finish();});recorder.setOnErrorListener((r,what,extra)->finish());recorder.prepare(); display=projection.createVirtualDisplay("ScreenNote",width,height,getResources().getDisplayMetrics().densityDpi,DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,recorder.getSurface(),null,null);recorder.start();recording=true;active=true; }catch(Exception e){finish();getSharedPreferences("capture",MODE_PRIVATE).edit().putString("error","Recording could not start. Try again with a fresh screen-sharing permission.").apply();} return START_NOT_STICKY; diff --git a/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java b/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java index 5fad00a..6a11dd9 100644 --- a/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java +++ b/android/app/src/test/java/com/buttercode/mobile/OriginPolicyTest.java @@ -4,6 +4,5 @@ public class OriginPolicyTest { @Test public void exactOriginOnly(){String o="https://contractcheck.online";assertTrue(OriginPolicy.trusted(o+"/report",o));for(String s:new String[]{"http://contractcheck.online","https://contractcheck.online.evil.test","https://contractcheck.online@evil.test","https://evil.test@contractcheck.online","https://contractcheck.online:443","file:///etc/passwd","javascript:alert(1)","null"})assertFalse(s,OriginPolicy.trusted(s,o));} @Test public void filesCannotEscapeTheirRoots(){for(String p:new String[]{"/../secret","/%2e%2e/secret","/a\\b","/a\u0000b"})assertNull(p,OriginPolicy.assetPath(p));assertEquals("index.html",OriginPolicy.assetPath("/"));assertEquals("assets/signs/stop.svg",OriginPolicy.assetPath("/assets/signs/stop.svg"));} @Test public void exportNamesAndTypesAreBounded(){assertEquals(".._private.pdf",OriginPolicy.safeName("../private.pdf"));assertTrue(OriginPolicy.safeName("x".repeat(300)).length()<=120);assertFalse(OriginPolicy.exportMime("text/html;\r\nHeader:x"));assertTrue(OriginPolicy.exportMime("application/pdf"));assertFalse(OriginPolicy.exportMime("application/vnd.android.package-archive"));} - @Test public void largeVideoExportsDoNotExpandDocumentLimits(){assertEquals(250000000L,OriginPolicy.exportLimit("video/mp4"));assertEquals(250000000L,OriginPolicy.exportLimit("video/webm"));assertEquals(30L*1024*1024,OriginPolicy.exportLimit("application/json"));} @Test public void recordingHandlesAreUnambiguous(){assertTrue(OriginPolicy.recordingId("11111111-1111-4111-8111-111111111111"));assertFalse(OriginPolicy.recordingId("../../recording"));assertFalse(OriginPolicy.recordingId("1-1-1-1-1"));} } diff --git a/android/emulator-check.sh b/android/emulator-check.sh index 15eb00a..39ed2f2 100755 --- a/android/emulator-check.sh +++ b/android/emulator-check.sh @@ -23,14 +23,7 @@ assert_no_system_dialog() { run_mode() { local mode="$1" result=0 quieten_launcher - adb logcat -c - adb logcat -v threadtime > "android/emulator-evidence/${mode}-logcat.txt" 2>&1 & - local log_pid=$! ./android/gradlew -p android --no-daemon connectedDebugAndroidTest || result=$? - kill "$log_pid" 2>/dev/null || true - free -m > "android/emulator-evidence/${mode}-host-memory.txt" - adb shell uiautomator dump /sdcard/window-dump.xml >/dev/null 2>&1 || true - adb pull /sdcard/window-dump.xml "android/emulator-evidence/${mode}-window.xml" >/dev/null 2>&1 || true if ! adb pull /sdcard/Pictures/AndroidAcceptance android/emulator-evidence/; then if [ "$result" = 0 ]; then return 1; fi fi diff --git a/android/gradle.properties b/android/gradle.properties index 8e65f7b..691500e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/android/host-support.js b/android/host-support.js index 7a38bbf..34272af 100644 --- a/android/host-support.js +++ b/android/host-support.js @@ -4,7 +4,7 @@ let sequence=0;const pending=new Map(); AppHost.onmessage=e=>{let response;try{response=JSON.parse(e.data)}catch{return}const p=pending.get(response.id);if(!p)return;clearTimeout(p.timeout);pending.delete(response.id);response.ok?p.resolve(response.data):p.reject(Error(response.error||'Native file transfer failed.'))}; function call(op,body={}){return new Promise((resolve,reject)=>{const id=String(++sequence),timeout=setTimeout(()=>{pending.delete(id);reject(Error('The Android file transfer timed out.'))},30000);pending.set(id,{resolve,reject,timeout});AppHost.postMessage(JSON.stringify({id,op,...body}))})} - async function exportUrl(url,name,mime){let token;try{const response=await fetch(url);if(!response.ok)throw Error('Could not read the exported file.');const blob=await response.blob();({token}=await call('exportBegin',{name:name||'export',mime:(blob.type||mime||'application/octet-stream').split(';')[0],size:blob.size}));let index=0;for(let offset=0;offset{});alert(e.message)}} + async function exportUrl(url,name,mime){let token;try{const response=await fetch(url);if(!response.ok)throw Error('Could not read the exported file.');const blob=await response.blob();({token}=await call('exportBegin',{name:name||'export',mime:blob.type||mime||'application/octet-stream',size:blob.size}));let index=0;for(let offset=0;offset{});alert(e.message)}} window.AndroidFiles={exportUrl};window.print=()=>call('print').catch(e=>alert(e.message));const handled=new WeakSet(); function handle(anchor){if(!anchor||!anchor.hasAttribute('download')||! /^(blob:|data:)/.test(anchor.href))return false;if(!handled.has(anchor)){handled.add(anchor);exportUrl(anchor.href,anchor.download,'application/octet-stream').finally(()=>handled.delete(anchor))}return true} const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(!handle(this))return original.call(this)}; diff --git a/android/play-store/listing-draft.json b/android/play-store/listing-draft.json index 3a65e1a..3f35599 100644 --- a/android/play-store/listing-draft.json +++ b/android/play-store/listing-draft.json @@ -2,7 +2,7 @@ "status": "DRAFT — do not submit without release-gate review", "title": "ScreenNote", "shortDescription": "Record, review and share short videos with contextual conversations.", - "fullDescription": "ScreenNote\n\nRecord your Android screen with optional microphone audio.\nReview and trim videos before explicitly publishing a link.\nDiscuss specific moments using comments and threaded replies.\nKeep native recordings private on this device until you choose to share.\n\nThe first Android recorder supports screen-only and microphone capture, up to 30 minutes or 250 MB locally. Shared clips support up to 10 minutes and 50 MB. System audio and camera overlays are not included. Online links use shared beta capacity and expire according to the selected period.", + "fullDescription": "ScreenNote\n\nRecord your Android screen with optional microphone audio.\nReview and trim videos before explicitly publishing a link.\nDiscuss specific moments using comments and threaded replies.\nKeep native recordings private on this device until you choose to share.\n\nThe first Android recorder supports screen-only and microphone capture, up to 5 minutes or 25 MB. System audio and camera overlays are not included. Online links use shared beta capacity and expire according to the selected period.", "category": "Productivity", "supportEmail": "buttercode101@gmail.com", "privacyPolicyUrl": "https://screennote-woad.vercel.app/privacy.html", diff --git a/android/release-gates.json b/android/release-gates.json index 6a1c25f..e2568a1 100644 --- a/android/release-gates.json +++ b/android/release-gates.json @@ -4,7 +4,7 @@ "packageIdStatus": "provisional until first Play upload", "targetSdk": 36, "minSdk": 26, - "versionCode": 4, + "versionCode": 2, "signedRelease": false, "physicalDeviceAcceptance": false, "playConsoleSetup": false, diff --git a/api/clip.js b/api/clip.js index 7dfb56d..172f58d 100644 --- a/api/clip.js +++ b/api/clip.js @@ -1,4 +1,3 @@ -import {REVIEW_STATES} from '../lib/library.js'; import {applyCommentAction,commentOperations} from '../lib/comment-actions.js'; import {defaultCapacityLimits,capacitySummary,hasCapacity,reserveCapacity,releaseCapacity,finalizeCapacity} from '../lib/capacity.js'; import {validTitle} from '../lib/titles.js'; @@ -8,7 +7,7 @@ import {validateExtras} from '../lib/enrichment.js'; import {RETENTION_GRACE,reclaimable} from '../lib/retention.js'; import{createHash,timingSafeEqual}from'node:crypto'; import{handleUpload}from'@vercel/blob/client'; -import{CHUNK_BYTES,MAX_BYTES,MAX_SECONDS,MAX_SHARED,validId,clipInput,publicClip,isVideo}from'../lib/rules.js'; +import{CHUNK_BYTES,MAX_BYTES,MAX_SHARED,validId,clipInput,publicClip,isVideo}from'../lib/rules.js'; import{configured,key,media,read,write,mutate,mediaInfo,mediaMagic,mediaURL,removeMedia,uploadSmall,uploadPart,assemble,clearParts,uploadedParts}from'../lib/store.js'; export const config={maxDuration:60}; const hash=v=>createHash('sha256').update(v).digest('hex'); @@ -37,14 +36,14 @@ async function recoverCapacity(requiredBytes){ } } async function viewerAccess(c,password){available(c);if(!c.passwordHash)return;if((c.passwordLockedUntil||0)>Date.now())throw fail('Too many password attempts. Try again in one minute.',429);if(!await checkPassword(c,password)){if(password)await mutate(key(c.id),d=>{d.passwordFailures=(d.passwordFailures||0)+1;if(d.passwordFailures>=8){d.passwordLockedUntil=Date.now()+60000;d.passwordFailures=0}});throw Object.assign(fail('This video needs its sharing password.',401),{code:'PASSWORD_REQUIRED'})}} -async function find(id){if(!validId(id))throw fail('Clip not found.',404);const r=await read(key(id));if(!r)throw fail('Clip not found. Older links may need to be republished from their original local video after the storage migration.',404);return r.data};return async function handler(req,res){res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff');try{const op=req.query?.op||new URL(req.url,'http://localhost').searchParams.get('op'),id=req.query?.id||new URL(req.url,'http://localhost').searchParams.get('id');if(req.method==='GET'&&op==='health'){const ready=configured();const capacity=ready?await currentCapacity():{ids:[]};return res.status(200).json({configured:ready,maxBytes:MAX_BYTES,maxSeconds:MAX_SECONDS,...capacitySummary(capacity,limits)})}if(!configured())throw fail('Sharing is not configured yet. Your local recording is safe; download it or try again later.',503); +async function find(id){if(!validId(id))throw fail('Clip not found.',404);const r=await read(key(id));if(!r)throw fail('Clip not found. Older links may need to be republished from their original local video after the storage migration.',404);return r.data};return async function handler(req,res){res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff');try{const op=req.query?.op||new URL(req.url,'http://localhost').searchParams.get('op'),id=req.query?.id||new URL(req.url,'http://localhost').searchParams.get('id');if(req.method==='GET'&&op==='health'){const ready=configured();const capacity=ready?await currentCapacity():{ids:[]};return res.status(200).json({configured:ready,maxBytes:MAX_BYTES,maxSeconds:300,...capacitySummary(capacity,limits)})}if(!configured())throw fail('Sharing is not configured yet. Your local recording is safe; download it or try again later.',503); if(req.method==='GET'){if(op!=='view')throw fail('Unknown action.');const c=await find(id);await viewerAccess(c);return res.status(200).json({...publicClip(c),mediaUrl:await mediaURL(id)})} if(req.method!=='POST'){res.setHeader('Allow','GET, POST');throw fail('Method not allowed.',405)} const origin=req.headers.origin;if(origin&&new URL(origin).host!==req.headers.host)throw fail('Use the ScreenNote page to make this request.',403); let body=req.body;if(typeof body==='string')body=JSON.parse(body);if(!body||typeof body!=='object'||Array.isArray(body)||JSON.stringify(body).length>(['upload-small','upload-chunk'].includes(op)?4200000:40000))throw fail('Request is too large.'); if(op==='upload'){const result=await uploader({body,request:req,onBeforeGenerateToken:async(path,payload)=>{const p=JSON.parse(payload||'{}'),c=await find(p.id);owner(c,p.owner);if(c.status!=='pending'||(c.uploadUntil||c.createdAt+600000){}});return res.status(200).json(result)} if(op==='reserve'){const input=clipInput(body);if(!validId(body.id)||typeof body.owner!=='string'||body.owner.length<40||body.owner.length>100)throw fail('Invalid recording identity.');const existing=await read(key(body.id));if(existing){owner(existing.data,body.owner);if(body.password&&(!existing.data.passwordHash||!await checkPassword(existing.data,body.password)))throw fail('Use the original sharing password for this upload. Delete the upload first to change its protection.');if(!['pending','published'].includes(existing.data.status))throw fail('This clip is no longer publishable. Create a new share link.',409);if(existing.data.size!==input.size||existing.data.type!==input.type||existing.data.duration!==input.duration)throw fail('This upload does not match its reservation.',409);if(existing.data.status==='pending')await mutate(key(body.id),d=>{owner(d,body.owner);if(d.status!=='pending')throw fail('This clip changed. Try again.',409);Object.assign(d,input);d.uploadUntil=Date.now()+600000});return res.status(200).json({id:body.id,status:existing.data.status})}const credentials=await passwordRecord(body.password);await recoverCapacity(2*input.size);await mutate('screennote/capacity.json',d=>reserveCapacity(d,body.id,input.size,limits),()=>({ids:[],allocations:{}}));const c={...input,...credentials,id:body.id,ownerHash:hash(body.owner),createdAt:Date.now(),uploadUntil:Date.now()+600000,expiresAt:Date.now()+7*86400000,status:'pending',comments:[],votes:{}};try{await write(key(c.id),c)}catch(e){const saved=await read(key(c.id));if(!saved)await releaseSlot(c.id);else{owner(saved.data,body.owner);return res.status(200).json({id:c.id,status:saved.data.status})}throw e}return res.status(201).json({id:c.id,status:c.status})} -const c=await find(body.id);if(op==='upload-state'){owner(c,body.owner);if(c.status!=='pending')return res.status(200).json({parts:[]});return res.status(200).json({parts:uploadedParts?await uploadedParts(c):[]})}if(op==='view'){await viewerAccess(c,body.accessPassword);return res.status(200).json({...publicClip(c),mediaUrl:await mediaURL(c.id)})}if(op==='restore-media'){owner(c,body.owner);available(c);return res.status(200).json({mediaUrl:await mediaURL(c.id)})}if(op==='review-status'){owner(c,body.owner);if(!REVIEW_STATES.includes(body.reviewStatus))throw fail('Choose a valid review status.');await mutate(key(c.id),d=>{owner(d,body.owner);d.reviewStatus=body.reviewStatus});return res.status(200).json({reviewStatus:body.reviewStatus})}if(op==='title'){owner(c,body.owner);const title=validTitle(body.title);await mutate(key(c.id),d=>{owner(d,body.owner);d.title=title});return res.status(200).json({title})}if(op==='details'){owner(c,body.owner);const extras=validateExtras(body,c.duration);await mutate(key(c.id),d=>{owner(d,body.owner);Object.assign(d,extras)});return res.status(200).json(extras)}if(op==='manage'){owner(c,body.owner);return res.status(200).json({...publicClip(c),type:c.type,size:c.size,status:c.status,cleanupComplete:!!c.cleanupComplete})}if(op==='upload-chunk'){owner(c,body.owner);if(c.status!=='pending'||(c.uploadUntil||c.createdAt+600000)=count||typeof body.data!=='string'||body.data.length>2800000)throw fail('Invalid upload part.');const bytes=Buffer.from(body.data,'base64'),expected=Math.min(CHUNK_BYTES,c.size-part*CHUNK_BYTES);if(bytes.length!==expected||bytes.toString('base64')!==body.data||part===0&&!isVideo(bytes,c.type))throw fail('The upload part does not match this video.');try{await uploadPart(c.id,part,bytes)}catch(e){if(!/already exists/i.test(e.message))throw e}return res.status(200).json({part,uploaded:true})}if(op==='upload-small'){owner(c,body.owner);if(c.status!=='pending'||(c.uploadUntil||c.createdAt+600000)4194304)throw fail('This transfer supports files up to 3 MB.');const bytes=Buffer.from(body.data,'base64');if(bytes.length>3145728||bytes.length!==c.size||bytes.toString('base64')!==body.data||!isVideo(bytes,c.type))throw fail('The upload does not match the video.');try{await uploadSmall(c.id,bytes,c.type)}catch(e){if(!/already exists/i.test(e.message))throw e;const info=await mediaInfo(c.id);if(info.size!==c.size||!info.contentType.startsWith(c.type))throw fail('This upload does not match its reservation.')}return res.status(200).json({uploaded:true})}if(op==='publish'){owner(c,body.owner);if(c.status==='published'){try{await finishUpload(c)}catch{}return res.status(200).json(publicClip(c))}if(c.status!=='pending')throw fail('This clip is no longer publishable.');if((c.uploadUntil||c.createdAt+600000)MAX_BYTES||info.size!==c.size||!info.contentType.startsWith(c.type)||!isVideo(await mediaMagic(c.id),c.type))throw fail('The uploaded file does not match the video. Keep your local copy and try another file.');const verified=await inspect(await mediaURL(c.id),c);const result=await mutate(key(c.id),d=>{owner(d,body.owner);Object.assign(d,{width:verified.width,height:verified.height});if(d.status!=='pending'&&d.status!=='published')throw fail('This clip is unavailable.');if(d.status==='pending')d.expiresAt=Date.now()+(d.linkDays||7)*86400000;d.status='published';return publicClip(d)});try{await finishUpload({...c,...result})}catch{}return res.status(200).json(result)} +const c=await find(body.id);if(op==='upload-state'){owner(c,body.owner);if(c.status!=='pending')return res.status(200).json({parts:[]});return res.status(200).json({parts:uploadedParts?await uploadedParts(c):[]})}if(op==='view'){await viewerAccess(c,body.accessPassword);return res.status(200).json({...publicClip(c),mediaUrl:await mediaURL(c.id)})}if(op==='title'){owner(c,body.owner);const title=validTitle(body.title);await mutate(key(c.id),d=>{owner(d,body.owner);d.title=title});return res.status(200).json({title})}if(op==='details'){owner(c,body.owner);const extras=validateExtras(body,c.duration);await mutate(key(c.id),d=>{owner(d,body.owner);Object.assign(d,extras)});return res.status(200).json(extras)}if(op==='manage'){owner(c,body.owner);return res.status(200).json({...publicClip(c),type:c.type,size:c.size,status:c.status,cleanupComplete:!!c.cleanupComplete})}if(op==='upload-chunk'){owner(c,body.owner);if(c.status!=='pending'||(c.uploadUntil||c.createdAt+600000)=count||typeof body.data!=='string'||body.data.length>2800000)throw fail('Invalid upload part.');const bytes=Buffer.from(body.data,'base64'),expected=Math.min(CHUNK_BYTES,c.size-part*CHUNK_BYTES);if(bytes.length!==expected||bytes.toString('base64')!==body.data||part===0&&!isVideo(bytes,c.type))throw fail('The upload part does not match this video.');try{await uploadPart(c.id,part,bytes)}catch(e){if(!/already exists/i.test(e.message))throw e}return res.status(200).json({part,uploaded:true})}if(op==='upload-small'){owner(c,body.owner);if(c.status!=='pending'||(c.uploadUntil||c.createdAt+600000)4194304)throw fail('This transfer supports files up to 3 MB.');const bytes=Buffer.from(body.data,'base64');if(bytes.length>3145728||bytes.length!==c.size||bytes.toString('base64')!==body.data||!isVideo(bytes,c.type))throw fail('The upload does not match the video.');try{await uploadSmall(c.id,bytes,c.type)}catch(e){if(!/already exists/i.test(e.message))throw e;const info=await mediaInfo(c.id);if(info.size!==c.size||!info.contentType.startsWith(c.type))throw fail('This upload does not match its reservation.')}return res.status(200).json({uploaded:true})}if(op==='publish'){owner(c,body.owner);if(c.status==='published'){try{await finishUpload(c)}catch{}return res.status(200).json(publicClip(c))}if(c.status!=='pending')throw fail('This clip is no longer publishable.');if((c.uploadUntil||c.createdAt+600000)MAX_BYTES||info.size!==c.size||!info.contentType.startsWith(c.type)||!isVideo(await mediaMagic(c.id),c.type))throw fail('The uploaded file does not match the video. Keep your local copy and try another file.');const verified=await inspect(await mediaURL(c.id),c);const result=await mutate(key(c.id),d=>{owner(d,body.owner);Object.assign(d,{width:verified.width,height:verified.height});if(d.status!=='pending'&&d.status!=='published')throw fail('This clip is unavailable.');if(d.status==='pending')d.expiresAt=Date.now()+(d.linkDays||7)*86400000;d.status='published';return publicClip(d)});try{await finishUpload({...c,...result})}catch{}return res.status(200).json(result)} if(op==='revoke'){owner(c,body.owner);await mutate(key(c.id),d=>{owner(d,body.owner);d.status='revoked'});return res.status(200).json({revoked:true})} if(op==='delete'){owner(c,body.owner);await mutate(key(c.id),d=>{owner(d,body.owner);d.status='deleted'});await mutate('screennote/capacity.json',d=>{if(d.ids.includes(c.id))(d.deadlines||={})[c.id]=0},()=>({ids:[]}));await cleanup(c);return res.status(200).json({deleted:true})} await viewerAccess(c,body.accessPassword);if(!validId(body.viewer))throw fail('Refresh this page before replying.');if(commentOperations.includes(op)){const result=await mutate(key(c.id),d=>{try{return applyCommentAction(d,op,body)}catch(e){e.status??=400;throw e}});return res.status(200).json(result)} diff --git a/lib/import-media.js b/lib/import-media.js index fe51bad..729df85 100644 --- a/lib/import-media.js +++ b/lib/import-media.js @@ -1,8 +1,8 @@ -import {LOCAL_MAX_BYTES,isVideo} from './rules.js'; +import {MAX_BYTES,isVideo} from './rules.js'; // Some device file providers omit MIME or use application/octet-stream. // Infer only supported extensions, verify their container header, then let the browser decode. export async function importMedia(file){ - if(file.size>LOCAL_MAX_BYTES)throw Error('Choose a video up to 250 MB for local editing. Your file has not been uploaded.'); + if(file.size>MAX_BYTES)throw Error('Choose a video smaller than 25 MB. Your file has not been uploaded.'); const declared=String(file.type||'').split(';')[0].toLowerCase(); const type=['video/mp4','video/webm'].includes(declared)?declared:(!declared||declared==='application/octet-stream')?(/\.mp4$/i.test(file.name)?'video/mp4':/\.webm$/i.test(file.name)?'video/webm':''):''; if(!type)throw Error('Choose an MP4 or WebM video.'); diff --git a/lib/library.js b/lib/library.js deleted file mode 100644 index 07098bd..0000000 --- a/lib/library.js +++ /dev/null @@ -1,7 +0,0 @@ -export const REVIEW_STATES=['needs-feedback','changes-requested','approved']; -export const reviewLabel=s=>({'needs-feedback':'Needs feedback','changes-requested':'Changes requested',approved:'Approved'}[s]||'Needs feedback'); -export function filterRecordings(clips,{query='',status='all',sort='newest'}={}){ - const q=query.trim().toLocaleLowerCase(); - const result=clips.filter(c=>(!q||String(c.title).toLocaleLowerCase().includes(q))&&(status==='all'||status==='shared'&&c.shared&&!c.revoked&&!c.cloudDeleted&&c.expiresAt>Date.now()||status==='local'&&!!c.blob&&(!c.shared||c.revoked||c.cloudDeleted||c.expiresAt<=Date.now())||status===(c.reviewStatus||'needs-feedback'))); - return result.sort((a,b)=>sort==='title'?String(a.title).localeCompare(String(b.title)):sort==='oldest'?a.createdAt-b.createdAt:b.createdAt-a.createdAt); -} diff --git a/lib/recording-policy.js b/lib/recording-policy.js deleted file mode 100644 index b6fd1ba..0000000 --- a/lib/recording-policy.js +++ /dev/null @@ -1,20 +0,0 @@ -import {MAX_BYTES,MAX_SECONDS,LOCAL_MAX_BYTES,LOCAL_MAX_SECONDS} from './rules.js'; -export const PROFILES = { - saver: {label:'Data saver',width:960,fps:15,video:350000,audio:48000}, - balanced: {label:'Balanced',width:1280,fps:24,video:600000,audio:64000}, - clear: {label:'Clear text',width:1920,fps:24,video:1600000,audio:64000} -}; -export const profileFor = name => PROFILES[name] || PROFILES.balanced; -export function recordingStats(bytes,seconds,profile='balanced') { - const rate=seconds>2&&bytes>0?bytes/seconds:(profileFor(profile).video+profileFor(profile).audio)/8; - return {bytes,seconds,remaining:Math.max(0,Math.min(LOCAL_MAX_SECONDS-seconds,(LOCAL_MAX_BYTES-bytes)/rate)), - shareRemaining:Math.max(0,Math.min(MAX_SECONDS-seconds,(MAX_BYTES-bytes)/rate)), - shareable:bytes<=MAX_BYTES&&seconds<=MAX_SECONDS,rate}; -} -export function sharingProblem(clip) { - const size=clip.blob?.size??clip.size??0; - if(clip.duration>MAX_SECONDS)return 'This video is longer than 10 minutes. Trim a shorter copy to share; your original stays here.'; - if(size>MAX_BYTES)return 'This video is larger than 50 MB. Make a smaller copy to share; your original stays here.'; - return ''; -} -export const stopMessage = reason => ({size:'Saved: the 250 MB local recording limit was reached.',duration:'Saved: the 30-minute local recording limit was reached.',source:'Saved: screen sharing ended.',error:'Recording stopped unexpectedly. The captured video is saved.',user:'Recording saved.'}[reason]||'Recording saved.'); diff --git a/lib/rules.js b/lib/rules.js index dcc618f..7c36f22 100644 --- a/lib/rules.js +++ b/lib/rules.js @@ -1,12 +1,11 @@ -import {REVIEW_STATES} from './library.js'; import {validateExtras} from './enrichment.js'; -export const MAX_BYTES=50_000_000,MAX_SECONDS=600,LOCAL_MAX_BYTES=250_000_000,LOCAL_MAX_SECONDS=1800,MAX_SHARED=10000,STORAGE_BUDGET_BYTES=1_000_000_000; +export const MAX_BYTES=25*1024*1024,MAX_SECONDS=300,MAX_SHARED=10000,STORAGE_BUDGET_BYTES=1_000_000_000; export const TYPES=['video/webm','video/mp4']; export const validId=v=>typeof v==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(v); export function text(v,max,required=false){if(typeof v!=='string'||v.trim().length>max||required&&!v.trim())throw Error('Please check the text fields.');return v.trim()} -export function clipInput(v){const title=text(v.title,100,true),type=TYPES.find(t=>String(v.type).split(';')[0].trim()===t);if(!type||!Number.isFinite(v.duration)||v.duration<=0||v.duration>MAX_SECONDS+1||!Number.isInteger(v.size)||v.size<1||v.size>MAX_BYTES)throw Error('Use a playable WebM or MP4, up to ten minutes and 50 MB.');const start=Number(v.start),end=Number(v.end);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>v.duration+.05||end-start<.25)throw Error('Choose a valid start and end, at least a quarter-second apart.');const linkDays=v.linkDays??7;if(![1,3,7].includes(linkDays))throw Error('Choose a link expiry of 1, 3 or 7 days.');const reviewStatus=v.reviewStatus??'needs-feedback';if(!REVIEW_STATES.includes(reviewStatus))throw Error('Choose a valid review status.');return{reviewStatus,title,type,duration:v.duration,size:v.size,start,end,linkDays,...validateExtras(v,v.duration)}} +export function clipInput(v){const title=text(v.title,100,true),type=TYPES.find(t=>String(v.type).split(';')[0].trim()===t);if(!type||!Number.isFinite(v.duration)||v.duration<=0||v.duration>MAX_SECONDS+1||!Number.isInteger(v.size)||v.size<1||v.size>MAX_BYTES)throw Error('Use a playable WebM or MP4, up to five minutes and 25 MB.');const start=Number(v.start),end=Number(v.end);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>v.duration+.05||end-start<.25)throw Error('Choose a valid start and end, at least a quarter-second apart.');const linkDays=v.linkDays??7;if(![1,3,7].includes(linkDays))throw Error('Choose a link expiry of 1, 3 or 7 days.');return{title,type,duration:v.duration,size:v.size,start,end,linkDays,...validateExtras(v,v.duration)}} export function commentInput(v,clip){const body=text(v.text,500,true),name=text(typeof v.name==='string'?(v.name.trim()||'Guest'):(v.name||'Guest'),40,true),time=Number(v.time),parentId=v.parentId||null;if(parentId&&!validId(parentId))throw Error('Choose an existing reply.');if(!Number.isFinite(time)||timeclip.end+.1)throw Error('Choose a timestamp inside this clip.');return{text:body,name,time:Math.max(clip.start,Math.min(time,clip.end)),parentId}} -export function publicClip(c){return{id:c.id,title:c.title,reviewStatus:c.reviewStatus||'needs-feedback',width:c.width,height:c.height,start:c.start,end:c.end,duration:c.duration,createdAt:c.createdAt,expiresAt:c.expiresAt,passwordProtected:!!c.passwordHash,discussionRevision:c.discussionRevision||0,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null,comments:(c.comments||[]).map(({id,name,text,time,createdAt,parentId=null,editedAt,deleted=false,resolved=false,creator=false})=>({id,name:deleted?'Deleted comment':name,text:deleted?'':text,time,createdAt,parentId,editedAt,deleted,resolved,creator})),reactions:Object.fromEntries(['like','thanks','idea'].map(k=>[k,Object.values(c.votes||{}).filter(v=>v===k).length]))}} +export function publicClip(c){return{id:c.id,title:c.title,width:c.width,height:c.height,start:c.start,end:c.end,duration:c.duration,createdAt:c.createdAt,expiresAt:c.expiresAt,passwordProtected:!!c.passwordHash,discussionRevision:c.discussionRevision||0,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null,comments:(c.comments||[]).map(({id,name,text,time,createdAt,parentId=null,editedAt,deleted=false,resolved=false,creator=false})=>({id,name:deleted?'Deleted comment':name,text:deleted?'':text,time,createdAt,parentId,editedAt,deleted,resolved,creator})),reactions:Object.fromEntries(['like','thanks','idea'].map(k=>[k,Object.values(c.votes||{}).filter(v=>v===k).length]))}} export function formatTime(n){n=Math.max(0,Math.floor(Number(n)||0));return `${Math.floor(n/60)}:${String(n%60).padStart(2,'0')}`} export function isVideo(bytes,type){return type==='video/webm'?bytes[0]===26&&bytes[1]===69&&bytes[2]===223&&bytes[3]===163:String.fromCharCode(...bytes.slice(4,8))==='ftyp'} diff --git a/lib/transfer.js b/lib/transfer.js index 6df8e17..bb9db51 100644 --- a/lib/transfer.js +++ b/lib/transfer.js @@ -1 +1 @@ -export async function retryTransfer(send,onRetry=()=>{},delay=ms=>new Promise(r=>setTimeout(r,ms))){for(let attempt=0;;attempt++){try{return await send()}catch(e){if(e.name==='AbortError'||e.code==='STORAGE_ACCESS_UNAVAILABLE'||attempt>=3||e.status&&e.status<500&&e.status!==429)throw e;onRetry(attempt+1);await delay(500*2**attempt)}}} +export async function retryTransfer(send,onRetry=()=>{},delay=ms=>new Promise(r=>setTimeout(r,ms))){for(let attempt=0;;attempt++){try{return await send()}catch(e){if(e.code==='STORAGE_ACCESS_UNAVAILABLE'||attempt>=3||e.status&&e.status<500&&e.status!==429)throw e;onRetry(attempt+1);await delay(500*2**attempt)}}} diff --git a/package-lock.json b/package-lock.json index 49e06e3..166fc0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "screennote", - "version": "0.5.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "screennote", - "version": "0.5.0", + "version": "0.4.0", "dependencies": { "@vercel/blob": "2.8.2", "esbuild": "0.28.2", diff --git a/package.json b/package.json index 56f8ef5..08b8b1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "screennote", - "version": "0.5.0", + "version": "0.4.0", "type": "module", "private": true, "scripts": { diff --git a/public/app.js b/public/app.js index 0f8b06a..6627bc6 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,3 @@ -import {PROFILES,sharingProblem,stopMessage} from '../lib/recording-policy.js'; -import {filterRecordings,reviewLabel} from '../lib/library.js'; import {discussionMarkup,mountDiscussion} from './discussion-ui.js'; import {importTitle,isGeneratedTitle,validTitle} from '../lib/titles.js'; import {version as APP_VERSION} from '../package.json'; @@ -12,92 +10,45 @@ import {trimExtras} from '../lib/enrichment.js'; import {createDraftWriter} from '../lib/draft-writer.js'; import {recoveryDocument,recoveryKeys} from '../lib/recovery.js'; import fixDuration from'fix-webm-duration'; -import{Capture,trimVideo}from'./capture.js';import*as db from'./db.js';import{CHUNK_BYTES,randomId,formatTime,MAX_BYTES,MAX_SECONDS,LOCAL_MAX_BYTES,LOCAL_MAX_SECONDS}from'../lib/rules.js'; +import{Capture,trimVideo}from'./capture.js';import*as db from'./db.js';import{CHUNK_BYTES,randomId,formatTime,MAX_BYTES,MAX_SECONDS}from'../lib/rules.js'; const $=s=>document.querySelector(s),esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const paths={video:'',grid:'',screen:'',mic:'',sound:'',play:'',arrow:'',plus:'',download:'',shield:'',moon:'',pause:'',stop:'',back:''}; const icon=k=>``; const brand=()=>''; -let libraryFilters={query:'',status:'all',sort:'newest'},uploadController=null,cameraBubble={x:.76,y:.72,size:.28}; let clips=[],selected=null,objectURL=null,capture=null,recording=false,paused=false,busy=false,health=false,healthChecked=false,healthChecking=false,sharingIssue='',libraryReady=false,draft=null,viewData=null,viewer=randomId(),vote='',discussionUI=null,viewerOwner='',noticeTimer,markupUI=null,recordMarkup=[],countdownTimer=null,viewerPassword='',emergencyBlob=null,emergencyURL=null; try{document.documentElement.dataset.theme=localStorage.getItem('screennote-theme')||'light';viewer=localStorage.getItem('screennote-viewer')||viewer;localStorage.setItem('screennote-viewer',viewer)}catch{} const app=$('#app'),modal=$('#modal'); -function updateConnection(){const b=$('#connection-state');if(b){b.textContent=navigator.onLine?'':'Offline · local work is still available';b.hidden=navigator.onLine}} -window.addEventListener('offline',updateConnection);window.addEventListener('online',()=>{updateConnection();if(libraryReady)checkSharing()}); let renderedView=null; function notify(message){clearTimeout(noticeTimer);$('#notice').textContent=message;$('#notice').style.display='block';noticeTimer=setTimeout(()=>$('#notice').style.display='none',7000)} function error(e){console.error(e);const box=$('#error');if(box){box.textContent=e.message||String(e);box.scrollIntoView({block:'nearest'})}else notify(e.message||String(e))} -async function checkSharing(){if(healthChecking)return;healthChecking=true;const retry=$('[data-action=check-sharing]');if(retry)retry.disabled=true;const status=$('#sharing-status');if(status)status.textContent='Checking sharing connection…';try{const result=await retryTransfer(()=>api('health'));health=!!result.configured;sharingIssue=health?'':'Online sharing is unavailable. Local editing and downloads still work.'}catch(e){health=false;sharingIssue=e.code==='STORAGE_ACCESS_UNAVAILABLE'?e.message:'Sharing connection failed. Your local copy is safe; retry when connected.'}finally{healthChecked=true;healthChecking=false;const banner=$('#service-status');if(banner){banner.textContent=sharingIssue;banner.hidden=health}const publish=$('[data-action=publish]');if(publish)publish.disabled=!health||!selected?.blob||!!sharingProblem(selected);const message=$('#sharing-status');if(message)message.textContent=health?'':sharingIssue;const button=$('[data-action=check-sharing]');if(button){button.hidden=health;button.disabled=false}}} -async function api(op,data,id){const signal=uploadController?AbortSignal.any([uploadController.signal,AbortSignal.timeout(65000)]):AbortSignal.timeout(65000);const r=await fetch('/api/clip?op='+op+(id?'&id='+encodeURIComponent(id):''),data?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal}:{signal});let v;try{v=await r.json()}catch(e){if(e.name==='AbortError')throw e;throw Error('The sharing service did not respond. Your local copy is safe.')}if(!r.ok)throw Object.assign(Error(v.error||'Sharing failed. Keep your local copy and retry.'),{status:r.status,code:v.code});return v} -function shell(content,page='My recordings',view=page){discussionUI?.destroy();discussionUI=null;const navigated=renderedView!==view;renderedView=view;markupUI?.destroy();markupUI=null;app.innerHTML=`
Your space ${page}${brand()}
Early access · local first

${navigator.onLine?'':'Offline · local work is still available'}

${esc(sharingIssue)}
${emergencyBlob?``:''}${content}
ScreenNote · A little more human.Your recordings stay here until you choose to publish.
`;if(navigated){window.scrollTo({top:0,left:0,behavior:'instant'});$('#main').focus({preventScroll:true})}} +async function checkSharing(){if(healthChecking)return;healthChecking=true;const retry=$('[data-action=check-sharing]');if(retry)retry.disabled=true;const status=$('#sharing-status');if(status)status.textContent='Checking sharing connection…';try{const result=await retryTransfer(()=>api('health'));health=!!result.configured;sharingIssue=health?'':'Online sharing is unavailable. Local editing and downloads still work.'}catch(e){health=false;sharingIssue=e.code==='STORAGE_ACCESS_UNAVAILABLE'?e.message:'Sharing connection failed. Your local copy is safe; retry when connected.'}finally{healthChecked=true;healthChecking=false;const banner=$('#service-status');if(banner){banner.textContent=sharingIssue;banner.hidden=health}const publish=$('[data-action=publish]');if(publish)publish.disabled=!health||!selected?.blob;const message=$('#sharing-status');if(message)message.textContent=health?'':sharingIssue;const button=$('[data-action=check-sharing]');if(button){button.hidden=health;button.disabled=false}}} +async function api(op,data,id){const r=await fetch('/api/clip?op='+op+(id?'&id='+encodeURIComponent(id):''),data?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal:AbortSignal.timeout(65000)}:{signal:AbortSignal.timeout(65000)});let v;try{v=await r.json()}catch{throw Error('The sharing service did not respond. Your local copy is safe.')}if(!r.ok)throw Object.assign(Error(v.error||'Sharing failed. Keep your local copy and retry.'),{status:r.status,code:v.code});return v} +function shell(content,page='My recordings',view=page){discussionUI?.destroy();discussionUI=null;const navigated=renderedView!==view;renderedView=view;markupUI?.destroy();markupUI=null;app.innerHTML=`
Your space ${page}${brand()}
Early access · local first
${esc(sharingIssue)}
${emergencyBlob?``:''}${content}
ScreenNote · A little more human.Your recordings stay here until you choose to publish.
`;if(navigated){window.scrollTo({top:0,left:0,behavior:'instant'});$('#main').focus({preventScroll:true})}} const isShared=c=>!!c.shared&&!c.revoked&&c.expiresAt>Date.now(); function unsavedTitle(){const field=$('#recording-title-input');return !!(selected&&field&&field.value!==(isGeneratedTitle(selected.title)?'Untitled recording':selected.title))} function confirmEditorLeave(){return !unsavedTitle()||confirm('Leave this recording? Your unsaved title will be lost. Your saved video stays on this device.')} -function home(){ - if(recording||busy)return; - const canRecord=globalThis.isSecureContext&&!!navigator.mediaDevices?.getDisplayMedia&&!!globalThis.MediaRecorder; - selected=null;resetURL(); - const returning=clips.length>0; - shell(`
YOUR WORK. CLEARLY EXPLAINED.

${returning?'Your recordings.':'Show it. Send it. Sorted.'}

${returning?'Pick up a conversation, or start a new one.':'Record a walkthrough. Share a link. Get useful feedback.'}

-
Local until you publish · No account needed to watch
- ${returning?libraryMarkup():''} -
${canRecord||window.__ANDROID_APP__?'Record something new':'Import a device recording'}
A LITTLE CONTEXT GOES A LONG WAY

${canRecord?'Ready to record?':'Bring your recording.'}

${canRecord?'Choose your screen next. Review before you share.':'Use your phone’s screen recorder, then import the video here.'}

${icon('screen')}
- - ${!canRecord?'

'+(window.__ANDROID_APP__?'Use Record screen below to capture, then bring the recording in here.':'This browser does not offer screen capture. MP4 and WebM imports work here.')+'

':''} -
${option('mic','Microphone','Include your voice','mic',canRecord)}
-
Camera, audio & microphone source${option('video','Camera bubble','Move and resize it in the preview','camera',canRecord)}${option('sound','Shared-screen audio','When your browser offers it','system',canRecord)}
-

Local: 30 min / 250 MB · Share: 10 min / 50 MB

-
- ${returning?'':libraryMarkup()}`); - $('#import-video').onchange=e=>{const file=e.target.files[0];if(file)run(async()=>{const status=$('#import-status');status.textContent='Checking your video…';e.target.disabled=true;try{await importVideo(file)}finally{e.target.value='';e.target.disabled=false;if(status.isConnected)status.textContent=''}})}; - for(const name of ['library-search','library-status','library-sort'])$('#'+name).addEventListener(name==='library-search'?'input':'change',renderLibrary); - renderLibrary(); -} -function libraryMarkup(){return `

Your library ${clips.length}

On this device

`} -function renderLibrary(){ - libraryFilters={query:$('#library-search').value,status:$('#library-status').value,sort:$('#library-sort').value}; - const visible=filterRecordings(clips,libraryFilters);$('#library-count').textContent=clips.length?`${visible.length} of ${clips.length} recordings`:''; - $('#library-grid').innerHTML=visible.length?visible.map(c=>`
${isShared(c)?'Shared':'Local'}${reviewLabel(c.reviewStatus)}

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${c.cloudDeleted?'Upload deleted':isShared(c)?'Link expires '+new Date(c.expiresAt).toLocaleDateString():c.revoked?'Sharing stopped':c.shared?'Link expired':c.managementOnly?'Management access only':c.sample?'Sample walkthrough':'Saved on this device'} · ${new Date(c.createdAt).toLocaleDateString()}
`).join(''):`

${clips.length?'No matching recordings.':'Your first clear explanation starts here.'}

${clips.length?'Try another title or filter.':'Record a message or explore a sample walkthrough.'}

${clips.length?'':''}
`; -} +function home(){if(recording||busy)return;const canRecord=globalThis.isSecureContext&&!!navigator.mediaDevices?.getDisplayMedia&&!!globalThis.MediaRecorder;selected=null;resetURL();shell(`
LESS BACK-AND-FORTH. MORE CLARITY.

A quick video.
A lot less explaining.

Show your screen. Add your voice. Send the link.
Let the conversation happen in its own time.

Free beta · Viewers need no account. Up to 5 minutes / 25 MB. Shared storage uses a 1 GB budget; capacity depends on video sizes.
YOUR NEXT GOOD EXPLANATION
${icon('play')}

Ready when you are.

A walkthrough, an update, or a little context.

Up to 5 minutes · 25 MB per shared clipReview first. Publish when ready.
MAKE YOURSELF CLEAR

${canRecord?'What are we recording?':'Bring your recording.'}

${canRecord?'Choose a source next, then prepare your recording before the countdown.':'Import a device recording, review it, then share when ready.'}

${option('mic','Microphone','Your voice, alongside your screen','mic',canRecord)}
${option('video','Camera bubble','Put a face to your message','camera',canRecord)}${option('sound','Shared-screen audio','When your browser offers it','system',canRecord)}${!canRecord?('

'+(window.__ANDROID_APP__?'Use Record screen below to capture, then bring the recording in here.':'Screen capture is unavailable in this browser. Import an MP4 or WebM instead.')+'

'):''}

Your recordings ${clips.length}

Saved in this browser
${clips.length?clips.map(c=>`
${c.thumbnail?``:icon('video')}${formatTime(c.duration)}

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${c.cloudDeleted?'Uploaded video deleted':isShared(c)?'Shared link · expires '+new Date(c.expiresAt).toLocaleDateString():c.revoked?'Sharing stopped':c.shared?'Shared link expired':c.managementOnly?'Recovered management access':c.sample?'Sample clip · local only':'Only on this device'} · ${new Date(c.createdAt).toLocaleDateString()}
`).join(''):`

Your ideas belong here.

Record your first message, import a video, or explore with a real sample clip.

`}
`);$('#import-video').onchange=e=>{const file=e.target.files[0];if(file)run(async()=>{const status=$('#import-status');if(status)status.textContent='Reading and checking your video…';e.target.disabled=true;try{await importVideo(file)}finally{e.target.value='';e.target.disabled=false;if(status?.isConnected)status.textContent=''}})}} function option(i,title,copy,id,enabled=true){return ``} function resetURL(){if(objectURL)URL.revokeObjectURL(objectURL);objectURL=null} function fitPlayer(video){if(!video)return;const fit=()=>{if(video.videoWidth&&video.videoHeight){video.style.aspectRatio=video.videoWidth+'/'+video.videoHeight;video.parentElement.style.setProperty('--media-ratio',video.videoWidth/video.videoHeight)}};video.addEventListener('loadedmetadata',fit);video.addEventListener('resize',fit);fit()} function localVideo(){resetURL();if(!selected.blob)return '
Management access restored. The video file is not stored on this device. Use the shared viewer while the link is active.
';objectURL=URL.createObjectURL(selected.blob);return ``} -function editor(c){selected=c;const shareLocked=c.uploadAttempted&&!newShareIdentity(c),shared=isShared(c),cloud=!!(c.shared||c.revoked||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing;shell(`

Review & share

${c.blob?'Saved on this device. Review your video before sharing.':'Manage your shared video.'}

${localVideo()}
More actions${c.originalFilename?`
Original filename${esc(c.originalFilename)}
`:''}
${c.blob?`
Trim video

Trim a new copy on this device. Processing runs at playback speed; keep this tab open. The original stays intact.

`:''}${extrasPanel(c)}${c.sample?'
EXAMPLE FEEDBACK

One moment. A useful conversation.

Illustrative comments for this walkthrough.

Alex · Could the checkout button be clearer?

Sam · Yes — increase the contrast and keep the label short.

Alex · That works. Ready for another review.

':''}${c.blob?markupEditor():''}
`,'Recording','recording:'+c.id);fitPlayer($('#player'));$('#review-status').onchange=()=>run(async()=>{const field=$('#review-status'),next=field.value,old=c.reviewStatus||'needs-feedback';field.disabled=true;try{if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing)await api('review-status',{id:c.id,owner:c.owner,reviewStatus:next});c.reviewStatus=next;await db.put(c);clips=await db.all();$('#review-status-message').textContent='Status saved.'}catch(e){field.value=old;throw e}finally{field.disabled=false}});mountExtras(c,{save:async extras=>{busy=true;try{if(c.shared&&!c.cloudDeleted)await api('details',{id:c.id,owner:c.owner,...extras});Object.assign(c,extras);await db.put(c);clips=await db.all()}finally{busy=false}},download:saveFile,error});if(c.blob)mountMarkupEditor(c);for(const b of document.querySelectorAll('[data-sample-time]'))b.onclick=()=>{$('#player').currentTime=Number(b.dataset.sampleTime);$('#player').play().catch(()=>{})};} +function editor(c){selected=c;const shareLocked=c.uploadAttempted&&!newShareIdentity(c),shared=isShared(c),cloud=!!(c.shared||c.revoked||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing;shell(`

Review & share

${c.blob?'Saved on this device. Review your video before sharing.':'Manage your shared video.'}

${localVideo()}
More actions${c.originalFilename?`
Original filename${esc(c.originalFilename)}
`:''}
${c.blob?`
Trim video

Trim a new copy on this device. Processing runs at playback speed; keep this tab open. The original stays intact.

`:''}${extrasPanel(c)}${c.blob?markupEditor():''}
`,'Recording','recording:'+c.id);fitPlayer($('#player'));mountExtras(c,{save:async extras=>{busy=true;try{if(c.shared&&!c.cloudDeleted)await api('details',{id:c.id,owner:c.owner,...extras});Object.assign(c,extras);await db.put(c);clips=await db.all()}finally{busy=false}},download:saveFile,error});if(c.blob)mountMarkupEditor(c);} async function fixed(blob,seconds){return blob.type.startsWith('video/webm')?await new Promise(resolve=>fixDuration(blob,seconds*1000,resolve,{logger:false})):blob} -async function metadata(blob,knownDuration){const v=document.createElement('video'),url=URL.createObjectURL(blob);v.muted=true;v.preload='metadata';v.src=url;try{await new Promise((resolve,reject)=>{const t=setTimeout(()=>reject(Error('Could not read this video. Try another MP4 or WebM.')),10000);v.onloadedmetadata=()=>{clearTimeout(t);resolve()};v.onerror=()=>{clearTimeout(t);reject(Error('This file is not a playable video in this browser.'))}});let duration=knownDuration||v.duration;if(!Number.isFinite(duration))throw Error('This video has no readable duration. Re-export it as MP4 or WebM, then import again.');if(!v.videoWidth||!v.videoHeight)throw Error('This file does not contain a playable video track.');if(duration<=0||duration>LOCAL_MAX_SECONDS+1)throw Error('Choose a local video up to 30 minutes long.');v.currentTime=Math.min(.2,duration/2);await new Promise(resolve=>{v.onseeked=resolve;setTimeout(resolve,1200)});const canvas=document.createElement('canvas');canvas.width=480;canvas.height=Math.round(480*v.videoHeight/v.videoWidth);canvas.getContext('2d').drawImage(v,0,0,canvas.width,canvas.height);return{duration,thumbnail:canvas.toDataURL('image/jpeg',.6)}}finally{URL.revokeObjectURL(url)}} +async function metadata(blob,knownDuration){const v=document.createElement('video'),url=URL.createObjectURL(blob);v.muted=true;v.preload='metadata';v.src=url;try{await new Promise((resolve,reject)=>{const t=setTimeout(()=>reject(Error('Could not read this video. Try another MP4 or WebM.')),10000);v.onloadedmetadata=()=>{clearTimeout(t);resolve()};v.onerror=()=>{clearTimeout(t);reject(Error('This file is not a playable video in this browser.'))}});let duration=knownDuration||v.duration;if(!Number.isFinite(duration))throw Error('This video has no readable duration. Re-export it as MP4 or WebM, then import again.');if(!v.videoWidth||!v.videoHeight)throw Error('This file does not contain a playable video track.');if(duration<=0||duration>301)throw Error('Choose a video up to five minutes long.');v.currentTime=Math.min(.2,duration/2);await new Promise(resolve=>{v.onseeked=resolve;setTimeout(resolve,1200)});const canvas=document.createElement('canvas');canvas.width=480;canvas.height=Math.round(480*v.videoHeight/v.videoWidth);canvas.getContext('2d').drawImage(v,0,0,canvas.width,canvas.height);return{duration,thumbnail:canvas.toDataURL('image/jpeg',.6)}}finally{URL.revokeObjectURL(url)}} async function importVideo(file,{sample=false,title}={}){if(busy||recording)return;busy=true;try{const media=await importMedia(file);const info=await metadata(media.blob),c={id:randomId(),owner:secret(),title:importTitle(file.name,title),originalFilename:file.name,blob:media.blob,type:media.type,createdAt:Date.now(),sample,...info};await db.put(c);clips=await db.all();editor(c)}finally{busy=false}} function secret(){return[...crypto.getRandomValues(new Uint8Array(32))].map(n=>n.toString(16).padStart(2,'0')).join('')} function markupTools(){return `
0 marks
`} function fitCaptureFrame(video){const update=()=>{if(video.videoWidth&&video.videoHeight)video.parentElement.style.setProperty('--capture-ratio',video.videoWidth/video.videoHeight)};video.addEventListener('loadedmetadata',update);video.addEventListener('resize',update);update()} function mountTools(items){markupUI=bindMarkup($('#markup-canvas'),items,n=>$('#markup-count').textContent=n+' marks',$('#markup-video'));$('#markup-tool').onchange=e=>markupUI.setKind(e.target.value);$('#undo-markup').onclick=()=>markupUI.undo();$('#clear-markup').onclick=()=>markupUI.clear()} -function updateCaptureMetrics(stats){ - if(!$('#record-size'))return; - $('#record-size').textContent=(stats.bytes/1e6).toFixed(1)+' MB'; - $('#record-remaining').textContent=`About ${formatTime(stats.remaining)} local recording left${stats.paused?' · paused':''}`; - $('#mic-meter').value=stats.level||0; - const message=$('#record-share-warning');message.textContent=!stats.shareable?'Past the sharing limit. Your local recording continues; trim or make a smaller copy before publishing.':stats.shareRemaining<60?`About ${formatTime(stats.shareRemaining)} left within sharing limits. Local recording can continue.`:''; -} -function mountCameraHandle(enabled){ - if(!enabled)return;const frame=$('#live-preview').parentElement,handle=document.createElement('button'); - handle.type='button';handle.className='camera-handle';handle.setAttribute('aria-label','Move camera bubble (arrow keys or drag)');handle.textContent='Move camera';frame.append(handle); - const place=()=>{const width=frame.clientWidth,height=frame.clientHeight,size=Math.min(width,height)*cameraBubble.size;cameraBubble.x=Math.max(0,Math.min(cameraBubble.x,1-size/width));cameraBubble.y=Math.max(0,Math.min(cameraBubble.y,1-size/height));handle.style.left=(cameraBubble.x*100)+'%';handle.style.top=(cameraBubble.y*100)+'%';handle.style.width=size+'px';handle.style.height=size+'px'}; - let dragging=false,dx=0,dy=0; - handle.onpointerdown=e=>{dragging=true;const r=handle.getBoundingClientRect();dx=e.clientX-r.left;dy=e.clientY-r.top;handle.setPointerCapture(e.pointerId);e.preventDefault()}; - handle.onpointermove=e=>{if(!dragging)return;const r=frame.getBoundingClientRect();cameraBubble.x=(e.clientX-r.left-dx)/r.width;cameraBubble.y=(e.clientY-r.top-dy)/r.height;place()}; - handle.onpointerup=handle.onpointercancel=()=>dragging=false; - handle.onkeydown=e=>{const dirs={ArrowLeft:[-.03,0],ArrowRight:[.03,0],ArrowUp:[0,-.03],ArrowDown:[0,.03]};if(dirs[e.key]){e.preventDefault();cameraBubble.x+=dirs[e.key][0];cameraBubble.y+=dirs[e.key][1];place()}}; - $('#bubble-size').oninput=e=>{cameraBubble.size=Number(e.target.value)/100;place()}; - $('#live-preview').addEventListener('loadedmetadata',place);place(); -} function cancelCapture(){clearInterval(countdownTimer);capture?.cancel();capture=null;recording=false;busy=false;home()} -function beginRecording(){if(busy||!capture)return;busy=true;let n=3;const button=$('[data-action=begin-recording]');button.disabled=true;$('#capture-countdown').textContent=`Starting in ${n}…`;countdownTimer=setInterval(()=>{n--;if(n){$('#capture-countdown').textContent=`Starting in ${n}…`;return}clearInterval(countdownTimer);run(async()=>{try{capture.begin();recording=true;busy=false;paused=false;$('#capture-countdown').textContent='';$('.recording')?.classList.add('is-live');$('#capture-heading').textContent='Show what you mean.';$('#capture-status').textContent='Your video stays local. Stop to review and publish.';$('#setup-actions').hidden=true;$('#live-actions').hidden=false;$('#recording-status').textContent='RECORDING IN PROGRESS'}catch(e){cancelCapture();throw e}})},1000)} -async function startRecording(){if(recording||busy)return;if(emergencyBlob)throw Error('Download the unsaved recording and choose I saved my copy before recording again.');busy=true;const options={mic:$('#mic').checked,camera:$('#camera').checked,system:$('#system').checked,micId:$('#mic-source')?.value||'',defer:true,quality:$('#record-quality')?.value||'balanced',bubble:cameraBubble={x:.76,y:.72,size:.28},markup:recordMarkup=[]};draft={id:randomId(),chunks:[],createdAt:Date.now(),seconds:0};const writer=createDraftWriter(d=>db.saveDraft(d),()=>notify('Recovery storage is unavailable. Keep this tab open and download your video after stopping.'));const session=capture=new Capture({setupEnded:()=>{cancelCapture();notify('Screen sharing ended before recording. Choose a source again.')},chunk:(blob,seconds)=>{draft.chunks.push(blob);draft.seconds=seconds;if(seconds-(draft.savedSeconds||0)>=5){draft.savedSeconds=seconds;writer.save(structuredClone(draft))}},status:s=>{if($('#record-time'))$('#record-time').textContent=formatTime(s)},metrics:updateCaptureMetrics,finished:async(blob,duration,e,diagnostics)=>{busy=true;recording=false;try{writer.save(structuredClone(draft));await writer.flush();if(!blob.size)throw Error('No video was captured. Try recording again.');blob=await fixed(blob,duration);const info=await metadata(blob,duration),c={id:randomId(),owner:secret(),title:'A quick walkthrough',blob,type:blob.type.split(';')[0],createdAt:Date.now(),diagnostics,...info};await db.finish(c,draft.id);clips=await db.all();busy=false;editor(c);notify(stopMessage(e?'error':diagnostics?.reason))}catch(err){busy=false;if(!blob.size){home();error(err);return}resetURL();emergencyBlob=blob;emergencyURL=URL.createObjectURL(blob);shell(`

Download your recording now.

The captured video is still in this tab, but could not be saved to the local library. Download it before leaving or closing this tab.

`,'Recording');}}});try{const prepared=session.start(options);shell(`
BROWSER PERMISSION

Choose what to share.

Waiting for your browser’s screen picker or device permission. Nothing is recording yet.

Select a screen, window or tab in the browser prompt. Requests time out after one minute. If the prompt blocks this page, dismiss it in your browser. You can then retry or import a device recording.

`,'Recording permissions');const stream=await prepared;if(capture!==session)return;busy=false;recording=true;paused=false;shell(`
CHOOSE WHAT TO SHOW

Prepare your recording.

Nothing is recording yet. Add masks and marks on the preview, then begin.

${options.camera?'
Drag the camera handle to move it.
':''}${markupTools()}

Drag on the preview to add a mark (up to 40). Opaque masks cover pixels; soft blur is a visual effect and may leave details readable. Masks stay at fixed frame positions, so keep sensitive content inside them. Only frames captured after a change include that change. For full-screen walkthroughs, return here to change marks, or mask the saved video before publishing.

0 MBRemaining time is estimated while recording

Keep this tab open. Local recording stops at 30 minutes or 250 MB. Shared clips are limited to 10 minutes / 50 MB. Shared audio depends on your browser and source. Shortcuts: Alt+Shift+P pauses/resumes; Alt+Shift+S stops.
`,'Recording');$('#live-preview').srcObject=stream;fitCaptureFrame($('#live-preview'));$('#live-preview').play().catch(()=>{});mountTools(recordMarkup);mountCameraHandle(options.camera);updateCaptureMetrics({bytes:0,remaining:LOCAL_MAX_SECONDS,shareRemaining:MAX_SECONDS,level:0,shareable:true})}catch(e){session.cancel();if(capture!==session)return;capture=null;busy=false;recording=false;home();throw Error(e.name==='NotAllowedError'?'Recording permission was declined. Nothing was uploaded. Try again or import a video.':e.message)}} -async function publish(){let c=selected;if(c)c.title=validTitle($('#recording-title-input')?$('#recording-title-input').value:c.title);if(!health||!c?.blob)throw Error('Sharing is not available yet.');const problem=sharingProblem(c);if(problem)throw Error(problem);const {linkDays,password,newIdentity}=shareOptions(c,Number($('#link-days')?.value||c.linkDays||7),$('#share-password')?.value||'');if(newIdentity){c={...c,id:randomId(),owner:secret(),revoked:false,shared:false,cloudDeleted:false,cloudMissing:false,uploadAttempted:false,passwordProtected:!!password};await db.put(c);selected=c;clips=await db.all()}c.linkDays=linkDays;c.passwordProtected=!!password||!!c.passwordProtected;busy=true;uploadController=new AbortController();const button=$('[data-action=publish]');button.disabled=true;try{const input={id:c.id,owner:c.owner,title:c.title,type:c.type,duration:c.duration,size:c.blob.size,start:0,end:c.duration,linkDays,password,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null,reviewStatus:c.reviewStatus||'needs-feedback'};$('#cancel-upload').hidden=false;$('#upload-meter').hidden=false;$('#upload-meter').value=0;$('#upload-progress').textContent='Preparing a private upload…';c.uploadAttempted=true;await db.put(c);let reservation;try{reservation=await retryTransfer(()=>api('reserve',input),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying upload setup (${attempt}/3)…`)}catch(e){if(e.status!==409)throw e;const state=await api('manage',{id:c.id,owner:c.owner});if(!['expired','deleted','revoked'].includes(state.status))throw e;c.revoked=true;await db.put(c);busy=false;uploadController=null;return await publish()}const uploaded=new Set(reservation.status==='published'?[]:(await retryTransfer(()=>api('upload-state',{id:c.id,owner:c.owner}))).parts||[]);$('#upload-progress').textContent=uploaded.size?'Resuming saved upload… Your local copy is safe.':'Uploading video… Your local copy is safe.';for(let part=0,offset=0;reservation.status!=='published'&&offset{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not read the local video.'));reader.readAsDataURL(chunk)});await retryTransfer(()=>api('upload-chunk',{id:c.id,owner:c.owner,part,data}),attempt=>$('#upload-progress').textContent=`Connection interrupted. Retrying part ${part+1} (${attempt}/3)…`);$('#upload-meter').value=Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100);$('#upload-progress').textContent=`Uploading ${Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100)}% · your local copy is safe.`}$('#upload-progress').textContent='Finalizing your video…';const shared=await retryTransfer(()=>api('publish',{id:c.id,owner:c.owner}),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying finalization (${attempt}/3)…`);c.shared=true;c.uploadPaused=false;c.passwordProtected=shared.passwordProtected;c.expiresAt=shared.expiresAt;await db.put(c);clips=await db.all();busy=false;editor(c);notify('Your link is ready. The local copy is still here.')}catch(e){busy=false;if(e.name==='AbortError')c.uploadPaused=true;await db.put(c);editor(c);throw e}finally{uploadController=null;busy=false;button.disabled=false}} +function beginRecording(){if(busy||!capture)return;busy=true;let n=3;const button=$('[data-action=begin-recording]');button.disabled=true;$('#capture-countdown').textContent=`Starting in ${n}…`;countdownTimer=setInterval(()=>{n--;if(n){$('#capture-countdown').textContent=`Starting in ${n}…`;return}clearInterval(countdownTimer);run(async()=>{try{capture.begin();recording=true;busy=false;paused=false;$('#capture-countdown').textContent='';$('#capture-heading').textContent='Show what you mean.';$('#capture-status').textContent='Your video stays local. Stop to review and publish.';$('#setup-actions').hidden=true;$('#live-actions').hidden=false;$('#recording-status').textContent='RECORDING IN PROGRESS'}catch(e){cancelCapture();throw e}})},1000)} +async function startRecording(){if(recording||busy)return;if(emergencyBlob)throw Error('Download the unsaved recording and choose I saved my copy before recording again.');busy=true;const options={mic:$('#mic').checked,camera:$('#camera').checked,system:$('#system').checked,micId:$('#mic-source')?.value||'',defer:true,markup:recordMarkup=[]};draft={id:randomId(),chunks:[],createdAt:Date.now(),seconds:0};const writer=createDraftWriter(d=>db.saveDraft(d),()=>notify('Recovery storage is unavailable. Keep this tab open and download your video after stopping.'));const session=capture=new Capture({setupEnded:()=>{cancelCapture();notify('Screen sharing ended before recording. Choose a source again.')},chunk:(blob,seconds)=>{draft.chunks.push(blob);draft.seconds=seconds;writer.save(structuredClone(draft))},status:s=>{if($('#record-time'))$('#record-time').textContent=formatTime(s)},finished:async(blob,duration,e)=>{busy=true;recording=false;try{await writer.flush();if(!blob.size)throw Error('No video was captured. Try recording again.');blob=await fixed(blob,duration);const info=await metadata(blob,duration),c={id:randomId(),owner:secret(),title:'A quick walkthrough',blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.finish(c,draft.id);clips=await db.all();busy=false;editor(c);if(e)notify('Recording stopped early. The captured video is saved.')}catch(err){busy=false;if(!blob.size){home();error(err);return}resetURL();emergencyBlob=blob;emergencyURL=URL.createObjectURL(blob);shell(`

Download your recording now.

The captured video is still in this tab, but could not be saved to the local library. Download it before leaving or closing this tab.

`,'Recording');}}});try{const prepared=session.start(options);shell(`
BROWSER PERMISSION

Choose what to share.

Waiting for your browser’s screen picker or device permission. Nothing is recording yet.

Select a screen, window or tab in the browser prompt. Requests time out after one minute. If the prompt blocks this page, dismiss it in your browser. You can then retry or import a device recording.

`,'Recording permissions');const stream=await prepared;if(capture!==session)return;busy=false;recording=true;paused=false;shell(`
CHOOSE WHAT TO SHOW

Prepare your recording.

Nothing is recording yet. Add masks and marks on the preview, then begin.

${markupTools()}

Drag on the preview to add a mark (up to 40). Opaque masks cover pixels; soft blur is a visual effect and may leave details readable. Masks stay at fixed frame positions, so keep sensitive content inside them. Only frames captured after a change include that change. For full-screen walkthroughs, return here to change marks, or mask the saved video before publishing.

Keep this tab open. Stops at five minutes or the recording size limit. Shared audio depends on your browser and source. Shortcuts: Alt+Shift+P pauses/resumes; Alt+Shift+S stops.
`,'Recording');$('#live-preview').srcObject=stream;fitCaptureFrame($('#live-preview'));$('#live-preview').play().catch(()=>{});mountTools(recordMarkup)}catch(e){session.cancel();if(capture!==session)return;capture=null;busy=false;recording=false;home();throw Error(e.name==='NotAllowedError'?'Recording permission was declined. Nothing was uploaded. Try again or import a video.':e.message)}} +async function publish(){let c=selected;if(c)c.title=validTitle($('#recording-title-input')?$('#recording-title-input').value:c.title);if(!health||!c?.blob)throw Error('Sharing is not available yet.');const {linkDays,password,newIdentity}=shareOptions(c,Number($('#link-days')?.value||c.linkDays||7),$('#share-password')?.value||'');if(newIdentity){c={...c,id:randomId(),owner:secret(),revoked:false,shared:false,cloudDeleted:false,cloudMissing:false,uploadAttempted:false,passwordProtected:!!password};await db.put(c);selected=c;clips=await db.all()}c.linkDays=linkDays;c.passwordProtected=!!password||!!c.passwordProtected;busy=true;const button=$('[data-action=publish]');button.disabled=true;try{const input={id:c.id,owner:c.owner,title:c.title,type:c.type,duration:c.duration,size:c.blob.size,start:0,end:c.duration,linkDays,password,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null};$('#upload-meter').hidden=false;$('#upload-meter').value=0;$('#upload-progress').textContent='Preparing a private upload…';c.uploadAttempted=true;await db.put(c);let reservation;try{reservation=await retryTransfer(()=>api('reserve',input),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying upload setup (${attempt}/3)…`)}catch(e){if(e.status!==409)throw e;const state=await api('manage',{id:c.id,owner:c.owner});if(!['expired','deleted','revoked'].includes(state.status))throw e;c.revoked=true;await db.put(c);busy=false;return await publish()}const uploaded=new Set(reservation.status==='published'?[]:(await retryTransfer(()=>api('upload-state',{id:c.id,owner:c.owner}))).parts||[]);$('#upload-progress').textContent=uploaded.size?'Resuming saved upload… Your local copy is safe.':'Uploading video… Your local copy is safe.';for(let part=0,offset=0;reservation.status!=='published'&&offset{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not read the local video.'));reader.readAsDataURL(chunk)});await retryTransfer(()=>api('upload-chunk',{id:c.id,owner:c.owner,part,data}),attempt=>$('#upload-progress').textContent=`Connection interrupted. Retrying part ${part+1} (${attempt}/3)…`);$('#upload-meter').value=Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100);$('#upload-progress').textContent=`Uploading ${Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100)}% · your local copy is safe.`}$('#upload-progress').textContent='Finalizing your video…';const shared=await retryTransfer(()=>api('publish',{id:c.id,owner:c.owner}),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying finalization (${attempt}/3)…`);c.shared=true;c.passwordProtected=shared.passwordProtected;c.expiresAt=shared.expiresAt;await db.put(c);clips=await db.all();busy=false;editor(c);notify('Your link is ready. The local copy is still here.')}catch(e){busy=false;editor(c);throw e}finally{busy=false;button.disabled=false}} function saveFile(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000)} function download(){const a=document.createElement('a');a.href=objectURL||URL.createObjectURL(selected.blob);a.download=selected.title.replace(/[^\p{L}\p{N} _-]/gu,'').slice(0,80)+(selected.type==='video/mp4'?'.mp4':'.webm');document.body.append(a);a.click();a.remove()} -function privacy(){if(recording||busy)return;shell(`
CLEAR BY DEFAULT

Your screen.
Your call.

ScreenNote only uploads a video when you choose Publish. Recording and trimming happen in your browser.

Before you publish

Recordings and recovery chunks are stored in this browser. Download videos you want to keep. Clearing site data, private browsing or losing this device can remove your local library.

After you publish

Older links from the previous storage service may be unavailable. Their videos have not been copied while that service denies access. If you have the original local video, refresh its shared status and publish a replacement link. Keep your original recovery keys.

The whole reviewed video is uploaded to private Supabase storage. An unlisted link lets anyone who has it watch and reply without a ScreenNote account. Do not include passwords or information you do not want the recipient to see. Unlisted does not mean encrypted end to end.

Links expire after 1, 3 or 7 days, as chosen before publication. Optional sharing passwords are verified on the server; issued playback URLs are bearer links with a five-minute lifetime. Captions, chapters and bug-report text you save are shared with viewers. Stop sharing blocks new viewer requests; already issued playback URLs may work for up to five more minutes. Expired uploads and abandoned reservations become eligible for deletion after a further 24 hours. ScreenNote reclaims them when new uploads reach shared capacity; cleanup is demand-driven, not a timed deletion guarantee.

Small, honest first release

Back up your recovery keys to restore management access after changing browsers or clearing site data. Keep them private: they authorize stopping sharing and deleting uploads. Videos are not included.

Clips are limited to ten minutes and 50 MB. Shared uploads use a 1 GB storage budget. Pending uploads reserve space for both chunks and the assembled video. Deleting an uploaded video frees its slot after cleanup succeeds. Old expired uploads are also reclaimed when capacity is full. Local recording and downloads remain usable when sharing is full. Hosting quotas may pause online sharing; no paid upgrade is enabled by this application.

Screen capture needs a supported desktop browser. On phones or browsers without screen capture, record with your device and import MP4 or WebM. Camera, microphone and shared-screen audio require your browser’s permissions. Online sharing uses Supabase private file storage and a database for recording details and feedback. The app is hosted on Vercel. No AI analysis, advertising trackers or per-viewer charges are built in.

Replies and reactions

Replies are visible to everyone with the link. Names are self-entered, not verified identities. You can reply to any comment or reply. New replies reopen resolved threads. Comment authors can edit or remove their comments from the browser that posted them; the video creator can also remove comments and resolve threads. Clearing site data loses anonymous comment controls. Unsent drafts are kept in this tab’s session storage. Optional bubbles preview comments in the inline player; native fullscreen and picture in picture show the video only. Each clip supports 100 total comments and replies, including removed entries. Reactions use an anonymous browser identifier and are not verified unique-person counts.

`,'Privacy')} -function recovery(){if(recording||busy)return;shell(`
KEEP CONTROL

Take your keys with you.

A private recovery file restores the ability to stop sharing or delete your uploaded videos from another browser at this same ScreenNote address.

Back up management access

Keep this file private. Anyone with its keys can manage the uploads. It contains no video files: download videos separately.

Restore your keys

Choose a recovery file, or paste its complete JSON below. Keys are checked with the sharing service before saving. Existing local videos remain intact.

`,'Recovery');$('#recovery-file').onchange=e=>run(async()=>{const file=e.target.files[0];if(!file)return;if(file.size>524288)throw Error('Choose a recovery file smaller than 512 KB.');$('#recovery-json').value=await file.text()})} -async function restoreKeys(){const keys=recoveryKeys($('#recovery-json').value,location.origin);busy=true;const button=$('[data-action=restore-keys]');button.disabled=true;let restored=0,failed=0,videoFailed=0;try{for(const key of keys){$('#recovery-progress').textContent=`Checking key ${restored+failed+1} of ${keys.length}…`;try{const state=await api('manage',key),current=clips.find(c=>c.id===key.id);if(current&¤t.owner!==key.owner)throw Error('This recording belongs to another key.');const record={...current,...state,...key,shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),uploadAttempted:true,cloudDeleted:state.cleanupComplete,managementOnly:!current?.blob};await db.put(record);clips=await db.all();if($('#restore-videos').checked&&state.status==='published'&&state.expiresAt>Date.now()&&!record.blob){try{const response=await fetch(await api('restore-media',key).then(v=>v.mediaUrl));if(!response.ok)throw Error('Video download failed.');const file=new File([await response.blob()],record.title+'.'+(record.type==='video/mp4'?'mp4':'webm'),{type:record.type});const media=await importMedia(file),info=await metadata(media.blob);record.blob=media.blob;Object.assign(record,info,{managementOnly:false})}catch{videoFailed++}}await db.put(record);clips=await db.all();restored++}catch{failed++}}$('#recovery-json').value='';$('#recovery-file').value='';$('#recovery-progress').textContent=`Restored ${restored} recording${restored===1?'':'s'}.${failed?' '+failed+' key(s) could not be verified. Keep your file and retry when sharing is available.':''} ${videoFailed?' '+videoFailed+' video download(s) could not finish; management keys were restored. Keep the recovery file to retry.':''} Open My recordings to manage them.`}finally{busy=false;button.disabled=false}} +function privacy(){if(recording||busy)return;shell(`
CLEAR BY DEFAULT

Your screen.
Your call.

ScreenNote only uploads a video when you choose Publish. Recording and trimming happen in your browser.

Before you publish

Recordings and recovery chunks are stored in this browser. Download videos you want to keep. Clearing site data, private browsing or losing this device can remove your local library.

After you publish

Older links from the previous storage service may be unavailable. Their videos have not been copied while that service denies access. If you have the original local video, refresh its shared status and publish a replacement link. Keep your original recovery keys.

The whole reviewed video is uploaded to private Supabase storage. An unlisted link lets anyone who has it watch and reply without a ScreenNote account. Do not include passwords or information you do not want the recipient to see. Unlisted does not mean encrypted end to end.

Links expire after 1, 3 or 7 days, as chosen before publication. Optional sharing passwords are verified on the server; issued playback URLs are bearer links with a five-minute lifetime. Captions, chapters and bug-report text you save are shared with viewers. Stop sharing blocks new viewer requests; already issued playback URLs may work for up to five more minutes. Expired uploads and abandoned reservations become eligible for deletion after a further 24 hours. ScreenNote reclaims them when new uploads reach shared capacity; cleanup is demand-driven, not a timed deletion guarantee.

Small, honest first release

Back up your recovery keys to restore management access after changing browsers or clearing site data. Keep them private: they authorize stopping sharing and deleting uploads. Videos are not included.

Clips are limited to five minutes and 25 MB. Shared uploads use a 1 GB storage budget. Pending uploads reserve space for both chunks and the assembled video. Deleting an uploaded video frees its slot after cleanup succeeds. Old expired uploads are also reclaimed when capacity is full. Local recording and downloads remain usable when sharing is full. Hosting quotas may pause online sharing; no paid upgrade is enabled by this application.

Screen capture needs a supported desktop browser. On phones or browsers without screen capture, record with your device and import MP4 or WebM. Camera, microphone and shared-screen audio require your browser’s permissions. Online sharing uses Supabase private file storage and a database for recording details and feedback. The app is hosted on Vercel. No AI analysis, advertising trackers or per-viewer charges are built in.

Replies and reactions

Replies are visible to everyone with the link. Names are self-entered, not verified identities. You can reply to any comment or reply. New replies reopen resolved threads. Comment authors can edit or remove their comments from the browser that posted them; the video creator can also remove comments and resolve threads. Clearing site data loses anonymous comment controls. Unsent drafts are kept in this tab’s session storage. Optional bubbles preview comments in the inline player; native fullscreen and picture in picture show the video only. Each clip supports 100 total comments and replies, including removed entries. Reactions use an anonymous browser identifier and are not verified unique-person counts.

`,'Privacy')} +function recovery(){if(recording||busy)return;shell(`
KEEP CONTROL

Take your keys with you.

A private recovery file restores the ability to stop sharing or delete your uploaded videos from another browser at this same ScreenNote address.

Back up management access

Keep this file private. Anyone with its keys can manage the uploads. It contains no video files: download videos separately.

Restore your keys

Choose a recovery file, or paste its complete JSON below. Keys are checked with the sharing service before saving. Existing local videos remain intact.

`,'Recovery');$('#recovery-file').onchange=e=>run(async()=>{const file=e.target.files[0];if(!file)return;if(file.size>524288)throw Error('Choose a recovery file smaller than 512 KB.');$('#recovery-json').value=await file.text()})} +async function restoreKeys(){const keys=recoveryKeys($('#recovery-json').value,location.origin);busy=true;const button=$('[data-action=restore-keys]');button.disabled=true;let restored=0,failed=0;try{for(const key of keys){$('#recovery-progress').textContent=`Checking key ${restored+failed+1} of ${keys.length}…`;try{const state=await api('manage',key),current=clips.find(c=>c.id===key.id);if(current&¤t.owner!==key.owner)throw Error('This recording belongs to another key.');const record={...current,...state,...key,shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),uploadAttempted:true,cloudDeleted:state.cleanupComplete,managementOnly:!current?.blob};await db.put(record);clips=await db.all();restored++}catch{failed++}}$('#recovery-json').value='';$('#recovery-file').value='';$('#recovery-progress').textContent=`Restored ${restored} recording${restored===1?'':'s'}.${failed?' '+failed+' key(s) could not be verified. Keep your file and retry when sharing is available.':''} Open My recordings to manage them.`}finally{busy=false;button.disabled=false}} function dialog(title,body,save,label='Save'){ const opener=document.activeElement;modal.onclose=()=>{const target=opener?.isConnected?opener:$('#main');target?.focus({preventScroll:true})}; let saving=false; @@ -113,13 +64,13 @@ function dialog(title,body,save,label='Save'){ };modal.showModal(); } async function run(fn){try{if($('#error'))$('#error').textContent='';await fn()}catch(e){error(e)}} -const actions={'bug-report':()=>{const panel=$('.extra-panel');panel.open=true;panel.scrollIntoView({block:'nearest',behavior:'smooth'});$('#report-actual').focus()},'cancel-upload':()=>uploadController?.abort(Object.assign(Error('Upload paused. Your local copy is safe. Publish again to resume saved parts.'),{name:'AbortError'})),'new-recording':()=>{const setup=$('#record-setup');setup.open=true;setup.scrollIntoView({block:'nearest',behavior:'smooth'});(setup.querySelector('[data-action=record]:not(:disabled)')||$('#import-video')).focus()},'clear-filters':()=>{libraryFilters={query:'',status:'all',sort:'newest'};home()},help,'check-sharing':checkSharing,'list-mics':listMicrophones,'begin-recording':beginRecording,'cancel-capture':cancelCapture,recovery,'acknowledge-recovery':()=>{emergencyBlob=null;if(emergencyURL)URL.revokeObjectURL(emergencyURL);emergencyURL=null;home()},'refresh-share':async()=>{const c=selected;busy=true;try{const state=await api('manage',{id:c.id,owner:c.owner});Object.assign(c,state,{shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),cloudDeleted:state.cleanupComplete});await db.put(c);clips=await db.all();busy=false;editor(c);notify('Shared status refreshed.')}catch(e){if(e.status!==404)throw e;c.shared=false;c.revoked=true;c.cloudMissing=true;await db.put(c);clips=await db.all();busy=false;editor(c);notify(c.blob?'The previous upload is unavailable. Your local video is safe; publish a replacement link.':'The previous upload is unavailable. Import the original video to publish a replacement link.')}finally{busy=false}},'backup-keys':()=>{const raw=recoveryDocument(clips,location.origin);if(!JSON.parse(raw).keys.length)throw Error('No uploaded recordings need recovery keys yet.');saveFile(new Blob([raw],{type:'application/json'}),'screennote-recovery.json');notify('Recovery keys downloaded. Keep this file private; download videos separately.')},'restore-keys':restoreKeys,'delete-cloud':()=>dialog('Delete the uploaded video?',`

This permanently deletes the uploaded video and stops its shared link. ${selected.blob?'Your local file remains saved on this device.':'There is no local video file on this device.'}

`,async()=>{await api('delete',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;selected.cloudDeleted=true;await db.put(selected);clips=await db.all();editor(selected)},'Delete uploaded video'),home:()=>{if(location.pathname.startsWith('/v/'))location.assign('/');else home()},privacy,record:startRecording,stop:()=>{capture.stop();$('#record-time').textContent='Saving…';$('#pause-button').disabled=true;$('[data-action=stop]').disabled=true;},pause:()=>{paused=!paused;paused?capture.pause():capture.resume();$('#pause-button').innerHTML=icon(paused?'play':'pause')+(paused?'Resume':'Pause');$('#recording-status').textContent=paused?'PAUSED · NOT CAPTURING':'RECORDING IN PROGRESS';$('.recording')?.classList.toggle('is-paused',paused)},theme:()=>{const theme=document.documentElement.dataset.theme==='dark'?'light':'dark';document.documentElement.dataset.theme=theme;try{localStorage.setItem('screennote-theme',theme)}catch{}},compress:async()=>{const c=selected;if(!c?.blob||c.duration>MAX_SECONDS)throw Error('Trim a clip up to 10 minutes first.');busy=true;const b=$('[data-action=compress]');b.disabled=true;try{notify('Making a smaller copy at playback speed. Keep this tab visible.');const blob=await fixed(await trimVideo(c.blob,0,c.duration,p=>$('#upload-progress').textContent=`Making smaller copy · ${Math.round(p*100)}%`,[],'saver'),c.duration),info=await metadata(blob,c.duration);if(blob.size>=c.blob.size)throw Error('This copy would not be smaller. Try trimming a shorter section instead.');const copy={...c,id:randomId(),owner:secret(),title:c.title.slice(0,80)+' · smaller',blob,...info,shared:false,revoked:false,uploadAttempted:false,passwordProtected:false,cloudDeleted:false,cloudMissing:false,createdAt:Date.now()};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify(sharingProblem(copy)||'Smaller copy saved. Your original stays in your library.')}finally{busy=false;b.disabled=false}},sample:async()=>{const r=await fetch('/sample.webm');if(!r.ok)throw Error('Sample clip is unavailable. Import your own video.');await importVideo(new File([await r.blob()],'A little context goes a long way.webm',{type:'video/webm'}),{sample:true,title:'A clearer checkout · sample walkthrough'});Object.assign(selected,{captions:[{start:0,end:4,text:'Show the detail that needs attention.'},{start:4,end:9,text:'Add a timestamp so feedback points to the right moment.'},{start:9,end:15,text:'Reply, resolve and keep the work moving.'}],chapters:[{time:0,title:'Show the detail'},{time:5,title:'Pin the feedback'},{time:10,title:'Resolve the conversation'}]});await db.put(selected);editor(selected)},download,publish,'copy-link':async()=>{const link=location.origin+'/v/'+selected.id;try{await navigator.clipboard.writeText(link);notify('Link copied.')}catch{dialog('Your share link',`
`,async()=>{},'Done')}},'save-title':async()=>{const c=selected,title=validTitle($('#recording-title-input').value);busy=true;try{if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing){try{await api('title',{id:c.id,owner:c.owner,title})}catch(e){if(e.status!==404||c.shared)throw e}}c.title=title;await db.put(c);clips=await db.all();$('#title-status').textContent='Title saved.'}finally{busy=false}},rename:()=>dialog('Give it a clear title',`
`,async f=>{const title=f.get('title').trim();if(!title)throw Error('Add a title.');selected.title=title;await db.put(selected);clips=await db.all();editor(selected)}),'remove-local':()=>dialog(selected.blob?'Remove this local copy?':'Remove recovery access?',`

${selected.blob?'This removes the video from this browser.':'This removes the saved management keys from this browser.'} ${(selected.shared||selected.revoked||selected.uploadAttempted)&&!selected.cloudDeleted?'This also removes this browser’s ability to manage the uploaded video. Delete the uploaded video first if you want to remove it from the web.':selected.blob?'Download it first if you want to keep it.':'Keep an exported recovery file if you need this access later.'}

`,async()=>{await db.remove(selected.id);clips=await db.all();busy=false;home()},selected.blob?'Remove local copy':'Remove recovery access'),revoke:()=>dialog('Stop sharing this video?',`

New viewers will no longer be able to open it. Playback URLs already issued may work for up to five minutes. ${selected.blob?'Your local video stays here.':'Your recovered management keys stay here.'}

`,async()=>{await api('revoke',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;await db.put(selected);clips=await db.all();editor(selected)},'Stop sharing'),trim:async()=>{const c=selected,start=Number($('#trim-start').value),end=Number($('#trim-end').value);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>c.duration+.05||end-start<.25)throw Error('Choose a valid range of at least 0.25 seconds.');busy=true;$('[data-action=trim]').disabled=true;try{let blob=await trimVideo(c.blob,start,end,p=>$('#trim-progress').textContent=`Trimming locally · ${Math.round(p*100)}%`);blob=await fixed(blob,end-start);const info=await metadata(blob,end-start),copy={id:randomId(),owner:secret(),title:c.title.slice(0,85)+' · trimmed',...trimExtras(c,start,end),blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify('Trimmed copy saved. The original is still in your library.')}finally{busy=false;const b=$('[data-action=trim]');if(b)b.disabled=false}}}; -app.addEventListener('click',e=>{const b=e.target.closest('[data-action],[data-open],[data-rename]');if(!b||b.disabled||busy&&!['cancel-capture','cancel-upload'].includes(b.dataset.action))return;if((b.dataset.open||['home','help','privacy','recovery','refresh-share'].includes(b.dataset.action))&&!confirmEditorLeave())return;if(b.dataset.rename){const c=clips.find(c=>c.id===b.dataset.rename);dialog('Rename recording',`
`,async f=>{const title=validTitle(f.get('title'));if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing)await api('title',{id:c.id,owner:c.owner,title});c.title=title;await db.put(c);clips=await db.all();renderLibrary()});return}if(b.dataset.open){editor(clips.find(c=>c.id===b.dataset.open));return}run(()=>actions[b.dataset.action]?.())}); +const actions={help,'check-sharing':checkSharing,'list-mics':listMicrophones,'begin-recording':beginRecording,'cancel-capture':cancelCapture,recovery,'acknowledge-recovery':()=>{emergencyBlob=null;if(emergencyURL)URL.revokeObjectURL(emergencyURL);emergencyURL=null;home()},'refresh-share':async()=>{const c=selected;busy=true;try{const state=await api('manage',{id:c.id,owner:c.owner});Object.assign(c,state,{shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),cloudDeleted:state.cleanupComplete});await db.put(c);clips=await db.all();busy=false;editor(c);notify('Shared status refreshed.')}catch(e){if(e.status!==404)throw e;c.shared=false;c.revoked=true;c.cloudMissing=true;await db.put(c);clips=await db.all();busy=false;editor(c);notify(c.blob?'The previous upload is unavailable. Your local video is safe; publish a replacement link.':'The previous upload is unavailable. Import the original video to publish a replacement link.')}finally{busy=false}},'backup-keys':()=>{const raw=recoveryDocument(clips,location.origin);if(!JSON.parse(raw).keys.length)throw Error('No uploaded recordings need recovery keys yet.');saveFile(new Blob([raw],{type:'application/json'}),'screennote-recovery.json');notify('Recovery keys downloaded. Keep this file private; download videos separately.')},'restore-keys':restoreKeys,'delete-cloud':()=>dialog('Delete the uploaded video?',`

This permanently deletes the uploaded video and stops its shared link. ${selected.blob?'Your local file remains saved on this device.':'There is no local video file on this device.'}

`,async()=>{await api('delete',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;selected.cloudDeleted=true;await db.put(selected);clips=await db.all();editor(selected)},'Delete uploaded video'),home:()=>{if(location.pathname.startsWith('/v/'))location.assign('/');else home()},privacy,record:startRecording,stop:()=>{capture.stop();$('#record-time').textContent='Saving…';$('#pause-button').disabled=true;$('[data-action=stop]').disabled=true;},pause:()=>{paused=!paused;paused?capture.pause():capture.resume();$('#pause-button').innerHTML=icon(paused?'play':'pause')+(paused?'Resume':'Pause');$('#recording-status').textContent=paused?'PAUSED · NOT CAPTURING':'RECORDING IN PROGRESS'},theme:()=>{const theme=document.documentElement.dataset.theme==='dark'?'light':'dark';document.documentElement.dataset.theme=theme;try{localStorage.setItem('screennote-theme',theme)}catch{}},sample:async()=>{const r=await fetch('/sample.webm');if(!r.ok)throw Error('Sample clip is unavailable. Import your own video.');await importVideo(new File([await r.blob()],'A little context goes a long way.webm',{type:'video/webm'}),{sample:true})},download,publish,'copy-link':async()=>{const link=location.origin+'/v/'+selected.id;try{await navigator.clipboard.writeText(link);notify('Link copied.')}catch{dialog('Your share link',`
`,async()=>{},'Done')}},'save-title':async()=>{const c=selected,title=validTitle($('#recording-title-input').value);busy=true;try{if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing){try{await api('title',{id:c.id,owner:c.owner,title})}catch(e){if(e.status!==404||c.shared)throw e}}c.title=title;await db.put(c);clips=await db.all();$('#title-status').textContent='Title saved.'}finally{busy=false}},rename:()=>dialog('Give it a clear title',`
`,async f=>{const title=f.get('title').trim();if(!title)throw Error('Add a title.');selected.title=title;await db.put(selected);clips=await db.all();editor(selected)}),'remove-local':()=>dialog(selected.blob?'Remove this local copy?':'Remove recovery access?',`

${selected.blob?'This removes the video from this browser.':'This removes the saved management keys from this browser.'} ${(selected.shared||selected.revoked||selected.uploadAttempted)&&!selected.cloudDeleted?'This also removes this browser’s ability to manage the uploaded video. Delete the uploaded video first if you want to remove it from the web.':selected.blob?'Download it first if you want to keep it.':'Keep an exported recovery file if you need this access later.'}

`,async()=>{await db.remove(selected.id);clips=await db.all();busy=false;home()},selected.blob?'Remove local copy':'Remove recovery access'),revoke:()=>dialog('Stop sharing this video?',`

New viewers will no longer be able to open it. Playback URLs already issued may work for up to five minutes. ${selected.blob?'Your local video stays here.':'Your recovered management keys stay here.'}

`,async()=>{await api('revoke',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;await db.put(selected);clips=await db.all();editor(selected)},'Stop sharing'),trim:async()=>{const c=selected,start=Number($('#trim-start').value),end=Number($('#trim-end').value);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>c.duration+.05||end-start<.25)throw Error('Choose a valid range of at least 0.25 seconds.');busy=true;$('[data-action=trim]').disabled=true;try{let blob=await trimVideo(c.blob,start,end,p=>$('#trim-progress').textContent=`Trimming locally · ${Math.round(p*100)}%`);blob=await fixed(blob,end-start);const info=await metadata(blob,end-start),copy={id:randomId(),owner:secret(),title:c.title.slice(0,85)+' · trimmed',...trimExtras(c,start,end),blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify('Trimmed copy saved. The original is still in your library.')}finally{busy=false;const b=$('[data-action=trim]');if(b)b.disabled=false}}}; +app.addEventListener('click',e=>{const b=e.target.closest('[data-action],[data-open]');if(!b||b.disabled||busy&&b.dataset.action!=='cancel-capture')return;if((b.dataset.open||['home','help','privacy','recovery','refresh-share'].includes(b.dataset.action))&&!confirmEditorLeave())return;if(b.dataset.open){editor(clips.find(c=>c.id===b.dataset.open));return}run(()=>actions[b.dataset.action]?.())}); window.addEventListener('beforeunload',e=>{if(recording||busy||emergencyBlob||unsavedTitle()){e.preventDefault();e.returnValue=''}}); async function viewerPage(id){try{await db.open();const local=await db.get(id);viewerOwner=local?.owner||''}catch{viewerOwner=''}try{vote=localStorage.getItem('screennote-vote-'+id)||''}catch{}app.innerHTML=`
${brand()}Make your own video ${icon('arrow')}

Opening your message…

`;try{viewData=await api('view',viewerPassword?{id,accessPassword:viewerPassword}:null,id);renderViewer()}catch(e){if(e.status===401){$('#main').innerHTML=`

A password keeps this video private.

${viewerPassword?'Password not accepted. ':''}Ask the sender for the sharing password.

`;$('#unlock-form').onsubmit=e=>{e.preventDefault();viewerPassword=$('#viewer-password').value;run(()=>viewerPage(id))};return}$('#main').innerHTML=`

This message is unavailable.

The link may have expired, stopped sharing, or the hosting service may be unavailable.

Open ScreenNote`}} function renderViewer(){ discussionUI?.destroy();const c=viewData; - $('#main').innerHTML=`
A LITTLE CONTEXT FOR YOU

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${reviewLabel(c.reviewStatus)}

${document.pictureInPictureEnabled?'':''}
${[['like','👍','Helpful'],['thanks','🙌','Thanks'],['idea','💡','Good idea']].map(([key,emoji,label])=>``).join('')}

${formatTime(c.end-c.start)} · Link expires ${new Date(c.expiresAt).toLocaleDateString()}
Anyone with this link can see replies. Guest names are not verified.

${viewerContext(c)}
`; + $('#main').innerHTML=`
A LITTLE CONTEXT FOR YOU

${esc(isGeneratedTitle(c.title)?'Untitled recording':c.title)}

${document.pictureInPictureEnabled?'':''}
${[['like','👍','Helpful'],['thanks','🙌','Thanks'],['idea','💡','Good idea']].map(([key,emoji,label])=>``).join('')}

${formatTime(c.end-c.start)} · Link expires ${new Date(c.expiresAt).toLocaleDateString()}
Anyone with this link can see replies. Guest names are not verified.

${viewerContext(c)}
`; const p=$('#viewer-player');fitPlayer(p);mountViewerContext(c,p,saveFile); $('#playback-speed').onchange=e=>p.playbackRate=Number(e.target.value); if($('#picture-in-picture'))$('#picture-in-picture').onclick=()=>run(async()=>{if(document.pictureInPictureElement)await document.exitPictureInPicture();else await p.requestPictureInPicture()}); @@ -130,7 +81,7 @@ function renderViewer(){ discussionUI=mountDiscussion({clip:c,player:p,panel:$('.reply-panel'),api,owner:viewerOwner,viewer,accessPassword:viewerPassword,update:next=>viewData=next,notify,error}); $('#refresh-replies').onclick=()=>run(async()=>{ const button=$('#refresh-replies');button.disabled=true;try{ - const next=await api('view',viewerPassword?{id:c.id,accessPassword:viewerPassword}:null,c.id);viewData=next;$('#viewer-review').textContent=reviewLabel(next.reviewStatus);$('#viewer-review').dataset.review=next.reviewStatus||'needs-feedback'; + const next=await api('view',viewerPassword?{id:c.id,accessPassword:viewerPassword}:null,c.id);viewData=next; $('#main h1').textContent=isGeneratedTitle(next.title)?'Untitled recording':next.title; const time=p.currentTime,playing=!p.paused;p.onloadedmetadata=()=>{p.currentTime=Math.max(c.start,Math.min(c.end,time));if(playing)p.play().catch(()=>{})};p.src=next.mediaUrl; discussionUI.update(next);for(const b of document.querySelectorAll('[data-reaction]'))b.querySelector('span').textContent=next.reactions[b.dataset.reaction];notify('Conversation and playback access refreshed. Your draft is kept.') @@ -141,7 +92,7 @@ function renderViewer(){ async function listMicrophones(){if(!navigator.mediaDevices?.enumerateDevices)throw Error('This browser chooses the microphone in its permission prompt.');const devices=await navigator.mediaDevices.enumerateDevices(),mics=devices.filter(d=>d.kind==='audioinput'&&d.deviceId);const select=$('#mic-source');select.innerHTML=''+mics.map((d,i)=>``).join('');notify(mics.length?'Choose your microphone source. Labels may appear only after permission is granted.':'No named inputs are available yet. Browser default will use its permission chooser.')} function markupEditor(){return `
Annotate or hide sensitive areas

Create a new video with marks burned into every frame. Your original stays unchanged and must not be shared if it contains secrets. Pause the preview, then draw fixed masks or marks. Check the new copy before publishing.

${markupTools()}

`} function mountMarkupEditor(c){const video=$('#markup-video');video.src=objectURL;fitCaptureFrame(video);const items=[];mountTools(items);$('#markup-toggle').onclick=()=>video.paused?video.play().catch(error):video.pause();$('#choose-thumbnail').onclick=()=>run(async()=>{if(video.readyState<2)throw Error('Wait for the video preview to load.');const canvas=document.createElement('canvas');canvas.width=480;canvas.height=Math.round(480*video.videoHeight/video.videoWidth);canvas.getContext('2d').drawImage(video,0,0,canvas.width,canvas.height);c.thumbnail=canvas.toDataURL('image/jpeg',.7);await db.put(c);clips=await db.all();notify('Library thumbnail updated on this device.')});$('#save-marked').onclick=()=>run(async()=>{if(!items.length)throw Error('Draw at least one mark on the preview.');busy=true;$('#save-marked').disabled=true;try{const blob=await fixed(await trimVideo(c.blob,0,c.duration,p=>$('#marked-progress').textContent=`Saving marked copy · ${Math.round(p*100)}%`,structuredClone(items)),c.duration),info=await metadata(blob,c.duration),copy={id:randomId(),owner:secret(),title:c.title.slice(0,80)+' · marked',...trimExtras(c,0,c.duration),blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify('Marked copy saved. Review it before publishing. The original is still in your library.')}finally{busy=false;const b=$('#save-marked');if(b)b.disabled=false}})} -function help(){if(recording||busy)return;shell(`
A CLEAR FIRST MESSAGE

Show the problem.
Share the moment.

Record or import a short video, review your local copy, then publish when ready. Viewers need no account. ScreenNote is a limited free beta; no payment or subscription is enabled.

Record with confidence

  1. Use a desktop browser with screen capture, or import a device recording on mobile.
  2. Choose microphone, camera and screen audio, then choose the source in your browser.
  3. Add fixed masks and marks in setup. Begin starts a three-second countdown.
  4. Stop and review the whole video, especially any sensitive areas. Download a backup.
  5. Add optional captions, chapters or a bug report. Publish explicitly.

ScreenNote cannot silently capture a screen. Permission is required each session. If your source or audio is unavailable, choose another source or import a video.

Keyboard & playback

While recording: Alt+Shift+P pauses/resumes; Alt+Shift+S stops. In a video view: K plays/pauses, J/L seek five seconds, M toggles mute. Shortcuts do not run while typing in a field.

Native video controls provide fullscreen and captions. The viewer also has playback speed and picture in picture when the browser supports it.

Private by choice

Anyone with an unlisted link can view it unless you set a sharing password. Send that password separately. Playback links already issued can work for up to five minutes after revocation. Export management keys and download videos separately.

Recording & sharing limits

Local recording and import: up to 30 minutes / 250 MB. Shared video: up to 10 minutes / 50 MB. Shared storage has a 1 GB budget across the app; smaller videos leave room for more recordings. Links expire after 1, 3 or 7 days. Capacity or hosting quotas can pause sharing; local tools remain available.

Captions are supplied by you in SRT/WebVTT format. No paid transcription service, AI analysis or advertising tracker runs here. Browser storage can be lost; keep downloads.

Help validate this beta

Try a short, non-sensitive recording. Check playback, audio, captions and feedback with your recipient. Download a local test report to share through your own channel. No personal information is required, and feedback is not sent automatically.

Real Android, iOS, Safari, tablet and native permission testing remains a release gate. This app does not promise universal browser support.

`,'Help')} +function help(){if(recording||busy)return;shell(`
A CLEAR FIRST MESSAGE

Show the problem.
Share the moment.

Record or import a short video, review your local copy, then publish when ready. Viewers need no account. ScreenNote is a limited free beta; no payment or subscription is enabled.

Record with confidence

  1. Use a desktop browser with screen capture, or import a device recording on mobile.
  2. Choose microphone, camera and screen audio, then choose the source in your browser.
  3. Add fixed masks and marks in setup. Begin starts a three-second countdown.
  4. Stop and review the whole video, especially any sensitive areas. Download a backup.
  5. Add optional captions, chapters or a bug report. Publish explicitly.

ScreenNote cannot silently capture a screen. Permission is required each session. If your source or audio is unavailable, choose another source or import a video.

Keyboard & playback

While recording: Alt+Shift+P pauses/resumes; Alt+Shift+S stops. In a video view: K plays/pauses, J/L seek five seconds, M toggles mute. Shortcuts do not run while typing in a field.

Native video controls provide fullscreen and captions. The viewer also has playback speed and picture in picture when the browser supports it.

Private by choice

Anyone with an unlisted link can view it unless you set a sharing password. Send that password separately. Playback links already issued can work for up to five minutes after revocation. Export management keys and download videos separately.

Free beta limits

Five minutes and 25 MB per shared video. Shared storage has a 1 GB budget across the app; smaller videos leave room for more recordings. Links expire after 1, 3 or 7 days. Capacity or hosting quotas can pause sharing; local tools remain available.

Captions are supplied by you in SRT/WebVTT format. No paid transcription service, AI analysis or advertising tracker runs here. Browser storage can be lost; keep downloads.

Help validate this beta

Try a short, non-sensitive recording. Check playback, audio, captions and feedback with your recipient. Download a local test report to share through your own channel. No personal information is required, and feedback is not sent automatically.

Real Android, iOS, Safari, tablet and native permission testing remains a release gate. This app does not promise universal browser support.

`,'Help')} actions['beta-feedback']=()=>dialog('Report your test',`

Save this report locally. Nothing is sent automatically.

`,async f=>saveFile(new Blob([JSON.stringify({version:APP_VERSION,device:f.get('device'),task:f.get('task'),result:f.get('result')},null,2)],{type:'application/json'}),'screennote-beta-test.json'),'Download test report'); document.addEventListener('keydown',e=>{if(e.target.closest('input,textarea,select,[contenteditable=true]')||modal.open)return;if(busy)return;if(e.altKey&&e.shiftKey){if(capture?.begun&&recording&&e.code==='KeyP'){e.preventDefault();actions.pause()}if(capture?.begun&&recording&&e.code==='KeyS'){e.preventDefault();actions.stop()}return}if(e.ctrlKey||e.metaKey||e.altKey)return;const p=$('#viewer-player')||$('#player');if(!p)return;if(e.code==='KeyK'){e.preventDefault();p.paused?p.play().catch(()=>{}):p.pause()}else if(e.code==='KeyJ'||e.code==='KeyL'){e.preventDefault();p.currentTime=Math.min(p.duration||0,Math.max(0,p.currentTime+(e.code==='KeyJ'?-5:5)))}else if(e.code==='KeyM'){e.preventDefault();p.muted=!p.muted}}); diff --git a/public/capture.js b/public/capture.js index 2c1672b..f8791f8 100644 --- a/public/capture.js +++ b/public/capture.js @@ -1,13 +1,11 @@ -import {profileFor,recordingStats} from '../lib/recording-policy.js'; -import {LOCAL_MAX_BYTES,LOCAL_MAX_SECONDS} from '../lib/rules.js'; import {paintMarkup} from './markup.js'; export function chooseMime(Recorder=globalThis.MediaRecorder){return['video/webm;codecs=vp8,opus','video/webm','video/mp4'].find(t=>Recorder?.isTypeSupported(t))||''} export function release(streams){for(const s of streams)for(const t of s?.getTracks()||[])t.stop()} export class Capture{ - constructor({chunk,finished,status,setupEnded,metrics=()=>{},permissionTimeoutMs=60000}){this.metrics=metrics;this.bytes=0;this.stopReason='user';this.permissionTimeoutMs=permissionTimeoutMs;this.permissionAbort=new AbortController();this.onSetupEnded=setupEnded;this.onchunk=chunk;this.finished=finished;this.status=status;this.streams=[];this.chunks=[];this.started=0;this.elapsed=0;this.stopping=false} - async start({camera=false,mic=true,system=false,micId='',defer=false,markup=null,quality='balanced',bubble={x:.8,y:.76,size:.18}}){try{this.quality=quality;this.profile=profileFor(quality);this.bubble=bubble;if(!navigator.mediaDevices?.getDisplayMedia||!globalThis.MediaRecorder)throw Error('Screen recording is not supported here. Use desktop Chrome or Edge, or import a video recorded on your device.');const Audio=globalThis.AudioContext||globalThis.webkitAudioContext;if((mic||system)&&Audio){this.audio=new Audio();this.audio.resume().catch(()=>{})}const screen=await this.requestMedia(()=>navigator.mediaDevices.getDisplayMedia({video:{frameRate:this.profile.fps,width:{ideal:this.profile.width},height:{ideal:Math.round(this.profile.width*9/16)}},audio:system}));this.streams.push(screen);this.checkCancelled();let devices;if(camera||mic){devices=await this.requestMedia(()=>navigator.mediaDevices.getUserMedia({video:camera?{width:320,height:240}:false,audio:mic?(micId?{deviceId:{exact:micId}}:true):false}));this.streams.push(devices);this.checkCancelled()}let track=screen.getVideoTracks()[0];if(!track||track.readyState==='ended')throw Error('Screen sharing ended before recording started. Choose your screen again.');if(camera||markup||track.getSettings?.().width>this.profile.width){const screenVideo=document.createElement('video'),cameraVideo=document.createElement('video');for(const[v,s]of[[screenVideo,screen],...(camera?[[cameraVideo,devices]]:[])]){v.muted=true;v.playsInline=true;v.srcObject=s;await v.play();this.checkCancelled()}const settings=track.getSettings(),canvas=document.createElement('canvas');canvas.width=Math.min(settings.width||this.profile.width,this.profile.width);canvas.height=Math.round(canvas.width*(settings.height||720)/(settings.width||1280));const ctx=canvas.getContext('2d');this.ticker=new Worker('./ticker.js');this.ticker.onmessage=()=>{ctx.drawImage(screenVideo,0,0,canvas.width,canvas.height);if(camera){const size=Math.round(Math.min(canvas.width,canvas.height)*this.bubble.size),x=Math.max(0,Math.min(canvas.width-size,canvas.width*this.bubble.x)),y=Math.max(0,Math.min(canvas.height-size,canvas.height*this.bubble.y));ctx.save();ctx.beginPath();ctx.arc(x+size/2,y+size/2,size/2,0,Math.PI*2);ctx.clip();const side=Math.min(cameraVideo.videoWidth,cameraVideo.videoHeight);if(side)ctx.drawImage(cameraVideo,(cameraVideo.videoWidth-side)/2,(cameraVideo.videoHeight-side)/2,side,side,x,y,size,size);ctx.restore()}if(markup)paintMarkup(ctx,canvas.width,canvas.height,markup)};this.ticker.onmessage();const composed=canvas.captureStream(this.profile.fps);this.streams.push(composed);track=composed.getVideoTracks()[0]} - const audio=this.streams.slice(0,2).flatMap(s=>s.getAudioTracks());let audioTracks=[];if(audio.length){this.audio=this.audio||new Audio();await this.audio.resume();this.checkCancelled();const dest=this.audio.createMediaStreamDestination();for(const t of audio)this.audio.createMediaStreamSource(new MediaStream([t])).connect(dest);audioTracks=dest.stream.getAudioTracks();this.streams.push(dest.stream)}if(mic&&devices?.getAudioTracks().length&&this.audio?.createAnalyser){this.analyser=this.audio.createAnalyser();this.analyser.fftSize=256;this.audio.createMediaStreamSource(new MediaStream(devices.getAudioTracks())).connect(this.analyser);this.levels=new Uint8Array(this.analyser.fftSize)}const output=new MediaStream([track,...audioTracks]);const mimeType=chooseMime();this.recorder=new MediaRecorder(output,{...(mimeType?{mimeType}:{}),videoBitsPerSecond:this.profile.video,audioBitsPerSecond:this.profile.audio});this.recorder.ondataavailable=e=>{if(e.data.size){this.chunks.push(e.data);this.bytes+=e.data.size;this.onchunk(e.data,this.seconds());this.report();if(this.bytes>=LOCAL_MAX_BYTES-1024*1024)this.stop('size')}};this.recorder.onerror=e=>{this.error=e.error||Error('Recording stopped unexpectedly.');this.stop('error')};this.recorder.onstop=async()=>{this.elapsed=this.seconds();this.started=0;this.cleanup();const blob=new Blob(this.chunks,{type:this.recorder.mimeType});await this.finished(blob,this.elapsed,this.error,{reason:this.stopReason,bytes:blob.size,requestedVideoBitrate:this.profile.video,actualVideoBitrate:this.recorder.videoBitsPerSecond||null,quality:this.quality})};this.screenTrack=screen.getVideoTracks()[0];this.screenTrack.onended=()=>{if(this.begun)this.stop('source');else{this.cleanup();this.onSetupEnded?.()}};if(!defer)this.begin();return output}catch(e){this.cleanup();throw e}} - begin(){this.checkCancelled();if(this.begun)return;if(this.screenTrack?.readyState==='ended')throw Error('Screen sharing ended. Choose your screen again.');this.begun=true;this.started=performance.now();this.recorder.start(1000);this.timer=setInterval(()=>{this.status(this.seconds());this.report();if(this.seconds()>=LOCAL_MAX_SECONDS)this.stop('duration')},250);this.status(0)} + constructor({chunk,finished,status,setupEnded,permissionTimeoutMs=60000}){this.permissionTimeoutMs=permissionTimeoutMs;this.permissionAbort=new AbortController();this.onSetupEnded=setupEnded;this.onchunk=chunk;this.finished=finished;this.status=status;this.streams=[];this.chunks=[];this.started=0;this.elapsed=0;this.stopping=false} + async start({camera=false,mic=true,system=false,micId='',defer=false,markup=null}){try{if(!navigator.mediaDevices?.getDisplayMedia||!globalThis.MediaRecorder)throw Error('Screen recording is not supported here. Use desktop Chrome or Edge, or import a video recorded on your device.');const Audio=globalThis.AudioContext||globalThis.webkitAudioContext;if((mic||system)&&Audio){this.audio=new Audio();this.audio.resume().catch(()=>{})}const screen=await this.requestMedia(()=>navigator.mediaDevices.getDisplayMedia({video:{frameRate:24,width:{ideal:1280},height:{ideal:720}},audio:system}));this.streams.push(screen);this.checkCancelled();let devices;if(camera||mic){devices=await this.requestMedia(()=>navigator.mediaDevices.getUserMedia({video:camera?{width:320,height:240}:false,audio:mic?(micId?{deviceId:{exact:micId}}:true):false}));this.streams.push(devices);this.checkCancelled()}let track=screen.getVideoTracks()[0];if(!track||track.readyState==='ended')throw Error('Screen sharing ended before recording started. Choose your screen again.');if(camera||markup){const screenVideo=document.createElement('video'),cameraVideo=document.createElement('video');for(const[v,s]of[[screenVideo,screen],...(camera?[[cameraVideo,devices]]:[])]){v.muted=true;v.playsInline=true;v.srcObject=s;await v.play();this.checkCancelled()}const settings=track.getSettings(),canvas=document.createElement('canvas');canvas.width=Math.min(settings.width||1280,1280);canvas.height=Math.round(canvas.width*(settings.height||720)/(settings.width||1280));const ctx=canvas.getContext('2d');this.ticker=new Worker('./ticker.js');this.ticker.onmessage=()=>{ctx.drawImage(screenVideo,0,0,canvas.width,canvas.height);if(camera){const size=Math.round(canvas.width*.18),x=canvas.width-size-24,y=canvas.height-size-24;ctx.save();ctx.beginPath();ctx.arc(x+size/2,y+size/2,size/2,0,Math.PI*2);ctx.clip();const side=Math.min(cameraVideo.videoWidth,cameraVideo.videoHeight);if(side)ctx.drawImage(cameraVideo,(cameraVideo.videoWidth-side)/2,(cameraVideo.videoHeight-side)/2,side,side,x,y,size,size);ctx.restore()}if(markup)paintMarkup(ctx,canvas.width,canvas.height,markup)};this.ticker.onmessage();const composed=canvas.captureStream(24);this.streams.push(composed);track=composed.getVideoTracks()[0]} + const audio=this.streams.slice(0,2).flatMap(s=>s.getAudioTracks());let audioTracks=[];if(audio.length){this.audio=this.audio||new Audio();await this.audio.resume();this.checkCancelled();const dest=this.audio.createMediaStreamDestination();for(const t of audio)this.audio.createMediaStreamSource(new MediaStream([t])).connect(dest);audioTracks=dest.stream.getAudioTracks();this.streams.push(dest.stream)}const output=new MediaStream([track,...audioTracks]);const mimeType=chooseMime();this.recorder=new MediaRecorder(output,{...(mimeType?{mimeType}:{}),videoBitsPerSecond:600000,audioBitsPerSecond:64000});this.recorder.ondataavailable=e=>{if(e.data.size){this.chunks.push(e.data);this.onchunk(e.data,this.seconds());const size=this.chunks.reduce((n,b)=>n+b.size,0);if(size>=24*1024*1024)this.stop()}};this.recorder.onerror=e=>{this.error=e.error||Error('Recording stopped unexpectedly.');this.stop()};this.recorder.onstop=async()=>{this.elapsed=this.seconds();this.started=0;this.cleanup();const blob=new Blob(this.chunks,{type:this.recorder.mimeType});await this.finished(blob,this.elapsed,this.error)};this.screenTrack=screen.getVideoTracks()[0];this.screenTrack.onended=()=>{if(this.begun)this.stop();else{this.cleanup();this.onSetupEnded?.()}};if(!defer)this.begin();return output}catch(e){this.cleanup();throw e}} + begin(){this.checkCancelled();if(this.begun)return;if(this.screenTrack?.readyState==='ended')throw Error('Screen sharing ended. Choose your screen again.');this.begun=true;this.started=performance.now();this.recorder.start(1000);this.timer=setInterval(()=>{this.status(this.seconds());if(this.seconds()>=300)this.stop()},250);this.status(0)} checkCancelled(){if(this.cancelled)throw Object.assign(Error('Recording setup cancelled.'),{name:'AbortError'})} // Invoke the native request synchronously to preserve the click's user activation. // Browsers cannot dismiss every native picker programmatically. Reject our setup @@ -27,8 +25,7 @@ export class Capture{ seconds(){return this.elapsed+(this.started?(performance.now()-this.started)/1000:0)} pause(){if(this.recorder?.state!=='recording')return;this.elapsed=this.seconds();this.started=0;this.recorder.pause()} resume(){if(this.recorder?.state!=='paused')return;this.started=performance.now();this.recorder.resume()} - report(){let level=0;if(this.analyser&&this.levels&&this.recorder?.state!=='paused'){this.analyser.getByteTimeDomainData(this.levels);level=Math.min(1,Math.sqrt(this.levels.reduce((sum,n)=>sum+((n-128)/128)**2,0)/this.levels.length)*4)}this.metrics({...recordingStats(this.bytes,this.seconds(),this.quality),level,paused:this.recorder?.state==='paused'})} - stop(reason='user'){if(this.stopping)return;this.stopReason=reason;this.stopping=true;if(this.recorder&&this.recorder.state!=='inactive')this.recorder.stop();else this.cleanup()} + stop(){if(this.stopping)return;this.stopping=true;if(this.recorder&&this.recorder.state!=='inactive')this.recorder.stop();else this.cleanup()} cleanup(){clearInterval(this.timer);this.ticker?.terminate();release(this.streams);this.audio?.close().catch(()=>{})} } -export async function trimVideo(blob,start,end,onProgress,markup=[],quality='balanced'){const profile=profileFor(quality);const video=document.createElement('video'),url=URL.createObjectURL(blob);video.src=url;video.playsInline=true;video.muted=false;video.volume=1;let context,timer,watchdog,recorder,outputStreams=[];try{const Audio=globalThis.AudioContext||globalThis.webkitAudioContext;if(!Audio||!globalThis.MediaRecorder)throw Error('Local video processing is unavailable in this browser. Download the original or use a browser with MediaRecorder and Web Audio support.');context=new Audio();context.resume().catch(()=>{});await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Timed out reading the video. Try importing it again.')),10000);video.onloadedmetadata=()=>{clearTimeout(timeout);resolve()};video.onerror=()=>{clearTimeout(timeout);reject(Error('This browser cannot decode the video.'))}});if(start>0){video.currentTime=start;await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Could not seek to the trim start. Try another video.')),10000);video.onseeked=()=>{clearTimeout(timeout);resolve()}})}const canvas=document.createElement('canvas');canvas.width=Math.min(video.videoWidth,profile.width);canvas.height=Math.round(video.videoHeight*canvas.width/video.videoWidth);const ctx=canvas.getContext('2d'),stream=canvas.captureStream(profile.fps);outputStreams=[stream];await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Audio processing could not start. Tap Save again with this tab visible. Your original is safe.')),10000);context.resume().then(()=>{clearTimeout(timeout);resolve()},e=>{clearTimeout(timeout);reject(e)})});const source=context.createMediaElementSource(video),dest=context.createMediaStreamDestination();source.connect(dest);outputStreams=[stream,dest.stream];const tracks=[...stream.getVideoTracks(),...dest.stream.getAudioTracks()],mimeType=chooseMime();recorder=new MediaRecorder(new MediaStream(tracks),{...(mimeType?{mimeType}:{}),videoBitsPerSecond:profile.video,audioBitsPerSecond:profile.audio});const chunks=[];return await new Promise((resolve,reject)=>{let done=false;let lastTime=start,lastProgress=performance.now();watchdog=setInterval(()=>{if(video.currentTime>lastTime+.01){lastTime=video.currentTime;lastProgress=performance.now()}if(performance.now()-lastProgress>15000){done=true;reject(Error('Trimming stalled. Your original is safe; keep this tab visible and try again.'))}},1000);const finish=()=>{if(done)return;done=true;video.pause();recorder.stop()};recorder.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)};recorder.onstop=()=>{release([stream,dest.stream]);resolve(new Blob(chunks,{type:recorder.mimeType}))};recorder.onerror=()=>reject(Error('Could not trim this video. Your original remains unchanged.'));video.onended=finish;timer=setInterval(()=>{ctx.drawImage(video,0,0,canvas.width,canvas.height);paintMarkup(ctx,canvas.width,canvas.height,markup);onProgress(Math.min(1,(video.currentTime-start)/(end-start)));if(video.currentTime>=end)finish()},40);recorder.start(250);video.play().catch(reject)})}finally{clearInterval(timer);clearInterval(watchdog);video.pause();if(recorder&&recorder.state!=='inactive')recorder.stop();release(outputStreams);context?.close().catch(()=>{});URL.revokeObjectURL(url)}} +export async function trimVideo(blob,start,end,onProgress,markup=[]){const video=document.createElement('video'),url=URL.createObjectURL(blob);video.src=url;video.playsInline=true;video.muted=false;video.volume=1;let context,timer,watchdog,recorder,outputStreams=[];try{const Audio=globalThis.AudioContext||globalThis.webkitAudioContext;if(!Audio||!globalThis.MediaRecorder)throw Error('Local video processing is unavailable in this browser. Download the original or use a browser with MediaRecorder and Web Audio support.');context=new Audio();context.resume().catch(()=>{});await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Timed out reading the video. Try importing it again.')),10000);video.onloadedmetadata=()=>{clearTimeout(timeout);resolve()};video.onerror=()=>{clearTimeout(timeout);reject(Error('This browser cannot decode the video.'))}});if(start>0){video.currentTime=start;await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Could not seek to the trim start. Try another video.')),10000);video.onseeked=()=>{clearTimeout(timeout);resolve()}})}const canvas=document.createElement('canvas');canvas.width=Math.min(video.videoWidth,1280);canvas.height=Math.round(video.videoHeight*canvas.width/video.videoWidth);const ctx=canvas.getContext('2d'),stream=canvas.captureStream(24);outputStreams=[stream];await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(Error('Audio processing could not start. Tap Save again with this tab visible. Your original is safe.')),10000);context.resume().then(()=>{clearTimeout(timeout);resolve()},e=>{clearTimeout(timeout);reject(e)})});const source=context.createMediaElementSource(video),dest=context.createMediaStreamDestination();source.connect(dest);outputStreams=[stream,dest.stream];const tracks=[...stream.getVideoTracks(),...dest.stream.getAudioTracks()],mimeType=chooseMime();recorder=new MediaRecorder(new MediaStream(tracks),{...(mimeType?{mimeType}:{}),videoBitsPerSecond:600000,audioBitsPerSecond:64000});const chunks=[];return await new Promise((resolve,reject)=>{let done=false;let lastTime=start,lastProgress=performance.now();watchdog=setInterval(()=>{if(video.currentTime>lastTime+.01){lastTime=video.currentTime;lastProgress=performance.now()}if(performance.now()-lastProgress>15000){done=true;reject(Error('Trimming stalled. Your original is safe; keep this tab visible and try again.'))}},1000);const finish=()=>{if(done)return;done=true;video.pause();recorder.stop()};recorder.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)};recorder.onstop=()=>{release([stream,dest.stream]);resolve(new Blob(chunks,{type:recorder.mimeType}))};recorder.onerror=()=>reject(Error('Could not trim this video. Your original remains unchanged.'));video.onended=finish;timer=setInterval(()=>{ctx.drawImage(video,0,0,canvas.width,canvas.height);paintMarkup(ctx,canvas.width,canvas.height,markup);onProgress(Math.min(1,(video.currentTime-start)/(end-start)));if(video.currentTime>=end)finish()},40);recorder.start(250);video.play().catch(reject)})}finally{clearInterval(timer);clearInterval(watchdog);video.pause();if(recorder&&recorder.state!=='inactive')recorder.stop();release(outputStreams);context?.close().catch(()=>{});URL.revokeObjectURL(url)}} diff --git a/public/privacy.html b/public/privacy.html index 73587b6..b626a6a 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -20,13 +20,13 @@

2. What stays on your device

Recordings, drafts and your recording library stay in the app's private storage until you choose to publish. Android cloud backup is switched off for this app (allowBackup="false").

3. What is uploaded, and only when you publish

-

When you explicitly publish a link, the video file and the details needed to serve it are uploaded: title, duration, size, expiry, and any password you set. Viewers can watch, react, and leave comments or replies, which are stored with the clip. A clip is limited to 10 minutes and 50 MB, and the service runs on a shared 1 GB storage budget.

+

When you explicitly publish a link, the video file and the details needed to serve it are uploaded: title, duration, size, expiry, and any password you set. Viewers can watch, react, and leave comments or replies, which are stored with the clip. A clip is limited to 5 minutes and 25 MB, and the service runs on a shared 1 GB storage budget.

4. Names and access are not verified

Display names and reviewer identities used in discussions are not verified. Creator and author controls rely on private capability links held by whoever created the content, not on a verified account. A displayed name is not proof of identity.

5. Retention and deletion

-

Published clips expire after the window the creator chooses — 1, 3 or 7 days (7 days by default). When a creator stops sharing or deletes a published clip, media links already handed out may keep working for up to about ten minutes, because links are signed to be valid for that long. Deleting the local recording does not delete a published copy; stop sharing or delete the published clip to do that. Expired, deleted and unclaimed uploads are reclaimed by the service's retention process. The app runs on third-party hosting, which keeps ordinary request logs such as IP address and browser information for its own default retention period; the app does not add analytics of its own.

+

Published clips expire after the window the creator chooses — 1, 3 or 7 days (7 days by default). When a creator stops sharing or deletes a published clip, media links already handed out may keep working for up to about five minutes, because links are signed to be valid for that long. Deleting the local recording does not delete a published copy; stop sharing or delete the published clip to do that. Expired, deleted and unclaimed uploads are reclaimed by the service's retention process. The app runs on third-party hosting, which keeps ordinary request logs such as IP address and browser information for its own default retention period; the app does not add analytics of its own.

6. What you record, and content responsibility

You are responsible for what you record and publish, and for having the right to record and share it, including anything visible on your screen. Reviewers' comments are stored with the clip. This build has no in-app report button for a viewer; if content shows you and you want it removed, contact the person who shared the link, or raise it through the contact route in section 10.

diff --git a/public/sample.webm b/public/sample.webm index 7cf1fe1..7ba521f 100644 Binary files a/public/sample.webm and b/public/sample.webm differ diff --git a/public/style.css b/public/style.css index 0be989e..406544e 100644 --- a/public/style.css +++ b/public/style.css @@ -25,7 +25,7 @@ progress{width:100%;height:12px;margin-top:16px;accent-color:var(--accent)}selec .context-panel input{min-height:44px;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font:inherit}.canvas-editor summary{font-size:17px;font-weight:650;cursor:pointer;min-height:44px;padding:8px 0}.canvas-editor[open] summary{margin-bottom:16px} /* In stacked layouts, put the recording task before decorative artwork. */ - +@media(max-width:1024px){.record-grid>div:first-child{display:none}} .import-only .option,.import-only .field,.import-only [data-action=record]{display:none} @@ -62,15 +62,3 @@ progress{width:100%;height:12px;margin-top:16px;accent-color:var(--accent)}selec .discussion-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.discussion-heading h2{font-size:23px;line-height:1.15}.discussion-heading .eyebrow{margin-bottom:8px}.count-pill{background:var(--soft);border:1px solid var(--line);border-radius:24px;min-width:36px;padding:8px;text-align:center;font-size:13px}.reply-panel{max-height:none;overflow:visible}.reply-panel #reply-form{padding:16px 0 20px}.reply-panel .field{margin-bottom:12px}.reply-panel .field textarea{min-height:100px}.compose-meta{display:flex;align-items:center;justify-content:space-between;gap:8px}.text-button{background:none;border:0;color:var(--accent);padding:8px 6px;min-height:44px;font-size:12px;font-weight:600;text-align:left;overflow-wrap:anywhere}.text-button:hover{text-decoration:underline}.reply-context{border-left:3px solid var(--accent);background:var(--tint);padding:10px 12px;margin:0 0 14px;font-size:12px;overflow-wrap:anywhere}.reply-context strong,.reply-context span{display:block}.reply-context span{margin-top:5px;line-height:1.5}.discussion-toolbar{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid var(--line);padding:16px 0}.discussion-toolbar select{font-size:12px;min-height:44px;flex:1}.discussion-thread{border:1px solid var(--line);border-radius:10px;margin:0 0 14px;padding:0 14px;background:var(--panel)}.discussion-thread .comment{padding:14px 0}.discussion-thread .thread-reply{margin-left:12px;padding-left:12px;border-left:2px solid var(--line);border-bottom:0}.discussion-thread .comment-head{align-items:center;gap:8px;margin-bottom:6px}.comment-avatar{display:grid;place-items:center;flex:none;width:28px;height:28px;border-radius:50%;background:var(--soft);font-size:12px;font-weight:650}.comment-head strong{font-size:12px;line-height:1.5}.comment-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:5px}.comment-actions .text-button{padding-inline:2px}.creator-badge{font-size:10px;color:var(--accent);border:1px solid var(--line);padding:2px 4px;border-radius:4px;display:inline-block}.parent-reference{display:block;font-size:11px;color:var(--muted);max-width:100%;line-height:1.5;padding:4px 0;min-height:32px}.resolve-thread{width:100%;border-top:1px solid var(--line);color:var(--muted)}.is-resolved{background:var(--soft)}.resolved-label{padding-top:12px;font-size:11px;color:var(--muted);font-weight:650}.comment-selected{background:var(--tint);outline:2px solid var(--accent);outline-offset:4px;border-radius:4px}.discussion-empty{font-size:14px;padding:20px 0}.comment-link{width:100%;padding:10px;min-width:0}.conversation-stage{position:relative}.comment-overlay{position:absolute;z-index:2;top:12px;left:12px;right:12px;pointer-events:none;display:flex;justify-content:flex-start}.moment-bubble{display:flex;align-items:flex-start;max-width:min(370px,90%);border:1px solid #ffffff55;border-radius:12px 12px 12px 3px;background:#18221def;color:#fff;box-shadow:0 4px 18px #0003;pointer-events:auto;overflow:hidden;backdrop-filter:blur(8px)}.bubble-main{display:flex;flex-direction:column;gap:5px;align-items:flex-start;background:none;color:inherit;border:0;text-align:left;padding:12px;min-width:0;min-height:44px;flex:1}.bubble-byline{font-size:11px;font-weight:650;overflow-wrap:anywhere;line-height:1.4}.bubble-byline>span{font-weight:400;color:#d1dace}.bubble-text{font-size:13px;line-height:1.45;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}.bubble-hint{font-size:10px;color:#d1dace}.bubble-side{display:flex;flex-direction:column;flex:none}.bubble-side button{border:0;background:none;color:#fff;min-width:44px;min-height:44px;font-size:13px}.bubble-dismiss{font-size:24px!important}.conversation-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px}.conversation-controls .btn{font-size:12px}.conversation-controls [aria-pressed=true]{background:var(--tint);border-color:var(--accent)}.comment-timeline{position:relative;height:44px;border-bottom:2px solid var(--line);margin-top:2px}.comment-timeline:before{content:'';position:absolute;left:0;right:0;top:21px;border-top:2px solid var(--line)}.moment-marker{position:absolute;left:var(--moment);transform:translateX(-50%);min-width:44px;width:44px;min-height:44px;height:44px;padding:0;background:none;border:0;color:var(--accent);display:grid;place-items:center;z-index:1}.moment-marker>span{font-size:12px;border-radius:50%;width:20px;height:20px;display:grid;place-items:center;background:var(--tint);border:1px solid var(--accent)}.moment-marker:hover>span{background:var(--accent);color:var(--panel)}.viewer-media{min-width:0}.viewer-top .icon-button{flex:none} @media(min-width:1151px){.viewer-grid{grid-template-columns:minmax(0,1.55fr) minmax(330px,1fr)}.viewer-media{position:sticky;top:20px}} @media(max-width:480px){.moment-bubble{max-width:100%}.bubble-main{padding:9px 10px;gap:3px}.comment-overlay{top:8px;left:8px;right:8px}.bubble-text{font-size:12px}.bubble-byline{font-size:10px}.bubble-hint{display:none}.discussion-thread{padding-inline:10px}.discussion-thread .thread-reply{margin-left:5px;padding-left:9px}.discussion-heading h2{font-size:21px}.panel.reply-panel{padding:16px}.discussion-toolbar select{font-size:12px}.conversation-controls .btn{flex:1;padding:8px}.comment-head .timestamp{padding:4px}.comment-avatar{width:24px;height:24px}} - -/* A focused workspace, with motion that communicates state. */ -.library-heading h1{font-size:clamp(30px,3.6vw,44px);letter-spacing:-1.5px}.library-heading{align-items:center;margin-bottom:18px}.workspace-summary{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--muted);padding-bottom:24px}.workspace-summary .text-button{margin-left:auto}.status-dot{width:7px;height:7px;background:#518366;border-radius:50%}.library-section{margin-bottom:28px}.library-section .section-head{margin-top:18px}.library-filters{display:grid;grid-template-columns:minmax(0,1fr) 170px 150px;gap:12px}.library-filters .field{margin-bottom:0}.library-filters input,.library-filters select{min-height:44px;width:100%}.library-filters select,.record-defaults select,.advanced-recording select,.review-status-panel select{padding:10px 12px;border:1px solid var(--line);border-radius:7px;color:var(--text);background:var(--panel);font:inherit;min-width:0;max-width:100%}.library-filters+.small{margin:10px 0 16px}.record-setup{margin:20px 0 32px}.record-setup>summary{cursor:pointer;font-size:14px;font-weight:600;padding:16px 0;min-height:48px;border-top:1px solid var(--line)}.compact-recorder{grid-template-columns:minmax(0,1.25fr) minmax(240px,1fr);gap:24px}.recorder-intro{display:flex;justify-content:space-between;gap:15px;align-items:center}.recorder-intro>.svg,.recorder-intro>svg{width:32px;height:32px;color:var(--accent)}.recorder-intro .eyebrow{font-size:9px;margin-bottom:10px}.record-options .btn.primary{margin-top:8px;min-height:48px}.record-defaults{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:20px;margin-top:12px;align-items:center}.record-defaults .option{border:0}.record-defaults .field{margin:0}.record-defaults select{font-size:13px}.advanced-recording{border-top:1px solid var(--line);margin-top:14px}.advanced-recording>summary{min-height:48px;display:list-item;padding:16px 0;cursor:pointer;font-size:12px;color:var(--muted)}.advanced-recording .field{margin-top:14px}.record-limits{font-size:11px!important;margin:12px 0 0}.record-guide{border:1px solid var(--line);border-radius:12px;background:var(--soft);padding:28px}.guide-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:var(--dark);color:var(--light);margin-bottom:20px}.record-guide h2{font-size:25px}.record-guide ol{padding-left:20px;display:grid;gap:16px;color:var(--muted);font-size:13px;line-height:1.65;margin:20px 0}.record-guide li strong{color:var(--text)}.record-guide .btn{white-space:normal;width:100%}.clip-art{width:100%;padding:0;border:0;color:var(--text)}.card-play{position:absolute;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;background:#fffdf2ed;color:#253c2d;opacity:0;transform:scale(.9);transition:opacity .18s,transform .18s}.clip-art:hover .card-play,.clip-art:focus-visible .card-play{opacity:1;transform:scale(1)}.card-badges{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:12px}.clip-badge,.review-badge{font-size:10px;border-radius:5px;padding:4px 7px;background:var(--soft);color:var(--muted)}.clip-badge.shared{background:var(--tint);color:var(--accent)}.review-badge[data-review=approved]{background:#e2eee4;color:#285538}.review-badge[data-review=changes-requested]{background:#f4e5d4;color:#794619}.card-actions{display:flex;gap:8px;align-items:center;margin-top:14px}.card-actions>.btn{margin-top:0;flex:1;width:auto}.card-actions>.icon-button{flex:none}.review-status-panel{margin-top:14px}.review-status-panel label{display:block;font-size:12px;font-weight:600;margin-bottom:10px}.review-status-panel select{width:100%;font-size:13px}.review-status-panel p{margin-bottom:0}.recovery-note summary{cursor:pointer;font-weight:600}.recovery-note p{margin-bottom:0}.capture-metrics{display:grid;grid-template-columns:1fr auto;align-items:center;gap:12px;background:var(--soft);padding:16px;border-radius:10px;margin:16px 0}.capture-metrics strong{font-variant-numeric:tabular-nums;margin-right:12px}.capture-metrics span{font-size:12px;color:var(--muted)}.capture-metrics p{grid-column:1/-1;font-size:12px;margin:0}.capture-metrics p:empty{display:none}.mic-meter-label{font-size:12px;display:flex;align-items:center;gap:10px}.mic-meter-label meter{width:100px}.recording .capture-frame{max-height:58vh}.recording .capture-frame video{max-height:58vh;object-fit:contain}.live-controls{position:sticky;bottom:12px;z-index:8;background:var(--panel);padding:12px 20px;border:1px solid var(--line);border-radius:12px;box-shadow:0 8px 30px #14231c16;width:fit-content;margin:12px auto}.is-live #recording-status:before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:8px;animation:record-pulse 1.6s ease-in-out infinite}.is-paused #recording-status:before{animation:none;background:var(--muted)}.is-paused .timer{color:var(--muted)}.countdown:not(:empty){padding:16px;border-radius:10px;text-align:center;background:var(--tint);color:var(--accent);font-size:26px;font-weight:650;animation:count-in .3s ease-out}.camera-handle{position:absolute;z-index:6;background:#15231b22;border:2px dashed #fff;border-radius:50%;color:white;font-size:10px;min-height:0;touch-action:none;cursor:move;display:grid;place-items:center;text-shadow:0 1px 4px black}.camera-handle:hover,.camera-handle:focus-visible{background:#15231b88}.bubble-settings{display:flex;gap:20px;align-items:center;flex-wrap:wrap;font-size:12px;margin:12px 0}.bubble-settings label{display:flex;gap:12px;align-items:center}.sample-feedback blockquote{margin-left:18px;padding-left:14px;border-left:2px solid var(--line);font-size:13px}.sample-feedback p{font-size:13px}.share-problem .btn{margin-top:12px;width:100%;white-space:normal}.btn,.icon-button{transition:background-color .18s,border-color .18s,transform .18s,box-shadow .18s}.btn:active,.icon-button:active{transform:scale(.98)}.clip-card{transition:border-color .18s,box-shadow .18s;animation:surface-in .22s ease-out}.clip-card:hover{border-color:var(--muted);box-shadow:0 4px 14px #16241b0d}dialog[open]{animation:surface-in .18s ease-out}.moment-bubble{animation:bubble-in .2s ease-out}#notice{animation:toast-in .18s ease-out}progress{accent-color:var(--accent)} -@keyframes record-pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 #c4472633}50%{opacity:.55;box-shadow:0 0 0 5px #c4472600}}@keyframes count-in{from{opacity:.3;transform:scale(.96)}to{opacity:1;transform:scale(1)}}@keyframes surface-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}@keyframes bubble-in{from{opacity:0;transform:translateY(6px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes toast-in{from{opacity:0;margin-bottom:-5px}to{opacity:1;margin-bottom:0}} -@media(max-width:1024px){.compact-recorder{grid-template-columns:minmax(0,1fr)}.record-guide{display:none}.library-filters{grid-template-columns:minmax(0,1fr) 145px 135px}} -@media(max-width:767px){.library-heading{align-items:start}.library-heading .actions{width:100%}.library-heading .actions>.btn{flex:1}.library-heading h1{font-size:32px}.workspace-summary{font-size:11px;gap:7px;padding-bottom:8px}.workspace-summary .text-button{margin-left:0}.library-filters{grid-template-columns:1fr 1fr;gap:10px}.library-filters .field:first-child{grid-column:1/-1}.record-defaults{grid-template-columns:1fr;gap:0}.record-defaults .option{padding:14px 0}.record-defaults .field{margin-bottom:8px}.capture-metrics{grid-template-columns:1fr}.capture-metrics strong{display:block;margin-bottom:5px}.live-controls{bottom:8px;width:100%;padding:10px;gap:8px}.live-controls .btn{padding:10px}.live-controls .timer{font-size:24px;min-width:65px}.recording .capture-frame,.recording .capture-frame video{max-height:44vh}.card-play{opacity:1;transform:none;width:32px;height:32px}.review-status-panel{padding:18px}.record-setup{margin-top:12px}} -@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} - -@media(max-width:360px){.library-filters{grid-template-columns:1fr}.library-filters .field:first-child{grid-column:auto}.library-heading .actions{flex-direction:column;align-items:stretch}.library-heading .actions>.btn{width:100%;min-height:44px}} -/* The import target stays accessible to its label without widening narrow pages. */ -.file-input.visually-hidden{position:fixed;width:1px;height:1px;left:0;top:0;padding:0;margin:0;clip-path:inset(50%);overflow:hidden} -.viewer-review{display:inline-block;margin:0 0 20px;font-size:12px} diff --git a/scripts/generate-sample.py b/scripts/generate-sample.py deleted file mode 100644 index 5cd7486..0000000 --- a/scripts/generate-sample.py +++ /dev/null @@ -1,25 +0,0 @@ -from PIL import Image,ImageDraw,ImageFont -from pathlib import Path -import subprocess,math -root=Path(__file__).resolve().parent.parent -font='/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' -def f(n):return ImageFont.truetype(font,n) -# A deterministic, clearly illustrative product walkthrough; no AI imagery or real customer data. -out=subprocess.Popen(['ffmpeg','-y','-f','rawvideo','-vcodec','rawvideo','-pix_fmt','rgb24','-s','960x540','-r','20','-i','-','-f','lavfi','-i','sine=frequency=220:sample_rate=48000:duration=15','-filter:a','volume=0.03','-c:v','libvpx','-b:v','350k','-c:a','libopus','-b:a','32k','-t','15',str(root/'public/sample.webm')],stdin=subprocess.PIPE,stderr=subprocess.DEVNULL) -for n in range(300): - t=n/20;im=Image.new('RGB',(960,540),'#f7f6f2');d=ImageDraw.Draw(im) - d.rectangle((0,0,960,54),fill='#253c2d');d.text((26,16),'ScreenNote / illustrative walkthrough',font=f(17),fill='white') - step=min(2,int(t/5));titles=['Show the detail.','Pin the feedback.','Keep work moving.'] - d.text((34,80),titles[step],font=f(29),fill='#253c2d') - d.rounded_rectangle((34,132,590,448),radius=14,fill='white',outline='#dedfd7',width=2) - d.text((60,156),'CHECKOUT PREVIEW',font=f(13),fill='#6c716a');d.text((60,195),'One simple next step.',font=f(24),fill='#292c29') - for y,w in [(244,280),(270,350),(296,220)]:d.rounded_rectangle((60,y,60+w,y+10),radius=5,fill='#eeede7') - color='#c44726' if step==2 else '#b8b9ae';d.rounded_rectangle((60,348,315,400),radius=8,fill=color);d.text((95,363),'Continue to payment',font=f(16),fill='white' if step==2 else '#3c433a') - if step>=1: - d.rounded_rectangle((620,145,923,333),radius=12,fill='white',outline='#dedfd7',width=2);d.text((640,167),'0:05 Alex',font=f(15),fill='#c44726');d.text((640,202),'Could this button',font=f(18),fill='#292c29');d.text((640,229),'be clearer?',font=f(18),fill='#292c29');d.text((640,282),'Sam: Increase contrast.',font=f(14),fill='#6c716a') - if step==2: - d.rounded_rectangle((620,352,923,406),radius=8,fill='#e2eee4');d.text((640,370),'Changes ready for review',font=f(16),fill='#285538') - x=170+70*math.sin(t/2);y=370+25*math.cos(t/2);d.polygon([(x,y),(x+3,y+22),(x+9,y+16),(x+15,y+27),(x+20,y+24),(x+14,y+14),(x+23,y+12)],fill='#253c2d',outline='white') - d.rounded_rectangle((34,482,926,488),radius=3,fill='#dedfd7');d.rounded_rectangle((34,482,34+max(3,892*t/15),488),radius=3,fill='#c44726');d.text((34,507),'EXAMPLE ONLY / No customer data',font=f(11),fill='#6c716a') - out.stdin.write(im.tobytes()) -out.stdin.close();assert out.wait()==0 diff --git a/scripts/live-smoke.mjs b/scripts/live-smoke.mjs index 900fd0d..959d9eb 100644 --- a/scripts/live-smoke.mjs +++ b/scripts/live-smoke.mjs @@ -6,15 +6,14 @@ import {retryTransfer} from '../lib/transfer.js'; import {readFile} from 'node:fs/promises'; import {randomUUID,randomBytes,createHash} from 'node:crypto'; const origin=new URL(process.argv[2]).origin,bytes=await readFile(process.argv[3]); -assert.ok(bytes.length>0&&bytes.length<=50_000_000); -const duration=Number(process.argv[4]||15.008); +assert.ok(bytes.length>0&&bytes.length<=25*1024*1024); const records=[],results=[]; async function request(url,body){return await new Promise((resolve,reject)=>{const args=['--silent','--show-error','--max-time','65','--write-out','\n%{http_code}',url];if(body)args.push('-H','content-type: application/json','-H','origin: '+origin,'--data-binary','@-');const p=spawn('curl',args),out=[],err=[];p.stdout.on('data',b=>out.push(b));p.stderr.on('data',b=>err.push(b));p.on('error',reject);p.on('close',code=>{if(code)return reject(Error(Buffer.concat(err).toString()));const raw=Buffer.concat(out),split=raw.lastIndexOf(10);resolve({status:Number(raw.subarray(split+1).toString()),bytes:raw.subarray(0,split)})});p.stdin.end(body?JSON.stringify(body):undefined)})} async function call(op,body){const r=await request(origin+'/api/clip?op='+op,body);return{status:r.status,data:JSON.parse(r.bytes.toString())}} async function ok(op,body){return retryTransfer(async()=>{const r=await call(op,body);if(r.status<200||r.status>=300)throw Object.assign(Error(op+': '+r.status+' '+r.data.error),{status:r.status});return r.data})} try{ for(const protectedVideo of [false,true]){ - const c={id:randomUUID(),owner:randomBytes(32).toString('hex'),title:'Disposable ScreenNote production QA',type:'video/webm',size:bytes.length,duration,start:0,end:duration,linkDays:1,password:protectedVideo?'synthetic QA '+randomBytes(12).toString('hex'):'',captions:[{start:0,end:3,text:'QA literal '}],chapters:[{time:0,title:'Start'},{time:5,title:'Next'}],report:{actual:'Synthetic QA only'}}; + const c={id:randomUUID(),owner:randomBytes(32).toString('hex'),title:'Disposable ScreenNote production QA',type:'video/webm',size:bytes.length,duration:15,start:0,end:15,linkDays:1,password:protectedVideo?'synthetic QA '+randomBytes(12).toString('hex'):'',captions:[{start:0,end:3,text:'QA literal '}],chapters:[{time:0,title:'Start'},{time:5,title:'Next'}],report:{actual:'Synthetic QA only'}}; records.push(c);await ok('reserve',c);assert.equal((await call('manage',{id:c.id,owner:randomBytes(32).toString('hex')})).status,403); let parts=0;for(let offset=0;offsetObject.assign(Error(message),{status}); @@ -36,7 +36,7 @@ export function createGateway(client){ if(b.op==='info')out=result(await files.info(video(b.id))); else if(b.op==='sign')out=result(await files.createSignedUrl(video(b.id),300)).signedUrl; else if(b.op==='magic'){ - // The app caps shared videos at 50 MB. Only return the container header to Vercel. + // The app caps videos at 25 MiB. Only return the container header to Vercel. const blob=result(await files.download(video(b.id)));out=Array.from(new Uint8Array(await blob.slice(0,64).arrayBuffer())); }else if(b.op==='uploadPart'||b.op==='uploadSmall'){ if(typeof b.bytes!=='string'||b.bytes.length>4194304)throw fail('Invalid upload part.'); @@ -59,7 +59,7 @@ export function createGateway(client){ if(uploaded.error&&!/already exists|duplicate/i.test(uploaded.error.message))throw uploaded.error; }else if(b.op==='partsStatus'){ if(!Number.isInteger(b.size)||b.size<1||b.size>MAX_BYTES)throw fail('Invalid video size.'); - const items=result(await files.list(`screennote/${b.id}/parts`,{limit:Math.ceil(MAX_BYTES/CHUNK_BYTES)})); + const items=result(await files.list(`screennote/${b.id}/parts`,{limit:20})); out=items.filter(x=>/^(0|[1-9][0-9]*)$/.test(x.name)&&Number(x.name)Number(x.name)); }else if(b.op==='remove')result(await files.remove([video(b.id)])); else if(b.op==='clearParts'){ diff --git a/supabase/migrations/20261027070052_screennote_larger_shared_clips.sql b/supabase/migrations/20261027070052_screennote_larger_shared_clips.sql deleted file mode 100644 index 2437444..0000000 --- a/supabase/migrations/20261027070052_screennote_larger_shared_clips.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Keep this bucket private; raise only ScreenNote's per-file ceiling. -update storage.buckets set file_size_limit = 50000000 -where id = 'screennote-media' and public = false; diff --git a/tests/api.test.js b/tests/api.test.js index a5519b5..a9023af 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,6 +1,6 @@ import test from'node:test';import assert from'node:assert/strict';import{createHandler}from'../api/clip.js';import{randomId}from'../lib/rules.js'; const MAX_SHARED=16; -function setup(overrides={},inspect=async()=>({width:960,height:540,duration:7}),limits={maxBytes:2_000_000_000,maxClips:MAX_SHARED}){const map=new Map(),store={configured:()=>true,key:id=>'clip/'+id,media:id=>'media/'+id,read:async k=>map.has(k)?{data:structuredClone(map.get(k)),etag:'1'}:null,write:async(k,d)=>{if(map.has(k))throw Error('already exists');map.set(k,structuredClone(d))},mutate:async(k,fn,initial)=>{const d=structuredClone(map.get(k)||initial?.());if(!d)throw Object.assign(Error('Missing'),{status:404});const r=fn(d);map.set(k,d);return r??d},mediaInfo:async()=>({size:1000,contentType:'video/webm'}),mediaMagic:async()=>new Uint8Array([26,69,223,163]),mediaURL:async()=> 'https://example.test/signed-video',removeMedia:async()=>{},uploadSmall:async()=>{},uploadPart:async()=>{},assemble:async()=>{},clearParts:async()=>{}};Object.assign(store,overrides);const handler=createHandler(store,undefined,inspect,limits);async function call(op,body,method='POST',headers={host:'example.test',origin:'https://example.test'}){let status,result;const res={setHeader(){},status(n){status=n;return this},json(v){result=v;return this}};await handler({query:{op,id:body?.id},url:'/api/clip',method,headers,body},res);return{status,data:result}} +function setup(overrides={},inspect=async()=>({width:960,height:540,duration:7}),limits={maxBytes:1_000_000_000,maxClips:MAX_SHARED}){const map=new Map(),store={configured:()=>true,key:id=>'clip/'+id,media:id=>'media/'+id,read:async k=>map.has(k)?{data:structuredClone(map.get(k)),etag:'1'}:null,write:async(k,d)=>{if(map.has(k))throw Error('already exists');map.set(k,structuredClone(d))},mutate:async(k,fn,initial)=>{const d=structuredClone(map.get(k)||initial?.());if(!d)throw Object.assign(Error('Missing'),{status:404});const r=fn(d);map.set(k,d);return r??d},mediaInfo:async()=>({size:1000,contentType:'video/webm'}),mediaMagic:async()=>new Uint8Array([26,69,223,163]),mediaURL:async()=> 'https://example.test/signed-video',removeMedia:async()=>{},uploadSmall:async()=>{},uploadPart:async()=>{},assemble:async()=>{},clearParts:async()=>{}};Object.assign(store,overrides);const handler=createHandler(store,undefined,inspect,limits);async function call(op,body,method='POST',headers={host:'example.test',origin:'https://example.test'}){let status,result;const res={setHeader(){},status(n){status=n;return this},json(v){result=v;return this}};await handler({query:{op,id:body?.id},url:'/api/clip',method,headers,body},res);return{status,data:result}} return{map,store,call}} const input=()=>({id:randomId(),owner:'a'.repeat(64),title:'Message',type:'video/webm',size:1000,duration:7,start:0,end:7}); test('reserve, publish, view, react and comment form a complete API flow',async()=>{const{call}=setup(),c=input();assert.equal((await call('reserve',c)).status,201);assert.equal((await call('view',c,'GET')).status,404);assert.equal((await call('publish',c)).status,200);const view=await call('view',c,'GET');assert.equal(view.data.ownerHash,undefined);assert.ok(view.data.mediaUrl);const who=randomId();assert.equal((await call('reaction',{id:c.id,viewer:who,reaction:'like'})).data.reactions.like,1);assert.equal((await call('reaction',{id:c.id,viewer:who,reaction:'like'})).data.reactions.like,1);const comment={id:c.id,viewer:who,eventId:randomId(),text:'Looks good',time:3};assert.equal((await call('comment',comment)).data.comments.length,1);assert.equal((await call('comment',comment)).data.comments.length,1)}); @@ -164,16 +164,3 @@ test('protected discussions require password on every action and reject cross-cl for(const op of ['edit-comment','delete-comment','resolve-thread'])assert.equal((await call(op,{id:c.id,viewer,commentId:root,authorKey,text:'changed',resolved:true})).status,401); assert.equal((await call('comment',{id:c.id,viewer,eventId:randomId(),parentId:randomId(),authorKey,text:'bad parent',time:1,accessPassword:c.password})).status,409); }); -test('review status is owner-managed, validated and visible to recipients',async()=>{ - const {call}=setup(),c={...input(),reviewStatus:'needs-feedback'};await call('reserve',c);await call('publish',c); - assert.equal((await call('review-status',{id:c.id,owner:'b'.repeat(64),reviewStatus:'approved'})).status,403); - assert.equal((await call('review-status',{...c,reviewStatus:'invented'})).status,400); - assert.equal((await call('review-status',{...c,reviewStatus:'approved'})).status,200); - assert.equal((await call('view',c,'GET')).data.reviewStatus,'approved'); -}); -test('device restore media requires owner capability and an active shared recording',async()=>{ - const {call}=setup(),c={...input(),password:'long private test password'};await call('reserve',c); - assert.equal((await call('restore-media',c)).status,404);await call('publish',c); - assert.equal((await call('restore-media',{...c,owner:'b'.repeat(64)})).status,403); - assert.ok((await call('restore-media',c)).data.mediaUrl);await call('revoke',c);assert.equal((await call('restore-media',c)).status,404); -}); diff --git a/tests/capacity.test.js b/tests/capacity.test.js index 1b47db7..b81e56e 100644 --- a/tests/capacity.test.js +++ b/tests/capacity.test.js @@ -8,12 +8,12 @@ test('more than sixteen small recordings fit the storage budget',()=>{ assert.equal(c.ids.length,200);assert.equal(capacityBytes(c),200*1024*1024); }); test('maximum-sized sequential uploads use available quota while allowing assembly overlap',()=>{ - const c={ids:[]};for(let i=0;ireserveCapacity(c,'overflow',MAX_BYTES),{status:429}); + const c={ids:[]};for(let i=0;i<37;i++){reserveCapacity(c,String(i),MAX_BYTES);finalizeCapacity(c,String(i),MAX_BYTES,Date.now()+86400000)} + assert.equal(c.ids.length,37);assert.throws(()=>reserveCapacity(c,'overflow',MAX_BYTES),{status:429}); assert.ok(capacityBytes(c)+MAX_BYTES<=STORAGE_BUDGET_BYTES); }); test('simultaneous pending reservations cannot overbook temporary assembly space',()=>{ - const c={ids:[]};for(let i=0;ireserveCapacity(c,'overflow',MAX_BYTES),{status:429}); finalizeCapacity(c,'0',MAX_BYTES,Date.now()+86400000);releaseCapacity(c,'1');reserveCapacity(c,'next',MAX_BYTES); assert.ok(capacityBytes(c)<=STORAGE_BUDGET_BYTES); diff --git a/tests/capture.test.js b/tests/capture.test.js index 5c69b24..a639d25 100644 --- a/tests/capture.test.js +++ b/tests/capture.test.js @@ -32,6 +32,3 @@ test('permission timeout releases an acquired screen and cleans up a late device accept(device);await new Promise(r=>setImmediate(r));assert.equal(device.track.stopped,true); }); }); -test('recording continues past the former 24 MiB cutoff and saves its actual stop reason',async()=>{ - const s=stream();await environment({getDisplayMedia:async()=>s},async()=>{let done;const finished=new Promise(r=>done=r),metrics=[];const c=new Capture({chunk:()=>{},status:()=>{},metrics:v=>metrics.push(v),finished:(b,d,e,diagnostics)=>done(diagnostics)});await c.start({mic:false});c.recorder.ondataavailable({data:new Blob([new Uint8Array(30_000_000)])});assert.equal(c.recorder.state,'recording');assert.equal(c.bytes,30_000_000);assert.ok(metrics.at(-1).bytes>=30_000_000);c.stop('source');const result=await finished;assert.equal(result.reason,'source');assert.equal(result.quality,'balanced');assert.ok(result.bytes>30_000_000)}); -}); diff --git a/tests/media-validation.test.js b/tests/media-validation.test.js index f93c798..5985855 100644 --- a/tests/media-validation.test.js +++ b/tests/media-validation.test.js @@ -4,7 +4,7 @@ import {readFile} from 'node:fs/promises'; import {inspectMedia,validatePublishedMedia} from '../lib/media-validation.js'; test('real WebM, square MP4 and portrait MP4 retain tracks and measured dimensions', async () => { - for (const [path,type,duration,width,height] of [['public/sample.webm','video/webm',15.008,960,540],['tests/media/square.mp4','video/mp4',2,360,360],['tests/media/portrait.mp4','video/mp4',2,270,480]]) { + for (const [path,type,duration,width,height] of [['public/sample.webm','video/webm',7.008,960,540],['tests/media/square.mp4','video/mp4',2,360,360],['tests/media/portrait.mp4','video/mp4',2,270,480]]) { const b=await readFile(path), info=await inspectMedia(b,{type,duration,size:b.length}); assert.equal(info.width,width);assert.equal(info.height,height);assert.ok(Math.abs(info.duration-duration)<.1); } diff --git a/tests/recording-policy.test.js b/tests/recording-policy.test.js deleted file mode 100644 index eee1b24..0000000 --- a/tests/recording-policy.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {profileFor,recordingStats,sharingProblem,stopMessage} from '../lib/recording-policy.js'; -import {MAX_BYTES,MAX_SECONDS,LOCAL_MAX_BYTES,LOCAL_MAX_SECONDS} from '../lib/rules.js'; -import {filterRecordings} from '../lib/library.js'; -import {retryTransfer} from '../lib/transfer.js'; -test('local and sharing policies are separate, bounded and explicit',()=>{ - assert.ok(LOCAL_MAX_BYTES>MAX_BYTES);assert.ok(LOCAL_MAX_SECONDS>MAX_SECONDS); - assert.equal(sharingProblem({blob:{size:MAX_BYTES},duration:MAX_SECONDS}),''); - assert.match(sharingProblem({blob:{size:MAX_BYTES+1},duration:30}),/smaller copy/); - assert.match(sharingProblem({size:1000,duration:MAX_SECONDS+1}),/Trim/); - assert.match(stopMessage('size'),/250 MB/);assert.match(stopMessage('duration'),/30-minute/); -}); -test('remaining time uses observed byte growth and never goes negative',()=>{ - const s=recordingStats(30_000_000,60,'balanced');assert.equal(s.rate,500000);assert.equal(s.remaining,440);assert.equal(s.shareRemaining,40);assert.equal(s.shareable,true); - const full=recordingStats(LOCAL_MAX_BYTES+1,LOCAL_MAX_SECONDS+1);assert.equal(full.remaining,0);assert.equal(full.shareRemaining,0);assert.equal(full.shareable,false); - assert.ok(recordingStats(0,0).remaining>0);assert.equal(profileFor('unknown'),profileFor('balanced'));assert.ok(profileFor('saver').video{ - const a={id:'a',title:'Checkout',createdAt:2,blob:{}},b={id:'b',title:'Alpha',createdAt:1,shared:true,expiresAt:Date.now()+60000,reviewStatus:'approved'}; - const clips=[a,b];assert.deepEqual(filterRecordings(clips,{status:'needs-feedback'}).map(c=>c.id),['a']);assert.deepEqual(filterRecordings(clips,{query:'CHECK'}).map(c=>c.id),['a']);assert.deepEqual(filterRecordings(clips,{status:'shared'}).map(c=>c.id),['b']);assert.deepEqual(filterRecordings(clips,{sort:'title'}).map(c=>c.id),['b','a']);assert.deepEqual(clips,[a,b]); -}); -test('a deliberately paused upload is never retried',async()=>{ - let calls=0;await assert.rejects(retryTransfer(async()=>{calls++;throw Object.assign(Error('Paused'),{name:'AbortError'})}),{name:'AbortError'});assert.equal(calls,1); -}); diff --git a/tests/rules.test.js b/tests/rules.test.js index 8a5cc89..5ce1cbf 100644 --- a/tests/rules.test.js +++ b/tests/rules.test.js @@ -1,6 +1,6 @@ -import test from'node:test';import assert from'node:assert/strict';import{clipInput,commentInput,publicClip,isVideo,randomId,validId,MAX_BYTES,MAX_SECONDS}from'../lib/rules.js'; +import test from'node:test';import assert from'node:assert/strict';import{clipInput,commentInput,publicClip,isVideo,randomId,validId,MAX_BYTES}from'../lib/rules.js'; const good={title:'A clear message',type:'video/webm',duration:7,size:1000,start:0,end:7}; -test('video metadata enforces size, duration, type and trim boundaries',()=>{assert.equal(clipInput(good).end,7);for(const override of [{size:0},{size:MAX_BYTES+1},{duration:NaN},{duration:MAX_SECONDS+1.1},{type:'text/html'},{start:-1},{end:8},{end:0},{title:' '},{title:'a'.repeat(101)}])assert.throws(()=>clipInput({...good,...override}))}); +test('video metadata enforces size, duration, type and trim boundaries',()=>{assert.equal(clipInput(good).end,7);for(const override of [{size:0},{size:MAX_BYTES+1},{duration:NaN},{duration:301.1},{type:'text/html'},{start:-1},{end:8},{end:0},{title:' '},{title:'a'.repeat(101)}])assert.throws(()=>clipInput({...good,...override}))}); test('comments require valid timestamps and text, with bounded guest names',()=>{assert.equal(commentInput({text:'Useful',time:2},good).name,'Guest');for(const v of [{text:'',time:2},{text:'x',time:-1},{text:'x',time:9},{text:'x',time:'bad'},{text:'x'.repeat(501),time:2},{text:'x',time:2,name:'a'.repeat(41)}])assert.throws(()=>commentInput(v,good))}); test('public clip never reveals owner capability or anonymous voter keys',()=>{const v=publicClip({...good,id:randomId(),ownerHash:'secret',votes:{a:'like',b:'thanks',c:'like'}});assert.equal(v.ownerHash,undefined);assert.equal(v.votes,undefined);assert.deepEqual(v.reactions,{like:2,thanks:1,idea:0})}); test('container sniffing rejects mislabeled payloads',()=>{assert.equal(isVideo(new Uint8Array([26,69,223,163]),'video/webm'),true);assert.equal(isVideo(new TextEncoder().encode(''),'video/webm'),false);assert.equal(isVideo(new Uint8Array([0,0,0,12,102,116,121,112]),'video/mp4'),true)}); diff --git a/tests/supabase-store.test.js b/tests/supabase-store.test.js index 5a72909..d464de0 100644 --- a/tests/supabase-store.test.js +++ b/tests/supabase-store.test.js @@ -47,7 +47,7 @@ test('storage gateway validates JSON, record paths and metadata CAS conflicts',a test('storage gateway lists only complete resumable parts and rejects oversized assembly',async()=>{ const a=gatewayFixture({files:{list:async()=>({data:[{name:'0',metadata:{size:2097152}},{name:'1',metadata:{size:10}},{name:'2',metadata:{size:12}},{name:'not-a-part'}],error:null})}}); const r=await a.call({op:'partsStatus',id,size:2097162});assert.equal(r.status,200);assert.deepEqual((await r.json()).data,[0,1]); - assert.equal((await a.call({op:'assemble',id,size:50000001,type:'video/webm'})).status,400); + assert.equal((await a.call({op:'assemble',id,size:26214401,type:'video/webm'})).status,400); }); test('storage gateway refuses a public bucket during health and does not leak database errors',async()=>{ const a=gatewayFixture({rpc:async()=>({data:null,error:{message:'sensitive internal connection'}})}); diff --git a/tests/ui/fixture.js b/tests/ui/fixture.js index 20bbc17..4ead474 100644 --- a/tests/ui/fixture.js +++ b/tests/ui/fixture.js @@ -3,7 +3,7 @@ const mode=new URLSearchParams(location.search).get('case')||'empty'; document.documentElement.dataset.qaCase=mode; const lag=Number(new URLSearchParams(location.search).get('lag'))||200; const run=new URLSearchParams(location.search).get('run')||'default'; -let healthCalls=0,reserveCalls=0,publishCalls=0;const uploadedParts=new Set(); +let healthCalls=0,reserveCalls=0,publishCalls=0; const realFetch=window.fetch.bind(window),realOpen=indexedDB.open.bind(indexedDB); const id='11111111-1111-4111-8111-111111111111',owner='a'.repeat(64); if(mode==='clipboard-failed')Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw Error('Clipboard unavailable (QA simulation).')}}}); @@ -19,7 +19,7 @@ if(mode==='viewer-discussion'){ indexedDB.open=(name,version)=>realOpen('screennote-qa-'+mode+'-'+run+'-'+name,version); if(mode==='storage-unavailable')indexedDB.open=()=>{throw Error('Device storage is unavailable (QA simulation).')}; if(!['empty','viewer','unavailable','storage-unavailable','recording','permission','quota','permission-pending','portrait-capture'].includes(mode)){ - let blob=await(await realFetch(mediaPath)).blob();if(mode==='large-local'){blob=new Blob([blob,new Uint8Array(51_000_000-blob.size)],{type:'video/webm'});Object.assign(record,{duration:15.008,end:15.008,size:blob.size,title:'Oversized local QA recording',captions:[],chapters:[],report:null})} + const blob=await(await realFetch(mediaPath)).blob(); const db=await new Promise((resolve,reject)=>{const r=indexedDB.open('screennote',1);r.onupgradeneeded=()=>{r.result.createObjectStore('clips',{keyPath:'id'});r.result.createObjectStore('drafts',{keyPath:'id'})};r.onsuccess=()=>resolve(r.result);r.onerror=reject}); await new Promise((resolve,reject)=>{const t=db.transaction('clips','readwrite'),store=t.objectStore('clips');const r=store.get(id);r.onsuccess=()=>{if(!r.result&&mode!=='interrupted')store.put({...record,revoked:mode==='protected',blob:mode==='recovered'?undefined:blob,managementOnly:mode==='recovered',shared:['shared','recovered','clipboard-failed'].includes(mode),uploadAttempted:['shared','recovered','clipboard-failed','protected'].includes(mode)})};t.oncomplete=resolve;t.onerror=reject});if(mode==='interrupted')await new Promise((resolve,reject)=>{const t=db.transaction('drafts','readwrite');t.objectStore('drafts').put({id,createdAt:Date.now(),seconds:7.008,chunks:[blob]});t.oncomplete=resolve;t.onerror=reject});db.close(); } @@ -43,15 +43,14 @@ window.fetch=async(url,options={})=>{ const op=u.searchParams.get('op'),body=options.body?JSON.parse(options.body):{}; const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'Content-Type':'application/json'}}); if(op==='health'){if(mode==='storage-suspended')return json({error:'Online sharing is unavailable. Keep or download your local copy. The site owner needs to restore sharing access.',code:'STORAGE_ACCESS_UNAVAILABLE'},503);healthCalls++;if(mode==='health-retry'&&healthCalls<=4)return json({error:'Temporary health failure (QA)'},503);return json({configured:mode!=='offline'})} - await new Promise((resolve,reject)=>{const signal=options.signal;if(signal?.aborted){reject(signal.reason);return}const abort=()=>{clearTimeout(timer);reject(signal.reason)};const timer=setTimeout(()=>{signal?.removeEventListener('abort',abort);resolve()},lag);signal?.addEventListener('abort',abort,{once:true})}); + await new Promise(resolve=>setTimeout(resolve,lag)); if(op==='view'){if(mode==='unavailable')return json({error:'This link has expired or is no longer shared.'},404);return json({...record,mediaUrl:mediaPath})} - if(op==='review-status'){record.reviewStatus=body.reviewStatus;return json({reviewStatus:record.reviewStatus})}if(op==='restore-media')return json({mediaUrl:mediaPath}); if(op==='title'){if(body.owner!==owner)return json({error:'Wrong owner'},403);record.title=body.title;return json({title:body.title})} if(op==='details'){Object.assign(record,{captions:body.captions,chapters:body.chapters,report:body.report});return json(body)} - if(op==='upload-state')return json({parts:[...uploadedParts]}); + if(op==='upload-state')return json({parts:[]}); if(op==='manage')return body.owner===owner?json(record):json({error:'This browser does not have permission to manage the link.'},403); if(op==='reserve'){if(mode==='transfer-retry'&&++reserveCalls<3)return json({error:'Temporary setup failure (QA)'},503);record.passwordProtected=!!body.password;return json({id:body.id,status:'pending'},201)}; - if(op==='upload-chunk'){if(mode!=='upload-failed')uploadedParts.add(body.part);return mode==='upload-failed'?json({error:'Upload failed (QA simulation). Your local copy is safe.'},503):json({uploaded:true})}; + if(op==='upload-chunk')return mode==='upload-failed'?json({error:'Upload failed (QA simulation). Your local copy is safe.'},503):json({uploaded:true}); if(op==='publish'){if(mode==='transfer-retry'&&++publishCalls<3)return json({error:'Temporary finalization failure (QA)'},503);Object.assign(record,{...body,status:'published'});return json(record)} if(op==='revoke'){record.status='revoked';return json({revoked:true})} if(op==='delete'){record.status='deleted';record.cleanupComplete=true;return json({deleted:true})} diff --git a/tests/ui/matrix.html b/tests/ui/matrix.html index e16b8f5..cf5d85d 100644 --- a/tests/ui/matrix.html +++ b/tests/ui/matrix.html @@ -1 +1 @@ -ScreenNote full UI QA
Internal QA · simulated services and capture
+ScreenNote full UI QA
Internal QA · simulated services and capture
diff --git a/vercel.json b/vercel.json index 09a9753..1fb0ba8 100644 --- a/vercel.json +++ b/vercel.json @@ -24,7 +24,7 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob: https://burrqaxylibmlkgfqlww.supabase.co https://*.blob.vercel-storage.com; connect-src 'self' https://burrqaxylibmlkgfqlww.supabase.co https://*.blob.vercel-storage.com https://blob.vercel-storage.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob: https://burrqaxylibmlkgfqlww.supabase.co https://*.blob.vercel-storage.com; connect-src 'self' https://*.blob.vercel-storage.com https://blob.vercel-storage.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" } ] }