├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── ascii-roulette.gif ├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── app.go ├── avatar ├── README.md ├── contributors │ ├── Sean-Der.png │ ├── djbaskin.png │ └── maxhawkins.png └── main.go ├── camera ├── cam.go ├── cam_avfoundation.h ├── cam_avfoundation.mm ├── cam_callback_darwin.go ├── cam_darwin.go └── cam_linux.go ├── capture.go ├── cmd └── ascii_roulette │ └── main.go ├── conn.go ├── go.mod ├── go.sum ├── install.sh ├── match.go ├── signal ├── Dockerfile ├── README.md ├── conn.go ├── go.mod ├── go.sum ├── group.go ├── main.go └── server.go ├── term ├── ansi.go ├── input.go ├── input_bsd.go ├── input_linux.go └── window.go ├── ui ├── ansi.go ├── events.go ├── reducer.go ├── renderer.go └── state.go ├── videos ├── bindata.go ├── ivfreader.go ├── player.go ├── src │ ├── globe.ivf │ └── pion.ivf └── videos.go ├── vpx ├── decoder.c ├── decoder.go ├── encoder.c ├── encoder.go └── errors.go └── yuv └── encoding.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ascii-roulette.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/.github/ascii-roulette.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ascii_roulette 2 | *.a 3 | *.o 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: go 3 | env: 4 | - GO111MODULE=on 5 | go: 6 | - 1.12.1 7 | git: 8 | depth: 1 9 | 10 | before_install: 11 | - sudo apt-get update 12 | - sudo apt-get install -y libvpx-dev 13 | 14 | install: true 15 | 16 | script: 17 | - go test -v -race ./... 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Dialup, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CC = g++ 2 | 3 | CFLAGS = \ 4 | -x objective-c++ \ 5 | -Wall \ 6 | -Wcast-align \ 7 | -Wundef \ 8 | -Wformat-security \ 9 | -Wwrite-strings \ 10 | -Wno-sign-compare \ 11 | -Wno-conversion 12 | 13 | LDFLAGS = \ 14 | -framework Foundation \ 15 | -framework AVFoundation 16 | 17 | ifeq ($(shell uname -s), Darwin) 18 | libs = camera/libcam.a 19 | else 20 | libs = 21 | endif 22 | 23 | .PHONY: run 24 | run: ascii_roulette 25 | ./ascii_roulette 26 | 27 | ascii_roulette: $(libs) *.go 28 | go build -o $@ ./cmd/ascii_roulette 29 | 30 | camera/libcam.a: camera/cam_avfoundation.o 31 | $(AR) -cr $@ $< 32 | 33 | camera/cam_avfoundation.o: camera/cam_avfoundation.mm 34 | $(CC) $(CFLAGS) -o $@ -c $< 35 | 36 | clean: 37 | rm -f camera/libcam.a camera/cam_avfoundation.o ascii_roulette 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASCII Roulette 2 | 3 | > 👾 Make friends on the command line! 4 | 5 | [![License](https://img.shields.io/github/license/dialup-inc/ascii.svg)](LICENSE) 6 | [![Build Status](https://travis-ci.org/dialup-inc/ascii.svg?branch=master)](https://travis-ci.org/dialup-inc/ascii) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/dialup-inc/ascii?)](https://goreportcard.com/report/github.com/dialup-inc/ascii) 8 | [![GoDoc](https://godoc.org/github.com/dialup-inc/ascii?status.svg)](https://godoc.org/github.com/dialup-inc/ascii) 9 | [![Pion](https://img.shields.io/badge/Pion-v2.0.22-red.svg)](https://github.com/pion/webrtc) 10 | 11 |

12 | 13 |

14 |

Chat with a random person without ever leaving your terminal window.

15 | 16 | ## Installation 17 | 18 | **Download the fast way:** 19 | 20 | ```sh 21 | # Paste this command into your terminal to install 22 | bash <(curl -Ls dialup.com/ascii) 23 | ``` 24 | 25 | **Or, build from from source:** 26 | 27 | First, install [libvpx](https://www.webmproject.org/code/). On OSX you can run `brew install libvpx` 28 | 29 | ```sh 30 | git clone https://github.com/dialup-inc/ascii.git 31 | cd ascii 32 | make 33 | ``` 34 | 35 | ## Contributing 36 | 37 | Contributions and bug reports are welcome! Please check [the issues section](https://github.com/dialup-inc/ascii/issues) before submitting. 38 | 39 | Here are some starter issues if you'd like to help out: 40 | 41 | - [#13 - Windows Support](https://github.com/dialup-inc/ascii/issues/13) 42 | - [#36 - Rooms](https://github.com/dialup-inc/ascii/issues/34) 43 | - [#10 - Scrollback](https://github.com/dialup-inc/ascii/issues/10) 44 | - [#27 - Typing Indicator](https://github.com/dialup-inc/ascii/issues/27) 45 | 46 | #### Our Contributors 47 | 48 | 49 | 50 | 51 | 52 | ## About Dialup 53 | 54 | This app is a project from the Dialup company hackathon. 55 | 56 | [Dialup](https://dialup.com) is a WebRTC-based voice chat app that connects you to a random person with a similar interest. 57 | 58 | _We're hiring!_ Visit [our jobs page](https://dialup.com/jobs) to learn more. We 💖 Go and WebRTC. 59 | 60 | ## Copyright 61 | 62 | Code and documentation Copyright 2019 [Dialup, Inc.](https://dialup.com) 63 | 64 | Code released under the [MIT license](LICENSE). 65 | 66 | [webmaster@dialup.com](mailto:webmaster@dialup.com) 67 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package ascii 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "image" 9 | "image/color" 10 | "log" 11 | "math" 12 | "os" 13 | "runtime/debug" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/dialup-inc/ascii/term" 19 | "github.com/dialup-inc/ascii/ui" 20 | "github.com/dialup-inc/ascii/videos" 21 | "github.com/dialup-inc/ascii/vpx" 22 | "github.com/pion/stun" 23 | "github.com/pion/webrtc/v2" 24 | ) 25 | 26 | const defaultSTUNServer = "stun.l.google.com:19302" 27 | 28 | type App struct { 29 | STUNServer string 30 | 31 | decoder *vpx.Decoder 32 | 33 | signalerURL string 34 | 35 | cancelMu sync.Mutex 36 | quit context.CancelFunc 37 | skipIntro context.CancelFunc 38 | nextPartner context.CancelFunc 39 | startChat context.CancelFunc 40 | 41 | renderer *ui.Renderer 42 | 43 | conn *Conn 44 | 45 | capture *Capture 46 | } 47 | 48 | func (a *App) run(ctx context.Context) error { 49 | a.cancelMu.Lock() 50 | if a.quit != nil { 51 | a.cancelMu.Unlock() 52 | return errors.New("app can only be run once") 53 | } 54 | ctx, cancel := context.WithCancel(ctx) 55 | a.quit = cancel 56 | a.cancelMu.Unlock() 57 | 58 | if err := term.CaptureStdin(a.onKeypress); err != nil { 59 | return err 60 | } 61 | 62 | winSize, _ := term.GetWinSize() 63 | if winSize.Rows < 15 || winSize.Cols < 50 { 64 | ansi := term.ANSI{os.Stdout} 65 | ansi.ResizeWindow(15, 50) 66 | } 67 | 68 | go a.watchWinSize(ctx) 69 | 70 | var introCtx context.Context 71 | introCtx, skipIntro := context.WithCancel(ctx) 72 | 73 | a.cancelMu.Lock() 74 | a.skipIntro = skipIntro 75 | a.cancelMu.Unlock() 76 | 77 | // Play Dialup intro 78 | a.renderer.Dispatch(ui.SetPageEvent(ui.GlobePage)) 79 | 80 | player, err := videos.NewPlayer(videos.Globe()) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | player.OnFrame = func(img image.Image) { 85 | a.renderer.Dispatch(ui.FrameEvent(img)) 86 | } 87 | player.Play(introCtx) 88 | 89 | // Play Pion intro 90 | a.renderer.Dispatch(ui.SetPageEvent(ui.PionPage)) 91 | 92 | player, err = videos.NewPlayer(videos.Pion()) 93 | if err != nil { 94 | log.Fatal(err) 95 | } 96 | player.OnFrame = func(img image.Image) { 97 | a.renderer.Dispatch(ui.FrameEvent(img)) 98 | } 99 | player.Play(introCtx) 100 | 101 | // Show confirmation page 102 | a.renderer.Dispatch(ui.SetPageEvent(ui.ConfirmPage)) 103 | 104 | startCtx, startChat := context.WithCancel(ctx) 105 | 106 | a.cancelMu.Lock() 107 | a.startChat = startChat 108 | a.cancelMu.Unlock() 109 | 110 | <-startCtx.Done() 111 | 112 | // give user a chance to quit 113 | if err := ctx.Err(); err != nil { 114 | return nil 115 | } 116 | 117 | a.renderer.Dispatch(ui.SetPageEvent(ui.ChatPage)) 118 | 119 | // Start up camera 120 | for { 121 | if err := ctx.Err(); err != nil { 122 | return nil 123 | } 124 | 125 | err := a.capture.Start(0, 5) 126 | if err == nil { 127 | break 128 | } 129 | msg := fmt.Sprintf("camera error: %v", err) 130 | a.renderer.Dispatch(ui.LogEvent{ 131 | Level: ui.LogLevelError, 132 | Text: msg, 133 | }) 134 | 135 | select { 136 | case <-time.After(1500 * time.Millisecond): 137 | continue 138 | case <-ctx.Done(): 139 | return nil 140 | } 141 | } 142 | 143 | // Attempt to find match 144 | var backoff float64 145 | for { 146 | if err := ctx.Err(); err != nil { 147 | break 148 | } 149 | 150 | var connCtx context.Context 151 | connCtx, nextPartner := context.WithCancel(ctx) 152 | 153 | a.cancelMu.Lock() 154 | a.nextPartner = nextPartner 155 | a.cancelMu.Unlock() 156 | 157 | endReason, err := a.connect(connCtx) 158 | // HACK(maxhawkins): these errors get returned when the context passed 159 | // into match is canceled, so we ignore them. There's probably a more elegant 160 | // way to close the websocket without all this error munging. 161 | if err != nil && strings.Contains(err.Error(), "use of closed network connection") { 162 | continue 163 | } else if err != nil && strings.Contains(err.Error(), "operation was canceled") { 164 | continue 165 | } else if err != nil { 166 | a.renderer.Dispatch(ui.LogEvent{ 167 | Level: ui.LogLevelError, 168 | Text: err.Error(), 169 | }) 170 | 171 | sec := math.Pow(2, backoff) - 1 172 | if backoff < 4 { 173 | backoff++ 174 | } 175 | 176 | select { 177 | case <-time.After(time.Duration(sec) * time.Second): 178 | continue 179 | case <-ctx.Done(): 180 | return nil 181 | } 182 | } 183 | 184 | a.renderer.Dispatch(ui.ConnEndedEvent{endReason}) 185 | a.renderer.Dispatch(ui.FrameEvent(nil)) 186 | 187 | time.Sleep(100 * time.Millisecond) 188 | } 189 | 190 | return nil 191 | } 192 | 193 | func (a *App) catchError(msg interface{}, stack []byte) { 194 | buf := bytes.NewBuffer(nil) 195 | ansi := term.ANSI{buf} 196 | 197 | ansi.CursorPosition(1, 1) 198 | ansi.Reset() 199 | 200 | ansi.Bold() 201 | ansi.Foreground(color.RGBA{0xFF, 0x00, 0x00, 0xFF}) 202 | buf.WriteString("Oops! ASCII Roulette hit a snag.\n") 203 | ansi.Normal() 204 | ansi.ForegroundReset() 205 | 206 | buf.WriteString("\n") 207 | buf.WriteString("Please report this error at https://github.com/dialup-inc/ascii/issues/new/choose\n") 208 | buf.WriteString("\n") 209 | buf.WriteString("\n") 210 | 211 | buf.WriteString(fmt.Sprintf("[panic] %v\n", msg)) 212 | buf.WriteString("\n") 213 | buf.Write(stack) 214 | buf.WriteString("\n") 215 | 216 | data := bytes.ReplaceAll(buf.Bytes(), []byte("\n"), []byte("\r\n")) 217 | os.Stderr.Write(data) 218 | } 219 | 220 | func (a *App) Run(ctx context.Context) error { 221 | // Show a nice error page if there's a panic somewhere in the code 222 | defer func() { 223 | if r := recover(); r != nil { 224 | a.catchError(r, debug.Stack()) 225 | } 226 | }() 227 | 228 | err := a.run(ctx) 229 | 230 | // Clean up: 231 | a.renderer.Stop() 232 | if a.conn != nil && a.conn.IsConnected() { 233 | a.conn.SendBye() 234 | } 235 | 236 | return err 237 | } 238 | 239 | func (a *App) watchWinSize(ctx context.Context) error { 240 | checkWinSize := func() { 241 | winSize, err := term.GetWinSize() 242 | if err != nil { 243 | return 244 | } 245 | a.renderer.Dispatch(ui.ResizeEvent(winSize)) 246 | } 247 | 248 | checkWinSize() 249 | 250 | tick := time.Tick(500 * time.Millisecond) 251 | for { 252 | select { 253 | case <-ctx.Done(): 254 | return ctx.Err() 255 | case <-tick: 256 | checkWinSize() 257 | } 258 | } 259 | } 260 | 261 | func (a *App) sendMessage() { 262 | if a.conn == nil || !a.conn.IsConnected() { 263 | return 264 | } 265 | 266 | msg := a.renderer.GetState().Input 267 | msg = strings.TrimSpace(msg) 268 | 269 | // Don't send empty messages 270 | if len(msg) == 0 { 271 | return 272 | } 273 | 274 | if err := a.conn.SendMessage(msg); err != nil { 275 | a.renderer.Dispatch(ui.LogEvent{ 276 | Level: ui.LogLevelError, 277 | Text: fmt.Sprintf("sending failed: %v", err), 278 | }) 279 | } else { 280 | a.renderer.Dispatch(ui.SentMessageEvent(msg)) 281 | } 282 | } 283 | 284 | func (a *App) checkConnection(ctx context.Context) error { 285 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 286 | defer cancel() 287 | 288 | c, err := stun.Dial("udp", a.STUNServer) 289 | if err != nil { 290 | return errors.New("connection error: check your firewall or network") 291 | } 292 | message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) 293 | 294 | errChan := make(chan error) 295 | 296 | go c.Do(message, func(res stun.Event) { 297 | errChan <- res.Error 298 | }) 299 | 300 | select { 301 | case err := <-errChan: 302 | if err != nil { 303 | return errors.New("binding request failed: firewall may be blocking connections") 304 | } 305 | return nil 306 | 307 | case <-ctx.Done(): 308 | switch ctx.Err() { 309 | case context.Canceled: 310 | return err 311 | case context.DeadlineExceeded: 312 | return errors.New("binding request failed: firewall may be blocking connections") 313 | default: 314 | return nil 315 | } 316 | } 317 | } 318 | 319 | func (a *App) connect(ctx context.Context) (ui.EndConnReason, error) { 320 | ctx, cancel := context.WithCancel(ctx) 321 | defer cancel() 322 | 323 | frameTimeout := time.NewTimer(999 * time.Hour) 324 | frameTimeout.Stop() 325 | 326 | connectTimeout := time.NewTimer(999 * time.Hour) 327 | connectTimeout.Stop() 328 | 329 | ended := make(chan ui.EndConnReason) 330 | 331 | conn, err := NewConn(webrtc.Configuration{ 332 | ICEServers: []webrtc.ICEServer{ 333 | {URLs: []string{fmt.Sprintf("stun:%s", a.STUNServer)}}, 334 | }, 335 | }) 336 | if err != nil { 337 | return ui.EndConnSetupError, err 338 | } 339 | a.conn = conn 340 | 341 | defer func() { 342 | // Turn off callbacks 343 | conn.OnBye = func() {} 344 | conn.OnMessage = func(string) {} 345 | conn.OnICEConnectionStateChange = func(webrtc.ICEConnectionState) {} 346 | conn.OnFrame = func([]byte) {} 347 | conn.OnPLI = func() {} 348 | conn.OnDataOpen = func() {} 349 | 350 | // Send Goodbye packet 351 | if conn.IsConnected() { 352 | conn.SendBye() 353 | } 354 | }() 355 | 356 | conn.OnBye = func() { 357 | a.renderer.Dispatch(ui.FrameEvent(nil)) 358 | ended <- ui.EndConnGone 359 | } 360 | conn.OnMessage = func(s string) { 361 | a.renderer.Dispatch(ui.ReceivedChatEvent(s)) 362 | } 363 | conn.OnICEConnectionStateChange = func(s webrtc.ICEConnectionState) { 364 | switch s { 365 | case webrtc.ICEConnectionStateConnected: 366 | a.capture.RequestKeyframe() 367 | connectTimeout.Stop() 368 | a.renderer.Dispatch(ui.ConnStartedEvent{}) 369 | 370 | case webrtc.ICEConnectionStateDisconnected: 371 | a.renderer.Dispatch(ui.LogEvent{ 372 | Level: ui.LogLevelInfo, 373 | Text: "Reconnecting...", 374 | }) 375 | 376 | case webrtc.ICEConnectionStateFailed: 377 | ended <- ui.EndConnDisconnected 378 | } 379 | } 380 | conn.OnDataOpen = func() { 381 | a.renderer.Dispatch(ui.DataOpenedEvent{}) 382 | } 383 | 384 | a.capture.SetTrack(conn.SendTrack) 385 | 386 | dec, err := vpx.NewDecoder(320, 240) 387 | if err != nil { 388 | return ui.EndConnSetupError, err 389 | } 390 | conn.OnFrame = func(frame []byte) { 391 | frameTimeout.Reset(5 * time.Second) 392 | connectTimeout.Stop() 393 | 394 | img, err := dec.Decode(frame) 395 | if err != nil { 396 | conn.SendPLI() 397 | return 398 | } 399 | a.renderer.Dispatch(ui.FrameEvent(img)) 400 | } 401 | conn.OnPLI = func() { 402 | a.capture.RequestKeyframe() 403 | } 404 | 405 | a.renderer.Dispatch(ui.LogEvent{ 406 | Level: ui.LogLevelInfo, 407 | Text: "Searching for match...", 408 | }) 409 | 410 | err = Match(ctx, a.signalerURL, conn.pc) 411 | if err == errMatchFailed { 412 | return ui.EndConnMatchError, nil 413 | } 414 | if err != nil { 415 | return ui.EndConnMatchError, err 416 | } 417 | 418 | connectTimeout.Reset(10 * time.Second) 419 | 420 | a.renderer.Dispatch(ui.LogEvent{ 421 | Level: ui.LogLevelInfo, 422 | Text: "Found match. Connecting...", 423 | }) 424 | 425 | if err := a.checkConnection(ctx); err != nil { 426 | return ui.EndConnSetupError, err 427 | } 428 | 429 | var reason ui.EndConnReason 430 | select { 431 | case <-ctx.Done(): 432 | reason = ui.EndConnNormal 433 | case <-connectTimeout.C: 434 | reason = ui.EndConnTimedOut 435 | case <-frameTimeout.C: 436 | reason = ui.EndConnDisconnected 437 | case r := <-ended: 438 | reason = r 439 | } 440 | 441 | return reason, nil 442 | } 443 | 444 | func (a *App) onKeypress(c rune) { 445 | switch c { 446 | case 3: // ctrl-c 447 | 448 | a.renderer.Dispatch(ui.LogEvent{ 449 | Level: ui.LogLevelInfo, 450 | Text: "Quitting...", 451 | }) 452 | 453 | a.cancelMu.Lock() 454 | if a.quit != nil { 455 | a.quit() 456 | } 457 | a.cancelMu.Unlock() 458 | 459 | case 4: // ctrl-d 460 | a.cancelMu.Lock() 461 | if a.nextPartner != nil { 462 | a.nextPartner() 463 | a.nextPartner = nil 464 | } 465 | a.cancelMu.Unlock() 466 | 467 | a.renderer.Dispatch(ui.SkipEvent{}) 468 | 469 | case 20: // ctrl-t 470 | a.renderer.Dispatch(ui.ToggleHelpEvent{}) 471 | 472 | case 127: // backspace 473 | a.renderer.Dispatch(ui.BackspaceEvent{}) 474 | 475 | case '\n', '\r': 476 | a.cancelMu.Lock() 477 | if a.startChat != nil { 478 | a.startChat() 479 | a.startChat = nil 480 | } 481 | a.cancelMu.Unlock() 482 | 483 | a.sendMessage() 484 | 485 | case ' ': 486 | a.renderer.Dispatch(ui.KeypressEvent(c)) 487 | 488 | a.cancelMu.Lock() 489 | if a.skipIntro != nil { 490 | a.skipIntro() 491 | a.skipIntro = nil 492 | } 493 | if a.startChat != nil { 494 | a.startChat() 495 | a.startChat = nil 496 | } 497 | a.cancelMu.Unlock() 498 | 499 | default: 500 | a.renderer.Dispatch(ui.KeypressEvent(c)) 501 | } 502 | } 503 | 504 | func New(signalerURL string) (*App, error) { 505 | cap, err := NewCapture(320, 240) 506 | if err != nil { 507 | return nil, err 508 | } 509 | 510 | a := &App{ 511 | signalerURL: signalerURL, 512 | STUNServer: defaultSTUNServer, 513 | 514 | renderer: ui.NewRenderer(), 515 | capture: cap, 516 | } 517 | a.renderer.Start() 518 | 519 | return a, nil 520 | } 521 | -------------------------------------------------------------------------------- /avatar/README.md: -------------------------------------------------------------------------------- 1 | # ASCII Avatar Script 2 | 3 | This is a tiny script to make the ascii-fied avatars that appear on 4 | the contributor list. 5 | 6 | Just run: 7 | 8 | ```bash 9 | go run *.go 10 | ``` 11 | -------------------------------------------------------------------------------- /avatar/contributors/Sean-Der.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/avatar/contributors/Sean-Der.png -------------------------------------------------------------------------------- /avatar/contributors/djbaskin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/avatar/contributors/djbaskin.png -------------------------------------------------------------------------------- /avatar/contributors/maxhawkins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/avatar/contributors/maxhawkins.png -------------------------------------------------------------------------------- /avatar/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "image" 7 | "log" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "github.com/dialup-inc/ascii/term" 13 | "github.com/dialup-inc/ascii/ui" 14 | 15 | "image/color" 16 | _ "image/jpeg" 17 | _ "image/png" 18 | ) 19 | 20 | type profile struct { 21 | AvatarURL string `json:"avatar_url"` 22 | } 23 | 24 | func fetchAvatar(username string) (image.Image, error) { 25 | resp, err := http.Get("https://api.github.com/users/" + username) 26 | if err != nil { 27 | return nil, err 28 | } 29 | defer resp.Body.Close() 30 | 31 | var prof profile 32 | if err := json.NewDecoder(resp.Body).Decode(&prof); err != nil { 33 | return nil, err 34 | } 35 | 36 | resp, err = http.Get(prof.AvatarURL) 37 | if err != nil { 38 | return nil, err 39 | } 40 | defer resp.Body.Close() 41 | 42 | img, _, err := image.Decode(resp.Body) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return img, nil 48 | } 49 | 50 | func main() { 51 | flag.Parse() 52 | 53 | username := flag.Arg(0) 54 | 55 | img, err := fetchAvatar(username) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | ansi := term.ANSI{os.Stdout} 61 | 62 | defer func() { 63 | ansi.ShowCursor() 64 | ansi.Reset() 65 | os.Stdout.Sync() 66 | }() 67 | 68 | ws, err := term.GetWinSize() 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | aspect := float64(ws.Height) * float64(ws.Cols) / float64(ws.Rows) / float64(ws.Width) 73 | 74 | ansi.ResizeWindow(10, int(10*aspect)) 75 | 76 | // Let the resize happen 77 | time.Sleep(500 * time.Millisecond) 78 | 79 | ws, err = term.GetWinSize() 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | 84 | ansi.Background(color.RGBA{0, 0, 0, 255}) 85 | ansi.CursorPosition(1, 1) 86 | 87 | imgANSI := ui.Image2ANSI(img, ws.Cols, ws.Rows, aspect, false) 88 | os.Stdout.Write(imgANSI) 89 | 90 | ansi.HideCursor() 91 | 92 | buf := make([]byte, 1) 93 | os.Stdin.Read(buf) 94 | } 95 | -------------------------------------------------------------------------------- /camera/cam.go: -------------------------------------------------------------------------------- 1 | package camera 2 | 3 | import "image" 4 | 5 | type FrameCallback func(image.Image, error) 6 | -------------------------------------------------------------------------------- /camera/cam_avfoundation.h: -------------------------------------------------------------------------------- 1 | #ifndef _CAM_H_ 2 | #define _CAM_H_ 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | #define E_OK 0 9 | #define E_INIT_FAILED -1 10 | #define E_OPEN_CAMERA_FAILED -2 11 | #define E_CAMERA_NOT_FOUND -3 12 | 13 | typedef void (*FrameCallback)(void *userdata, void *buf, int len, int width, 14 | int height); 15 | 16 | typedef void *Camera; 17 | int cam_init(Camera *cam, FrameCallback callback, void *userdata); 18 | int cam_start(Camera cam, int cam_id, int width, int height); 19 | int cam_close(Camera cam); 20 | 21 | #ifdef __cplusplus 22 | } 23 | #endif 24 | 25 | #endif 26 | -------------------------------------------------------------------------------- /camera/cam_avfoundation.mm: -------------------------------------------------------------------------------- 1 | #import "cam_avfoundation.h" 2 | 3 | #import 4 | 5 | // image_src is the source image, image_dst is the converted image 6 | void NV12_YUV420P(const unsigned char *image_src, unsigned char *image_dst, 7 | int image_width, int image_height) { 8 | unsigned char *p = image_dst; 9 | memcpy(p, image_src, image_width * image_height * 3 / 2); 10 | const unsigned char *pNV = image_src + image_width * image_height; 11 | unsigned char *pU = p + image_width * image_height; 12 | unsigned char *pV = 13 | p + image_width * image_height + ((image_width * image_height) >> 2); 14 | for (int i = 0; i < (image_width * image_height) / 2; i++) { 15 | if ((i % 2) == 0) 16 | *pU++ = *(pNV + i); 17 | else 18 | *pV++ = *(pNV + i); 19 | } 20 | } 21 | 22 | @interface FrameDelegate 23 | : NSObject { 24 | FrameCallback mCallback; 25 | void *mUserdata; 26 | } 27 | 28 | - (void)captureOutput:(AVCaptureOutput *)captureOutput 29 | didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer 30 | fromConnection:(AVCaptureConnection *)connection; 31 | 32 | @end 33 | 34 | @implementation FrameDelegate 35 | 36 | - (id)initWithCallback:(FrameCallback)callback Userdata:(void *)userdata { 37 | [super init]; 38 | 39 | self->mCallback = callback; 40 | self->mUserdata = userdata; 41 | 42 | return self; 43 | } 44 | 45 | - (void)captureOutput:(AVCaptureOutput *)captureOutput 46 | didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer 47 | fromConnection:(AVCaptureConnection *)connection { 48 | 49 | if (CMSampleBufferGetNumSamples(sampleBuffer) != 1 || 50 | !CMSampleBufferIsValid(sampleBuffer) || 51 | !CMSampleBufferDataIsReady(sampleBuffer)) { 52 | return; 53 | } 54 | 55 | CVImageBufferRef image_buffer = CMSampleBufferGetImageBuffer(sampleBuffer); 56 | if (image_buffer == NULL) { 57 | return; 58 | } 59 | 60 | image_buffer = CVBufferRetain(image_buffer); 61 | 62 | CVReturn ret = 63 | CVPixelBufferLockBaseAddress(image_buffer, kCVPixelBufferLock_ReadOnly); 64 | if (ret != kCVReturnSuccess) { 65 | return; 66 | } 67 | static size_t const kYPlaneIndex = 0; 68 | static size_t const kUVPlaneIndex = 1; 69 | uint8_t *y_plane_address = static_cast( 70 | CVPixelBufferGetBaseAddressOfPlane(image_buffer, kYPlaneIndex)); 71 | size_t y_plane_height = 72 | CVPixelBufferGetHeightOfPlane(image_buffer, kYPlaneIndex); 73 | size_t y_plane_width = 74 | CVPixelBufferGetWidthOfPlane(image_buffer, kYPlaneIndex); 75 | size_t y_plane_bytes_per_row = 76 | CVPixelBufferGetBytesPerRowOfPlane(image_buffer, kYPlaneIndex); 77 | size_t uv_plane_height = 78 | CVPixelBufferGetHeightOfPlane(image_buffer, kUVPlaneIndex); 79 | size_t uv_plane_bytes_per_row = 80 | CVPixelBufferGetBytesPerRowOfPlane(image_buffer, kUVPlaneIndex); 81 | size_t frame_size = y_plane_bytes_per_row * y_plane_height + 82 | uv_plane_bytes_per_row * uv_plane_height; 83 | 84 | // TODO(maxhawkins): is this slow? 85 | unsigned char *i420_image = (unsigned char *)malloc(frame_size); 86 | NV12_YUV420P(y_plane_address, i420_image, y_plane_width, y_plane_height); 87 | 88 | self->mCallback(self->mUserdata, i420_image, frame_size, y_plane_width, 89 | y_plane_height); 90 | 91 | free(i420_image); 92 | 93 | CVPixelBufferUnlockBaseAddress(image_buffer, 0); 94 | CVBufferRelease(image_buffer); 95 | } 96 | 97 | @end 98 | 99 | class Capture { 100 | public: 101 | Capture(FrameCallback callback, void *userdata); 102 | ~Capture(); 103 | 104 | int start(int cam_id, int width, int height); 105 | 106 | private: 107 | AVCaptureSession *mSession; 108 | AVCaptureDeviceInput *mInput; 109 | AVCaptureVideoDataOutput *mOutput; 110 | AVCaptureDevice *mCamera; 111 | FrameDelegate *mDelegate; 112 | 113 | FrameCallback mCallback; 114 | void *mUserdata; 115 | }; 116 | 117 | Capture::Capture(FrameCallback callback, void *userdata) { 118 | mCallback = callback; 119 | mUserdata = userdata; 120 | } 121 | 122 | int Capture::start(int cam_id, int width, int height) { 123 | NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; 124 | 125 | NSArray *cameras = [[AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo] 126 | arrayByAddingObjectsFromArray:[AVCaptureDevice 127 | devicesWithMediaType:AVMediaTypeVideo]]; 128 | 129 | if (cam_id < 0 || cam_id >= int(cameras.count)) { 130 | [pool drain]; 131 | return E_CAMERA_NOT_FOUND; 132 | } 133 | mCamera = cameras[cam_id]; 134 | if (!mCamera) { 135 | [pool drain]; 136 | return E_CAMERA_NOT_FOUND; 137 | } 138 | 139 | NSError *error = nil; 140 | mInput = [[AVCaptureDeviceInput alloc] initWithDevice:mCamera error:&error]; 141 | if (error) { 142 | NSLog(@"Camera init failed: %@", error.localizedDescription); 143 | [pool drain]; 144 | return E_OPEN_CAMERA_FAILED; 145 | } 146 | 147 | mDelegate = [[FrameDelegate alloc] initWithCallback:mCallback 148 | Userdata:mUserdata]; 149 | 150 | mOutput = [[AVCaptureVideoDataOutput alloc] init]; 151 | dispatch_queue_t queue = 152 | dispatch_queue_create("captureQueue", DISPATCH_QUEUE_SERIAL); 153 | [mOutput setSampleBufferDelegate:mDelegate queue:queue]; 154 | dispatch_release(queue); 155 | 156 | mOutput.videoSettings = @{ 157 | (id)kCVPixelBufferWidthKey : @(1.0 * width), 158 | (id)kCVPixelBufferHeightKey : @(1.0 * height), 159 | (id)kCVPixelBufferPixelFormatTypeKey : 160 | @(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange) 161 | }; 162 | mOutput.alwaysDiscardsLateVideoFrames = YES; 163 | 164 | mSession = [[AVCaptureSession alloc] init]; 165 | mSession.sessionPreset = AVCaptureSessionPresetMedium; 166 | [mSession addInput:mInput]; 167 | [mSession addOutput:mOutput]; 168 | 169 | [mSession startRunning]; 170 | 171 | [pool drain]; 172 | return E_OK; 173 | } 174 | 175 | extern "C" { 176 | 177 | // cam_init allocates a new Camera and sets its frame callback 178 | int cam_init(Camera *cam, FrameCallback callback, void *userdata) { 179 | if (!callback) { 180 | return E_INIT_FAILED; 181 | } 182 | 183 | Capture *capture = new Capture(callback, userdata); 184 | *cam = capture; 185 | 186 | return E_OK; 187 | }; 188 | 189 | // cam_start makes a Camera begin reading frames from the device 190 | int cam_start(Camera cam, int cam_id, int width, int height) { 191 | Capture *capture = (Capture *)cam; 192 | return capture->start(cam_id, width, height); 193 | }; 194 | } 195 | -------------------------------------------------------------------------------- /camera/cam_callback_darwin.go: -------------------------------------------------------------------------------- 1 | package camera 2 | 3 | import ( 4 | "sync" 5 | "unsafe" 6 | ) 7 | 8 | /* 9 | extern void onFrame(void *userdata, void *buf, int len, int width, int height); 10 | */ 11 | import "C" 12 | 13 | type frameCb func(frame []byte, width, height int) 14 | 15 | var mu sync.Mutex 16 | var nextID handleID 17 | var handles = make(map[handleID]frameCb) 18 | 19 | type handleID int 20 | 21 | //export onFrame 22 | func onFrame(userdata unsafe.Pointer, buf unsafe.Pointer, size, cWidth, cHeight C.int) { 23 | data := C.GoBytes(buf, size) 24 | width, height := int(cWidth), int(cHeight) 25 | 26 | 27 | handleNum := (*C.int)(userdata) 28 | 29 | cb := lookup(handleID(*handleNum)) 30 | cb(data, width, height) 31 | } 32 | 33 | func register(fn frameCb) handleID { 34 | mu.Lock() 35 | defer mu.Unlock() 36 | 37 | nextID++ 38 | for handles[nextID] != nil { 39 | nextID++ 40 | } 41 | handles[nextID] = fn 42 | 43 | return nextID 44 | } 45 | 46 | func lookup(i handleID) frameCb { 47 | mu.Lock() 48 | defer mu.Unlock() 49 | 50 | return handles[i] 51 | } 52 | 53 | func unregister(i handleID) { 54 | mu.Lock() 55 | defer mu.Unlock() 56 | 57 | delete(handles, i) 58 | } 59 | -------------------------------------------------------------------------------- /camera/cam_darwin.go: -------------------------------------------------------------------------------- 1 | package camera 2 | 3 | /* 4 | #cgo LDFLAGS: -L. -lcam -lc++ -framework Foundation -framework AVFoundation -framework CoreVideo -framework CoreMedia 5 | 6 | #include "cam_avfoundation.h" 7 | 8 | extern void onFrame(void *userdata, void *buf, int len, int width, int height); 9 | void onFrame_cgo(void *userdata, void *buf, int len, int width, int height) { 10 | onFrame(userdata, buf, len, width, height); 11 | } 12 | */ 13 | import "C" 14 | import ( 15 | "fmt" 16 | "unsafe" 17 | 18 | "github.com/dialup-inc/ascii/yuv" 19 | ) 20 | type Camera struct { 21 | c C.Camera 22 | 23 | handleID handleID 24 | callback FrameCallback 25 | } 26 | 27 | type CamError int 28 | 29 | const ( 30 | ErrCamOK CamError = 0 31 | ErrCamInitFailed CamError = -1 32 | ErrCamOpenFailed = -2 33 | ErrCamNotFound = -3 34 | ) 35 | 36 | func (c CamError) Error() string { 37 | switch c { 38 | case ErrCamInitFailed: 39 | return "init failed" 40 | case ErrCamOpenFailed: 41 | return "open failed" 42 | case ErrCamNotFound: 43 | return "not found" 44 | default: 45 | return fmt.Sprintf("error %d", c) 46 | } 47 | } 48 | 49 | func (c *Camera) Start(camID, width, height int) error { 50 | if ret := C.cam_start(c.c, C.int(camID), C.int(width), C.int(height)); ret != 0 { 51 | return CamError(ret) 52 | } 53 | return nil 54 | } 55 | 56 | func (c *Camera) Close() error { 57 | // TODO 58 | return nil 59 | } 60 | 61 | func (c *Camera) onFrame(data []byte, width, height int) { 62 | c.callback(yuv.FromI420(data, width, height)) 63 | } 64 | 65 | func New(cb FrameCallback) (*Camera, error) { 66 | cam := &Camera{} 67 | 68 | cam.callback = cb 69 | cam.handleID = register(func(data []byte, width, height int) { 70 | cam.onFrame(data, width, height) 71 | }) 72 | 73 | if ret := C.cam_init(&cam.c, (C.FrameCallback)(unsafe.Pointer(C.onFrame_cgo)), unsafe.Pointer(&cam.handleID)); ret != 0 { 74 | return nil, fmt.Errorf("error %d", CamError(ret)) 75 | } 76 | return cam, nil 77 | } 78 | -------------------------------------------------------------------------------- /camera/cam_linux.go: -------------------------------------------------------------------------------- 1 | package camera 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image/jpeg" 7 | "os" 8 | "strings" 9 | 10 | "github.com/blackjack/webcam" 11 | ) 12 | 13 | const webcamReadTimeout = 5 14 | 15 | type Camera struct { 16 | callback FrameCallback 17 | } 18 | 19 | func (c *Camera) Start(camID, width, height int) error { 20 | cam, err := webcam.Open("/dev/video0") 21 | if err != nil { 22 | return err 23 | } 24 | 25 | var selectedFormat webcam.PixelFormat 26 | for v, k := range cam.GetSupportedFormats() { 27 | if strings.HasPrefix(k, "Motion-JPEG") { 28 | selectedFormat = v 29 | break 30 | } 31 | } 32 | 33 | if selectedFormat == 0 { 34 | return fmt.Errorf("Only Motion-JPEG supported") 35 | } 36 | 37 | if _, _, _, err = cam.SetImageFormat(selectedFormat, uint32(width), uint32(height)); err != nil { 38 | return err 39 | } 40 | 41 | if err = cam.StartStreaming(); err != nil { 42 | return err 43 | } 44 | 45 | go func() { 46 | for { 47 | err = cam.WaitForFrame(webcamReadTimeout) 48 | switch err.(type) { 49 | case nil: 50 | case *webcam.Timeout: 51 | fmt.Fprint(os.Stderr, err.Error()) 52 | continue 53 | default: 54 | c.callback(nil, err) 55 | return 56 | } 57 | 58 | frame, err := cam.ReadFrame() 59 | if len(frame) != 0 { 60 | img, err := jpeg.Decode(bytes.NewReader(frame)) 61 | c.callback(img, err) 62 | } else if err != nil { 63 | c.callback(nil, err) 64 | } 65 | } 66 | }() 67 | return nil 68 | } 69 | 70 | func (c *Camera) Close() error { 71 | // TODO 72 | return nil 73 | } 74 | 75 | func New(cb FrameCallback) (*Camera, error) { 76 | return &Camera{callback: cb}, nil 77 | } 78 | -------------------------------------------------------------------------------- /capture.go: -------------------------------------------------------------------------------- 1 | package ascii 2 | 3 | import ( 4 | "image" 5 | "sync" 6 | "sync/atomic" 7 | 8 | "github.com/dialup-inc/ascii/camera" 9 | "github.com/dialup-inc/ascii/vpx" 10 | "github.com/pion/webrtc/v2" 11 | "github.com/pion/webrtc/v2/pkg/media" 12 | ) 13 | 14 | func NewCapture(width, height int) (*Capture, error) { 15 | cap := &Capture{ 16 | vpxBuf: make([]byte, 5*1024*1024), 17 | width: width, 18 | height: height, 19 | } 20 | 21 | enc, err := vpx.NewEncoder(width, height) 22 | if err != nil { 23 | return nil, err 24 | } 25 | cap.enc = enc 26 | 27 | cam, err := camera.New(cap.onFrame) 28 | if err != nil { 29 | return nil, err 30 | } 31 | cap.cam = cam 32 | 33 | return cap, nil 34 | } 35 | 36 | type Capture struct { 37 | enc *vpx.Encoder 38 | cam *camera.Camera 39 | 40 | width int 41 | height int 42 | 43 | ptsMu sync.Mutex 44 | pts int 45 | 46 | vpxBuf []byte 47 | 48 | forceKeyframe uint32 49 | encodeLock uint32 50 | 51 | track *webrtc.Track 52 | } 53 | 54 | func (c *Capture) Start(camID int, frameRate float32) error { 55 | return c.cam.Start(camID, c.width, c.height) 56 | } 57 | 58 | func (c *Capture) Stop() error { 59 | // TODO 60 | return nil 61 | } 62 | 63 | func (c *Capture) RequestKeyframe() { 64 | atomic.StoreUint32(&c.forceKeyframe, 1) 65 | } 66 | 67 | func (c *Capture) SetTrack(track *webrtc.Track) { 68 | c.track = track 69 | } 70 | 71 | func (c *Capture) onFrame(img image.Image, err error) { 72 | if err != nil { 73 | return 74 | } 75 | 76 | if !atomic.CompareAndSwapUint32(&c.encodeLock, 0, 1) { 77 | return 78 | } 79 | defer atomic.StoreUint32(&c.encodeLock, 0) 80 | 81 | forceKeyframe := atomic.CompareAndSwapUint32(&c.forceKeyframe, 1, 0) 82 | 83 | n, err := c.enc.Encode(c.vpxBuf, img, c.pts, forceKeyframe) 84 | if err != nil { 85 | // fmt.Println("encode: ", err) 86 | return 87 | } 88 | c.pts++ 89 | 90 | data := c.vpxBuf[:n] 91 | samp := media.Sample{Data: data, Samples: 1} 92 | 93 | if c.track == nil { 94 | return 95 | } 96 | 97 | if err := c.track.WriteSample(samp); err != nil { 98 | // fmt.Println("write sample: ", err) 99 | return 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /cmd/ascii_roulette/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | 8 | "github.com/dialup-inc/ascii" 9 | ) 10 | 11 | func main() { 12 | var ( 13 | signalerURL = flag.String("signaler-url", "wss://roulette.dialup.com/ws", "host and port of the signaler") 14 | ) 15 | flag.Parse() 16 | 17 | ctx := context.Background() 18 | 19 | app, err := ascii.New(*signalerURL) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | if err := app.Run(ctx); err != nil { 25 | log.Fatal(err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package ascii 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "math/rand" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/pion/rtcp" 11 | "github.com/pion/rtp/codecs" 12 | "github.com/pion/webrtc/v2" 13 | "github.com/pion/webrtc/v2/pkg/media/samplebuilder" 14 | ) 15 | 16 | // mode for frames width per timestamp from a 30 second capture 17 | const rtpAverageFrameWidth = 7 18 | 19 | type DCMessage struct { 20 | Event string 21 | Payload []byte 22 | } 23 | 24 | func NewConn(config webrtc.Configuration) (*Conn, error) { 25 | conn := &Conn{ 26 | OnPLI: func() {}, 27 | OnFrame: func([]byte) {}, 28 | OnMessage: func(string) {}, 29 | OnBye: func() {}, 30 | OnDataOpen: func() {}, 31 | OnICEConnectionStateChange: func(webrtc.ICEConnectionState) {}, 32 | } 33 | 34 | m := webrtc.MediaEngine{} 35 | m.RegisterCodec(webrtc.NewRTPVP8Codec(webrtc.DefaultPayloadTypeVP8, 90000)) 36 | api := webrtc.NewAPI(webrtc.WithMediaEngine(m)) 37 | 38 | pc, err := api.NewPeerConnection(config) 39 | if err != nil { 40 | return nil, err 41 | } 42 | conn.pc = pc 43 | 44 | pc.OnICEConnectionStateChange(conn.onICEConnectionStateChange) 45 | pc.OnTrack(conn.onTrack) 46 | 47 | if _, err = pc.AddTransceiver(webrtc.RTPCodecTypeVideo); err != nil { 48 | return nil, err 49 | } 50 | 51 | track, err := pc.NewTrack(webrtc.DefaultPayloadTypeVP8, rand.Uint32(), "video", "roulette") 52 | if err != nil { 53 | return nil, err 54 | } 55 | if _, err := pc.AddTrack(track); err != nil { 56 | return nil, err 57 | } 58 | conn.SendTrack = track 59 | 60 | dc, err := pc.CreateDataChannel("chat", nil) 61 | if err != nil { 62 | return nil, err 63 | } 64 | conn.dc = dc 65 | 66 | dc.OnMessage(conn.onMessage) 67 | dc.OnOpen(conn.onDataOpen) 68 | 69 | return conn, nil 70 | } 71 | 72 | type Conn struct { 73 | SendTrack *webrtc.Track 74 | OnPLI func() 75 | OnFrame func([]byte) 76 | OnMessage func(string) 77 | OnICEConnectionStateChange func(webrtc.ICEConnectionState) 78 | OnBye func() 79 | OnDataOpen func() 80 | 81 | pc *webrtc.PeerConnection 82 | recvTrack *webrtc.Track 83 | ssrc uint32 84 | 85 | dc *webrtc.DataChannel 86 | 87 | lastPLI time.Time 88 | } 89 | 90 | func (c *Conn) readRTCP(recv *webrtc.RTPReceiver) { 91 | for { 92 | rtcps, err := recv.ReadRTCP() 93 | if err == io.EOF { 94 | break 95 | } 96 | if err != nil { 97 | continue 98 | // fmt.Println(err) 99 | } 100 | 101 | for _, pkt := range rtcps { 102 | switch p := pkt.(type) { 103 | case *rtcp.PictureLossIndication: 104 | c.OnPLI() 105 | case *rtcp.Goodbye: 106 | for _, ssrc := range p.Sources { 107 | if ssrc == recv.Track().SSRC() { 108 | c.OnBye() 109 | break 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | 117 | func (c *Conn) readRTP(track *webrtc.Track) { 118 | builder := samplebuilder.New(rtpAverageFrameWidth*5, &codecs.VP8Packet{}) 119 | 120 | for { 121 | pkt, err := track.ReadRTP() 122 | if err == io.EOF { 123 | break 124 | } 125 | if err != nil { 126 | // fmt.Println(err) 127 | continue 128 | } 129 | 130 | builder.Push(pkt) 131 | 132 | for s := builder.Pop(); s != nil; s = builder.Pop() { 133 | c.OnFrame(s.Data) 134 | } 135 | } 136 | } 137 | 138 | func (c *Conn) onDataOpen() { 139 | c.OnDataOpen() 140 | } 141 | 142 | func (c *Conn) onMessage(msg webrtc.DataChannelMessage) { 143 | var dcm DCMessage 144 | if err := json.Unmarshal(msg.Data, &dcm); err != nil { 145 | // TODO 146 | } 147 | if dcm.Event == "chat" { 148 | c.OnMessage(string(dcm.Payload)) 149 | } 150 | } 151 | 152 | func (c *Conn) onICEConnectionStateChange(s webrtc.ICEConnectionState) { 153 | c.OnICEConnectionStateChange(s) 154 | } 155 | 156 | func (c *Conn) SendMessage(m string) error { 157 | data, err := json.Marshal(DCMessage{ 158 | Event: "chat", 159 | Payload: []byte(m), 160 | }) 161 | if err != nil { 162 | return err 163 | } 164 | return c.dc.Send(data) 165 | } 166 | 167 | func (c *Conn) SendPLI() error { 168 | if time.Since(c.lastPLI) < 500*time.Millisecond { 169 | return nil 170 | } 171 | if c.recvTrack == nil { 172 | return nil 173 | } 174 | 175 | pli := &rtcp.PictureLossIndication{MediaSSRC: c.SendTrack.SSRC()} 176 | if err := c.pc.WriteRTCP([]rtcp.Packet{pli}); err != nil { 177 | return err 178 | } 179 | 180 | c.lastPLI = time.Now() 181 | return nil 182 | } 183 | 184 | func (c *Conn) SendBye() error { 185 | if c.SendTrack == nil { 186 | return nil 187 | } 188 | 189 | bye := &rtcp.Goodbye{Sources: []uint32{c.SendTrack.SSRC()}} 190 | if err := c.pc.WriteRTCP([]rtcp.Packet{bye}); err != nil { 191 | return err 192 | } 193 | 194 | return nil 195 | } 196 | 197 | func (c *Conn) IsConnected() bool { 198 | switch c.pc.ICEConnectionState() { 199 | case webrtc.ICEConnectionStateCompleted, webrtc.ICEConnectionStateConnected: 200 | return true 201 | default: 202 | return false 203 | } 204 | } 205 | 206 | func (c *Conn) onTrack(track *webrtc.Track, recv *webrtc.RTPReceiver) { 207 | if !atomic.CompareAndSwapUint32(&c.ssrc, 0, track.SSRC()) { 208 | return 209 | } 210 | c.recvTrack = track 211 | 212 | go c.readRTCP(recv) 213 | c.readRTP(track) 214 | } 215 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dialup-inc/ascii 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/blackjack/webcam v0.0.0-20190407142958-6cd3de4f4861 7 | github.com/dialup-inc/ascii/signal v0.0.0-20190715163141-e9715ef66a09 // indirect 8 | github.com/golang/mock v1.3.1 // indirect 9 | github.com/golang/protobuf v1.3.2 // indirect 10 | github.com/gorilla/websocket v1.4.0 11 | github.com/kr/pretty v0.1.0 // indirect 12 | github.com/lucas-clemente/quic-go v0.11.2 // indirect 13 | github.com/marten-seemann/qtls v0.3.2 // indirect 14 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 15 | github.com/onsi/ginkgo v1.8.0 // indirect 16 | github.com/onsi/gomega v1.5.0 // indirect 17 | github.com/pion/dtls v1.4.0 // indirect 18 | github.com/pion/ice v0.5.1 // indirect 19 | github.com/pion/rtcp v1.2.1 20 | github.com/pion/rtp v1.1.3 21 | github.com/pion/stun v0.3.1 22 | github.com/pion/webrtc/v2 v2.0.24-0.20190715150138-632530bc69a7 23 | github.com/prometheus/client_golang v1.0.0 // indirect 24 | github.com/shuLhan/go-bindata v3.4.0+incompatible // indirect 25 | github.com/stretchr/objx v0.2.0 // indirect 26 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect 27 | golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7 28 | golang.org/x/text v0.3.2 // indirect 29 | golang.org/x/tools v0.0.0-20190716194459-b667c4c58e8b // indirect 30 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 31 | gopkg.in/yaml.v2 v2.2.2 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 2 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 3 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 4 | github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= 5 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 6 | github.com/blackjack/webcam v0.0.0-20190407142958-6cd3de4f4861/go.mod h1:G0X+rEqYPWSq0dG8OMf8M446MtKytzpPjgS3HbdOJZ4= 7 | github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= 8 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dialup-inc/ascii/signal v0.0.0-20190715163141-e9715ef66a09 h1:kmKnbk13MkcrEwJ4aEjPgItY8nx9k0R3oMRN8dCvSgY= 12 | github.com/dialup-inc/ascii/signal v0.0.0-20190715163141-e9715ef66a09/go.mod h1:PqJU9NJVIZYt4nxlU9hpa8unKlr/CkJb+TXcHuLSZrA= 13 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 14 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 15 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 16 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 17 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 18 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 19 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= 20 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 22 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 23 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 24 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 25 | github.com/gortc/turn v0.7.1/go.mod h1:3FZ+LvCZKCKu6YYgwuYPqEi3FqCtdjfSFnFqVQNwfjk= 26 | github.com/gortc/turn v0.7.3 h1:CE72C79erbcsfa6L/QDhKztcl2kDq1UK20ImrJWDt/w= 27 | github.com/gortc/turn v0.7.3/go.mod h1:gvguwaGAFyv5/9KrcW9MkCgHALYD+e99mSM7pSCYYho= 28 | github.com/gortc/turn v0.8.0 h1:WWQi1jkoPmc2E7qgUMcZleveKikT9Ksi3QGIl8ZtY3Q= 29 | github.com/gortc/turn v0.8.0/go.mod h1:gvguwaGAFyv5/9KrcW9MkCgHALYD+e99mSM7pSCYYho= 30 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 31 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 32 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 33 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 34 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 35 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 36 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 37 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 38 | github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= 39 | github.com/lucas-clemente/quic-go v0.11.2/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= 40 | github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= 41 | github.com/marten-seemann/qtls v0.3.2/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= 42 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 44 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 45 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 46 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 47 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 48 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 49 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 50 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 51 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 52 | github.com/pion/datachannel v1.4.3 h1:tqS6YiqqAiFCxGGhvn1K7fHEzemK9Aov025dE/isGFo= 53 | github.com/pion/datachannel v1.4.3/go.mod h1:SpMJbuu8v+qbA94m6lWQwSdCf8JKQvgmdSHDNtcbe+w= 54 | github.com/pion/datachannel v1.4.4 h1:vVzvjCwEEgOF3KSS0jxNeF9z+DOrn8nIM6eD6qh9sIo= 55 | github.com/pion/datachannel v1.4.4/go.mod h1:SpMJbuu8v+qbA94m6lWQwSdCf8JKQvgmdSHDNtcbe+w= 56 | github.com/pion/dtls v1.3.5 h1:mBioifvh6JSE9pD4FtJh5WoizygoqkOJNJyS5Ns+y1U= 57 | github.com/pion/dtls v1.3.5/go.mod h1:CjlPLfQdsTg3G4AEXjJp8FY5bRweBlxHrgoFrN+fQsk= 58 | github.com/pion/dtls v1.4.0 h1:s7TM+K8kwOg2jI+w5BLUk/+n51aGeI0Ri8Z2PFZU84o= 59 | github.com/pion/dtls v1.4.0/go.mod h1:CjlPLfQdsTg3G4AEXjJp8FY5bRweBlxHrgoFrN+fQsk= 60 | github.com/pion/ice v0.4.0 h1:BdTXHTjzdsJHGi9yMFnj9ffgr+Kg2oHVv1qk4B0mQ8A= 61 | github.com/pion/ice v0.4.0/go.mod h1:/gw3aFmD/pBG8UM3TcEHs6HuaOEMSd/v1As3TodE7Ss= 62 | github.com/pion/ice v0.4.3/go.mod h1:/gw3aFmD/pBG8UM3TcEHs6HuaOEMSd/v1As3TodE7Ss= 63 | github.com/pion/ice v0.5.1 h1:4DaXfgr64yJ4hMl5t8BMcKzQVeP8Q5UnRsrCEaPhAPE= 64 | github.com/pion/ice v0.5.1/go.mod h1:dh9icbJ2IxF+OFx2cOPy9QAOQXLALPClLdAxPIzQqmU= 65 | github.com/pion/logging v0.2.1 h1:LwASkBKZ+2ysGJ+jLv1E/9H1ge0k1nTfi1X+5zirkDk= 66 | github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 67 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= 68 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 69 | github.com/pion/mdns v0.0.2 h1:T22Gg4dSuYVYsZ21oRFh9z7twzAm27+5PEKiABbjCvM= 70 | github.com/pion/mdns v0.0.2/go.mod h1:VrN3wefVgtfL8QgpEblPUC46ag1reLIfpqekCnKunLE= 71 | github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k= 72 | github.com/pion/rtcp v1.2.0 h1:rT2FptW5YHIern+4XlbGYnnsT26XGxurnkNLnzhtDXg= 73 | github.com/pion/rtcp v1.2.0/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM= 74 | github.com/pion/rtcp v1.2.1 h1:S3yG4KpYAiSmBVqKAfgRa5JdwBNj4zK3RLUa8JYdhak= 75 | github.com/pion/rtcp v1.2.1/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM= 76 | github.com/pion/rtp v1.1.2 h1:ERNugzYHW9F2ldpwoARbeFGKRoq1REe5Jxdjvm/rOx8= 77 | github.com/pion/rtp v1.1.2/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= 78 | github.com/pion/rtp v1.1.3 h1:GTYSTsSLF5vH+UqShGYQEBdoYasWjTTC9UeYglnUO+o= 79 | github.com/pion/rtp v1.1.3/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= 80 | github.com/pion/sctp v1.6.3 h1:SC4vKOjcddK8tXiTNj05a+0/GyPpCmuNfeBA/rzNFqs= 81 | github.com/pion/sctp v1.6.3/go.mod h1:cCqpLdYvgEUdl715+qbWtgT439CuQrAgy8BZTp0aEfA= 82 | github.com/pion/sctp v1.6.4 h1:edUNxTabSErLWOdeUUAxds8gwx5kGnFam4zL5DWpILk= 83 | github.com/pion/sctp v1.6.4/go.mod h1:cCqpLdYvgEUdl715+qbWtgT439CuQrAgy8BZTp0aEfA= 84 | github.com/pion/sdp/v2 v2.2.0 h1:JiixCEU8g6LbSsh1Bg5SOk0TPnJrn2HBOA1yJ+mRYhI= 85 | github.com/pion/sdp/v2 v2.2.0/go.mod h1:idSlWxhfWQDtTy9J05cgxpHBu/POwXN2VDRGYxT/EjU= 86 | github.com/pion/srtp v1.2.4 h1:wwGKC5ewuBukkZ+i+pZ8aO33+t6z2y/XRiYtyP0Xpv0= 87 | github.com/pion/srtp v1.2.4/go.mod h1:52qiP0g3FVMG/5NL6Ko8Vr2qirevKH+ukYbNS/4EX40= 88 | github.com/pion/srtp v1.2.6 h1:mHQuAMh0P67R7/j1F260u3O+fbRWLyjKLRPZYYvODFM= 89 | github.com/pion/srtp v1.2.6/go.mod h1:rd8imc5htjfs99XiEoOjLMEOcVjME63UHx9Ek9IGst0= 90 | github.com/pion/stun v0.3.0/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= 91 | github.com/pion/stun v0.3.1 h1:d09JJzOmOS8ZzIp8NppCMgrxGZpJ4Ix8qirfNYyI3BA= 92 | github.com/pion/stun v0.3.1/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= 93 | github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= 94 | github.com/pion/transport v0.7.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= 95 | github.com/pion/transport v0.8.0 h1:YHZnWBBrBuMqkuvMFUHeAETXS+LgfwW1IsVd2K2cyW8= 96 | github.com/pion/transport v0.8.0/go.mod h1:nAmRRnn+ArVtsoNuwktvAD+jrjSD7pA+H3iRmZwdUno= 97 | github.com/pion/transport v0.8.4/go.mod h1:nAmRRnn+ArVtsoNuwktvAD+jrjSD7pA+H3iRmZwdUno= 98 | github.com/pion/transport v0.8.5 h1:uo7g9BH6+Nm7DaJ1qX8+sv1o5rYREIM41kzrPVQYg14= 99 | github.com/pion/transport v0.8.5/go.mod h1:nAmRRnn+ArVtsoNuwktvAD+jrjSD7pA+H3iRmZwdUno= 100 | github.com/pion/turn v1.1.4/go.mod h1:2O2GFDGO6+hJ5gsyExDhoNHtVcacPB1NOyc81gkq0WA= 101 | github.com/pion/turn v1.3.1 h1:HyhVtxg/MYz5Fa/HTwpWNUZuV54JyLMO/q/i0IazDkw= 102 | github.com/pion/turn v1.3.1/go.mod h1:hWq4GRmlIgzbv2/6IneaETwooIIu/03uw5nJmsrAlyM= 103 | github.com/pion/turnc v0.0.6 h1:FHsmwYvdJ8mhT1/ZtWWer9L0unEb7AyRgrymfWy6mEY= 104 | github.com/pion/turnc v0.0.6/go.mod h1:4MSFv5i0v3MRkDLdo5eF9cD/xJtj1pxSphHNnxKL2W8= 105 | github.com/pion/webrtc/v2 v2.0.23 h1:v/tDKsP4zB6Sj+Wx861fLsaNmbwWbxacciHUhetH288= 106 | github.com/pion/webrtc/v2 v2.0.23/go.mod h1:AgremGibyNcHWIEkDbXt4ujKzKBO3tMuoYXybVRa8zo= 107 | github.com/pion/webrtc/v2 v2.0.24-0.20190715150138-632530bc69a7 h1:XERySyqxh3Jdamk1YR8pys9xp1xt7wdTQA427DFhlPw= 108 | github.com/pion/webrtc/v2 v2.0.24-0.20190715150138-632530bc69a7/go.mod h1:UfiEw3qQTlAcD9WkqE39o7bxGoVFHX+BnUrx7qFSMOU= 109 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 110 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 111 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 112 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 113 | github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x7enabFceM= 114 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 115 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 116 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= 117 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 118 | github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= 119 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 120 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 121 | github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNGfs= 122 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 123 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 124 | github.com/rs/zerolog v1.14.3 h1:4EGfSkR2hJDB0s3oFfrlPqjU1e4WLncergLil3nEKW0= 125 | github.com/rs/zerolog v1.14.3/go.mod h1:3WXPzbXEEliJ+a6UFE4vhIxV8qR1EML6ngzP9ug4eYg= 126 | github.com/shuLhan/go-bindata v3.4.0+incompatible/go.mod h1:pkcPAATLBDD2+SpAPnX5vEM90F7fcwHCvvLCMXcmw3g= 127 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 128 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 129 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 130 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 131 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 132 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 133 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 134 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 135 | golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 136 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 137 | golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 138 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= 139 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 140 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 141 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 142 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 143 | golang.org/x/net v0.0.0-20190403144856-b630fd6fe46b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 144 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 145 | golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 146 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= 147 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 148 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= 149 | golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 150 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 151 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 152 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 153 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 154 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 155 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 156 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 157 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 158 | golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 159 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7 h1:LepdCS8Gf/MVejFIt8lsiexZATdoGVyp5bcyS+rYoUI= 162 | golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 163 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 164 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 165 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 166 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 167 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 168 | golang.org/x/tools v0.0.0-20190716194459-b667c4c58e8b/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= 169 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 170 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 171 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 173 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 174 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 175 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 176 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ASCII Roulette v1.0.0 4 | # 5 | # presented by: 6 | # 7 | # ||` '||` 8 | # || '' || 9 | # .|''|| || '''|. || '|| ||` '||''|, .|'', .|''|, '||),,(|, 10 | # || || || .|''|| || || || || || || || || || || || 11 | # `|..||. .||. `|..||. .||. `|..'|. ||..|' .. `|..' `|..|' .|| ||. 12 | # || 13 | # (we're hiring!) .|| 14 | # 15 | # 16 | # ASCII Roulette is a command-line based video chat client that connects 17 | # you with a random person. It was written as a demonstration of the 18 | # Pion WebRTC library. 19 | # 20 | # [[ This project is completely open source! ]] 21 | # Github: https://github.com/dialup-inc/ascii 22 | # Website: https://dialup.com/ascii 23 | # Email: webmaster@dialup.com 24 | 25 | function fail_unsupported() { 26 | platform="${1:-"your platform"}" 27 | echo "ASCII Roulette isn't supported on $platform yet :-( Try Mac or Linux instead." 28 | echo "" 29 | echo "Contributions are welcome! https://github.com/dialup-inc/ascii" 30 | exit 1 31 | } 32 | 33 | function detect_platform() { 34 | case $(uname | tr '[:upper:]' '[:lower:]') in 35 | linux*) 36 | case $(uname -m) in 37 | x86_64) 38 | echo "linux64" 39 | ;; 40 | *) 41 | fail_unsupported "32-bit Linux" 42 | ;; 43 | esac 44 | ;; 45 | darwin*) 46 | case $(uname -m) in 47 | x86_64) 48 | echo "darwin64" 49 | ;; 50 | *) 51 | fail_unsupported "32-bit Macs" 52 | ;; 53 | esac 54 | ;; 55 | msys*) 56 | fail_unsupported "Windows" 57 | ;; 58 | *) 59 | fail_unsupported 60 | ;; 61 | esac 62 | } 63 | 64 | function getMD5() { 65 | file="$1" 66 | if ! [ -f "$file" ]; then 67 | return 68 | fi 69 | 70 | if [ -x "$(command -v md5sum)" ]; then 71 | md5sum | cut -d ' ' -f 1 72 | elif [ -x "$(command -v md5)" ]; then 73 | md5 -r $file | cut -d ' ' -f 1 74 | fi 75 | } 76 | 77 | function checkEtag() { 78 | url="$1" 79 | etag="$2" 80 | 81 | code="$(curl -s --head -o /dev/null -w "%{http_code}" -H "If-None-Match: $etag" "$url")" 82 | 83 | if [ "$code" != "304" ]; then 84 | echo "changed" 85 | fi 86 | } 87 | 88 | function main() { 89 | platform="$(detect_platform)" 90 | binaryURL="https://storage.googleapis.com/asciiroulette/ascii_roulette.$platform" 91 | md5="$(getMD5 /tmp/ascii_roulette)" 92 | 93 | if [ -n "$(checkEtag $binaryURL $md5)" ]; then 94 | printf "Downloading ASCII Roulette\033[5m...\033[0m\n" 95 | curl -fs "$binaryURL" > /tmp/ascii_roulette 96 | fi 97 | 98 | chmod +x /tmp/ascii_roulette 99 | 100 | clear 101 | /tmp/ascii_roulette 102 | 103 | if [ $? -eq 0 ]; then 104 | reset 105 | fi 106 | } 107 | 108 | echo < 1 || column > 1 { 20 | cmd = strconv.AppendInt(cmd, int64(row), 10) 21 | } 22 | if column > 1 { 23 | cmd = append(cmd, ';') 24 | cmd = strconv.AppendInt(cmd, int64(column), 10) 25 | } 26 | cmd = append(cmd, 'H') 27 | 28 | return a.Display.Write(cmd) 29 | } 30 | 31 | func (a *ANSI) Foreground(c color.Color) (int, error) { 32 | index := ANSIPalette.Index(c) 33 | 34 | var cmd []byte 35 | cmd = append(cmd, '\033', '[', '3', '8', ';', '5', ';') 36 | cmd = strconv.AppendInt(cmd, int64(index), 10) 37 | cmd = append(cmd, 'm') 38 | 39 | return a.Display.Write(cmd) 40 | } 41 | 42 | func (a *ANSI) ForegroundReset() (int, error) { 43 | return a.Display.Write([]byte{'\033', '[', '3', '9', 'm'}) 44 | } 45 | 46 | func (a *ANSI) Background(c color.Color) (int, error) { 47 | index := ANSIPalette.Index(c) 48 | 49 | var cmd []byte 50 | cmd = append(cmd, '\033', '[', '4', '8', ';', '5', ';') 51 | cmd = strconv.AppendInt(cmd, int64(index), 10) 52 | cmd = append(cmd, 'm') 53 | 54 | return a.Display.Write(cmd) 55 | } 56 | 57 | func (a *ANSI) ResizeWindow(rows, cols int) (int, error) { 58 | var cmd []byte 59 | cmd = append(cmd, '\033', '[', '8', ';') 60 | cmd = strconv.AppendInt(cmd, int64(rows), 10) 61 | cmd = append(cmd, ';') 62 | cmd = strconv.AppendInt(cmd, int64(cols), 10) 63 | cmd = append(cmd, 't') 64 | 65 | return a.Display.Write(cmd) 66 | } 67 | 68 | func (a *ANSI) BackgroundReset() (int, error) { 69 | return a.Display.Write([]byte{'\033', '[', '4', '9', 'm'}) 70 | } 71 | 72 | func (a *ANSI) Normal() (int, error) { 73 | return a.Display.Write([]byte{'\033', '[', '2', '2', 'm'}) 74 | } 75 | 76 | func (a *ANSI) Bold() (int, error) { 77 | return a.Display.Write([]byte{'\033', '[', '1', 'm'}) 78 | } 79 | 80 | func (a *ANSI) HideCursor() (int, error) { 81 | return a.Display.Write([]byte{'\033', '[', '?', '2', '5', 'l'}) 82 | } 83 | 84 | func (a *ANSI) ShowCursor() (int, error) { 85 | return a.Display.Write([]byte{'\033', '[', '?', '2', '5', 'h'}) 86 | } 87 | 88 | func (a *ANSI) Blink() (int, error) { 89 | return a.Display.Write([]byte{'\033', '[', '5', 'm'}) 90 | } 91 | 92 | func (a *ANSI) BlinkOff() (int, error) { 93 | return a.Display.Write([]byte{'\033', '[', '2', '5', 'm'}) 94 | } 95 | 96 | func (a *ANSI) Reset() (int, error) { 97 | return a.Display.Write([]byte{'\033', 'c'}) 98 | } 99 | 100 | var ANSIPalette = color.Palette{ 101 | color.RGBA{0x00, 0x00, 0x00, 0xFF}, 102 | color.RGBA{0x80, 0x00, 0x00, 0xFF}, 103 | color.RGBA{0x00, 0x80, 0x00, 0xFF}, 104 | color.RGBA{0x80, 0x80, 0x00, 0xFF}, 105 | color.RGBA{0x00, 0x00, 0x80, 0xFF}, 106 | color.RGBA{0x80, 0x00, 0x80, 0xFF}, 107 | color.RGBA{0x00, 0x80, 0x80, 0xFF}, 108 | color.RGBA{0xc0, 0xc0, 0xc0, 0xFF}, 109 | color.RGBA{0x80, 0x80, 0x80, 0xFF}, 110 | color.RGBA{0xff, 0x00, 0x00, 0xFF}, 111 | color.RGBA{0x00, 0xff, 0x00, 0xFF}, 112 | color.RGBA{0xff, 0xff, 0x00, 0xFF}, 113 | color.RGBA{0x00, 0x00, 0xff, 0xFF}, 114 | color.RGBA{0xff, 0x00, 0xff, 0xFF}, 115 | color.RGBA{0x00, 0xff, 0xff, 0xFF}, 116 | color.RGBA{0xff, 0xff, 0xff, 0xFF}, 117 | color.RGBA{0x00, 0x00, 0x00, 0xFF}, 118 | color.RGBA{0x00, 0x00, 0x5f, 0xFF}, 119 | color.RGBA{0x00, 0x00, 0x87, 0xFF}, 120 | color.RGBA{0x00, 0x00, 0xaf, 0xFF}, 121 | color.RGBA{0x00, 0x00, 0xd7, 0xFF}, 122 | color.RGBA{0x00, 0x00, 0xff, 0xFF}, 123 | color.RGBA{0x00, 0x5f, 0x00, 0xFF}, 124 | color.RGBA{0x00, 0x5f, 0x5f, 0xFF}, 125 | color.RGBA{0x00, 0x5f, 0x87, 0xFF}, 126 | color.RGBA{0x00, 0x5f, 0xaf, 0xFF}, 127 | color.RGBA{0x00, 0x5f, 0xd7, 0xFF}, 128 | color.RGBA{0x00, 0x5f, 0xff, 0xFF}, 129 | color.RGBA{0x00, 0x87, 0x00, 0xFF}, 130 | color.RGBA{0x00, 0x87, 0x5f, 0xFF}, 131 | color.RGBA{0x00, 0x87, 0x87, 0xFF}, 132 | color.RGBA{0x00, 0x87, 0xaf, 0xFF}, 133 | color.RGBA{0x00, 0x87, 0xd7, 0xFF}, 134 | color.RGBA{0x00, 0x87, 0xff, 0xFF}, 135 | color.RGBA{0x00, 0xaf, 0x00, 0xFF}, 136 | color.RGBA{0x00, 0xaf, 0x5f, 0xFF}, 137 | color.RGBA{0x00, 0xaf, 0x87, 0xFF}, 138 | color.RGBA{0x00, 0xaf, 0xaf, 0xFF}, 139 | color.RGBA{0x00, 0xaf, 0xd7, 0xFF}, 140 | color.RGBA{0x00, 0xaf, 0xff, 0xFF}, 141 | color.RGBA{0x00, 0xd7, 0x00, 0xFF}, 142 | color.RGBA{0x00, 0xd7, 0x5f, 0xFF}, 143 | color.RGBA{0x00, 0xd7, 0x87, 0xFF}, 144 | color.RGBA{0x00, 0xd7, 0xaf, 0xFF}, 145 | color.RGBA{0x00, 0xd7, 0xd7, 0xFF}, 146 | color.RGBA{0x00, 0xd7, 0xff, 0xFF}, 147 | color.RGBA{0x00, 0xff, 0x00, 0xFF}, 148 | color.RGBA{0x00, 0xff, 0x5f, 0xFF}, 149 | color.RGBA{0x00, 0xff, 0x87, 0xFF}, 150 | color.RGBA{0x00, 0xff, 0xaf, 0xFF}, 151 | color.RGBA{0x00, 0xff, 0xd7, 0xFF}, 152 | color.RGBA{0x00, 0xff, 0xff, 0xFF}, 153 | color.RGBA{0x5f, 0x00, 0x00, 0xFF}, 154 | color.RGBA{0x5f, 0x00, 0x5f, 0xFF}, 155 | color.RGBA{0x5f, 0x00, 0x87, 0xFF}, 156 | color.RGBA{0x5f, 0x00, 0xaf, 0xFF}, 157 | color.RGBA{0x5f, 0x00, 0xd7, 0xFF}, 158 | color.RGBA{0x5f, 0x00, 0xff, 0xFF}, 159 | color.RGBA{0x5f, 0x5f, 0x00, 0xFF}, 160 | color.RGBA{0x5f, 0x5f, 0x5f, 0xFF}, 161 | color.RGBA{0x5f, 0x5f, 0x87, 0xFF}, 162 | color.RGBA{0x5f, 0x5f, 0xaf, 0xFF}, 163 | color.RGBA{0x5f, 0x5f, 0xd7, 0xFF}, 164 | color.RGBA{0x5f, 0x5f, 0xff, 0xFF}, 165 | color.RGBA{0x5f, 0x87, 0x00, 0xFF}, 166 | color.RGBA{0x5f, 0x87, 0x5f, 0xFF}, 167 | color.RGBA{0x5f, 0x87, 0x87, 0xFF}, 168 | color.RGBA{0x5f, 0x87, 0xaf, 0xFF}, 169 | color.RGBA{0x5f, 0x87, 0xd7, 0xFF}, 170 | color.RGBA{0x5f, 0x87, 0xff, 0xFF}, 171 | color.RGBA{0x5f, 0xaf, 0x00, 0xFF}, 172 | color.RGBA{0x5f, 0xaf, 0x5f, 0xFF}, 173 | color.RGBA{0x5f, 0xaf, 0x87, 0xFF}, 174 | color.RGBA{0x5f, 0xaf, 0xaf, 0xFF}, 175 | color.RGBA{0x5f, 0xaf, 0xd7, 0xFF}, 176 | color.RGBA{0x5f, 0xaf, 0xff, 0xFF}, 177 | color.RGBA{0x5f, 0xd7, 0x00, 0xFF}, 178 | color.RGBA{0x5f, 0xd7, 0x5f, 0xFF}, 179 | color.RGBA{0x5f, 0xd7, 0x87, 0xFF}, 180 | color.RGBA{0x5f, 0xd7, 0xaf, 0xFF}, 181 | color.RGBA{0x5f, 0xd7, 0xd7, 0xFF}, 182 | color.RGBA{0x5f, 0xd7, 0xff, 0xFF}, 183 | color.RGBA{0x5f, 0xff, 0x00, 0xFF}, 184 | color.RGBA{0x5f, 0xff, 0x5f, 0xFF}, 185 | color.RGBA{0x5f, 0xff, 0x87, 0xFF}, 186 | color.RGBA{0x5f, 0xff, 0xaf, 0xFF}, 187 | color.RGBA{0x5f, 0xff, 0xd7, 0xFF}, 188 | color.RGBA{0x5f, 0xff, 0xff, 0xFF}, 189 | color.RGBA{0x87, 0x00, 0x00, 0xFF}, 190 | color.RGBA{0x87, 0x00, 0x5f, 0xFF}, 191 | color.RGBA{0x87, 0x00, 0x87, 0xFF}, 192 | color.RGBA{0x87, 0x00, 0xaf, 0xFF}, 193 | color.RGBA{0x87, 0x00, 0xd7, 0xFF}, 194 | color.RGBA{0x87, 0x00, 0xff, 0xFF}, 195 | color.RGBA{0x87, 0x5f, 0x00, 0xFF}, 196 | color.RGBA{0x87, 0x5f, 0x5f, 0xFF}, 197 | color.RGBA{0x87, 0x5f, 0x87, 0xFF}, 198 | color.RGBA{0x87, 0x5f, 0xaf, 0xFF}, 199 | color.RGBA{0x87, 0x5f, 0xd7, 0xFF}, 200 | color.RGBA{0x87, 0x5f, 0xff, 0xFF}, 201 | color.RGBA{0x87, 0x87, 0x00, 0xFF}, 202 | color.RGBA{0x87, 0x87, 0x5f, 0xFF}, 203 | color.RGBA{0x87, 0x87, 0x87, 0xFF}, 204 | color.RGBA{0x87, 0x87, 0xaf, 0xFF}, 205 | color.RGBA{0x87, 0x87, 0xd7, 0xFF}, 206 | color.RGBA{0x87, 0x87, 0xff, 0xFF}, 207 | color.RGBA{0x87, 0xaf, 0x00, 0xFF}, 208 | color.RGBA{0x87, 0xaf, 0x5f, 0xFF}, 209 | color.RGBA{0x87, 0xaf, 0x87, 0xFF}, 210 | color.RGBA{0x87, 0xaf, 0xaf, 0xFF}, 211 | color.RGBA{0x87, 0xaf, 0xd7, 0xFF}, 212 | color.RGBA{0x87, 0xaf, 0xff, 0xFF}, 213 | color.RGBA{0x87, 0xd7, 0x00, 0xFF}, 214 | color.RGBA{0x87, 0xd7, 0x5f, 0xFF}, 215 | color.RGBA{0x87, 0xd7, 0x87, 0xFF}, 216 | color.RGBA{0x87, 0xd7, 0xaf, 0xFF}, 217 | color.RGBA{0x87, 0xd7, 0xd7, 0xFF}, 218 | color.RGBA{0x87, 0xd7, 0xff, 0xFF}, 219 | color.RGBA{0x87, 0xff, 0x00, 0xFF}, 220 | color.RGBA{0x87, 0xff, 0x5f, 0xFF}, 221 | color.RGBA{0x87, 0xff, 0x87, 0xFF}, 222 | color.RGBA{0x87, 0xff, 0xaf, 0xFF}, 223 | color.RGBA{0x87, 0xff, 0xd7, 0xFF}, 224 | color.RGBA{0x87, 0xff, 0xff, 0xFF}, 225 | color.RGBA{0xaf, 0x00, 0x00, 0xFF}, 226 | color.RGBA{0xaf, 0x00, 0x5f, 0xFF}, 227 | color.RGBA{0xaf, 0x00, 0x87, 0xFF}, 228 | color.RGBA{0xaf, 0x00, 0xaf, 0xFF}, 229 | color.RGBA{0xaf, 0x00, 0xd7, 0xFF}, 230 | color.RGBA{0xaf, 0x00, 0xff, 0xFF}, 231 | color.RGBA{0xaf, 0x5f, 0x00, 0xFF}, 232 | color.RGBA{0xaf, 0x5f, 0x5f, 0xFF}, 233 | color.RGBA{0xaf, 0x5f, 0x87, 0xFF}, 234 | color.RGBA{0xaf, 0x5f, 0xaf, 0xFF}, 235 | color.RGBA{0xaf, 0x5f, 0xd7, 0xFF}, 236 | color.RGBA{0xaf, 0x5f, 0xff, 0xFF}, 237 | color.RGBA{0xaf, 0x87, 0x00, 0xFF}, 238 | color.RGBA{0xaf, 0x87, 0x5f, 0xFF}, 239 | color.RGBA{0xaf, 0x87, 0x87, 0xFF}, 240 | color.RGBA{0xaf, 0x87, 0xaf, 0xFF}, 241 | color.RGBA{0xaf, 0x87, 0xd7, 0xFF}, 242 | color.RGBA{0xaf, 0x87, 0xff, 0xFF}, 243 | color.RGBA{0xaf, 0xaf, 0x00, 0xFF}, 244 | color.RGBA{0xaf, 0xaf, 0x5f, 0xFF}, 245 | color.RGBA{0xaf, 0xaf, 0x87, 0xFF}, 246 | color.RGBA{0xaf, 0xaf, 0xaf, 0xFF}, 247 | color.RGBA{0xaf, 0xaf, 0xd7, 0xFF}, 248 | color.RGBA{0xaf, 0xaf, 0xff, 0xFF}, 249 | color.RGBA{0xaf, 0xd7, 0x00, 0xFF}, 250 | color.RGBA{0xaf, 0xd7, 0x5f, 0xFF}, 251 | color.RGBA{0xaf, 0xd7, 0x87, 0xFF}, 252 | color.RGBA{0xaf, 0xd7, 0xaf, 0xFF}, 253 | color.RGBA{0xaf, 0xd7, 0xd7, 0xFF}, 254 | color.RGBA{0xaf, 0xd7, 0xff, 0xFF}, 255 | color.RGBA{0xaf, 0xff, 0x00, 0xFF}, 256 | color.RGBA{0xaf, 0xff, 0x5f, 0xFF}, 257 | color.RGBA{0xaf, 0xff, 0x87, 0xFF}, 258 | color.RGBA{0xaf, 0xff, 0xaf, 0xFF}, 259 | color.RGBA{0xaf, 0xff, 0xd7, 0xFF}, 260 | color.RGBA{0xaf, 0xff, 0xff, 0xFF}, 261 | color.RGBA{0xd7, 0x00, 0x00, 0xFF}, 262 | color.RGBA{0xd7, 0x00, 0x5f, 0xFF}, 263 | color.RGBA{0xd7, 0x00, 0x87, 0xFF}, 264 | color.RGBA{0xd7, 0x00, 0xaf, 0xFF}, 265 | color.RGBA{0xd7, 0x00, 0xd7, 0xFF}, 266 | color.RGBA{0xd7, 0x00, 0xff, 0xFF}, 267 | color.RGBA{0xd7, 0x5f, 0x00, 0xFF}, 268 | color.RGBA{0xd7, 0x5f, 0x5f, 0xFF}, 269 | color.RGBA{0xd7, 0x5f, 0x87, 0xFF}, 270 | color.RGBA{0xd7, 0x5f, 0xaf, 0xFF}, 271 | color.RGBA{0xd7, 0x5f, 0xd7, 0xFF}, 272 | color.RGBA{0xd7, 0x5f, 0xff, 0xFF}, 273 | color.RGBA{0xd7, 0x87, 0x00, 0xFF}, 274 | color.RGBA{0xd7, 0x87, 0x5f, 0xFF}, 275 | color.RGBA{0xd7, 0x87, 0x87, 0xFF}, 276 | color.RGBA{0xd7, 0x87, 0xaf, 0xFF}, 277 | color.RGBA{0xd7, 0x87, 0xd7, 0xFF}, 278 | color.RGBA{0xd7, 0x87, 0xff, 0xFF}, 279 | color.RGBA{0xd7, 0xaf, 0x00, 0xFF}, 280 | color.RGBA{0xd7, 0xaf, 0x5f, 0xFF}, 281 | color.RGBA{0xd7, 0xaf, 0x87, 0xFF}, 282 | color.RGBA{0xd7, 0xaf, 0xaf, 0xFF}, 283 | color.RGBA{0xd7, 0xaf, 0xd7, 0xFF}, 284 | color.RGBA{0xd7, 0xaf, 0xff, 0xFF}, 285 | color.RGBA{0xd7, 0xd7, 0x00, 0xFF}, 286 | color.RGBA{0xd7, 0xd7, 0x5f, 0xFF}, 287 | color.RGBA{0xd7, 0xd7, 0x87, 0xFF}, 288 | color.RGBA{0xd7, 0xd7, 0xaf, 0xFF}, 289 | color.RGBA{0xd7, 0xd7, 0xd7, 0xFF}, 290 | color.RGBA{0xd7, 0xd7, 0xff, 0xFF}, 291 | color.RGBA{0xd7, 0xff, 0x00, 0xFF}, 292 | color.RGBA{0xd7, 0xff, 0x5f, 0xFF}, 293 | color.RGBA{0xd7, 0xff, 0x87, 0xFF}, 294 | color.RGBA{0xd7, 0xff, 0xaf, 0xFF}, 295 | color.RGBA{0xd7, 0xff, 0xd7, 0xFF}, 296 | color.RGBA{0xd7, 0xff, 0xff, 0xFF}, 297 | color.RGBA{0xff, 0x00, 0x00, 0xFF}, 298 | color.RGBA{0xff, 0x00, 0x5f, 0xFF}, 299 | color.RGBA{0xff, 0x00, 0x87, 0xFF}, 300 | color.RGBA{0xff, 0x00, 0xaf, 0xFF}, 301 | color.RGBA{0xff, 0x00, 0xd7, 0xFF}, 302 | color.RGBA{0xff, 0x00, 0xff, 0xFF}, 303 | color.RGBA{0xff, 0x5f, 0x00, 0xFF}, 304 | color.RGBA{0xff, 0x5f, 0x5f, 0xFF}, 305 | color.RGBA{0xff, 0x5f, 0x87, 0xFF}, 306 | color.RGBA{0xff, 0x5f, 0xaf, 0xFF}, 307 | color.RGBA{0xff, 0x5f, 0xd7, 0xFF}, 308 | color.RGBA{0xff, 0x5f, 0xff, 0xFF}, 309 | color.RGBA{0xff, 0x87, 0x00, 0xFF}, 310 | color.RGBA{0xff, 0x87, 0x5f, 0xFF}, 311 | color.RGBA{0xff, 0x87, 0x87, 0xFF}, 312 | color.RGBA{0xff, 0x87, 0xaf, 0xFF}, 313 | color.RGBA{0xff, 0x87, 0xd7, 0xFF}, 314 | color.RGBA{0xff, 0x87, 0xff, 0xFF}, 315 | color.RGBA{0xff, 0xaf, 0x00, 0xFF}, 316 | color.RGBA{0xff, 0xaf, 0x5f, 0xFF}, 317 | color.RGBA{0xff, 0xaf, 0x87, 0xFF}, 318 | color.RGBA{0xff, 0xaf, 0xaf, 0xFF}, 319 | color.RGBA{0xff, 0xaf, 0xd7, 0xFF}, 320 | color.RGBA{0xff, 0xaf, 0xff, 0xFF}, 321 | color.RGBA{0xff, 0xd7, 0x00, 0xFF}, 322 | color.RGBA{0xff, 0xd7, 0x5f, 0xFF}, 323 | color.RGBA{0xff, 0xd7, 0x87, 0xFF}, 324 | color.RGBA{0xff, 0xd7, 0xaf, 0xFF}, 325 | color.RGBA{0xff, 0xd7, 0xd7, 0xFF}, 326 | color.RGBA{0xff, 0xd7, 0xff, 0xFF}, 327 | color.RGBA{0xff, 0xff, 0x00, 0xFF}, 328 | color.RGBA{0xff, 0xff, 0x5f, 0xFF}, 329 | color.RGBA{0xff, 0xff, 0x87, 0xFF}, 330 | color.RGBA{0xff, 0xff, 0xaf, 0xFF}, 331 | color.RGBA{0xff, 0xff, 0xd7, 0xFF}, 332 | color.RGBA{0xff, 0xff, 0xff, 0xFF}, 333 | color.RGBA{0x08, 0x08, 0x08, 0xFF}, 334 | color.RGBA{0x12, 0x12, 0x12, 0xFF}, 335 | color.RGBA{0x1c, 0x1c, 0x1c, 0xFF}, 336 | color.RGBA{0x26, 0x26, 0x26, 0xFF}, 337 | color.RGBA{0x30, 0x30, 0x30, 0xFF}, 338 | color.RGBA{0x3a, 0x3a, 0x3a, 0xFF}, 339 | color.RGBA{0x44, 0x44, 0x44, 0xFF}, 340 | color.RGBA{0x4e, 0x4e, 0x4e, 0xFF}, 341 | color.RGBA{0x58, 0x58, 0x58, 0xFF}, 342 | color.RGBA{0x62, 0x62, 0x62, 0xFF}, 343 | color.RGBA{0x6c, 0x6c, 0x6c, 0xFF}, 344 | color.RGBA{0x76, 0x76, 0x76, 0xFF}, 345 | color.RGBA{0x80, 0x80, 0x80, 0xFF}, 346 | color.RGBA{0x8a, 0x8a, 0x8a, 0xFF}, 347 | color.RGBA{0x94, 0x94, 0x94, 0xFF}, 348 | color.RGBA{0x9e, 0x9e, 0x9e, 0xFF}, 349 | color.RGBA{0xa8, 0xa8, 0xa8, 0xFF}, 350 | color.RGBA{0xb2, 0xb2, 0xb2, 0xFF}, 351 | color.RGBA{0xbc, 0xbc, 0xbc, 0xFF}, 352 | color.RGBA{0xc6, 0xc6, 0xc6, 0xFF}, 353 | color.RGBA{0xd0, 0xd0, 0xd0, 0xFF}, 354 | color.RGBA{0xda, 0xda, 0xda, 0xFF}, 355 | color.RGBA{0xe4, 0xe4, 0xe4, 0xFF}, 356 | color.RGBA{0xee, 0xee, 0xee, 0xFF}, 357 | } 358 | -------------------------------------------------------------------------------- /term/input.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | 8 | "golang.org/x/sys/unix" 9 | ) 10 | 11 | func makeStdinRaw() error { 12 | fd := int(os.Stdin.Fd()) 13 | 14 | termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // This attempts to replicate the behaviour documented for cfmakeraw in 20 | // the termios(3) manpage. 21 | termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON 22 | termios.Oflag &^= unix.OPOST 23 | termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN 24 | termios.Cflag &^= unix.CSIZE | unix.PARENB 25 | termios.Cflag |= unix.CS8 26 | termios.Cc[unix.VMIN] = 1 27 | termios.Cc[unix.VTIME] = 0 28 | if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, termios); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func CaptureStdin(onRune func(rune)) error { 36 | if err := makeStdinRaw(); err != nil { 37 | return err 38 | } 39 | 40 | go func() { 41 | reader := bufio.NewReader(os.Stdin) 42 | for { 43 | r, _, err := reader.ReadRune() 44 | if err == io.EOF { 45 | break 46 | } 47 | if err != nil { 48 | continue 49 | } 50 | onRune(r) 51 | } 52 | }() 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /term/input_bsd.go: -------------------------------------------------------------------------------- 1 | // +build darwin dragonfly freebsd netbsd openbsd 2 | 3 | package term 4 | 5 | import "golang.org/x/sys/unix" 6 | 7 | const ioctlReadTermios = unix.TIOCGETA 8 | const ioctlWriteTermios = unix.TIOCSETA 9 | -------------------------------------------------------------------------------- /term/input_linux.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import "golang.org/x/sys/unix" 4 | 5 | const ioctlReadTermios = unix.TCGETS 6 | const ioctlWriteTermios = unix.TCSETS 7 | -------------------------------------------------------------------------------- /term/window.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "os" 5 | 6 | "golang.org/x/sys/unix" 7 | ) 8 | 9 | type WinSize struct { 10 | Rows int 11 | Cols int 12 | Width int 13 | Height int 14 | } 15 | 16 | func GetWinSize() (WinSize, error) { 17 | ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) 18 | if err != nil { 19 | return WinSize{}, os.NewSyscallError("GetWinsize", err) 20 | } 21 | return WinSize{ 22 | Rows: int(ws.Row), 23 | Cols: int(ws.Col), 24 | Width: int(ws.Xpixel), 25 | Height: int(ws.Ypixel), 26 | }, nil 27 | } 28 | -------------------------------------------------------------------------------- /ui/ansi.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "image" 6 | "image/color" 7 | "image/draw" 8 | 9 | "github.com/dialup-inc/ascii/term" 10 | "github.com/nfnt/resize" 11 | ) 12 | 13 | var chars = []byte(" .,:;i1tfLCG08@") 14 | 15 | func Image2ANSI(img image.Image, cols, rows int, aspect float64, lightBackground bool) []byte { 16 | buf := bytes.NewBuffer(nil) 17 | a := term.ANSI{buf} 18 | 19 | // FIXME: Work around panic in resize when image is too small 20 | if rows < 2 || cols < 2 { 21 | return nil 22 | } 23 | 24 | colors := term.ANSIPalette 25 | canvasRect := image.Rect(0, 0, cols, rows) 26 | canvas := image.NewPaletted(canvasRect, colors) 27 | 28 | // If there's an image, resize to fit inside canvas dimensions... 29 | if img != nil { 30 | imgRect := img.Bounds() 31 | imgW, imgH := float64(imgRect.Dx())*aspect, float64(imgRect.Dy()) 32 | fitW, fitH := float64(cols)/imgW, float64(rows)/imgH 33 | 34 | var scaleW, scaleH uint 35 | if fitW < fitH { 36 | scaleW = uint(imgW * fitW) 37 | scaleH = uint(imgH * fitW) 38 | } else { 39 | scaleW = uint(imgW * fitH) 40 | scaleH = uint(imgH * fitH) 41 | } 42 | 43 | scaled := resize.Resize(scaleW, scaleH, img, resize.Bilinear) 44 | 45 | offsetW, offsetH := (cols-int(scaleW))/2, (rows-int(scaleH))/2 46 | fitRect := image.Rect( 47 | offsetW, 48 | offsetH, 49 | offsetW+int(scaleW), 50 | offsetH+int(scaleH), 51 | ) 52 | draw.Draw(canvas, fitRect, scaled, image.ZP, draw.Over) 53 | } 54 | 55 | // Draw a character and colored ANSI escape sequence for each pixel... 56 | currentColor := -1 57 | for _, p := range canvas.Pix { 58 | pxColor := colors[p] 59 | 60 | if int(p) != currentColor { 61 | a.Foreground(pxColor) 62 | 63 | currentColor = int(p) 64 | } 65 | 66 | k, _, _, _ := color.GrayModel.Convert(pxColor).RGBA() 67 | chr := int(k) * (len(chars) - 1) / 0xffff 68 | 69 | if lightBackground { 70 | chr = len(chars) - chr - 1 71 | } 72 | 73 | buf.WriteByte(chars[chr]) 74 | } 75 | 76 | return buf.Bytes() 77 | } 78 | -------------------------------------------------------------------------------- /ui/events.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/dialup-inc/ascii/term" 7 | ) 8 | 9 | // An Event represents a user action that cause changes in the UI state. 10 | // 11 | // They're processed by Renderer's Dispatch method. 12 | type Event interface{} 13 | 14 | // SentMessageEvent fires after a message has been sent to the current partner 15 | type SentMessageEvent string 16 | 17 | // FrameEvent is sent when the video decoder renders a new frame 18 | type FrameEvent image.Image 19 | 20 | // ReceivedChatEvent is fired when the user submits text in the chat input box. 21 | type ReceivedChatEvent string 22 | 23 | // KeypressEvent is fired when the user presses the keyboard. 24 | type KeypressEvent rune 25 | 26 | // BackspaceEvent is fired when the backspace button is pressed. 27 | type BackspaceEvent struct{} 28 | 29 | // ResizeEvent indicates that the terminal window's size has changed to the specified dimensions 30 | type ResizeEvent term.WinSize 31 | 32 | // ToggleHelpEvent toggles the help modal 33 | type ToggleHelpEvent struct{} 34 | 35 | // SkipEvent skips to the next match 36 | type SkipEvent struct{} 37 | 38 | // DataOpenedEvent fires when the text chat data channel opens and the user can begin typing 39 | type DataOpenedEvent struct{} 40 | 41 | // ConnStartedEvent fires when an ICE connection has been established and the call can begin 42 | type ConnStartedEvent struct{} 43 | 44 | // ConnEndedEvent fires when the connection with your partner has been lost 45 | type ConnEndedEvent struct { 46 | // The reason for the disconnection 47 | Reason EndConnReason 48 | } 49 | 50 | // SetPageEvent transitions to the specified page 51 | type SetPageEvent Page 52 | 53 | // LogLevel indicates the severity of a LogEvent message 54 | type LogLevel int 55 | 56 | const ( 57 | // LogLevelInfo is for non-urgent, informational logs 58 | LogLevelInfo LogLevel = iota 59 | // LogLevelError is for logs that indicate problems 60 | LogLevelError 61 | ) 62 | 63 | // A LogEvent prints a message to the console 64 | type LogEvent struct { 65 | Text string 66 | Level LogLevel 67 | } 68 | -------------------------------------------------------------------------------- /ui/reducer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | "regexp" 6 | "unicode" 7 | "unicode/utf8" 8 | 9 | "github.com/dialup-inc/ascii/term" 10 | ) 11 | 12 | func StateReducer(s State, event Event) State { 13 | s.Image = imageReducer(s.Image, event) 14 | s.ChatActive = chatActiveReducer(s.ChatActive, event) 15 | s.Input = inputReducer(s.Input, s.ChatActive, event) 16 | s.Messages = messagesReducer(s.Messages, event) 17 | s.Page = pageReducer(s.Page, event) 18 | s.WinSize = winSizeReducer(s.WinSize, event) 19 | s.HelpOn = helpOnReducer(s.HelpOn, event) 20 | 21 | return s 22 | } 23 | 24 | func chatActiveReducer(s bool, event Event) bool { 25 | switch event.(type) { 26 | case DataOpenedEvent: 27 | return true 28 | case ConnEndedEvent: 29 | return false 30 | default: 31 | return s 32 | } 33 | } 34 | 35 | func helpOnReducer(s bool, event Event) bool { 36 | switch event.(type) { 37 | case ToggleHelpEvent: 38 | return !s 39 | case SkipEvent: 40 | return false 41 | case SentMessageEvent: 42 | return false 43 | default: 44 | return s 45 | } 46 | } 47 | 48 | func pageReducer(s Page, event Event) Page { 49 | switch e := event.(type) { 50 | case SetPageEvent: 51 | return Page(e) 52 | default: 53 | return s 54 | } 55 | } 56 | 57 | func winSizeReducer(s term.WinSize, event Event) term.WinSize { 58 | switch e := event.(type) { 59 | case ResizeEvent: 60 | return term.WinSize(e) 61 | default: 62 | return s 63 | } 64 | } 65 | 66 | var ansiRegex = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))") 67 | 68 | func inputReducer(s string, chatActive bool, event Event) string { 69 | switch e := event.(type) { 70 | case ConnStartedEvent: 71 | return "" 72 | 73 | case KeypressEvent: 74 | if !chatActive { 75 | return s 76 | } 77 | 78 | s += string(e) 79 | 80 | // Strip ANSI escape codes 81 | s = ansiRegex.ReplaceAllString(s, "") 82 | 83 | // Strip unprintable characters 84 | var printable []rune 85 | for _, r := range s { 86 | if !unicode.IsPrint(r) { 87 | continue 88 | } 89 | printable = append(printable, r) 90 | } 91 | s = string(printable) 92 | 93 | return s 94 | 95 | case BackspaceEvent: 96 | if !chatActive { 97 | return s 98 | } 99 | 100 | if len(s) == 0 { 101 | return s 102 | } 103 | _, sz := utf8.DecodeLastRuneInString(s) 104 | return s[:len(s)-sz] 105 | 106 | case SentMessageEvent: 107 | return "" 108 | 109 | default: 110 | return s 111 | } 112 | } 113 | 114 | func imageReducer(s image.Image, event Event) image.Image { 115 | switch e := event.(type) { 116 | case FrameEvent: 117 | return image.Image(e) 118 | 119 | case SetPageEvent: 120 | return nil 121 | 122 | case SkipEvent: 123 | return nil 124 | 125 | default: 126 | return s 127 | } 128 | } 129 | 130 | func messagesReducer(s []Message, event Event) []Message { 131 | switch e := event.(type) { 132 | case SentMessageEvent: 133 | return append(s, Message{ 134 | Type: MessageTypeOutgoing, 135 | User: "You", 136 | Text: string(e), 137 | }) 138 | 139 | case ReceivedChatEvent: 140 | return append(s, Message{ 141 | Type: MessageTypeIncoming, 142 | User: "Them", 143 | Text: string(e), 144 | }) 145 | 146 | case ConnEndedEvent: 147 | var msg Message 148 | 149 | switch e.Reason { 150 | 151 | // Error handler will catch this 152 | case EndConnSetupError: 153 | return s 154 | 155 | // We ignore match errors 156 | case EndConnMatchError: 157 | return s 158 | 159 | case EndConnNormal: 160 | msg = Message{ 161 | Type: MessageTypeInfo, 162 | Text: "Skipping...", 163 | } 164 | 165 | case EndConnTimedOut: 166 | msg = Message{ 167 | Type: MessageTypeError, 168 | Text: "Connection timed out.", 169 | } 170 | 171 | case EndConnDisconnected: 172 | msg = Message{ 173 | Type: MessageTypeError, 174 | Text: "Lost connection.", 175 | } 176 | 177 | case EndConnGone: 178 | msg = Message{ 179 | Type: MessageTypeInfo, 180 | Text: "Your partner left the chat.", 181 | } 182 | } 183 | 184 | return append(s, msg) 185 | 186 | case ConnStartedEvent: 187 | return append(s, Message{ 188 | Type: MessageTypeInfo, 189 | Text: "Connected", 190 | }) 191 | 192 | case LogEvent: 193 | mtype := MessageTypeInfo 194 | if e.Level == LogLevelError { 195 | mtype = MessageTypeError 196 | } 197 | 198 | return append(s, Message{ 199 | Type: mtype, 200 | Text: e.Text, 201 | }) 202 | 203 | default: 204 | return s 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /ui/renderer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "image/color" 6 | "io" 7 | "math" 8 | "os" 9 | "reflect" 10 | "strings" 11 | "sync" 12 | "time" 13 | "unicode/utf8" 14 | 15 | "github.com/dialup-inc/ascii/term" 16 | ) 17 | 18 | const ( 19 | chatHeight = 5 20 | ) 21 | 22 | func NewRenderer() *Renderer { 23 | return &Renderer{ 24 | requestFrame: make(chan struct{}), 25 | } 26 | } 27 | 28 | type Renderer struct { 29 | requestFrame chan struct{} 30 | 31 | stateMu sync.Mutex 32 | state State 33 | 34 | start time.Time 35 | } 36 | 37 | func (r *Renderer) GetState() State { 38 | r.stateMu.Lock() 39 | defer r.stateMu.Unlock() 40 | 41 | return r.state 42 | } 43 | 44 | func (r *Renderer) Dispatch(e Event) { 45 | r.stateMu.Lock() 46 | newState := StateReducer(r.state, e) 47 | var changed bool 48 | if !reflect.DeepEqual(r.state, newState) { 49 | changed = true 50 | } 51 | r.state = newState 52 | r.stateMu.Unlock() 53 | 54 | if changed { 55 | r.RequestFrame() 56 | } 57 | } 58 | 59 | func (r *Renderer) RequestFrame() { 60 | select { 61 | case r.requestFrame <- struct{}{}: 62 | default: 63 | } 64 | } 65 | 66 | // pixels are rectangular, not square in the terminal. add a scale factor to account for this 67 | func getAspect(w term.WinSize) float64 { 68 | if w.Width == 0 || w.Height == 0 || w.Rows == 0 || w.Cols == 0 { 69 | return 2.0 70 | } 71 | return float64(w.Height) * float64(w.Cols) / float64(w.Rows) / float64(w.Width) 72 | } 73 | 74 | func (r *Renderer) drawVideo(buf *bytes.Buffer, s State, headHeight int) { 75 | a := term.ANSI{buf} 76 | 77 | vidW, vidH := s.WinSize.Cols, s.WinSize.Rows-chatHeight-headHeight 78 | 79 | a.CursorPosition(1+headHeight, 1) 80 | a.Background(color.Black) 81 | a.Bold() 82 | 83 | aspect := getAspect(s.WinSize) 84 | imgANSI := Image2ANSI(s.Image, vidW, vidH, aspect, false) 85 | buf.Write(imgANSI) 86 | } 87 | 88 | func (r *Renderer) drawHead(buf *bytes.Buffer, s State) { 89 | a := term.ANSI{buf} 90 | 91 | line1 := " dialup.com/ascii" 92 | line2 := " (we're hiring!)" 93 | 94 | a.CursorPosition(1, 1) 95 | a.Normal() 96 | 97 | a.Background(color.RGBA{0x12, 0x12, 0x12, 0xFF}) 98 | 99 | a.Foreground(color.RGBA{0x00, 0xFF, 0xFF, 0xFF}) 100 | buf.WriteString(line1) 101 | 102 | a.Foreground(color.RGBA{0x00, 0x44, 0x44, 0xFF}) 103 | buf.WriteString(line2) 104 | 105 | remaining := s.WinSize.Cols - len(line1) - len(line2) 106 | if remaining > 0 { 107 | buf.WriteString(strings.Repeat(" ", remaining)) 108 | } 109 | } 110 | 111 | func (r *Renderer) drawPrompt(buf *bytes.Buffer, s State) { 112 | a := term.ANSI{buf} 113 | 114 | prompt := " > " 115 | input := s.Input 116 | 117 | width := s.WinSize.Cols 118 | row := s.WinSize.Rows 119 | 120 | a.Background(color.RGBA{0x12, 0x12, 0x12, 0xFF}) 121 | a.Bold() 122 | 123 | // Clear what's there 124 | a.CursorPosition(row, 1) 125 | buf.WriteString(strings.Repeat(" ", width)) 126 | 127 | // Then write the line 128 | a.CursorPosition(row, 1) 129 | 130 | var lineLen int 131 | 132 | if !s.ChatActive { 133 | a.Foreground(color.RGBA{0x33, 0x33, 0x33, 0XFF}) 134 | buf.WriteString(prompt) 135 | return 136 | } 137 | 138 | // Add prompt 139 | a.Foreground(color.White) 140 | buf.WriteString(prompt) 141 | lineLen += len(prompt) 142 | 143 | // Add input 144 | input = truncate(input, width-lineLen, "…") 145 | buf.WriteString(input) 146 | lineLen += len(input) 147 | 148 | // Add blinking cursor where you're supposed to type 149 | cursor := "_" 150 | if lineLen+len(cursor) < width { 151 | a.Foreground(color.White) 152 | a.Blink() 153 | buf.WriteString(cursor) 154 | a.BlinkOff() 155 | lineLen += len(cursor) 156 | } 157 | 158 | // add label 159 | label := " Send a message." 160 | label = truncate(label, width-lineLen, "") 161 | if input == "" { 162 | a.Foreground(color.RGBA{0x33, 0x33, 0x33, 0xFF}) 163 | buf.WriteString(label) 164 | lineLen += len(label) 165 | } 166 | } 167 | 168 | func truncate(s string, n int, ellipsis string) string { 169 | if len(s) <= n { 170 | return s 171 | } 172 | 173 | maxLen := n - len(ellipsis) 174 | if len(s) > maxLen { 175 | s = s[:maxLen] 176 | } 177 | 178 | return s + ellipsis 179 | } 180 | 181 | func (r *Renderer) drawChat(buf *bytes.Buffer, s State) { 182 | a := term.ANSI{buf} 183 | 184 | width := s.WinSize.Cols 185 | chatTop := s.WinSize.Rows - chatHeight + 1 186 | logTop := chatTop + 1 187 | 188 | a.CursorPosition(chatTop, 1) 189 | // Draw background 190 | a.Normal() 191 | 192 | a.Background(color.RGBA{0x12, 0x12, 0x12, 0xFF}) 193 | label := "ASCII Roulette" 194 | link := "hit ctrl-t for help" 195 | buf.WriteString(" ") 196 | a.Foreground(color.RGBA{0x00, 0xff, 0xff, 0xff}) 197 | buf.WriteString(label) 198 | textLen := len(label) + len(link) + 2 199 | if width > textLen { 200 | buf.WriteString(strings.Repeat(" ", width-textLen)) 201 | } 202 | a.Foreground(color.RGBA{0x00, 0x99, 0x99, 0xff}) 203 | buf.WriteString(link) 204 | buf.WriteString(" ") 205 | 206 | a.Background(color.RGBA{0x22, 0x22, 0x22, 0xFF}) 207 | 208 | drawChatLine := func(m Message) { 209 | switch m.Type { 210 | case MessageTypeIncoming: 211 | a.Foreground(color.RGBA{0xFF, 0, 0, 0xFF}) 212 | buf.WriteString(" ") 213 | buf.WriteString(m.User) 214 | buf.WriteString(": ") 215 | a.Foreground(color.RGBA{0x99, 0x99, 0x99, 0xFF}) 216 | buf.WriteString(m.Text) 217 | 218 | textLen := utf8.RuneCountInString(m.User) + utf8.RuneCountInString(m.Text) + 3 219 | if width > textLen { 220 | buf.WriteString(strings.Repeat(" ", width-textLen)) 221 | } 222 | 223 | case MessageTypeOutgoing: 224 | a.Foreground(color.RGBA{0xFF, 0xFF, 0, 0xFF}) 225 | buf.WriteString(" ") 226 | buf.WriteString(m.User) 227 | buf.WriteString(": ") 228 | a.Foreground(color.RGBA{0x99, 0x99, 0x99, 0xFF}) 229 | buf.WriteString(m.Text) 230 | 231 | textLen := utf8.RuneCountInString(m.User) + utf8.RuneCountInString(m.Text) + 3 232 | if width > textLen { 233 | buf.WriteString(strings.Repeat(" ", width-textLen)) 234 | } 235 | 236 | case MessageTypeInfo: 237 | a.Foreground(color.RGBA{0x99, 0x99, 0x99, 0xFF}) 238 | buf.WriteString(" ") 239 | buf.WriteString(m.Text) 240 | 241 | textLen := utf8.RuneCountInString(m.Text) + 1 242 | if width > textLen { 243 | buf.WriteString(strings.Repeat(" ", width-textLen)) 244 | } 245 | 246 | case MessageTypeError: 247 | a.Foreground(color.RGBA{0xAA, 0x00, 0x00, 0xFF}) 248 | buf.WriteString(" ") 249 | buf.WriteString(m.Text) 250 | 251 | textLen := utf8.RuneCountInString(m.Text) + 1 252 | if width > textLen { 253 | buf.WriteString(strings.Repeat(" ", width-textLen)) 254 | } 255 | 256 | } 257 | } 258 | 259 | msgs := s.Messages 260 | if len(s.Messages) > 3 { 261 | msgs = msgs[len(s.Messages)-3:] 262 | } 263 | for i, m := range msgs { 264 | a.CursorPosition(logTop+i, 0) 265 | drawChatLine(m) 266 | } 267 | // blank if there arent enough messages 268 | for i := len(msgs); i < 3; i++ { 269 | a.CursorPosition(logTop+i, 0) 270 | buf.WriteString(strings.Repeat(" ", width)) 271 | } 272 | 273 | r.drawPrompt(buf, s) 274 | } 275 | 276 | func (r *Renderer) drawTitle(buf *bytes.Buffer, s State) { 277 | a := term.ANSI{buf} 278 | 279 | // Draw background 280 | a.Bold() 281 | 282 | var text string 283 | switch s.Page { 284 | case GlobePage: 285 | text = "Presented by dialup.com" 286 | case PionPage: 287 | text = "Powered by Pion" 288 | } 289 | 290 | a.Background(color.RGBA{0x00, 0x00, 0x00, 0xFF}) 291 | buf.WriteString(strings.Repeat(" ", s.WinSize.Cols*chatHeight)) 292 | 293 | a.Foreground(color.RGBA{0x00, 0xff, 0xff, 0xff}) 294 | a.CursorPosition(s.WinSize.Rows-2, (s.WinSize.Cols-len(text))/2+1) 295 | buf.WriteString(text) 296 | } 297 | 298 | func (r *Renderer) drawBlank(buf *bytes.Buffer, s State) { 299 | a := term.ANSI{buf} 300 | 301 | a.Background(color.RGBA{0x00, 0x00, 0x00, 0xFF}) 302 | 303 | a.CursorPosition(1, 1) 304 | buf.WriteString(strings.Repeat(" ", s.WinSize.Cols*s.WinSize.Rows)) 305 | } 306 | 307 | func (r *Renderer) drawConfirm(buf *bytes.Buffer, s State) { 308 | a := term.ANSI{buf} 309 | 310 | // Blank background 311 | a.Background(color.RGBA{0x00, 0x00, 0x00, 0xFF}) 312 | a.CursorPosition(1, 1) 313 | buf.WriteString(strings.Repeat(" ", s.WinSize.Cols*s.WinSize.Rows)) 314 | 315 | // Draw title 316 | if s.WinSize.Rows > 6 { 317 | line := "ASCII Roulette" 318 | if s.WinSize.Cols > 25 { 319 | line = "Welcome to " + line 320 | } 321 | 322 | timeOffset := float64(time.Since(r.start)/time.Millisecond) / 2000.0 323 | 324 | a.Bold() 325 | a.CursorPosition(2, (s.WinSize.Cols-len(line))/2+1) 326 | for i, r := range line { 327 | t := float64(i)/float64(len(line)) + timeOffset 328 | a.Foreground(rainbow(t)) 329 | buf.WriteRune(r) 330 | } 331 | } 332 | 333 | // Draw description 334 | descWidth := 40 335 | maxWidth := s.WinSize.Cols - 2 336 | if maxWidth < descWidth { 337 | descWidth = maxWidth 338 | } 339 | 340 | desc := "This program connects you in a video chat with a random person!\n🎥 Your webcam will activate\n🔉 There is no audio\nClicking, you agree to the TOS: dialup.com/terms" 341 | var descSections [][]string 342 | for _, line := range strings.Split(desc, "\n") { 343 | descSections = append(descSections, wordWrap(line, descWidth)) 344 | } 345 | 346 | // Hide parts of the description if they're too long 347 | var totalLength int 348 | for i, lines := range descSections { 349 | if totalLength+len(lines) > s.WinSize.Rows-8 { 350 | descSections = descSections[:i] 351 | break 352 | } 353 | 354 | totalLength += len(lines) 355 | if i > 0 { 356 | totalLength++ // for newline 357 | } 358 | } 359 | 360 | a.Normal() 361 | a.Foreground(color.RGBA{0xAA, 0xAA, 0xAA, 0xFF}) 362 | 363 | descOffset := 4 364 | for _, lines := range descSections { 365 | // Don't display if it'll clip the button 366 | 367 | for i, line := range lines { 368 | a.CursorPosition((s.WinSize.Rows-totalLength-8)/2+i+descOffset, (s.WinSize.Cols-len(line))/2+1) 369 | buf.WriteString(line) 370 | } 371 | 372 | descOffset += len(lines) + 1 373 | } 374 | 375 | // Draw button 376 | a.Bold() 377 | a.Background(color.RGBA{0x11, 0x11, 0x11, 0xFF}) 378 | a.Foreground(color.White) 379 | 380 | line := " Press Enter to Start Camera " 381 | if s.WinSize.Cols <= 25 { 382 | line = " Press Enter " 383 | } 384 | 385 | a.CursorPosition(s.WinSize.Rows-3, (s.WinSize.Cols-len(line))/2+1) 386 | if len(line) > 0 { 387 | buf.WriteString(strings.Repeat(" ", len(line))) 388 | } 389 | 390 | a.CursorPosition(s.WinSize.Rows-2, (s.WinSize.Cols-len(line))/2+1) 391 | buf.WriteString(line) 392 | 393 | a.CursorPosition(s.WinSize.Rows-1, (s.WinSize.Cols-len(line))/2+1) 394 | if len(line) > 0 { 395 | buf.WriteString(strings.Repeat(" ", len(line))) 396 | } 397 | } 398 | 399 | func (r *Renderer) drawHelp(buf *bytes.Buffer, s State) { 400 | a := term.ANSI{buf} 401 | 402 | rows := []string{ 403 | " ", 404 | " Skip ctrl-d ", 405 | " Help ctrl-t ", 406 | " Quit ctrl-c ", 407 | " ", 408 | } 409 | 410 | var boxWidth int 411 | for _, r := range rows { 412 | if len(r) > boxWidth { 413 | boxWidth = len(r) 414 | } 415 | } 416 | 417 | boxTop := (s.WinSize.Rows-len(rows)-1)/2 + 1 418 | boxLeft := (s.WinSize.Cols-boxWidth)/2 + 1 419 | 420 | a.CursorPosition(boxTop, boxLeft) 421 | a.Bold() 422 | a.Background(color.Black) 423 | a.Foreground(color.White) 424 | buf.WriteString(" Shortcuts ") 425 | 426 | a.Normal() 427 | a.Foreground(color.Black) 428 | a.Background(color.White) 429 | for i, line := range rows { 430 | a.CursorPosition(boxTop+i+1, boxLeft) 431 | buf.WriteString(line) 432 | } 433 | } 434 | 435 | func wordWrap(s string, lineLen int) []string { 436 | var lines []string 437 | 438 | var line string 439 | for _, word := range strings.Split(s, " ") { 440 | if len(line)+len(word)+1 > lineLen { 441 | lines = append(lines, line) 442 | line = "" 443 | } 444 | line += " " + word 445 | } 446 | if len(line) > 0 { 447 | lines = append(lines, line) 448 | } 449 | 450 | return lines 451 | } 452 | 453 | func rainbow(t float64) *color.RGBA { 454 | const freq = math.Pi 455 | r := math.Sin(freq*t)*127 + 128 456 | g := math.Sin(freq*t+2*math.Pi/3)*127 + 128 457 | b := math.Sin(freq*t+4*math.Pi/3)*127 + 128 458 | 459 | return &color.RGBA{uint8(r), uint8(g), uint8(b), 0xFF} 460 | } 461 | 462 | func (r *Renderer) draw() { 463 | buf := bytes.NewBuffer(nil) 464 | 465 | r.stateMu.Lock() 466 | s := r.state 467 | r.stateMu.Unlock() 468 | 469 | switch s.Page { 470 | case GlobePage: 471 | r.drawTitle(buf, s) 472 | r.drawVideo(buf, s, 0) 473 | 474 | case PionPage: 475 | r.drawTitle(buf, s) 476 | r.drawVideo(buf, s, 0) 477 | 478 | case ChatPage: 479 | r.drawHead(buf, s) 480 | r.drawChat(buf, s) 481 | r.drawVideo(buf, s, 1) 482 | if s.HelpOn { 483 | r.drawHelp(buf, s) 484 | } 485 | 486 | case ConfirmPage: 487 | r.drawConfirm(buf, s) 488 | 489 | default: 490 | r.drawBlank(buf, s) 491 | } 492 | 493 | io.Copy(os.Stdout, buf) 494 | } 495 | 496 | func (r *Renderer) loop() { 497 | r.start = time.Now() 498 | 499 | ticker := time.NewTicker(200 * time.Millisecond) 500 | for { 501 | r.draw() 502 | 503 | select { 504 | case <-r.requestFrame: 505 | case <-ticker.C: 506 | } 507 | } 508 | } 509 | 510 | func (r *Renderer) Start() { 511 | a := term.ANSI{os.Stdout} 512 | a.HideCursor() 513 | 514 | go r.loop() 515 | } 516 | 517 | func (r *Renderer) Stop() { 518 | r.stateMu.Lock() 519 | s := r.state 520 | r.stateMu.Unlock() 521 | 522 | buf := bytes.NewBuffer(nil) 523 | a := term.ANSI{buf} 524 | 525 | a.ShowCursor() 526 | a.Reset() 527 | a.BackgroundReset() 528 | a.ForegroundReset() 529 | a.Normal() 530 | a.CursorPosition(1, 1) 531 | buf.WriteString(strings.Repeat(" ", s.WinSize.Cols*s.WinSize.Rows)) 532 | a.CursorPosition(1, 1) 533 | 534 | io.Copy(os.Stdout, buf) 535 | } 536 | -------------------------------------------------------------------------------- /ui/state.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/dialup-inc/ascii/term" 7 | ) 8 | 9 | type Page string 10 | 11 | var ( 12 | GlobePage Page = "globe" 13 | PionPage Page = "pion" 14 | ConfirmPage Page = "confirm" 15 | ChatPage Page = "chat" 16 | ) 17 | 18 | type State struct { 19 | Page Page 20 | 21 | HelpOn bool 22 | 23 | Input string 24 | ChatActive bool 25 | 26 | Messages []Message 27 | Image image.Image 28 | WinSize term.WinSize 29 | } 30 | 31 | type MessageType int 32 | 33 | const ( 34 | MessageTypeIncoming MessageType = iota 35 | MessageTypeOutgoing 36 | MessageTypeInfo 37 | MessageTypeError 38 | ) 39 | 40 | type Message struct { 41 | Type MessageType 42 | User string 43 | Text string 44 | } 45 | 46 | type EndConnReason int 47 | 48 | const ( 49 | // User closed connection 50 | EndConnNormal EndConnReason = iota 51 | // Error during connection setup 52 | EndConnSetupError 53 | // Error during matching 54 | EndConnMatchError 55 | // Connection timed out 56 | EndConnTimedOut 57 | // Lost connection with partner 58 | EndConnDisconnected 59 | // Partner left 60 | EndConnGone 61 | ) 62 | -------------------------------------------------------------------------------- /videos/ivfreader.go: -------------------------------------------------------------------------------- 1 | package videos 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | ) 8 | 9 | type IVFReader struct { 10 | reader io.ReadSeeker 11 | Header IVFHeader 12 | } 13 | 14 | type IVFHeader struct { 15 | Signaure [4]byte 16 | Version uint16 17 | Size uint16 18 | Codec [4]byte 19 | Width uint16 20 | Height uint16 21 | FrameRate uint32 22 | FrameScale uint32 23 | FrameCount uint32 24 | _ uint32 25 | } 26 | 27 | type IVFFrameHeader struct { 28 | Size uint32 29 | PTS uint64 30 | } 31 | 32 | func NewIVFReader(r io.ReadSeeker) (*IVFReader, error) { 33 | var hdr IVFHeader 34 | if err := binary.Read(r, binary.LittleEndian, &hdr); err != nil { 35 | return nil, err 36 | } 37 | 38 | if string(hdr.Signaure[:]) != "DKIF" { 39 | return nil, errors.New("not a valid IVF file") 40 | } 41 | if hdr.Version != 0 { 42 | return nil, errors.New("unsupported IVF version") 43 | } 44 | 45 | return &IVFReader{ 46 | reader: r, 47 | Header: hdr, 48 | }, nil 49 | } 50 | 51 | func (i *IVFReader) Codec() string { 52 | return string(i.Header.Codec[:]) 53 | } 54 | 55 | func (i *IVFReader) ReadFrame() (data []byte, pts uint64, err error) { 56 | var hdr IVFFrameHeader 57 | if err := binary.Read(i.reader, binary.LittleEndian, &hdr); err != nil { 58 | return nil, 0, err 59 | } 60 | 61 | frame := make([]byte, hdr.Size) 62 | if _, err := io.ReadFull(i.reader, frame); err != nil { 63 | return nil, 0, err 64 | } 65 | 66 | return frame, hdr.PTS, nil 67 | } 68 | 69 | func (i *IVFReader) Rewind() error { 70 | _, err := i.reader.Seek(32, io.SeekStart) 71 | return err 72 | } 73 | -------------------------------------------------------------------------------- /videos/player.go: -------------------------------------------------------------------------------- 1 | package videos 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "image" 7 | "io" 8 | "sync" 9 | "time" 10 | 11 | "github.com/dialup-inc/ascii/vpx" 12 | ) 13 | 14 | type Player struct { 15 | reader *IVFReader 16 | decoder *vpx.Decoder 17 | 18 | OnFrame func(image.Image) 19 | 20 | ctxMu sync.Mutex 21 | ctx context.Context 22 | } 23 | 24 | func (p *Player) Play(ctx context.Context) error { 25 | p.ctxMu.Lock() 26 | if p.ctx != nil { 27 | p.ctxMu.Unlock() 28 | return nil 29 | } 30 | p.ctx = ctx 31 | p.ctxMu.Unlock() 32 | 33 | defer func() { 34 | p.ctxMu.Lock() 35 | p.ctx = ctx 36 | p.ctxMu.Unlock() 37 | }() 38 | 39 | if err := p.reader.Rewind(); err != nil { 40 | return err 41 | } 42 | 43 | lastFrame := time.Now() 44 | 45 | hdr := p.reader.Header 46 | 47 | period := time.Duration(float64(hdr.FrameScale) / float64(hdr.FrameRate) * float64(time.Second)) 48 | 49 | for { 50 | frame, _, err := p.reader.ReadFrame() 51 | if err == io.EOF { 52 | return nil 53 | } 54 | if err != nil { 55 | return err 56 | } 57 | 58 | img, err := p.decoder.Decode(frame) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | waitTime := period - time.Since(lastFrame) 64 | select { 65 | case <-time.After(waitTime): 66 | case <-ctx.Done(): 67 | return ctx.Err() 68 | } 69 | 70 | p.OnFrame(img) 71 | 72 | lastFrame = time.Now() 73 | } 74 | } 75 | 76 | func NewPlayer(r io.ReadSeeker) (*Player, error) { 77 | ivf, err := NewIVFReader(r) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if c := ivf.Codec(); c != "VP80" { 82 | return nil, fmt.Errorf("unknown codec %q", c) 83 | } 84 | 85 | decoder, err := vpx.NewDecoder(int(ivf.Header.Width), int(ivf.Header.Height)) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | player := &Player{ 91 | decoder: decoder, 92 | reader: ivf, 93 | OnFrame: func(image.Image) {}, 94 | } 95 | 96 | return player, nil 97 | } 98 | -------------------------------------------------------------------------------- /videos/src/globe.ivf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/videos/src/globe.ivf -------------------------------------------------------------------------------- /videos/src/pion.ivf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dialup-inc/ascii/07c3fdeef4e5805838478594a0be1e4301f4aed5/videos/src/pion.ivf -------------------------------------------------------------------------------- /videos/videos.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/shuLhan/go-bindata/cmd/go-bindata -pkg videos -nocompress src/... 2 | package videos 3 | 4 | import "bytes" 5 | 6 | func Globe() *bytes.Reader { 7 | return bytes.NewReader(MustAsset("src/globe.ivf")) 8 | } 9 | 10 | func Pion() *bytes.Reader { 11 | return bytes.NewReader(MustAsset("src/pion.ivf")) 12 | } 13 | -------------------------------------------------------------------------------- /vpx/decoder.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #define VPX_CODEC_DISABLE_COMPAT 1 7 | #include "vpx/vp8dx.h" 8 | #include "vpx/vpx_decoder.h" 9 | 10 | int vpx_init_dec(vpx_codec_ctx_t *ctx) { 11 | vpx_codec_iface_t *interface = vpx_codec_vp8_dx(); 12 | 13 | // Initialize codec 14 | int flags = 0; 15 | vpx_codec_err_t err = vpx_codec_dec_init(ctx, interface, NULL, flags); 16 | if (err) { 17 | return err; 18 | } 19 | 20 | return 0; 21 | } 22 | 23 | int vpx_decode(vpx_codec_ctx_t *ctx, const char *frame, int frame_len, 24 | char *yv12_frame, int yv12_cap, int *yv12_len) { 25 | // Decode the frame 26 | vpx_codec_err_t err = 27 | vpx_codec_decode(ctx, (const unsigned char *)frame, frame_len, NULL, 0); 28 | if (err) { 29 | return err; 30 | } 31 | 32 | // Write decoded data to yv12_frame 33 | vpx_codec_iter_t iter = NULL; 34 | vpx_image_t *img; 35 | 36 | while ((img = vpx_codec_get_frame(ctx, &iter))) { 37 | for (int plane = 0; plane < 3; plane++) { 38 | unsigned char *buf = img->planes[plane]; 39 | for (int y = 0; y < (plane ? (img->d_h + 1) >> 1 : img->d_h); y++) { 40 | if (*yv12_len > yv12_cap) { 41 | return VPX_CODEC_MEM_ERROR; 42 | } 43 | 44 | int len = (plane ? (img->d_w + 1) >> 1 : img->d_w); 45 | memcpy(yv12_frame + *yv12_len, buf, len); 46 | buf += img->stride[plane]; 47 | *yv12_len += len; 48 | } 49 | } 50 | } 51 | 52 | return 0; 53 | } 54 | 55 | int vpx_cleanup_dec(vpx_codec_ctx_t *ctx) { 56 | vpx_codec_err_t err = vpx_codec_destroy(ctx); 57 | if (err) { 58 | return err; 59 | } 60 | return 0; 61 | } 62 | -------------------------------------------------------------------------------- /vpx/decoder.go: -------------------------------------------------------------------------------- 1 | package vpx 2 | 3 | /* 4 | #cgo pkg-config: --static vpx 5 | 6 | #include "vpx/vpx_decoder.h" 7 | 8 | int vpx_init_dec(vpx_codec_ctx_t *ctx); 9 | int vpx_decode(vpx_codec_ctx_t *ctx, const char* frame, int frame_len, char* yv12_frame, int yv12_len, int *decoded_len); 10 | int vpx_cleanup_dec(vpx_codec_ctx_t *ctx); 11 | */ 12 | import "C" 13 | import ( 14 | "image" 15 | "sync" 16 | "unsafe" 17 | 18 | "github.com/dialup-inc/ascii/yuv" 19 | ) 20 | 21 | type Decoder struct { 22 | mu sync.Mutex 23 | 24 | width int 25 | height int 26 | buf []byte 27 | 28 | ctx C.vpx_codec_ctx_t 29 | } 30 | 31 | func NewDecoder(width, height int) (*Decoder, error) { 32 | d := &Decoder{ 33 | width: width, 34 | height: height, 35 | 36 | // PERF(maxhawkins): is this buffer too big? 37 | buf: make([]byte, width*height*4), 38 | } 39 | ret := C.vpx_init_dec(&d.ctx) 40 | if ret != 0 { 41 | return nil, VPXCodecErr(ret) 42 | } 43 | return d, nil 44 | } 45 | 46 | func (d *Decoder) Close() error { 47 | d.mu.Lock() 48 | defer d.mu.Unlock() 49 | 50 | ret := C.vpx_cleanup_dec(&d.ctx) 51 | if ret != 0 { 52 | return VPXCodecErr(ret) 53 | } 54 | return nil 55 | } 56 | 57 | func (d *Decoder) Decode(b []byte) (image.Image, error) { 58 | d.mu.Lock() 59 | defer d.mu.Unlock() 60 | 61 | if len(b) == 0 { 62 | return nil, nil 63 | } 64 | 65 | n, err := d.decode(d.buf, b) 66 | if err != nil { 67 | return nil, err 68 | } 69 | frame := d.buf[:n] 70 | 71 | return yuv.FromI420(frame, d.width, d.height) 72 | } 73 | 74 | func (d *Decoder) decode(out, in []byte) (n int, err error) { 75 | inP := (*C.char)(unsafe.Pointer(&in[0])) 76 | inL := C.int(len(in)) 77 | 78 | outP := (*C.char)(unsafe.Pointer(&out[0])) 79 | outCap := C.int(cap(out)) 80 | outL := (*C.int)(unsafe.Pointer(&n)) 81 | 82 | ret := C.vpx_decode(&d.ctx, inP, inL, outP, outCap, outL) 83 | if ret != 0 { 84 | return n, VPXCodecErr(ret) 85 | } 86 | 87 | return n, nil 88 | } 89 | -------------------------------------------------------------------------------- /vpx/encoder.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #define VPX_CODEC_DISABLE_COMPAT 1 6 | #include 7 | #include 8 | 9 | int vpx_init_enc(vpx_codec_ctx_t *codec, vpx_image_t **raw, int width, 10 | int height) { 11 | vpx_codec_iface_t *interface = vpx_codec_vp8_cx(); 12 | 13 | vpx_image_t *img = vpx_img_alloc(NULL, VPX_IMG_FMT_I420, width, height, 1); 14 | if (!img) { 15 | return VPX_CODEC_MEM_ERROR; 16 | } 17 | *raw = img; 18 | 19 | vpx_codec_enc_cfg_t cfg; 20 | 21 | // Populate encoder configuration 22 | vpx_codec_err_t err = vpx_codec_enc_config_default(interface, &cfg, 0); 23 | if (err) { 24 | return err; 25 | } 26 | 27 | // Update the default configuration with our settings 28 | cfg.rc_target_bitrate = 29 | width * height * cfg.rc_target_bitrate / cfg.g_w / cfg.g_h; 30 | cfg.g_w = width; 31 | cfg.g_h = height; 32 | cfg.g_lag_in_frames = 0; 33 | cfg.g_pass = VPX_RC_ONE_PASS; 34 | cfg.rc_end_usage = VPX_CBR; 35 | cfg.kf_mode = VPX_KF_AUTO; 36 | cfg.kf_max_dist = 1000; 37 | cfg.g_error_resilient = 38 | VPX_ERROR_RESILIENT_DEFAULT | VPX_ERROR_RESILIENT_PARTITIONS; 39 | 40 | // Initialize codec 41 | err = vpx_codec_enc_init(codec, interface, &cfg, 0); 42 | if (err) { 43 | return err; 44 | } 45 | 46 | return 0; 47 | } 48 | 49 | int vpx_encode(vpx_codec_ctx_t *ctx, vpx_image_t *raw, const char *yv12_frame, 50 | int yv12_len, char *encoded, int encoded_cap, int *encoded_len, 51 | int pts, int force_key_frame) { 52 | *encoded_len = 0; 53 | 54 | int flags = 0; 55 | if (force_key_frame) { 56 | flags |= VPX_EFLAG_FORCE_KF; 57 | } 58 | 59 | // This does not work correctly (only the 1st plane is encoded), why? 60 | // raw->planes[0] = (unsigned char *)yv12_frame; 61 | memcpy(raw->planes[0], yv12_frame, yv12_len); 62 | 63 | vpx_codec_err_t err = 64 | vpx_codec_encode(ctx, raw, pts, 1, flags, VPX_DL_REALTIME); 65 | if (err) { 66 | return err; 67 | } 68 | 69 | vpx_codec_iter_t iter = NULL; 70 | const vpx_codec_cx_pkt_t *pkt; 71 | while ((pkt = vpx_codec_get_cx_data(ctx, &iter))) { 72 | if (pkt->kind == VPX_CODEC_CX_FRAME_PKT) { 73 | size_t frame_size = pkt->data.frame.sz; 74 | if (frame_size + *encoded_len > encoded_cap) { 75 | return VPX_CODEC_MEM_ERROR; 76 | } 77 | 78 | memcpy(encoded, pkt->data.frame.buf, pkt->data.frame.sz); 79 | *encoded_len += frame_size; 80 | encoded += frame_size; 81 | 82 | // TODO(maxhawkins): should we be concatenating packets instead of 83 | // returning early? 84 | return 0; 85 | } 86 | } 87 | 88 | return 0; 89 | } 90 | 91 | int vpx_cleanup_enc(vpx_codec_ctx_t *codec, vpx_image_t *raw) { 92 | vpx_img_free(raw); 93 | 94 | vpx_codec_err_t err = vpx_codec_destroy(codec); 95 | if (err) { 96 | return err; 97 | } 98 | 99 | return 0; 100 | } 101 | -------------------------------------------------------------------------------- /vpx/encoder.go: -------------------------------------------------------------------------------- 1 | package vpx 2 | 3 | /* 4 | #cgo pkg-config: --static vpx 5 | 6 | #include "vpx/vpx_encoder.h" 7 | 8 | int vpx_init_enc(vpx_codec_ctx_t *ctx, vpx_image_t **raw, int width, int height); 9 | int vpx_encode(vpx_codec_ctx_t *ctx, vpx_image_t *raw, const char* yv12_frame, int yv12_len, char* encoded, int encoded_cap, int* size, int pts, int force_key_frame); 10 | int vpx_cleanup_enc(vpx_codec_ctx_t *ctx, vpx_image_t *raw); 11 | */ 12 | import "C" 13 | 14 | import ( 15 | "image" 16 | "sync" 17 | "unsafe" 18 | 19 | "github.com/dialup-inc/ascii/yuv" 20 | ) 21 | 22 | type Encoder struct { 23 | mu sync.Mutex 24 | 25 | ctx C.vpx_codec_ctx_t 26 | img *C.vpx_image_t 27 | } 28 | 29 | func NewEncoder(width, height int) (*Encoder, error) { 30 | e := &Encoder{} 31 | ret := C.vpx_init_enc(&e.ctx, &e.img, C.int(width), C.int(height)) 32 | if ret != 0 { 33 | return nil, VPXCodecErr(ret) 34 | } 35 | return e, nil 36 | } 37 | 38 | func (e *Encoder) Close() error { 39 | e.mu.Lock() 40 | defer e.mu.Unlock() 41 | 42 | ret := C.vpx_cleanup_enc(&e.ctx, e.img) 43 | if ret != 0 { 44 | return VPXCodecErr(ret) 45 | } 46 | return nil 47 | } 48 | 49 | func (e *Encoder) Encode(vpxFrame []byte, img image.Image, pts int, forceKeyframe bool) (n int, err error) { 50 | e.mu.Lock() 51 | defer e.mu.Unlock() 52 | 53 | i420, _, _ := yuv.ToI420(img) 54 | return e.encode(vpxFrame, i420, pts, forceKeyframe) 55 | } 56 | 57 | func (e *Encoder) encode(vpxFrame, yuvFrame []byte, pts int, forceKeyframe bool) (n int, err error) { 58 | inP := (*C.char)(unsafe.Pointer(&yuvFrame[0])) 59 | inL := C.int(len(yuvFrame)) 60 | 61 | outP := (*C.char)(unsafe.Pointer(&vpxFrame[0])) 62 | outCap := C.int(cap(vpxFrame)) 63 | outL := (*C.int)(unsafe.Pointer(&n)) 64 | 65 | forceKeyframeB := C.int(0) 66 | if forceKeyframe { 67 | forceKeyframeB = C.int(1) 68 | } 69 | 70 | ret := C.vpx_encode(&e.ctx, e.img, inP, inL, outP, outCap, outL, C.int(pts), forceKeyframeB) 71 | if ret != 0 { 72 | return n, VPXCodecErr(ret) 73 | } 74 | 75 | return n, nil 76 | } 77 | -------------------------------------------------------------------------------- /vpx/errors.go: -------------------------------------------------------------------------------- 1 | package vpx 2 | 3 | import "fmt" 4 | 5 | type VPXCodecErr int 6 | 7 | const ( 8 | VPX_CODEC_OK VPXCodecErr = iota 9 | VPX_CODEC_ERROR 10 | VPX_CODEC_MEM_ERROR 11 | VPX_CODEC_ABI_MISMATCH 12 | VPX_CODEC_INCAPABLE 13 | VPX_CODEC_UNSUP_BITSTREAM 14 | VPX_CODEC_UNSUP_FEATURE 15 | VPX_CODEC_CORRUPT_FRAME 16 | VPX_CODEC_INVALID_PARAM 17 | VPX_CODEC_LIST_END 18 | ) 19 | 20 | func (e VPXCodecErr) Error() string { 21 | switch e { 22 | case VPX_CODEC_OK: 23 | return "Success" 24 | case VPX_CODEC_ERROR: 25 | return "Unspecified internal error" 26 | case VPX_CODEC_MEM_ERROR: 27 | return "Memory allocation error" 28 | case VPX_CODEC_ABI_MISMATCH: 29 | return "ABI version mismatch" 30 | case VPX_CODEC_INCAPABLE: 31 | return "Codec does not implement requested capability" 32 | case VPX_CODEC_UNSUP_BITSTREAM: 33 | return "Bitstream not supported by this decoder" 34 | case VPX_CODEC_UNSUP_FEATURE: 35 | return "Bitstream required feature not supported by this decoder" 36 | case VPX_CODEC_CORRUPT_FRAME: 37 | return "Corrupt frame detected" 38 | case VPX_CODEC_INVALID_PARAM: 39 | return "Invalid parameter" 40 | case VPX_CODEC_LIST_END: 41 | return "End of iterated list" 42 | default: 43 | return fmt.Sprintf("codec error: %d", e) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /yuv/encoding.go: -------------------------------------------------------------------------------- 1 | package yuv 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | ) 8 | 9 | // FromI420 decodes an i420-encoded YUV image into a Go Image. 10 | // 11 | // See https://www.fourcc.org/pixel-format/yuv-i420/ 12 | func FromI420(frame []byte, width, height int) (*image.YCbCr, error) { 13 | yi := width * height 14 | cbi := yi + width*height/4 15 | cri := cbi + width*height/4 16 | 17 | if cri > len(frame) { 18 | return nil, fmt.Errorf("frame length (%d) less than expected (%d)", len(frame), cri) 19 | } 20 | 21 | return &image.YCbCr{ 22 | Y: frame[:yi], 23 | YStride: width, 24 | Cb: frame[yi:cbi], 25 | Cr: frame[cbi:cri], 26 | CStride: width / 2, 27 | SubsampleRatio: image.YCbCrSubsampleRatio420, 28 | Rect: image.Rect(0, 0, width, height), 29 | }, nil 30 | } 31 | 32 | // FromNV21 decodes an NV21-encoded, big-endian YUV image into a Go Image. 33 | // 34 | // See https://www.fourcc.org/pixel-format/yuv-nv21/ 35 | func FromNV21(frame []byte, width, height int) (*image.YCbCr, error) { 36 | yi := width * height 37 | ci := yi + width*height/2 38 | 39 | if ci > len(frame) { 40 | return nil, fmt.Errorf("frame length (%d) less than expected (%d)", len(frame), ci) 41 | } 42 | 43 | var cb, cr []byte 44 | for i := yi; i < ci; i += 2 { 45 | cb = append(cb, frame[i]) 46 | cr = append(cr, frame[i+1]) 47 | } 48 | 49 | return &image.YCbCr{ 50 | Y: frame[:yi], 51 | YStride: width, 52 | Cb: cb, 53 | Cr: cr, 54 | CStride: width / 2, 55 | SubsampleRatio: image.YCbCrSubsampleRatio420, 56 | Rect: image.Rect(0, 0, width, height), 57 | }, nil 58 | } 59 | 60 | func convertTo420(img image.Image) *image.YCbCr { 61 | bounds := img.Bounds() 62 | img420 := image.NewYCbCr(bounds, image.YCbCrSubsampleRatio420) 63 | 64 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 65 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 66 | r, g, b, _ := img.At(x, y).RGBA() 67 | yy, cb, cr := color.RGBToYCbCr(uint8(r>>8), uint8(g>>8), uint8(b>>8)) 68 | 69 | cy := img420.YOffset(x, y) 70 | ci := img420.COffset(x, y) 71 | img420.Y[cy] = yy 72 | img420.Cb[ci] = cb 73 | img420.Cr[ci] = cr 74 | } 75 | } 76 | 77 | return img420 78 | } 79 | 80 | // ToI420 converts a Go image into an I420-encoded YUV raw image slice 81 | // 82 | // See https://www.fourcc.org/pixel-format/yuv-i420/ 83 | func ToI420(img image.Image) (frame []byte, width, height int) { 84 | bounds := img.Bounds() 85 | 86 | var img420 *image.YCbCr 87 | if y, ok := img.(*image.YCbCr); ok && y.SubsampleRatio == image.YCbCrSubsampleRatio420 { 88 | // If the image is already I420, just use it 89 | img420 = y 90 | } else { 91 | // Otherwise convert it to I420 92 | img420 = convertTo420(img) 93 | } 94 | 95 | frame = append(frame, img420.Y...) 96 | frame = append(frame, img420.Cb...) 97 | frame = append(frame, img420.Cr...) 98 | 99 | return frame, bounds.Dx(), bounds.Dy() 100 | } 101 | --------------------------------------------------------------------------------